chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
# Cloudflare Workers
|
||||
|
||||
This directory contains Cloudflare Workers for automation and monitoring tasks that run on Cloudflare's edge network.
|
||||
|
||||
## Why Cloudflare Workers?
|
||||
|
||||
We separate monitoring and automation tasks into Cloudflare Workers to:
|
||||
|
||||
- ✅ **Separation of concerns**: the dashboard Pages project serves the web app, these Workers handle automated tasks
|
||||
- ✅ **Better performance**: Ultra-fast edge computing with 0 cold starts
|
||||
- ✅ **Free cron jobs**: Unlimited on the free tier
|
||||
- ✅ **Scalability**: 100k requests/day on free tier
|
||||
- ✅ **KV Storage included**: Save state without a database
|
||||
- ✅ **100% CLI managed**: No dashboard needed - everything via `wrangler` CLI
|
||||
|
||||
## 📦 Available Workers
|
||||
|
||||
### [`crons`](./crons/)
|
||||
|
||||
Scheduled caller for the dashboard API (replaces the old Vercel cron jobs).
|
||||
|
||||
**Functionality:**
|
||||
- Calls `/api/claude-code-check` every 30 minutes (monitors Claude Code npm releases)
|
||||
- Calls `/api/health-check` every hour
|
||||
- Reports errors and cron check-ins to Sentry
|
||||
|
||||
**Quick Start (CLI only):**
|
||||
```bash
|
||||
cd crons
|
||||
npm install
|
||||
wrangler login
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
**Config & secrets:** see [crons/wrangler.toml](./crons/wrangler.toml) (schedules, `DASHBOARD_URL`, `TRIGGER_SECRET`, `SENTRY_DSN`).
|
||||
|
||||
### [`docs-monitor`](./docs-monitor/)
|
||||
|
||||
Claude Code documentation monitor with Telegram notifications.
|
||||
|
||||
**Functionality:**
|
||||
- Monitors https://code.claude.com/docs every 6 hours
|
||||
- Detects changes using SHA-256 hash
|
||||
- Sends Telegram notifications when changes occur
|
||||
- Includes HTTP endpoint for manual triggers
|
||||
|
||||
**Quick Start (CLI only):**
|
||||
```bash
|
||||
cd docs-monitor
|
||||
npm install
|
||||
wrangler login
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
**Full documentation:** [docs-monitor/README.md](./docs-monitor/README.md)
|
||||
|
||||
### [`pulse`](./pulse/)
|
||||
|
||||
Weekly KPI report sent via Telegram every Sunday at 14:00 UTC.
|
||||
|
||||
**Functionality:**
|
||||
- Collects metrics from GitHub, Discord, Supabase, npm, and Google Analytics
|
||||
- Formats a consolidated weekly report
|
||||
- Sends to Telegram automatically via cron
|
||||
- Manual trigger via HTTP endpoint
|
||||
|
||||
**Quick Start (CLI only):**
|
||||
```bash
|
||||
cd pulse
|
||||
npm install
|
||||
wrangler login
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
**Full documentation:** [pulse/README.md](./pulse/README.md)
|
||||
|
||||
### [`daily-health-report`](./daily-health-report/)
|
||||
|
||||
Daily monitoring digest sent via Telegram, every day at 14:00 UTC (10:00 AM EDT).
|
||||
|
||||
**Functionality:**
|
||||
- Checks dashboard site health (reuses `/api/health-check`)
|
||||
- Summarizes unresolved Sentry issues from the last 24h across all 3 Sentry projects (`aitmpl-workers`, `aitmpl-dashboard`, `aitmpl-cli`)
|
||||
- Auto-resolves known test/verification noise (title match only, conservative by design — see `index.js`); real errors are always left open and listed for human review
|
||||
- Complements (doesn't replace) docs-monitor's change/error alerts and pulse's weekly KPI report — this is the "everything's fine" / "here's what's broken" heartbeat
|
||||
- Does NOT poll the other 3 workers directly (Cloudflare blocks Worker-to-Worker fetches over `*.workers.dev` within the same account — error 1042); their health is covered by Sentry Cron Monitor check-ins instead
|
||||
|
||||
**Quick Start (CLI only):**
|
||||
```bash
|
||||
cd daily-health-report
|
||||
npm install
|
||||
wrangler login
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
**Config & secrets:** see [daily-health-report/wrangler.toml](./daily-health-report/wrangler.toml) (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `SENTRY_AUTH_TOKEN`, `SENTRY_ORG_SLUG`).
|
||||
|
||||
## 🚀 General Setup (CLI)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Cloudflare account (free)
|
||||
2. Wrangler CLI installed:
|
||||
```bash
|
||||
npm install -g wrangler
|
||||
```
|
||||
|
||||
### Common CLI Commands
|
||||
|
||||
```bash
|
||||
# Authenticate
|
||||
wrangler login
|
||||
|
||||
# Develop locally
|
||||
wrangler dev
|
||||
|
||||
# Deploy to production
|
||||
wrangler deploy
|
||||
|
||||
# View real-time logs
|
||||
wrangler tail
|
||||
|
||||
# List deployments
|
||||
wrangler deployments list
|
||||
|
||||
# View deployment details
|
||||
wrangler deployments view <deployment-id>
|
||||
|
||||
# List workers
|
||||
wrangler deployments list
|
||||
|
||||
# Delete a worker
|
||||
wrangler delete
|
||||
```
|
||||
|
||||
### KV Storage Commands (CLI)
|
||||
|
||||
```bash
|
||||
# Create KV namespace
|
||||
wrangler kv:namespace create MY_KV
|
||||
|
||||
# List all namespaces
|
||||
wrangler kv:namespace list
|
||||
|
||||
# List keys in namespace
|
||||
wrangler kv:key list --namespace-id=<id>
|
||||
|
||||
# Get key value
|
||||
wrangler kv:key get <key> --namespace-id=<id>
|
||||
|
||||
# Set key value
|
||||
wrangler kv:key put <key> "<value>" --namespace-id=<id>
|
||||
|
||||
# Delete key
|
||||
wrangler kv:key delete <key> --namespace-id=<id>
|
||||
```
|
||||
|
||||
### Secrets Management (CLI)
|
||||
|
||||
```bash
|
||||
# Set a secret
|
||||
wrangler secret put SECRET_NAME
|
||||
|
||||
# List all secrets
|
||||
wrangler secret list
|
||||
|
||||
# Delete a secret
|
||||
wrangler secret delete SECRET_NAME
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
Each worker follows this structure:
|
||||
|
||||
```
|
||||
worker-name/
|
||||
├── index.js # Main worker code
|
||||
├── wrangler.toml # Cloudflare configuration
|
||||
├── package.json # Dependencies and scripts
|
||||
├── .env.example # Environment variables template
|
||||
├── .gitignore # Git ignored files
|
||||
└── README.md # Specific documentation
|
||||
```
|
||||
|
||||
## 🔐 Secrets Management
|
||||
|
||||
Secrets are stored securely in Cloudflare via CLI:
|
||||
|
||||
```bash
|
||||
# Add a secret (prompts for value)
|
||||
wrangler secret put SECRET_NAME
|
||||
|
||||
# List configured secrets (doesn't show values)
|
||||
wrangler secret list
|
||||
|
||||
# Delete a secret
|
||||
wrangler secret delete SECRET_NAME
|
||||
```
|
||||
|
||||
**⚠️ IMPORTANT**: Never commit secrets to code or .env files. Always use `wrangler secret put` for production.
|
||||
|
||||
## 💰 Costs
|
||||
|
||||
Cloudflare Workers **free tier** includes:
|
||||
|
||||
- 100,000 requests/day
|
||||
- 10ms CPU time/request
|
||||
- Unlimited cron triggers
|
||||
- KV: 100k reads/day, 1k writes/day
|
||||
- 1 GB storage
|
||||
|
||||
**For most use cases: $0.00/month**
|
||||
|
||||
## 📚 Resources
|
||||
|
||||
- [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers/)
|
||||
- [Wrangler CLI Reference](https://developers.cloudflare.com/workers/wrangler/)
|
||||
- [Workers Examples](https://developers.cloudflare.com/workers/examples/)
|
||||
- [Cloudflare KV Storage](https://developers.cloudflare.com/kv/)
|
||||
- [Cron Triggers Guide](https://developers.cloudflare.com/workers/configuration/cron-triggers/)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
To add a new worker:
|
||||
|
||||
1. Create a directory: `cloudflare-workers/my-worker/`
|
||||
2. Follow the standard structure (see above)
|
||||
3. Document clearly its purpose in README.md
|
||||
4. Add an entry in this main README
|
||||
5. Use CLI for all operations
|
||||
|
||||
## 🎯 CLI-First Philosophy
|
||||
|
||||
This project emphasizes CLI usage:
|
||||
|
||||
- ✅ No need to access Cloudflare Dashboard
|
||||
- ✅ Everything scriptable and automatable
|
||||
- ✅ Version control friendly (wrangler.toml)
|
||||
- ✅ CI/CD ready
|
||||
- ✅ Reproducible deployments
|
||||
|
||||
All worker management, deployment, monitoring, and debugging can be done via `wrangler` CLI.
|
||||
|
||||
---
|
||||
|
||||
**Part of the [claude-code-templates](https://github.com/danipower/claude-code-templates) project**
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* aitmpl-crons — Cloudflare Worker
|
||||
*
|
||||
* Replaces Vercel cron jobs by calling the dashboard API endpoints
|
||||
* on a schedule. Cron: every 30 minutes (claude-code-check) and hourly (health-check).
|
||||
*
|
||||
* Secrets (wrangler secret put):
|
||||
* DASHBOARD_URL — e.g. https://www.aitmpl.com
|
||||
* TRIGGER_SECRET — shared secret sent as Authorization header
|
||||
* SENTRY_DSN — DSN from the "aitmpl-workers" Sentry project (error tracking)
|
||||
*/
|
||||
|
||||
import { reportError, checkIn } from './sentry.js';
|
||||
|
||||
const MONITORS = {
|
||||
'*/30 * * * *': 'claude-code-check',
|
||||
'0 * * * *': 'health-check',
|
||||
};
|
||||
|
||||
export default {
|
||||
async scheduled(event, env, ctx) {
|
||||
const base = env.DASHBOARD_URL || 'https://www.aitmpl.com';
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${env.TRIGGER_SECRET || ''}`,
|
||||
'User-Agent': 'aitmpl-crons/1.0',
|
||||
};
|
||||
|
||||
const cron = event.cron;
|
||||
const endpoint = cron === '*/30 * * * *' ? '/api/claude-code-check'
|
||||
: cron === '0 * * * *' ? '/api/health-check'
|
||||
: null;
|
||||
|
||||
if (!endpoint) {
|
||||
console.log(`Unknown cron schedule: ${cron}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const monitorSlug = MONITORS[cron];
|
||||
const checkInId = await checkIn(env, monitorSlug, 'in_progress');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}${endpoint}`, { headers });
|
||||
console.log(`${endpoint}: ${res.status}`);
|
||||
|
||||
if (!res.ok) {
|
||||
await reportError(env, `${endpoint} returned ${res.status}`, {
|
||||
worker: 'aitmpl-crons',
|
||||
cron,
|
||||
endpoint,
|
||||
status: res.status,
|
||||
});
|
||||
await checkIn(env, monitorSlug, 'error', checkInId);
|
||||
} else {
|
||||
await checkIn(env, monitorSlug, 'ok', checkInId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${endpoint} failed:`, error.message);
|
||||
await reportError(env, error, { worker: 'aitmpl-crons', cron, endpoint });
|
||||
await checkIn(env, monitorSlug, 'error', checkInId);
|
||||
}
|
||||
},
|
||||
|
||||
// Manual trigger for testing: GET /trigger?cron=*/30+*+*+*+*
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/status') {
|
||||
return new Response(JSON.stringify({
|
||||
status: 'running',
|
||||
worker: 'aitmpl-crons',
|
||||
schedules: {
|
||||
'*/30 * * * *': '/api/claude-code-check',
|
||||
'0 * * * *': '/api/health-check',
|
||||
},
|
||||
}), { headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (url.pathname !== '/trigger') {
|
||||
return new Response('aitmpl-crons worker', { status: 200 });
|
||||
}
|
||||
|
||||
const auth = request.headers.get('Authorization');
|
||||
if (!env.TRIGGER_SECRET || auth !== `Bearer ${env.TRIGGER_SECRET}`) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const cron = url.searchParams.get('cron') || '*/30 * * * *';
|
||||
await this.scheduled({ cron }, env, {});
|
||||
return new Response(`Triggered: ${cron}`, { status: 200 });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Minimal Sentry error reporter for Cloudflare Workers (no npm dependency).
|
||||
*
|
||||
* Sends errors directly to the Sentry envelope API via fetch(), matching this
|
||||
* project's convention of single-file, zero-dependency Workers.
|
||||
*
|
||||
* Secret required (wrangler secret put SENTRY_DSN):
|
||||
* SENTRY_DSN — DSN from the "aitmpl-workers" Sentry project
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a Sentry DSN into the pieces needed to build the envelope endpoint.
|
||||
* DSN format: https://<publicKey>@<host>/<projectId>
|
||||
*/
|
||||
function parseDsn(dsn) {
|
||||
try {
|
||||
const url = new URL(dsn);
|
||||
return {
|
||||
publicKey: url.username,
|
||||
host: url.host,
|
||||
projectId: url.pathname.replace(/^\//, ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error to Sentry. Safe to call even if SENTRY_DSN is not configured
|
||||
* (no-op) or if the request to Sentry itself fails (never throws).
|
||||
*
|
||||
* @param {object} env - Worker env bindings (needs env.SENTRY_DSN)
|
||||
* @param {Error|string} error - The error (or message) to report
|
||||
* @param {object} [context] - Extra tags/context, e.g. { cron, endpoint, status }
|
||||
*/
|
||||
async function reportError(env, error, context = {}) {
|
||||
const dsn = env && env.SENTRY_DSN;
|
||||
if (!dsn) {
|
||||
console.error('[sentry] SENTRY_DSN not configured, skipping report:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDsn(dsn);
|
||||
if (!parsed) {
|
||||
console.error('[sentry] invalid SENTRY_DSN, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stacktrace = error instanceof Error && error.stack ? error.stack : undefined;
|
||||
const eventId = crypto.randomUUID().replace(/-/g, '');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const event = {
|
||||
event_id: eventId,
|
||||
timestamp,
|
||||
platform: 'javascript',
|
||||
logger: 'cloudflare-worker',
|
||||
environment: 'production',
|
||||
tags: { worker: context.worker || 'unknown', ...context.tags },
|
||||
extra: context,
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
type: error instanceof Error ? error.name || 'Error' : 'Error',
|
||||
value: message,
|
||||
stacktrace: stacktrace ? { frames: [{ filename: 'worker', function: 'anonymous', context_line: stacktrace }] } : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const envelopeHeader = JSON.stringify({ event_id: eventId, sent_at: timestamp });
|
||||
const itemHeader = JSON.stringify({ type: 'event' });
|
||||
const body = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(event)}\n`;
|
||||
|
||||
const endpoint = `https://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
||||
const authHeader = `Sentry sentry_version=7, sentry_client=aitmpl-worker/1.0, sentry_key=${parsed.publicKey}`;
|
||||
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
'X-Sentry-Auth': authHeader,
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch (sendError) {
|
||||
// Never let error reporting break the worker itself
|
||||
console.error('[sentry] failed to send report:', sendError.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Sentry Cron Monitor check-in.
|
||||
* @param {object} env - Worker env bindings (needs env.SENTRY_DSN)
|
||||
* @param {string} monitorSlug - Slug configured in Sentry (Crons > Add Monitor)
|
||||
* @param {'in_progress'|'ok'|'error'} status
|
||||
* @param {string} [checkInId] - Pass the id returned from the 'in_progress' call
|
||||
* so 'ok'/'error' updates the same check-in.
|
||||
* @returns {Promise<string|undefined>} checkInId
|
||||
*/
|
||||
async function checkIn(env, monitorSlug, status, checkInId) {
|
||||
const dsn = env && env.SENTRY_DSN;
|
||||
if (!dsn) return undefined;
|
||||
|
||||
const parsed = parseDsn(dsn);
|
||||
if (!parsed) return undefined;
|
||||
|
||||
const id = checkInId || crypto.randomUUID().replace(/-/g, '');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const envelopeHeader = JSON.stringify({ sent_at: timestamp });
|
||||
const itemPayload = { check_in_id: id, monitor_slug: monitorSlug, status };
|
||||
const itemHeader = JSON.stringify({ type: 'check_in' });
|
||||
const body = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(itemPayload)}\n`;
|
||||
|
||||
const endpoint = `https://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
||||
const authHeader = `Sentry sentry_version=7, sentry_client=aitmpl-worker/1.0, sentry_key=${parsed.publicKey}`;
|
||||
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
'X-Sentry-Auth': authHeader,
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch (sendError) {
|
||||
console.error('[sentry] failed to send check-in:', sendError.message);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export { reportError, checkIn };
|
||||
@@ -0,0 +1,19 @@
|
||||
name = "aitmpl-crons"
|
||||
main = "index.js"
|
||||
compatibility_date = "2024-09-23"
|
||||
|
||||
# Cron triggers:
|
||||
# - claude-code-check: every 30 minutes (monitors Claude Code npm releases)
|
||||
# - health-check: every hour (was every 15 min on Vercel, reduced to save invocations)
|
||||
[triggers]
|
||||
crons = ["*/30 * * * *", "0 * * * *"]
|
||||
|
||||
# Environment variables — set via: wrangler secret put <KEY>
|
||||
# Required:
|
||||
# - DASHBOARD_URL: https://www.aitmpl.com (no trailing slash)
|
||||
# - TRIGGER_SECRET: shared secret for authenticating internal cron calls
|
||||
#
|
||||
# Optional (error tracking):
|
||||
# - SENTRY_DSN: DSN from the "aitmpl-workers" Sentry project. Also configure
|
||||
# Cron Monitors in Sentry with slugs "claude-code-check" and "health-check"
|
||||
# to get alerted if a schedule stops running entirely.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Wrangler
|
||||
.wrangler/
|
||||
dist/
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.dev.vars
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Cloudflare Worker: Daily Health Report
|
||||
*
|
||||
* Sends a single daily Telegram digest covering:
|
||||
* 1. Dashboard site health (reuses GET /api/health-check)
|
||||
* 2. Sentry error summary (last 24h) across the 3 Sentry projects,
|
||||
* auto-resolving known test/verification noise first (see
|
||||
* NOISE_TITLE_PATTERNS below) so only real errors are reported.
|
||||
*
|
||||
* Auto-resolve policy (deliberately conservative): an issue is ONLY
|
||||
* auto-resolved if its title matches one of NOISE_TITLE_PATTERNS below —
|
||||
* literal substrings identifying our own manual verification events, never
|
||||
* a catch-all. Everything else (any real production error) is left
|
||||
* untouched and always listed in the Telegram digest for a human to review.
|
||||
* Never widen this to "resolve everything" — that would silently hide real
|
||||
* bugs instead of surfacing them, defeating the point of error tracking.
|
||||
*
|
||||
* Note: this worker does NOT poll the other 3 Cloudflare Workers' /status
|
||||
* endpoints. Cloudflare blocks Worker-to-Worker fetches over the public
|
||||
* *.workers.dev hostname within the same account (error 1042) — the fix
|
||||
* would be Service Bindings, not attempted here. Those workers' own health
|
||||
* is instead covered by their Sentry Cron Monitor check-ins (see
|
||||
* cloudflare-workers/{crons,pulse,docs-monitor}/sentry.js) surfaced in the
|
||||
* Sentry summary below.
|
||||
*
|
||||
* Complements, not replaces, the existing per-event notifications
|
||||
* (docs-monitor change/error alerts, pulse's weekly KPI report). This is a
|
||||
* proactive "everything's fine" / "here's what's broken" heartbeat, since
|
||||
* none of the other flows report positively when things are healthy.
|
||||
*
|
||||
* No npm dependencies — matches the zero-dependency style of the other
|
||||
* workers in this repo.
|
||||
*/
|
||||
|
||||
const TELEGRAM_API = 'https://api.telegram.org';
|
||||
const SENTRY_API = 'https://sentry.io/api/0';
|
||||
|
||||
const SENTRY_PROJECTS = ['aitmpl-workers', 'aitmpl-dashboard', 'aitmpl-cli'];
|
||||
|
||||
// Case-insensitive substrings identifying known noise (our own manual
|
||||
// verification test events). An issue is auto-resolved ONLY if its title
|
||||
// contains one of these — see the policy note above before adding more.
|
||||
const NOISE_TITLE_PATTERNS = [
|
||||
'manual verification test',
|
||||
'manual verification',
|
||||
];
|
||||
|
||||
export default {
|
||||
async scheduled(event, env, ctx) {
|
||||
await runReport(env);
|
||||
},
|
||||
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/status') {
|
||||
return jsonResponse({
|
||||
status: 'running',
|
||||
worker: 'daily-health-report',
|
||||
schedule: 'Daily 14:00 UTC (10:00 AM EDT)',
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/trigger' && request.method === 'POST') {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!env.TRIGGER_SECRET || authHeader !== `Bearer ${env.TRIGGER_SECRET}`) {
|
||||
return jsonResponse({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
const sendTelegram = url.searchParams.get('send') !== 'false';
|
||||
const result = await runReport(env, { sendTelegram });
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
'Daily Health Report Worker\n\nEndpoints:\n- POST /trigger (requires auth)\n- GET /status',
|
||||
{ headers: { 'Content-Type': 'text/plain' } }
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Report Runner ───────────────────────────────────────────────────────────
|
||||
|
||||
async function runReport(env, opts = {}) {
|
||||
const { sendTelegram = true } = opts;
|
||||
|
||||
const [siteHealth, sentry] = await Promise.all([
|
||||
checkSiteHealth(env),
|
||||
checkSentryErrors(env),
|
||||
]);
|
||||
|
||||
const reportText = formatReport({ siteHealth, sentry });
|
||||
|
||||
let telegramResult = null;
|
||||
if (sendTelegram) {
|
||||
telegramResult = await sendToTelegram(env, reportText);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
healthy: siteHealth.healthy !== false && !sentry.error,
|
||||
report: reportText,
|
||||
telegram: sendTelegram ? telegramResult : 'skipped',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Section 1: Dashboard site health ────────────────────────────────────────
|
||||
|
||||
async function checkSiteHealth(env) {
|
||||
const base = env.DASHBOARD_URL || 'https://www.aitmpl.com';
|
||||
try {
|
||||
const res = await fetchJSON(`${base}/api/health-check`);
|
||||
return {
|
||||
healthy: res.healthy,
|
||||
results: res.results || [],
|
||||
};
|
||||
} catch (error) {
|
||||
return { healthy: false, error: error.message, results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Section 2: Sentry error summary (last 24h) ──────────────────────────────
|
||||
|
||||
function isKnownNoise(title) {
|
||||
const lower = (title || '').toLowerCase();
|
||||
return NOISE_TITLE_PATTERNS.some(pattern => lower.includes(pattern));
|
||||
}
|
||||
|
||||
async function resolveIssue(env, issueId) {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${env.SENTRY_AUTH_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${SENTRY_API}/issues/${issueId}/`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ status: 'resolved' }),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSentryErrors(env) {
|
||||
if (!env.SENTRY_AUTH_TOKEN || !env.SENTRY_ORG_SLUG) {
|
||||
return { error: 'SENTRY_AUTH_TOKEN or SENTRY_ORG_SLUG not configured', projects: [] };
|
||||
}
|
||||
|
||||
const headers = { Authorization: `Bearer ${env.SENTRY_AUTH_TOKEN}` };
|
||||
|
||||
const projects = await Promise.all(SENTRY_PROJECTS.map(async (slug) => {
|
||||
try {
|
||||
const url = `${SENTRY_API}/organizations/${env.SENTRY_ORG_SLUG}/issues/` +
|
||||
`?project=${slug}&query=is:unresolved age:-24h&statsPeriod=24h`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) {
|
||||
return { slug, error: `HTTP ${res.status}` };
|
||||
}
|
||||
const issues = await res.json();
|
||||
const allIssues = Array.isArray(issues) ? issues : [];
|
||||
|
||||
const noiseIssues = allIssues.filter(i => isKnownNoise(i.title));
|
||||
const realIssues = allIssues.filter(i => !isKnownNoise(i.title));
|
||||
|
||||
const autoResolved = [];
|
||||
for (const issue of noiseIssues) {
|
||||
const ok = await resolveIssue(env, issue.id);
|
||||
if (ok) autoResolved.push(issue.title);
|
||||
}
|
||||
|
||||
return {
|
||||
slug,
|
||||
newIssueCount: realIssues.length,
|
||||
autoResolvedCount: autoResolved.length,
|
||||
autoResolvedTitles: autoResolved,
|
||||
topIssues: realIssues.slice(0, 3).map(i => ({
|
||||
title: i.title,
|
||||
count: i.count,
|
||||
permalink: i.permalink,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
return { slug, error: error.message };
|
||||
}
|
||||
}));
|
||||
|
||||
return { projects };
|
||||
}
|
||||
|
||||
// ─── Formatting ───────────────────────────────────────────────────────────────
|
||||
|
||||
function formatReport({ siteHealth, sentry }) {
|
||||
const now = new Date().toUTCString();
|
||||
const lines = [`<b>📋 Daily Health Report</b>`, `${now}`, ''];
|
||||
|
||||
// Site health
|
||||
lines.push(`<b>🌐 Site (aitmpl.com)</b>`);
|
||||
if (siteHealth.healthy === true) {
|
||||
lines.push(`✅ All endpoints healthy`);
|
||||
} else if (siteHealth.error) {
|
||||
lines.push(`❌ Could not reach health-check: ${siteHealth.error}`);
|
||||
} else {
|
||||
const failing = siteHealth.results.filter(r => r.error || r.status >= 500 || r.status === 0);
|
||||
lines.push(`⚠️ Unhealthy — ${failing.length} endpoint(s) failing:`);
|
||||
for (const f of failing) {
|
||||
lines.push(` • ${f.endpoint}: ${f.error || `HTTP ${f.status}`}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Sentry
|
||||
lines.push(`<b>🐛 Sentry (last 24h)</b>`);
|
||||
if (sentry.error) {
|
||||
lines.push(`⚠️ ${sentry.error}`);
|
||||
} else {
|
||||
for (const p of sentry.projects) {
|
||||
if (p.error) {
|
||||
lines.push(`⚠️ ${p.slug}: ${p.error}`);
|
||||
continue;
|
||||
}
|
||||
const noiseNote = p.autoResolvedCount > 0 ? ` (auto-resolved ${p.autoResolvedCount} test event(s))` : '';
|
||||
if (p.newIssueCount === 0) {
|
||||
lines.push(`✅ ${p.slug}: no new issues${noiseNote}`);
|
||||
} else {
|
||||
lines.push(`🔴 ${p.slug}: ${p.newIssueCount} unresolved issue(s)${noiseNote} — needs review`);
|
||||
for (const issue of p.topIssues) {
|
||||
lines.push(` • ${issue.title} (${issue.count}x)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ─── Telegram ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function sendToTelegram(env, text) {
|
||||
if (!env.TELEGRAM_BOT_TOKEN || !env.TELEGRAM_CHAT_ID) {
|
||||
console.error('Missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID');
|
||||
return { sent: false, error: 'missing_credentials' };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${TELEGRAM_API}/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: env.TELEGRAM_CHAT_ID,
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
return { sent: result.ok === true, result };
|
||||
} catch (error) {
|
||||
console.error('Failed to send Telegram message:', error.message);
|
||||
return { sent: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchJSON(url, options = {}) {
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} from ${url}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function jsonResponse(data, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "daily-health-report",
|
||||
"version": "1.0.0",
|
||||
"description": "Cloudflare Worker — daily monitoring digest (site health, workers, Sentry errors) sent via Telegram",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"tail": "wrangler tail",
|
||||
"test": "wrangler dev --test-scheduled",
|
||||
"secret:telegram-bot": "wrangler secret put TELEGRAM_BOT_TOKEN",
|
||||
"secret:telegram-chat": "wrangler secret put TELEGRAM_CHAT_ID",
|
||||
"secret:sentry-token": "wrangler secret put SENTRY_AUTH_TOKEN",
|
||||
"secret:sentry-org": "wrangler secret put SENTRY_ORG_SLUG",
|
||||
"secret:trigger": "wrangler secret put TRIGGER_SECRET"
|
||||
},
|
||||
"keywords": [
|
||||
"cloudflare-workers",
|
||||
"telegram",
|
||||
"monitoring",
|
||||
"health-check",
|
||||
"sentry"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.91.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Cloudflare Worker Configuration
|
||||
# Daily Health Report — sends a daily monitoring digest to Telegram
|
||||
|
||||
name = "daily-health-report"
|
||||
main = "index.js"
|
||||
compatibility_date = "2024-09-23"
|
||||
|
||||
# Cron Trigger - Daily at 14:00 UTC (10:00 AM EDT / 9:00 AM EST)
|
||||
# Fixed to EDT (UTC-4); adjust by 1 hour twice a year to track daylight saving
|
||||
# if exact 10:00 AM Michigan time matters. See README.md.
|
||||
[triggers]
|
||||
crons = ["0 14 * * *"]
|
||||
|
||||
# Secrets (set via: wrangler secret put <KEY>)
|
||||
# Required:
|
||||
# - TELEGRAM_BOT_TOKEN: Telegram bot token (same as docs-monitor/pulse)
|
||||
# - TELEGRAM_CHAT_ID: Telegram chat ID (same as docs-monitor/pulse)
|
||||
# - SENTRY_AUTH_TOKEN: Sentry API auth token (Settings > Auth Tokens),
|
||||
# scopes: Project=Read, Organization=Read, Issue & Event=WRITE (write is
|
||||
# required to auto-resolve issues, not just list them)
|
||||
# - SENTRY_ORG_SLUG: Sentry organization slug (e.g. "daniel-avila")
|
||||
#
|
||||
# Auto-resolve policy: on every run, issues whose title matches
|
||||
# NOISE_TITLE_PATTERNS in index.js (our own manual verification test
|
||||
# events) are automatically marked resolved in Sentry. Everything else is
|
||||
# left untouched and listed in the Telegram digest for human review — this
|
||||
# is deliberately conservative, see the comment at the top of index.js
|
||||
# before changing it.
|
||||
#
|
||||
# Optional:
|
||||
# - TRIGGER_SECRET: Secret for manual /trigger endpoint
|
||||
# - DASHBOARD_URL: e.g. https://www.aitmpl.com (defaults to that if unset)
|
||||
#
|
||||
# Note: this worker does NOT check the other 3 Cloudflare Workers directly —
|
||||
# Cloudflare blocks Worker-to-Worker fetches over the public *.workers.dev
|
||||
# hostname within the same account (error 1042). Their health is covered by
|
||||
# their own Sentry Cron Monitor check-ins instead, surfaced in the Sentry
|
||||
# summary section below.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Cloudflare Worker Environment Variables
|
||||
# This file is for reference only - secrets are set via CLI
|
||||
|
||||
# Telegram Bot Configuration
|
||||
# Get bot token from @BotFather on Telegram
|
||||
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
|
||||
# Your Telegram Chat ID
|
||||
# To get your chat ID, message @userinfobot on Telegram
|
||||
TELEGRAM_CHAT_ID=123456789
|
||||
|
||||
# Secret for manual trigger endpoint (optional)
|
||||
# Generate a random string for security
|
||||
TRIGGER_SECRET=your-random-secret-here
|
||||
|
||||
# ⚠️ IMPORTANT: Do NOT use .env files with Cloudflare Workers
|
||||
# All secrets must be set via CLI for production:
|
||||
#
|
||||
# Set secrets via wrangler CLI:
|
||||
# wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
# wrangler secret put TELEGRAM_CHAT_ID
|
||||
# wrangler secret put TRIGGER_SECRET
|
||||
#
|
||||
# List secrets:
|
||||
# wrangler secret list
|
||||
#
|
||||
# Delete a secret:
|
||||
# wrangler secret delete SECRET_NAME
|
||||
@@ -0,0 +1,15 @@
|
||||
# Wrangler
|
||||
.wrangler/
|
||||
dist/
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.dev.vars
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,84 @@
|
||||
# 🚀 Quick Start - 5 Minutes (100% CLI)
|
||||
|
||||
Super quick guide to deploy the monitor in 5 minutes using only the command line.
|
||||
|
||||
## Step 1: Create Telegram Bot (2 min)
|
||||
|
||||
1. Open Telegram and search for **@BotFather**
|
||||
2. Send: `/newbot`
|
||||
3. Follow instructions (name and username)
|
||||
4. **Save the token** it gives you (looks like: `1234567890:ABCdef...`)
|
||||
|
||||
## Step 2: Get Your Chat ID (1 min)
|
||||
|
||||
1. Search for **@userinfobot** in Telegram
|
||||
2. Send: `/start`
|
||||
3. **Save your ID** (looks like: `123456789`)
|
||||
|
||||
## Step 3: Setup Worker via CLI (2 min)
|
||||
|
||||
```bash
|
||||
# 1. Go to directory
|
||||
cd cloudflare-workers/docs-monitor
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Login to Cloudflare (opens browser once)
|
||||
npx wrangler login
|
||||
|
||||
# 4. Create KV namespace (via CLI)
|
||||
npx wrangler kv:namespace create DOCS_MONITOR_KV
|
||||
|
||||
# Copy the ID it gives you and edit wrangler.toml:
|
||||
# [[kv_namespaces]]
|
||||
# binding = "DOCS_MONITOR_KV"
|
||||
# id = "PASTE_ID_HERE" # ← Change this
|
||||
|
||||
# 5. Configure secrets (via CLI)
|
||||
npx wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
# → Paste bot token
|
||||
|
||||
npx wrangler secret put TELEGRAM_CHAT_ID
|
||||
# → Paste your chat ID
|
||||
|
||||
# 6. Deploy (via CLI)
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## Done! 🎉
|
||||
|
||||
Your worker is now running. You'll receive a Telegram notification whenever Claude Code documentation changes.
|
||||
|
||||
### Verify it works (CLI commands)
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
curl https://claude-docs-monitor.YOUR-USERNAME.workers.dev/status
|
||||
|
||||
# Force a check (optional)
|
||||
# First configure the secret:
|
||||
npx wrangler secret put TRIGGER_SECRET
|
||||
# → Type any password: my-secret-123
|
||||
|
||||
# Then use it:
|
||||
curl -X POST https://claude-docs-monitor.YOUR-USERNAME.workers.dev/trigger \
|
||||
-H "Authorization: Bearer my-secret-123"
|
||||
|
||||
# View real-time logs
|
||||
npx wrangler tail
|
||||
|
||||
# List all secrets
|
||||
npx wrangler secret list
|
||||
|
||||
# View deployments
|
||||
npx wrangler deployments list
|
||||
```
|
||||
|
||||
### Problems?
|
||||
|
||||
See the [full documentation](./README.md) or check the [Troubleshooting](./README.md#-troubleshooting) section.
|
||||
|
||||
---
|
||||
|
||||
**Next step**: Customize check frequency by editing `wrangler.toml` → `[triggers]`
|
||||
@@ -0,0 +1,320 @@
|
||||
# Claude Code Docs Monitor
|
||||
|
||||
Cloudflare Worker that monitors Claude Code documentation at https://code.claude.com/docs and sends Telegram notifications when changes are detected.
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- ✅ **Automatic monitoring** every 6 hours (configurable)
|
||||
- ✅ **Change detection** using SHA-256 hash
|
||||
- ✅ **Telegram notifications** with change details
|
||||
- ✅ **Content cleaning** to avoid false positives (ignores scripts, styles, analytics)
|
||||
- ✅ **HTTP endpoint** for manual triggers and status checks
|
||||
- ✅ **100% Serverless** - runs on Cloudflare Edge
|
||||
- ✅ **Free** within Cloudflare's free tier
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
1. **Cloudflare Account** (free): https://dash.cloudflare.com/sign-up
|
||||
2. **Telegram Bot**:
|
||||
- Talk to [@BotFather](https://t.me/BotFather) on Telegram
|
||||
- Send `/newbot` and follow instructions
|
||||
- Save the **token** it gives you
|
||||
3. **Your Telegram Chat ID**:
|
||||
- Talk to [@userinfobot](https://t.me/userinfobot)
|
||||
- Send `/start` and it will give you your **chat ID**
|
||||
|
||||
## 🚀 Installation (100% CLI)
|
||||
|
||||
### 1. Install Wrangler CLI
|
||||
|
||||
```bash
|
||||
npm install -g wrangler
|
||||
|
||||
# Or using the local project
|
||||
cd cloudflare-workers/docs-monitor
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Authenticate with Cloudflare
|
||||
|
||||
```bash
|
||||
wrangler login
|
||||
```
|
||||
|
||||
This will open your browser for authentication.
|
||||
|
||||
### 3. Create KV Namespace
|
||||
|
||||
The worker needs KV (Key-Value) storage to save state:
|
||||
|
||||
```bash
|
||||
# Create the namespace
|
||||
wrangler kv:namespace create DOCS_MONITOR_KV
|
||||
|
||||
# Expected output:
|
||||
# 🌀 Creating namespace with title "claude-docs-monitor-DOCS_MONITOR_KV"
|
||||
# ✨ Success!
|
||||
# Add the following to your configuration file in your kv_namespaces array:
|
||||
# { binding = "DOCS_MONITOR_KV", id = "xxxxxxxxxxxxxx" }
|
||||
```
|
||||
|
||||
**IMPORTANT**: Copy the `id` it gives you and paste it in `wrangler.toml`:
|
||||
|
||||
```toml
|
||||
[[kv_namespaces]]
|
||||
binding = "DOCS_MONITOR_KV"
|
||||
id = "YOUR_KV_ID_HERE" # ← Replace with your ID
|
||||
```
|
||||
|
||||
### 4. Configure Secrets (CLI)
|
||||
|
||||
Set your Telegram credentials as secrets:
|
||||
|
||||
```bash
|
||||
# Telegram bot token
|
||||
wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
# When prompted, paste: 1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
|
||||
# Your Telegram Chat ID
|
||||
wrangler secret put TELEGRAM_CHAT_ID
|
||||
# When prompted, paste: 123456789
|
||||
|
||||
# (Optional) Secret for manual trigger
|
||||
wrangler secret put TRIGGER_SECRET
|
||||
# When prompted, paste any random string: my-super-secret-123
|
||||
```
|
||||
|
||||
### 5. Deploy (CLI)
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
|
||||
# Or directly:
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
Done! Your worker is deployed and running 🎉
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Change monitoring frequency
|
||||
|
||||
Edit `wrangler.toml`:
|
||||
|
||||
```toml
|
||||
[triggers]
|
||||
# Every 6 hours (default)
|
||||
crons = ["0 */6 * * *"]
|
||||
|
||||
# Every 4 hours
|
||||
crons = ["0 */4 * * *"]
|
||||
|
||||
# Every 2 hours
|
||||
crons = ["0 */2 * * *"]
|
||||
|
||||
# Every hour
|
||||
crons = ["0 * * * *"]
|
||||
|
||||
# Every 30 minutes (for development)
|
||||
crons = ["*/30 * * * *"]
|
||||
```
|
||||
|
||||
After changing, redeploy:
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
### Monitor multiple URLs
|
||||
|
||||
To monitor more sites:
|
||||
|
||||
1. **Option A**: Duplicate the worker
|
||||
```bash
|
||||
cp -r docs-monitor github-releases-monitor
|
||||
# Edit index.js to change the URL
|
||||
# Change the name in wrangler.toml
|
||||
```
|
||||
|
||||
2. **Option B**: Modify `index.js` to accept multiple URLs
|
||||
```javascript
|
||||
const urls = [
|
||||
'https://code.claude.com/docs',
|
||||
'https://github.com/anthropics/claude-code/releases',
|
||||
];
|
||||
```
|
||||
|
||||
## 🎯 Usage (All via CLI)
|
||||
|
||||
### Check monitor status
|
||||
|
||||
```bash
|
||||
curl https://claude-docs-monitor.YOUR-USERNAME.workers.dev/status
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"lastHash": "a1b2c3d4...",
|
||||
"lastChecked": "2026-01-01T10:00:00.000Z",
|
||||
"lastChange": "2025-12-28T14:30:00.000Z",
|
||||
"monitoredUrl": "https://code.claude.com/docs"
|
||||
}
|
||||
```
|
||||
|
||||
### Manual trigger
|
||||
|
||||
To force an immediate check:
|
||||
|
||||
```bash
|
||||
curl -X POST https://claude-docs-monitor.YOUR-USERNAME.workers.dev/trigger \
|
||||
-H "Authorization: Bearer YOUR_TRIGGER_SECRET"
|
||||
```
|
||||
|
||||
### View logs in real-time (CLI)
|
||||
|
||||
```bash
|
||||
npm run tail
|
||||
|
||||
# Or directly:
|
||||
wrangler tail
|
||||
```
|
||||
|
||||
### Local development (CLI)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# Test cron job locally
|
||||
npm run test
|
||||
```
|
||||
|
||||
## 📊 Monitoring (CLI)
|
||||
|
||||
### View logs via CLI
|
||||
|
||||
```bash
|
||||
# Real-time logs
|
||||
wrangler tail
|
||||
|
||||
# View recent deployments
|
||||
wrangler deployments list
|
||||
|
||||
# View specific deployment
|
||||
wrangler deployments view <deployment-id>
|
||||
|
||||
# List secrets
|
||||
wrangler secret list
|
||||
|
||||
# Delete a secret
|
||||
wrangler secret delete SECRET_NAME
|
||||
```
|
||||
|
||||
### View KV data (CLI)
|
||||
|
||||
```bash
|
||||
# List all KV namespaces
|
||||
wrangler kv:namespace list
|
||||
|
||||
# List keys in namespace
|
||||
wrangler kv:key list --namespace-id=YOUR_KV_ID
|
||||
|
||||
# Get a specific key value
|
||||
wrangler kv:key get last_hash --namespace-id=YOUR_KV_ID
|
||||
|
||||
# Put a value (for testing)
|
||||
wrangler kv:key put test_key "test_value" --namespace-id=YOUR_KV_ID
|
||||
|
||||
# Delete a key
|
||||
wrangler kv:key delete test_key --namespace-id=YOUR_KV_ID
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### No notifications received
|
||||
|
||||
1. **Verify secrets**:
|
||||
```bash
|
||||
# List configured secrets
|
||||
wrangler secret list
|
||||
```
|
||||
|
||||
2. **Test bot manually**:
|
||||
```bash
|
||||
curl https://api.telegram.org/botYOUR_BOT_TOKEN/getMe
|
||||
# Should return bot info
|
||||
```
|
||||
|
||||
3. **Verify chat ID**:
|
||||
```bash
|
||||
# Send a test message
|
||||
curl -X POST https://api.telegram.org/botYOUR_BOT_TOKEN/sendMessage \
|
||||
-d chat_id=YOUR_CHAT_ID \
|
||||
-d text="Test"
|
||||
```
|
||||
|
||||
### Worker doesn't execute cron
|
||||
|
||||
- Cron triggers **only work in production** (after deploy)
|
||||
- They don't work in `wrangler dev`
|
||||
- Check via CLI: `wrangler tail` to see cron executions
|
||||
|
||||
### Error: "KV namespace not found"
|
||||
|
||||
- Make sure you created the KV namespace: `wrangler kv:namespace create DOCS_MONITOR_KV`
|
||||
- Verify the `id` in `wrangler.toml` is correct
|
||||
- Redeploy after changing config: `wrangler deploy`
|
||||
|
||||
### Update worker after changes
|
||||
|
||||
```bash
|
||||
# After editing index.js or wrangler.toml
|
||||
wrangler deploy
|
||||
|
||||
# View deployment status
|
||||
wrangler deployments list
|
||||
```
|
||||
|
||||
## 💰 Costs
|
||||
|
||||
**Completely FREE** in Cloudflare's free tier:
|
||||
|
||||
- ✅ Workers: 100,000 requests/day
|
||||
- ✅ Cron Triggers: Unlimited
|
||||
- ✅ KV Storage: 100k reads/day, 1k writes/day
|
||||
- ✅ CPU Time: 10ms/request (sufficient)
|
||||
|
||||
For this use case (~4 executions/day):
|
||||
- **Requests**: 4/day → 0.004% of limit
|
||||
- **KV Writes**: 4/day → 0.4% of limit
|
||||
- **KV Reads**: 4/day → 0.004% of limit
|
||||
|
||||
**Cost: $0.00/month** 🎉
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- ✅ Secrets stored securely in Cloudflare (via CLI)
|
||||
- ✅ Trigger endpoint protected with token
|
||||
- ✅ No sensitive information in logs
|
||||
- ✅ HTTPS by default for all communications
|
||||
- ✅ No dashboard access needed - everything via CLI
|
||||
|
||||
## 📚 Resources
|
||||
|
||||
- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/)
|
||||
- [Wrangler CLI Docs](https://developers.cloudflare.com/workers/wrangler/)
|
||||
- [Telegram Bot API](https://core.telegram.org/bots/api)
|
||||
- [Cron Expression Generator](https://crontab.guru/)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
This worker is part of the [claude-code-templates](https://github.com/danipower/claude-code-templates) project.
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for the Claude Code community**
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Cloudflare Worker: Claude Code Docs Monitor
|
||||
*
|
||||
* Monitors https://code.claude.com/docs for changes and sends Telegram notifications
|
||||
* Runs on Cloudflare Workers with Cron Triggers
|
||||
* Uses Cloudflare KV for state storage
|
||||
*
|
||||
* Error tracking: set SENTRY_DSN (wrangler secret put SENTRY_DSN) to report
|
||||
* failures to the "aitmpl-workers" Sentry project, in addition to the
|
||||
* existing Telegram error notification. Optional — degrades gracefully.
|
||||
*/
|
||||
|
||||
import { reportError, checkIn } from './sentry.js';
|
||||
|
||||
export default {
|
||||
async scheduled(event, env, ctx) {
|
||||
console.log('🔍 Starting docs monitoring check...');
|
||||
|
||||
const checkInId = await checkIn(env, 'docs-monitor', 'in_progress');
|
||||
|
||||
try {
|
||||
// 1. Fetch the documentation page
|
||||
const response = await fetch('https://code.claude.com/docs', {
|
||||
headers: {
|
||||
'User-Agent': 'Claude-Code-Docs-Monitor/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch docs: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// 2. Extract main content (remove scripts, styles, etc for cleaner hash)
|
||||
const cleanContent = cleanHTML(html);
|
||||
|
||||
// 3. Calculate hash of current content
|
||||
const currentHash = await hashContent(cleanContent);
|
||||
|
||||
// 4. Get previous hash from KV storage
|
||||
const previousHash = await env.DOCS_MONITOR_KV.get('last_hash');
|
||||
const lastChecked = await env.DOCS_MONITOR_KV.get('last_checked');
|
||||
|
||||
console.log('Previous hash:', previousHash || 'none');
|
||||
console.log('Current hash:', currentHash);
|
||||
|
||||
// 5. Check if content has changed - only notify on changes
|
||||
if (!previousHash) {
|
||||
// First run - just store the hash, no notification
|
||||
await env.DOCS_MONITOR_KV.put('last_hash', currentHash);
|
||||
await env.DOCS_MONITOR_KV.put('last_checked', new Date().toISOString());
|
||||
console.log('✅ First run - hash stored, monitoring started');
|
||||
} else if (currentHash !== previousHash) {
|
||||
// Changes detected - send notification
|
||||
console.log('🔔 Change detected! Sending notification...');
|
||||
|
||||
const notificationData = {
|
||||
type: 'change_detected',
|
||||
previousHash,
|
||||
currentHash,
|
||||
lastChecked,
|
||||
url: 'https://code.claude.com/docs'
|
||||
};
|
||||
|
||||
const notificationSent = await sendTelegramNotification(env, notificationData);
|
||||
|
||||
if (notificationSent) {
|
||||
// Update state in KV
|
||||
await env.DOCS_MONITOR_KV.put('last_hash', currentHash);
|
||||
await env.DOCS_MONITOR_KV.put('last_checked', new Date().toISOString());
|
||||
await env.DOCS_MONITOR_KV.put('last_change', new Date().toISOString());
|
||||
|
||||
console.log('✅ Change notification sent and state updated');
|
||||
} else {
|
||||
console.error('❌ Failed to send notification');
|
||||
}
|
||||
} else {
|
||||
// No changes - just update last checked time, no notification
|
||||
console.log('✅ No changes detected - no notification sent');
|
||||
await env.DOCS_MONITOR_KV.put('last_checked', new Date().toISOString());
|
||||
}
|
||||
|
||||
await checkIn(env, 'docs-monitor', 'ok', checkInId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in docs monitor:', error);
|
||||
|
||||
await reportError(env, error, { worker: 'claude-docs-monitor' });
|
||||
await checkIn(env, 'docs-monitor', 'error', checkInId);
|
||||
|
||||
// Send error notification to Telegram
|
||||
await sendTelegramNotification(env, {
|
||||
error: error.message,
|
||||
url: 'https://code.claude.com/docs'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Optional: HTTP endpoint for manual triggering
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Manual trigger endpoint
|
||||
if (url.pathname === '/trigger' && request.method === 'POST') {
|
||||
// Verify secret token
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (authHeader !== `Bearer ${env.TRIGGER_SECRET}`) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// Trigger the scheduled function manually
|
||||
await this.scheduled(null, env, ctx);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Manual check triggered'
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Status endpoint
|
||||
if (url.pathname === '/status') {
|
||||
const lastHash = await env.DOCS_MONITOR_KV.get('last_hash');
|
||||
const lastChecked = await env.DOCS_MONITOR_KV.get('last_checked');
|
||||
const lastChange = await env.DOCS_MONITOR_KV.get('last_change');
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
status: 'running',
|
||||
lastHash: lastHash ? lastHash.substring(0, 8) + '...' : null,
|
||||
lastChecked,
|
||||
lastChange: lastChange || 'Never',
|
||||
monitoredUrl: 'https://code.claude.com/docs'
|
||||
}), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Claude Code Docs Monitor Worker\n\nEndpoints:\n- POST /trigger (requires auth)\n- GET /status', {
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean HTML content for more stable hashing
|
||||
* Removes dynamic content like timestamps, analytics, etc.
|
||||
*/
|
||||
function cleanHTML(html) {
|
||||
// Remove common dynamic elements
|
||||
let clean = html;
|
||||
|
||||
// Remove script tags
|
||||
clean = clean.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
|
||||
// Remove style tags
|
||||
clean = clean.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
|
||||
|
||||
// Remove comments
|
||||
clean = clean.replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
// Remove common analytics/tracking attributes
|
||||
clean = clean.replace(/data-analytics-[^=]*="[^"]*"/gi, '');
|
||||
clean = clean.replace(/data-timestamp="[^"]*"/gi, '');
|
||||
|
||||
// Normalize whitespace
|
||||
clean = clean.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA-256 hash of content
|
||||
*/
|
||||
async function hashContent(content) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(content);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to Telegram
|
||||
*/
|
||||
async function sendTelegramNotification(env, data) {
|
||||
const botToken = env.TELEGRAM_BOT_TOKEN;
|
||||
const chatId = env.TELEGRAM_CHAT_ID;
|
||||
|
||||
if (!botToken || !chatId) {
|
||||
console.error('Missing Telegram credentials');
|
||||
return false;
|
||||
}
|
||||
|
||||
let message;
|
||||
const now = new Date().toLocaleString('en-US');
|
||||
|
||||
if (data.error) {
|
||||
// Error notification
|
||||
message = `🚨 *Claude Code Docs Monitor Error*\n\n` +
|
||||
`❌ Error: \`${data.error}\`\n` +
|
||||
`🔗 URL: ${data.url}\n` +
|
||||
`⏰ Time: ${now}`;
|
||||
} else if (data.type === 'first_run') {
|
||||
// First run notification
|
||||
message = `🎯 *Claude Code Docs Monitor Started!*\n\n` +
|
||||
`✅ Monitoring initialized successfully\n\n` +
|
||||
`🔗 URL: [code.claude.com/docs](${data.url})\n` +
|
||||
`📝 Initial hash: \`${data.currentHash.substring(0, 12)}...\`\n` +
|
||||
`⏰ Started: ${now}\n` +
|
||||
`🔄 Checking every 5 minutes\n\n` +
|
||||
`You'll receive notifications on every check:\n` +
|
||||
`• 🔔 Changes detected\n` +
|
||||
`• ✅ No changes (status update)`;
|
||||
} else if (data.type === 'change_detected') {
|
||||
// Change detected notification
|
||||
const lastCheckedDate = data.lastChecked ? new Date(data.lastChecked).toLocaleString('en-US') : 'Unknown';
|
||||
|
||||
message = `🔔 *CHANGE DETECTED!*\n\n` +
|
||||
`📍 **Changed URL:**\n` +
|
||||
`${data.url}\n\n` +
|
||||
`✅ Content was updated\n\n` +
|
||||
`📊 **Details:**\n` +
|
||||
`🔗 Full URL: [code.claude.com/docs](${data.url})\n` +
|
||||
`📝 Previous hash: \`${data.previousHash.substring(0, 12)}...\`\n` +
|
||||
`📝 New hash: \`${data.currentHash.substring(0, 12)}...\`\n` +
|
||||
`⏰ Last check: ${lastCheckedDate}\n` +
|
||||
`📅 Change detected: ${now}\n\n` +
|
||||
`👉 [View Documentation](${data.url})`;
|
||||
} else if (data.type === 'no_changes') {
|
||||
// No changes notification
|
||||
const lastCheckedDate = data.lastChecked ? new Date(data.lastChecked).toLocaleString('en-US') : 'Unknown';
|
||||
|
||||
message = `✅ *Docs Monitor - No Changes*\n\n` +
|
||||
`📊 Status update: No changes detected\n\n` +
|
||||
`🔗 URL: [code.claude.com/docs](${data.url})\n` +
|
||||
`📝 Current hash: \`${data.currentHash.substring(0, 12)}...\`\n` +
|
||||
`⏰ Last check: ${lastCheckedDate}\n` +
|
||||
`📅 Current check: ${now}\n` +
|
||||
`🔄 Next check: ~5 minutes\n\n` +
|
||||
`Everything is stable 👍`;
|
||||
}
|
||||
|
||||
try {
|
||||
const telegramUrl = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
const response = await fetch(telegramUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: message,
|
||||
parse_mode: 'Markdown',
|
||||
disable_web_page_preview: false
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.ok) {
|
||||
console.error('Telegram API error:', result);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Telegram notification sent successfully');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending Telegram notification:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+1584
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "claude-docs-monitor",
|
||||
"version": "1.0.0",
|
||||
"description": "Cloudflare Worker to monitor Claude Code documentation for changes",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"tail": "wrangler tail",
|
||||
"test": "wrangler dev --test-scheduled",
|
||||
"kv:create": "wrangler kv:namespace create DOCS_MONITOR_KV",
|
||||
"secret:telegram-bot": "wrangler secret put TELEGRAM_BOT_TOKEN",
|
||||
"secret:telegram-chat": "wrangler secret put TELEGRAM_CHAT_ID",
|
||||
"secret:trigger": "wrangler secret put TRIGGER_SECRET"
|
||||
},
|
||||
"keywords": [
|
||||
"cloudflare-workers",
|
||||
"monitoring",
|
||||
"telegram",
|
||||
"documentation",
|
||||
"claude-code"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.91.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Minimal Sentry error reporter for Cloudflare Workers (no npm dependency).
|
||||
*
|
||||
* Sends errors directly to the Sentry envelope API via fetch(), matching this
|
||||
* project's convention of single-file, zero-dependency Workers.
|
||||
*
|
||||
* Secret required (wrangler secret put SENTRY_DSN):
|
||||
* SENTRY_DSN — DSN from the "aitmpl-workers" Sentry project
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a Sentry DSN into the pieces needed to build the envelope endpoint.
|
||||
* DSN format: https://<publicKey>@<host>/<projectId>
|
||||
*/
|
||||
function parseDsn(dsn) {
|
||||
try {
|
||||
const url = new URL(dsn);
|
||||
return {
|
||||
publicKey: url.username,
|
||||
host: url.host,
|
||||
projectId: url.pathname.replace(/^\//, ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error to Sentry. Safe to call even if SENTRY_DSN is not configured
|
||||
* (no-op) or if the request to Sentry itself fails (never throws).
|
||||
*
|
||||
* @param {object} env - Worker env bindings (needs env.SENTRY_DSN)
|
||||
* @param {Error|string} error - The error (or message) to report
|
||||
* @param {object} [context] - Extra tags/context, e.g. { cron, endpoint, status }
|
||||
*/
|
||||
async function reportError(env, error, context = {}) {
|
||||
const dsn = env && env.SENTRY_DSN;
|
||||
if (!dsn) {
|
||||
console.error('[sentry] SENTRY_DSN not configured, skipping report:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDsn(dsn);
|
||||
if (!parsed) {
|
||||
console.error('[sentry] invalid SENTRY_DSN, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stacktrace = error instanceof Error && error.stack ? error.stack : undefined;
|
||||
const eventId = crypto.randomUUID().replace(/-/g, '');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const event = {
|
||||
event_id: eventId,
|
||||
timestamp,
|
||||
platform: 'javascript',
|
||||
logger: 'cloudflare-worker',
|
||||
environment: 'production',
|
||||
tags: { worker: context.worker || 'unknown', ...context.tags },
|
||||
extra: context,
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
type: error instanceof Error ? error.name || 'Error' : 'Error',
|
||||
value: message,
|
||||
stacktrace: stacktrace ? { frames: [{ filename: 'worker', function: 'anonymous', context_line: stacktrace }] } : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const envelopeHeader = JSON.stringify({ event_id: eventId, sent_at: timestamp });
|
||||
const itemHeader = JSON.stringify({ type: 'event' });
|
||||
const body = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(event)}\n`;
|
||||
|
||||
const endpoint = `https://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
||||
const authHeader = `Sentry sentry_version=7, sentry_client=aitmpl-worker/1.0, sentry_key=${parsed.publicKey}`;
|
||||
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
'X-Sentry-Auth': authHeader,
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch (sendError) {
|
||||
// Never let error reporting break the worker itself
|
||||
console.error('[sentry] failed to send report:', sendError.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Sentry Cron Monitor check-in.
|
||||
* @param {object} env - Worker env bindings (needs env.SENTRY_DSN)
|
||||
* @param {string} monitorSlug - Slug configured in Sentry (Crons > Add Monitor)
|
||||
* @param {'in_progress'|'ok'|'error'} status
|
||||
* @param {string} [checkInId] - Pass the id returned from the 'in_progress' call
|
||||
* so 'ok'/'error' updates the same check-in.
|
||||
* @returns {Promise<string|undefined>} checkInId
|
||||
*/
|
||||
async function checkIn(env, monitorSlug, status, checkInId) {
|
||||
const dsn = env && env.SENTRY_DSN;
|
||||
if (!dsn) return undefined;
|
||||
|
||||
const parsed = parseDsn(dsn);
|
||||
if (!parsed) return undefined;
|
||||
|
||||
const id = checkInId || crypto.randomUUID().replace(/-/g, '');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const envelopeHeader = JSON.stringify({ sent_at: timestamp });
|
||||
const itemPayload = { check_in_id: id, monitor_slug: monitorSlug, status };
|
||||
const itemHeader = JSON.stringify({ type: 'check_in' });
|
||||
const body = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(itemPayload)}\n`;
|
||||
|
||||
const endpoint = `https://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
||||
const authHeader = `Sentry sentry_version=7, sentry_client=aitmpl-worker/1.0, sentry_key=${parsed.publicKey}`;
|
||||
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
'X-Sentry-Auth': authHeader,
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch (sendError) {
|
||||
console.error('[sentry] failed to send check-in:', sendError.message);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export { reportError, checkIn };
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Setup script for Claude Docs Monitor Cloudflare Worker
|
||||
# Makes deployment easier with interactive prompts (100% CLI)
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Claude Code Docs Monitor - Setup Script"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Check if wrangler is installed
|
||||
if ! command -v wrangler &> /dev/null; then
|
||||
echo "❌ Wrangler CLI not found"
|
||||
echo "📦 Installing wrangler..."
|
||||
npm install -g wrangler
|
||||
fi
|
||||
|
||||
echo "✅ Wrangler CLI found"
|
||||
echo ""
|
||||
|
||||
# Check if logged in
|
||||
echo "🔐 Checking Cloudflare authentication..."
|
||||
if ! wrangler whoami &> /dev/null; then
|
||||
echo "📝 Please login to Cloudflare (via CLI)"
|
||||
wrangler login
|
||||
else
|
||||
echo "✅ Already authenticated"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Install dependencies
|
||||
echo "📦 Installing dependencies..."
|
||||
npm install
|
||||
echo ""
|
||||
|
||||
# Create KV namespace if needed
|
||||
echo "🗄️ Setting up KV namespace (via CLI)..."
|
||||
echo "Do you need to create a KV namespace? (y/n)"
|
||||
read -r create_kv
|
||||
|
||||
if [ "$create_kv" = "y" ]; then
|
||||
echo "Creating KV namespace via CLI..."
|
||||
wrangler kv:namespace create DOCS_MONITOR_KV
|
||||
echo ""
|
||||
echo "⚠️ IMPORTANT: Copy the 'id' from above and paste it in wrangler.toml"
|
||||
echo "Press Enter when you've updated wrangler.toml..."
|
||||
read -r
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Configure secrets (via CLI)
|
||||
echo "🔑 Configuring secrets via CLI..."
|
||||
echo ""
|
||||
|
||||
echo "1️⃣ Telegram Bot Token"
|
||||
echo " Get it from @BotFather on Telegram"
|
||||
echo " Do you want to set it now via CLI? (y/n)"
|
||||
read -r set_bot_token
|
||||
|
||||
if [ "$set_bot_token" = "y" ]; then
|
||||
wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "2️⃣ Telegram Chat ID"
|
||||
echo " Get it from @userinfobot on Telegram"
|
||||
echo " Do you want to set it now via CLI? (y/n)"
|
||||
read -r set_chat_id
|
||||
|
||||
if [ "$set_chat_id" = "y" ]; then
|
||||
wrangler secret put TELEGRAM_CHAT_ID
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "3️⃣ Trigger Secret (optional)"
|
||||
echo " For manual trigger endpoint security"
|
||||
echo " Do you want to set it now via CLI? (y/n)"
|
||||
read -r set_trigger_secret
|
||||
|
||||
if [ "$set_trigger_secret" = "y" ]; then
|
||||
wrangler secret put TRIGGER_SECRET
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Deploy
|
||||
echo "🚀 Ready to deploy via CLI!"
|
||||
echo "Deploy now? (y/n)"
|
||||
read -r deploy_now
|
||||
|
||||
if [ "$deploy_now" = "y" ]; then
|
||||
npm run deploy
|
||||
echo ""
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "📊 Next steps (all via CLI):"
|
||||
echo "1. Check status: curl https://claude-docs-monitor.YOUR-USER.workers.dev/status"
|
||||
echo "2. View logs: npm run tail (or: wrangler tail)"
|
||||
echo "3. List secrets: wrangler secret list"
|
||||
echo "4. View deployments: wrangler deployments list"
|
||||
else
|
||||
echo "Skipping deployment. Run 'npm run deploy' when ready."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✨ Setup complete! Check README.md for more CLI commands."
|
||||
@@ -0,0 +1,46 @@
|
||||
# Cloudflare Worker Configuration
|
||||
# Claude Code Docs Monitor
|
||||
|
||||
name = "claude-docs-monitor"
|
||||
main = "index.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
# Cron Triggers - Check for docs changes every 1 hour
|
||||
# Cron syntax: minute hour day month day-of-week
|
||||
# "0 * * * *" = Every hour at minute 0 (24 requests/day - 0.024% of free tier)
|
||||
# "*/5 * * * *" = Every 5 minutes (288 requests/day - 0.3% of free tier)
|
||||
# "0 */6 * * *" = Every 6 hours (4 requests/day)
|
||||
[triggers]
|
||||
crons = ["0 * * * *"]
|
||||
|
||||
# KV Namespace for storing state (hash, timestamps)
|
||||
# You need to create this KV namespace in Cloudflare Dashboard first:
|
||||
# 1. Go to Workers & Pages > KV
|
||||
# 2. Create namespace: "DOCS_MONITOR_KV"
|
||||
# 3. Copy the ID and paste it below
|
||||
[[kv_namespaces]]
|
||||
binding = "DOCS_MONITOR_KV"
|
||||
id = "a1cee9792e9c4d3ba3e728b0e07eed06"
|
||||
|
||||
# Environment variables (secrets)
|
||||
# Set these using: wrangler secret put <KEY>
|
||||
# Required secrets:
|
||||
# - TELEGRAM_BOT_TOKEN: Your Telegram bot token from @BotFather
|
||||
# - TELEGRAM_CHAT_ID: Your Telegram chat ID (can be user ID or group ID)
|
||||
# - TRIGGER_SECRET: Secret token for manual trigger endpoint (optional)
|
||||
|
||||
# Example of setting secrets:
|
||||
# wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
# wrangler secret put TELEGRAM_CHAT_ID
|
||||
# wrangler secret put TRIGGER_SECRET
|
||||
|
||||
# Optional (error tracking):
|
||||
# - SENTRY_DSN: DSN from the "aitmpl-workers" Sentry project. Also configure a
|
||||
# Cron Monitor in Sentry with slug "docs-monitor" to get alerted if this
|
||||
# check stops running entirely.
|
||||
# wrangler secret put SENTRY_DSN
|
||||
|
||||
# Optional: Route configuration if you want a custom domain
|
||||
# [routes]
|
||||
# pattern = "docs-monitor.yourdomain.com/*"
|
||||
# zone_name = "yourdomain.com"
|
||||
@@ -0,0 +1,83 @@
|
||||
# Newsletter — Weekly Community Components Email
|
||||
|
||||
Cloudflare Worker that composes and sends a simple weekly email featuring
|
||||
trending components (one Skill, Agent, MCP, Hook and Setting per send, in
|
||||
that fixed order) as a [Resend](https://resend.com) **Broadcast**.
|
||||
|
||||
- **Rotating selection**: each category pick is weighted-random by recent
|
||||
downloads — popular components appear more often, but every send differs.
|
||||
- **Rotating copy**: subjects, catalog intro, per-component sentences, stat
|
||||
phrasing and closers are drawn from pools, so no two emails read the same.
|
||||
- **Minimal formatting**: plain-text body plus a simple HTML version (bold +
|
||||
underlined component titles, clickable links, no styling beyond that).
|
||||
- **Unsubscribe built-in**: the body carries `{{{RESEND_UNSUBSCRIBE_URL}}}`;
|
||||
Resend replaces it per recipient, hosts the unsubscribe page, and skips
|
||||
unsubscribed contacts on future broadcasts automatically.
|
||||
- **Replies** go to `NEWSLETTER_REPLY_TO`.
|
||||
- **Tracking**: open/click tracking enabled on the `aitmpl.com` domain with
|
||||
tracking subdomain `track.aitmpl.com`. Per-broadcast metrics (delivered,
|
||||
opens, clicks per link) at resend.com/broadcasts.
|
||||
- Data sources: `https://www.aitmpl.com/trending-data.json` and
|
||||
`/components.json` (static dashboard assets).
|
||||
|
||||
## Safety gate
|
||||
|
||||
The broadcast targets the segment in the `RESEND_SEGMENT_ID` secret. Point it
|
||||
at a small pilot segment for tests, or the full-audience segment for
|
||||
community-wide sends. Nothing outside that segment can receive the email.
|
||||
|
||||
## Schedule
|
||||
|
||||
Cron: Sundays 16:00 UTC (`0 16 * * SUN`). Note: the Cloudflare account's free
|
||||
plan allows 5 cron triggers; this slot was freed by decommissioning the
|
||||
`claude-docs-monitor` worker (2026-07).
|
||||
|
||||
## Endpoints
|
||||
|
||||
```bash
|
||||
# Preview the generated email WITHOUT sending (hit repeatedly to see rotation)
|
||||
curl "https://aitmpl-newsletter.<subdomain>.workers.dev/preview?format=text" \
|
||||
-H "Authorization: Bearer $TRIGGER_SECRET"
|
||||
|
||||
# Dry run (full runner, no send)
|
||||
curl -X POST "https://aitmpl-newsletter.<subdomain>.workers.dev/trigger?send=false" \
|
||||
-H "Authorization: Bearer $TRIGGER_SECRET"
|
||||
|
||||
# Real send: creates + sends a Broadcast to RESEND_SEGMENT_ID
|
||||
curl -X POST "https://aitmpl-newsletter.<subdomain>.workers.dev/trigger" \
|
||||
-H "Authorization: Bearer $TRIGGER_SECRET"
|
||||
|
||||
# Health
|
||||
curl "https://aitmpl-newsletter.<subdomain>.workers.dev/status"
|
||||
```
|
||||
|
||||
## Development & Deploy
|
||||
|
||||
```bash
|
||||
cd cloudflare-workers/newsletter
|
||||
npm run dev # Local dev (http://localhost:8787)
|
||||
npx wrangler deploy # Deploy
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Public vars live in `wrangler.toml` `[vars]`: `DASHBOARD_URL`,
|
||||
`RESEND_FROM_EMAIL`.
|
||||
|
||||
Secrets (via `wrangler secret put <KEY>`):
|
||||
|
||||
| Secret | Purpose |
|
||||
|---|---|
|
||||
| `RESEND_API_KEY` | Resend **full access** API key (broadcasts + segments) |
|
||||
| `RESEND_SEGMENT_ID` | Segment the broadcast targets (pilot or full audience) |
|
||||
| `NEWSLETTER_REPLY_TO` | Reply-to address for the newsletter |
|
||||
| `TRIGGER_SECRET` | Auth for `/trigger` and `/preview` |
|
||||
| `SENTRY_DSN` | Optional — error reporting + cron check-ins (`newsletter-weekly` monitor slug) |
|
||||
|
||||
## Growing the audience
|
||||
|
||||
Contacts are loaded from Clerk (production instance) into Resend segments.
|
||||
The pilot batches were loaded with an ad-hoc script (Clerk API → `resend
|
||||
contacts create` + `add-segment`); the full-audience sync follows the same
|
||||
pattern over all users. Resend excludes unsubscribed/bounced contacts
|
||||
automatically.
|
||||
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Cloudflare Worker: Newsletter — Weekly Community Components Email
|
||||
*
|
||||
* Picks trending components (Skills, Agents, MCPs, Hooks, Settings) with
|
||||
* weighted randomness, composes a simple email whose wording rotates on
|
||||
* every send, and delivers it as a Resend Broadcast to the segment in
|
||||
* RESEND_SEGMENT_ID. Resend injects the per-recipient unsubscribe link
|
||||
* ({{{RESEND_UNSUBSCRIBE_URL}}} in the body) and manages the suppression
|
||||
* list automatically. Replies go to NEWSLETTER_REPLY_TO.
|
||||
*
|
||||
* The segment is the safety gate: point RESEND_SEGMENT_ID at a test segment
|
||||
* (single recipient) while iterating, and at the full-audience segment when
|
||||
* going community-wide.
|
||||
*
|
||||
* Zero npm dependencies, matching the other workers in this directory.
|
||||
*
|
||||
* Error tracking: set SENTRY_DSN (wrangler secret put SENTRY_DSN) to report
|
||||
* failures to the "aitmpl-workers" Sentry project. Optional — the worker
|
||||
* degrades gracefully (console.error only) if it's not configured.
|
||||
*/
|
||||
|
||||
import { reportError, checkIn } from './sentry.js';
|
||||
|
||||
const RESEND_API = 'https://api.resend.com';
|
||||
const MONITOR_SLUG = 'newsletter-weekly';
|
||||
|
||||
// Component categories featured in every email (one pick per category).
|
||||
// urlType matches the dashboard detail route: /component/<urlType>/<cleanPath>
|
||||
const CATEGORIES = [
|
||||
{ key: 'skills', label: 'Skill', flag: '--skill', urlType: 'skill' },
|
||||
{ key: 'agents', label: 'Agent', flag: '--agent', urlType: 'agent' },
|
||||
{ key: 'mcps', label: 'MCP', flag: '--mcp', urlType: 'mcp' },
|
||||
{ key: 'hooks', label: 'Hook', flag: '--hook', urlType: 'hook' },
|
||||
{ key: 'settings', label: 'Setting', flag: '--setting', urlType: 'setting' },
|
||||
];
|
||||
|
||||
// ─── Entry Points ────────────────────────────────────────────────────────────
|
||||
|
||||
export default {
|
||||
async scheduled(event, env, ctx) {
|
||||
console.log('📬 Newsletter: starting weekly send (cron)...');
|
||||
// dedupe guards against Cloudflare's documented occasional cron double-fire;
|
||||
// manual /trigger sends skip it so pilots/tests can send multiple times a day.
|
||||
await runNewsletter(env, { send: true, dedupe: true });
|
||||
},
|
||||
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const authorized = () => {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
return env.TRIGGER_SECRET && authHeader === `Bearer ${env.TRIGGER_SECRET}`;
|
||||
};
|
||||
|
||||
// Manual trigger (real send unless ?send=false)
|
||||
if (url.pathname === '/trigger' && request.method === 'POST') {
|
||||
if (!authorized()) return jsonResponse({ error: 'Unauthorized' }, 401);
|
||||
const send = url.searchParams.get('send') !== 'false';
|
||||
try {
|
||||
const result = await runNewsletter(env, { send });
|
||||
return jsonResponse(result);
|
||||
} catch (error) {
|
||||
return jsonResponse({ success: false, error: error.message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Content preview — composes the email without ever calling Resend.
|
||||
// Hit it repeatedly to see the wording/selection rotate.
|
||||
if (url.pathname === '/preview' && request.method === 'GET') {
|
||||
if (!authorized()) return jsonResponse({ error: 'Unauthorized' }, 401);
|
||||
try {
|
||||
const { subject, text, html } = await buildEmail(env);
|
||||
if (url.searchParams.get('format') === 'text') {
|
||||
return new Response(`Subject: ${subject}\n\n${text}`, {
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ subject, text, html });
|
||||
} catch (error) {
|
||||
return jsonResponse({ success: false, error: error.message }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/status') {
|
||||
return jsonResponse({
|
||||
status: 'running',
|
||||
worker: 'aitmpl-newsletter',
|
||||
schedule: 'Sundays 16:00 UTC (Resend Broadcast to RESEND_SEGMENT_ID)',
|
||||
categories: CATEGORIES.map((c) => c.key),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
'Newsletter — Weekly Community Components Email\n\nEndpoints:\n- POST /trigger (requires auth, ?send=false for dry run)\n- GET /preview (requires auth, ?format=text for plain text)\n- GET /status',
|
||||
{ headers: { 'Content-Type': 'text/plain' } }
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Runner ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function runNewsletter(env, opts = {}) {
|
||||
const { send = true, dedupe = false } = opts;
|
||||
const checkInId = await checkIn(env, MONITOR_SLUG, 'in_progress');
|
||||
|
||||
try {
|
||||
if (send && dedupe && (await broadcastAlreadySentToday(env))) {
|
||||
console.log('Newsletter: broadcast for today already exists, skipping (cron double-fire guard)');
|
||||
await checkIn(env, MONITOR_SLUG, 'ok', checkInId);
|
||||
return { success: true, skipped: 'broadcast for today already sent' };
|
||||
}
|
||||
|
||||
const { subject, text, html, picks } = await buildEmail(env);
|
||||
|
||||
let delivery = { sent: false };
|
||||
if (send) {
|
||||
delivery = await sendBroadcast(env, { subject, text, html }, { dedupe });
|
||||
}
|
||||
|
||||
await checkIn(env, MONITOR_SLUG, 'ok', checkInId);
|
||||
return {
|
||||
success: true,
|
||||
subject,
|
||||
text,
|
||||
picks: picks.map((p) => `${p.type}: ${p.name}`),
|
||||
delivery,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Newsletter failed:', error);
|
||||
await reportError(env, error, { worker: 'aitmpl-newsletter' });
|
||||
await checkIn(env, MONITOR_SLUG, 'error', checkInId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Data ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'aitmpl-newsletter/1.0' } });
|
||||
if (!res.ok) throw new Error(`Fetch failed ${res.status}: ${url}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function buildEmail(env) {
|
||||
const base = (env.DASHBOARD_URL || 'https://www.aitmpl.com').replace(/\/$/, '');
|
||||
const [trendingData, catalog] = await Promise.all([
|
||||
fetchJson(`${base}/trending-data.json`),
|
||||
fetchJson(`${base}/components.json`),
|
||||
]);
|
||||
|
||||
const picks = selectComponents(trendingData.trending, catalog, base);
|
||||
if (picks.length === 0) throw new Error('No components could be selected from trending data');
|
||||
|
||||
const { subject, text, html } = composeEmail(picks);
|
||||
return { subject, text, html, picks };
|
||||
}
|
||||
|
||||
/**
|
||||
* For each category, pick ONE component with probability proportional to its
|
||||
* recent downloads — popular components show up more often, but every send
|
||||
* can surface something different.
|
||||
*/
|
||||
function selectComponents(trending, catalog, base) {
|
||||
const picks = [];
|
||||
for (const cat of CATEGORIES) {
|
||||
const pool = (trending?.[cat.key] || []).filter((i) => i && i.name);
|
||||
if (pool.length === 0) continue;
|
||||
|
||||
// Occasionally weight by monthly downloads instead of weekly, so the mix
|
||||
// between "spiking now" and "steady favorite" also rotates.
|
||||
const statKey = Math.random() < 0.7 ? 'downloadsWeek' : 'downloadsMonth';
|
||||
const item = pickWeighted(pool, statKey);
|
||||
|
||||
const record = findInCatalog(catalog, cat.key, item);
|
||||
const path = installPath(record, item);
|
||||
picks.push({
|
||||
type: cat.label,
|
||||
flag: cat.flag,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
description: cleanDescription(record?.description),
|
||||
installPath: path,
|
||||
url: `${base}/component/${cat.urlType}/${path}`,
|
||||
downloadsToday: item.downloadsToday || 0,
|
||||
downloadsWeek: item.downloadsWeek || 0,
|
||||
downloadsMonth: item.downloadsMonth || 0,
|
||||
downloadsTotal: item.downloadsTotal || 0,
|
||||
});
|
||||
}
|
||||
return picks;
|
||||
}
|
||||
|
||||
function pickWeighted(items, statKey) {
|
||||
const weights = items.map((i) => Math.max(1, Number(i[statKey]) || 0));
|
||||
const total = weights.reduce((s, w) => s + w, 0);
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
r -= weights[i];
|
||||
if (r <= 0) return items[i];
|
||||
}
|
||||
return items[items.length - 1];
|
||||
}
|
||||
|
||||
function findInCatalog(catalog, typeKey, item) {
|
||||
const list = catalog?.[typeKey] || [];
|
||||
return (
|
||||
list.find((c) => c.name === item.name && c.category === item.category) ||
|
||||
list.find((c) => c.name === item.name) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors getInstallCommand in dashboard/src/lib/data.ts — the install path is
|
||||
// the catalog `path` without its extension (e.g. "git-workflow/smart-commit").
|
||||
function installPath(record, item) {
|
||||
if (record?.path) return record.path.replace(/\.(md|json)$/, '');
|
||||
if (item.category) return `${item.category}/${item.name}`;
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function cleanDescription(desc) {
|
||||
if (!desc) return '';
|
||||
let d = String(desc).trim().replace(/^["']+|["']+$/g, '').replace(/\s+/g, ' ');
|
||||
// Keep it to one sentence-ish line.
|
||||
const firstStop = d.indexOf('. ');
|
||||
if (firstStop > 40) d = d.slice(0, firstStop + 1);
|
||||
if (d.length > 160) d = d.slice(0, 157).replace(/\s+\S*$/, '').replace(/[,;:]$/, '') + '...';
|
||||
if (!/[.!?]$/.test(d)) d += '.';
|
||||
return d;
|
||||
}
|
||||
|
||||
// ─── Composition (rotating copy) ─────────────────────────────────────────────
|
||||
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
function formatCount(n) {
|
||||
if (n >= 1000) {
|
||||
const k = n / 1000;
|
||||
return `${k >= 10 ? Math.round(k) : Math.round(k * 10) / 10}K`;
|
||||
}
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const SUBJECTS = [
|
||||
(p) => `Claude Code Templates: this week on aitmpl.com`,
|
||||
(p) => `Claude Code Templates: ${p.length} components people are installing right now`,
|
||||
(p) => `Claude Code Templates: what people are installing this week`,
|
||||
(p) => `Claude Code Templates: ${p[0].name} and ${p.length - 1} more trending components`,
|
||||
(p) => `Claude Code Templates: fresh picks from aitmpl.com`,
|
||||
(p) => `Claude Code Templates: ${p.length} components worth a look`,
|
||||
(p) => `Claude Code Templates: the components people kept installing this week`,
|
||||
];
|
||||
|
||||
const GREETINGS = ['Hey!', 'Hi there,', 'Hello!', 'Hey,', 'Hi!'];
|
||||
|
||||
// The intro is two short paragraphs: Daniel introduces himself, then frames
|
||||
// the growing component catalog. The catalog line rotates.
|
||||
const INTRO_PRESENTATIONS = [
|
||||
'Daniel from aitmpl.com here (on X: https://x.com/dani_avila7). You signed up on the site a while back, and I figured you would like to see what people are up to.',
|
||||
];
|
||||
|
||||
const INTRO_CATALOGS = [
|
||||
"aitmpl.com keeps growing: over 2,000 Claude Code components and counting. Here's a quick pick of what people have been installing lately:",
|
||||
'New components land on aitmpl.com every week. These are the ones people have been installing the most lately:',
|
||||
'The catalog on aitmpl.com keeps getting bigger, so here goes a short list of what has been popular these days:',
|
||||
];
|
||||
|
||||
// Per-component sentence builders: (description, statPhrase) => line
|
||||
const ITEM_TEMPLATES = [
|
||||
(d, s) => (d ? `${d} It picked up ${s}.` : `Picked up ${s}.`),
|
||||
(d, s) => (d ? `${ucFirst(s)} and counting. ${d}` : `${ucFirst(s)} and counting.`),
|
||||
(d, s) => (d ? `${d} (${s})` : `(${s})`),
|
||||
(d, s) => (d ? `A community favorite right now with ${s}. ${d}` : `A community favorite right now with ${s}.`),
|
||||
(d, s) => (d ? `${d} Devs gave it ${s}.` : `Devs gave it ${s}.`),
|
||||
(d, s) => (d ? `${ucFirst(s)} for this one. ${d}` : `${ucFirst(s)} for this one.`),
|
||||
];
|
||||
|
||||
function ucFirst(s) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function statPhrase(p) {
|
||||
const options = [];
|
||||
if (p.downloadsWeek > 0) options.push(`${formatCount(p.downloadsWeek)} downloads this week`);
|
||||
if (p.downloadsMonth > 0) options.push(`${formatCount(p.downloadsMonth)} installs this month`);
|
||||
if (p.downloadsTotal > 0) options.push(`over ${formatCount(p.downloadsTotal)} installs all-time`);
|
||||
if (options.length === 0) options.push('a steady stream of installs');
|
||||
return pick(options);
|
||||
}
|
||||
|
||||
const CLOSERS = [
|
||||
"That's it for this week. Try one, break nothing, ship faster.",
|
||||
'See you next week with a fresh batch.',
|
||||
"That's the roundup. Happy building with Claude Code.",
|
||||
'More next week. Keep building.',
|
||||
"That's all for now. Until next week!",
|
||||
];
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Turn bare URLs into anchors (skips trailing punctuation like the ")" in
|
||||
// "(https://x.com/dani_avila7),"). Input must already be HTML-escaped.
|
||||
function autolink(s) {
|
||||
return s.replace(/https?:\/\/[^\s<]*[^\s<.,)!?]/g, (url) => `<a href="${url}">${url}</a>`);
|
||||
}
|
||||
|
||||
function composeEmail(picks) {
|
||||
// Category order is fixed (Skill, Agent, MCP, Hook, Setting) — picks arrive
|
||||
// already in CATEGORIES order.
|
||||
const subject = pick(SUBJECTS)(picks);
|
||||
const greeting = pick(GREETINGS);
|
||||
const presentation = pick(INTRO_PRESENTATIONS);
|
||||
const catalogLine = pick(INTRO_CATALOGS);
|
||||
const closer = pick(CLOSERS);
|
||||
const footerNote = 'You are receiving this because you have an account on aitmpl.com.';
|
||||
// Resend replaces this placeholder with a per-recipient unsubscribe link
|
||||
// when the email is sent as a Broadcast.
|
||||
const unsubscribe = 'Unsubscribe: {{{RESEND_UNSUBSCRIBE_URL}}}';
|
||||
|
||||
const blocks = picks.map((p) => ({
|
||||
title: `${p.type}: ${p.name}`,
|
||||
line: pick(ITEM_TEMPLATES)(p.description, statPhrase(p)),
|
||||
url: p.url,
|
||||
install: `npx claude-code-templates@latest ${p.flag} ${p.installPath}`,
|
||||
}));
|
||||
|
||||
// Plain-text version: blank lines between every element for phone readability.
|
||||
const text = [
|
||||
greeting,
|
||||
'',
|
||||
presentation,
|
||||
'',
|
||||
catalogLine,
|
||||
'',
|
||||
...blocks.flatMap((b) => [b.title, b.line, '', `Check it out: ${b.url}`, '', `Install: ${b.install}`, '']),
|
||||
closer,
|
||||
'',
|
||||
footerNote,
|
||||
unsubscribe,
|
||||
].join('\n');
|
||||
|
||||
// HTML version: same content, minimal markup — bold + underlined component
|
||||
// titles, real links, no styling beyond that.
|
||||
const para = (s) => `<p>${autolink(escapeHtml(s)).replace(/\n/g, '<br>\n')}</p>`;
|
||||
const html = [
|
||||
para(greeting),
|
||||
para(presentation),
|
||||
para(catalogLine),
|
||||
...blocks.map(
|
||||
(b) =>
|
||||
`<p><strong><u>${escapeHtml(b.title)}</u></strong><br>\n${autolink(escapeHtml(b.line))}</p>\n` +
|
||||
`<p>Check it out: <a href="${escapeHtml(b.url)}">${escapeHtml(b.url)}</a></p>\n` +
|
||||
`<p>Install: <code>${escapeHtml(b.install)}</code></p>`
|
||||
),
|
||||
para(closer),
|
||||
`<p>${escapeHtml(footerNote)}<br>\n<a href="{{{RESEND_UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>`,
|
||||
].join('\n');
|
||||
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// ─── Delivery (Resend) ───────────────────────────────────────────────────────
|
||||
|
||||
function todayBroadcastName() {
|
||||
return `newsletter ${new Date().toISOString().slice(0, 10)}`;
|
||||
}
|
||||
|
||||
// All broadcasts (any status) named `name`. Throws on listing errors so
|
||||
// callers decide whether to fail open or closed.
|
||||
async function listBroadcastsByName(env, name) {
|
||||
const res = await fetch(`${RESEND_API}/broadcasts`, {
|
||||
headers: { Authorization: `Bearer ${env.RESEND_API_KEY}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resend broadcast list failed (${res.status})`);
|
||||
const { data } = await res.json();
|
||||
return (data || []).filter((b) => b.name === name);
|
||||
}
|
||||
|
||||
// True if a non-draft broadcast named "newsletter <today>" already exists.
|
||||
// Catches sequential cron double-fires cheaply. Fails open: a listing error
|
||||
// must not block the legitimate weekly send.
|
||||
async function broadcastAlreadySentToday(env) {
|
||||
try {
|
||||
return (await listBroadcastsByName(env, todayBroadcastName())).some((b) => b.status !== 'draft');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBroadcast(env, id) {
|
||||
try {
|
||||
await fetch(`${RESEND_API}/broadcasts/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${env.RESEND_API_KEY}` },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Newsletter: failed to delete duplicate draft broadcast ${id}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a Broadcast targeting env.RESEND_SEGMENT_ID and sends it. Resend
|
||||
// injects the per-recipient unsubscribe link and skips unsubscribed contacts.
|
||||
//
|
||||
// With opts.dedupe (cron path), the create+send is split into a stateless
|
||||
// leader election to close the read-then-create race of two overlapping cron
|
||||
// invocations: each run creates its broadcast as a DRAFT, waits a settle
|
||||
// period so any concurrent run's draft becomes visible, then lists today's
|
||||
// broadcasts — if another run already sent, or a concurrent draft with a
|
||||
// smaller id exists, this run deletes its own draft and yields. Only the
|
||||
// deterministic winner calls /send, so the audience can never receive the
|
||||
// newsletter twice. (Workers KV offers no compare-and-set, so it cannot
|
||||
// provide a stronger lock than this without adding Durable Objects.)
|
||||
async function sendBroadcast(env, { subject, text, html }, opts = {}) {
|
||||
if (!env.RESEND_API_KEY) throw new Error('RESEND_API_KEY is not configured');
|
||||
if (!env.RESEND_FROM_EMAIL) throw new Error('RESEND_FROM_EMAIL is not configured');
|
||||
if (!env.RESEND_SEGMENT_ID) throw new Error('RESEND_SEGMENT_ID is not configured');
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const name = todayBroadcastName();
|
||||
|
||||
const createRes = await fetch(`${RESEND_API}/broadcasts`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
segment_id: env.RESEND_SEGMENT_ID,
|
||||
from: env.RESEND_FROM_EMAIL,
|
||||
reply_to: env.NEWSLETTER_REPLY_TO || undefined,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
name,
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) {
|
||||
const body = await createRes.text();
|
||||
throw new Error(`Resend broadcast create failed (${createRes.status}): ${body}`);
|
||||
}
|
||||
const { id } = await createRes.json();
|
||||
|
||||
if (opts.dedupe) {
|
||||
// Settle so a concurrent double-fire's draft is visible before electing.
|
||||
await new Promise((r) => setTimeout(r, 10_000));
|
||||
try {
|
||||
const peers = await listBroadcastsByName(env, name);
|
||||
const someoneSent = peers.some((b) => b.id !== id && b.status !== 'draft');
|
||||
// Only drafts created within the overlap window are election candidates.
|
||||
// An older draft belongs to a run that died before cleaning up — yielding
|
||||
// to it would mean nobody sends today (its owner is gone), so it is
|
||||
// ignored. Unparseable timestamps count as fresh (safe: worst case is an
|
||||
// extra yield, and send-failure cleanup below makes stale drafts rare).
|
||||
const now = Date.now();
|
||||
const isFresh = (b) => {
|
||||
const t = Date.parse(String(b.created_at || '').replace(' ', 'T').replace(/\+00$/, 'Z'));
|
||||
return Number.isNaN(t) || now - t < 5 * 60_000;
|
||||
};
|
||||
const candidateIds = peers
|
||||
.filter((b) => b.status === 'draft' && (b.id === id || isFresh(b)))
|
||||
.map((b) => b.id)
|
||||
.sort();
|
||||
const winner = candidateIds[0];
|
||||
if (someoneSent || (winner && winner !== id)) {
|
||||
console.log(`Newsletter: concurrent duplicate detected, yielding (mine=${id}, winner=${winner || 'already sent'})`);
|
||||
await deleteBroadcast(env, id);
|
||||
return { sent: false, skipped: 'concurrent duplicate detected', broadcastId: id };
|
||||
}
|
||||
} catch (e) {
|
||||
// Fail open: an election listing error must not block the weekly send.
|
||||
console.error('Newsletter: dedupe election listing failed, proceeding to send:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const sendRes = await fetch(`${RESEND_API}/broadcasts/${id}/send`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
const body = await sendRes.text();
|
||||
// Clean up the draft so it cannot win future elections that nobody sends,
|
||||
// and so a retry starts from a clean slate. If the send actually went
|
||||
// through despite the error response, the broadcast is no longer a draft
|
||||
// and this DELETE fails harmlessly.
|
||||
await deleteBroadcast(env, id);
|
||||
throw new Error(`Resend broadcast send failed for broadcast ${id} (${sendRes.status}), draft cleaned up: ${body}`);
|
||||
}
|
||||
|
||||
return { sent: true, broadcastId: id };
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function jsonResponse(data, status = 200) {
|
||||
return new Response(JSON.stringify(data, null, 2), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "aitmpl-newsletter",
|
||||
"version": "1.0.0",
|
||||
"description": "Cloudflare Worker — Weekly community components email via Resend",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"tail": "wrangler tail",
|
||||
"secret:resend": "wrangler secret put RESEND_API_KEY",
|
||||
"secret:trigger": "wrangler secret put TRIGGER_SECRET",
|
||||
"secret:segment": "wrangler secret put RESEND_SEGMENT_ID",
|
||||
"secret:reply-to": "wrangler secret put NEWSLETTER_REPLY_TO",
|
||||
"secret:sentry": "wrangler secret put SENTRY_DSN"
|
||||
},
|
||||
"keywords": [
|
||||
"cloudflare-workers",
|
||||
"resend",
|
||||
"newsletter",
|
||||
"email"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.91.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Minimal Sentry error reporter for Cloudflare Workers (no npm dependency).
|
||||
*
|
||||
* Sends errors directly to the Sentry envelope API via fetch(), matching this
|
||||
* project's convention of single-file, zero-dependency Workers.
|
||||
*
|
||||
* Secret required (wrangler secret put SENTRY_DSN):
|
||||
* SENTRY_DSN — DSN from the "aitmpl-workers" Sentry project
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a Sentry DSN into the pieces needed to build the envelope endpoint.
|
||||
* DSN format: https://<publicKey>@<host>/<projectId>
|
||||
*/
|
||||
function parseDsn(dsn) {
|
||||
try {
|
||||
const url = new URL(dsn);
|
||||
return {
|
||||
publicKey: url.username,
|
||||
host: url.host,
|
||||
projectId: url.pathname.replace(/^\//, ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error to Sentry. Safe to call even if SENTRY_DSN is not configured
|
||||
* (no-op) or if the request to Sentry itself fails (never throws).
|
||||
*
|
||||
* @param {object} env - Worker env bindings (needs env.SENTRY_DSN)
|
||||
* @param {Error|string} error - The error (or message) to report
|
||||
* @param {object} [context] - Extra tags/context, e.g. { cron, endpoint, status }
|
||||
*/
|
||||
async function reportError(env, error, context = {}) {
|
||||
const dsn = env && env.SENTRY_DSN;
|
||||
if (!dsn) {
|
||||
console.error('[sentry] SENTRY_DSN not configured, skipping report:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDsn(dsn);
|
||||
if (!parsed) {
|
||||
console.error('[sentry] invalid SENTRY_DSN, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stacktrace = error instanceof Error && error.stack ? error.stack : undefined;
|
||||
const eventId = crypto.randomUUID().replace(/-/g, '');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const event = {
|
||||
event_id: eventId,
|
||||
timestamp,
|
||||
platform: 'javascript',
|
||||
logger: 'cloudflare-worker',
|
||||
environment: 'production',
|
||||
tags: { worker: context.worker || 'unknown', ...context.tags },
|
||||
extra: context,
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
type: error instanceof Error ? error.name || 'Error' : 'Error',
|
||||
value: message,
|
||||
stacktrace: stacktrace ? { frames: [{ filename: 'worker', function: 'anonymous', context_line: stacktrace }] } : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const envelopeHeader = JSON.stringify({ event_id: eventId, sent_at: timestamp });
|
||||
const itemHeader = JSON.stringify({ type: 'event' });
|
||||
const body = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(event)}\n`;
|
||||
|
||||
const endpoint = `https://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
||||
const authHeader = `Sentry sentry_version=7, sentry_client=aitmpl-worker/1.0, sentry_key=${parsed.publicKey}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
'X-Sentry-Auth': authHeader,
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Surface invalid DSNs, auth failures, and rate limiting in worker logs
|
||||
console.error(`[sentry] report rejected (${res.status}):`, await res.text().catch(() => ''));
|
||||
}
|
||||
} catch (sendError) {
|
||||
// Never let error reporting break the worker itself
|
||||
console.error('[sentry] failed to send report:', sendError.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Sentry Cron Monitor check-in.
|
||||
* @param {object} env - Worker env bindings (needs env.SENTRY_DSN)
|
||||
* @param {string} monitorSlug - Slug configured in Sentry (Crons > Add Monitor)
|
||||
* @param {'in_progress'|'ok'|'error'} status
|
||||
* @param {string} [checkInId] - Pass the id returned from the 'in_progress' call
|
||||
* so 'ok'/'error' updates the same check-in.
|
||||
* @returns {Promise<string|undefined>} checkInId
|
||||
*/
|
||||
async function checkIn(env, monitorSlug, status, checkInId) {
|
||||
const dsn = env && env.SENTRY_DSN;
|
||||
if (!dsn) return undefined;
|
||||
|
||||
const parsed = parseDsn(dsn);
|
||||
if (!parsed) return undefined;
|
||||
|
||||
const id = checkInId || crypto.randomUUID().replace(/-/g, '');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const envelopeHeader = JSON.stringify({ sent_at: timestamp });
|
||||
const itemPayload = { check_in_id: id, monitor_slug: monitorSlug, status };
|
||||
const itemHeader = JSON.stringify({ type: 'check_in' });
|
||||
const body = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(itemPayload)}\n`;
|
||||
|
||||
const endpoint = `https://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
||||
const authHeader = `Sentry sentry_version=7, sentry_client=aitmpl-worker/1.0, sentry_key=${parsed.publicKey}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
'X-Sentry-Auth': authHeader,
|
||||
},
|
||||
body,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`[sentry] check-in rejected (${res.status}):`, await res.text().catch(() => ''));
|
||||
}
|
||||
} catch (sendError) {
|
||||
console.error('[sentry] failed to send check-in:', sendError.message);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export { reportError, checkIn };
|
||||
@@ -0,0 +1,31 @@
|
||||
# Cloudflare Worker Configuration
|
||||
# Newsletter — Weekly Community Components Email (via Resend Broadcasts)
|
||||
|
||||
name = "aitmpl-newsletter"
|
||||
main = "index.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
# Cron Trigger — Sundays 16:00 UTC. The account's 5-cron-trigger limit (free
|
||||
# plan) had this slot taken by claude-docs-monitor, which was decommissioned
|
||||
# (2026-07) to make room for the newsletter.
|
||||
# The broadcast goes to the segment in RESEND_SEGMENT_ID, which acts as the
|
||||
# safety gate: keep it pointed at a pilot segment until the full-audience
|
||||
# segment is ready.
|
||||
[triggers]
|
||||
crons = ["0 16 * * SUN"]
|
||||
|
||||
[vars]
|
||||
# Public deploy-time values (not secrets)
|
||||
DASHBOARD_URL = "https://www.aitmpl.com"
|
||||
RESEND_FROM_EMAIL = "Daniel Avila <daniel.avila@aitmpl.com>"
|
||||
|
||||
# Secrets (set via: wrangler secret put <KEY>)
|
||||
# Required:
|
||||
# - RESEND_API_KEY: Resend FULL ACCESS API key (broadcasts + segments)
|
||||
# - RESEND_SEGMENT_ID: Resend segment the broadcast targets (test or full audience)
|
||||
# - NEWSLETTER_REPLY_TO: reply-to address for the newsletter
|
||||
# - TRIGGER_SECRET: Secret for manual /trigger and /preview endpoints
|
||||
#
|
||||
# Optional (error tracking):
|
||||
# - SENTRY_DSN: DSN from the "aitmpl-workers" Sentry project. Also configure a
|
||||
# Cron Monitor in Sentry with slug "newsletter-weekly".
|
||||
@@ -0,0 +1,15 @@
|
||||
# Wrangler
|
||||
.wrangler/
|
||||
dist/
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.dev.vars
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,121 @@
|
||||
# Pulse — Weekly KPI Report (Cloudflare Worker)
|
||||
|
||||
Collects metrics from GitHub, Discord, Supabase, Vercel, and Google Analytics every Sunday at 14:00 UTC and sends a consolidated report via Telegram.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
cd cloudflare-workers/pulse
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure secrets
|
||||
|
||||
Set each secret using `wrangler secret put`:
|
||||
|
||||
```bash
|
||||
# Telegram (same bot/chat as docs-monitor)
|
||||
wrangler secret put TELEGRAM_BOT_TOKEN
|
||||
wrangler secret put TELEGRAM_CHAT_ID
|
||||
|
||||
# GitHub
|
||||
wrangler secret put GITHUB_TOKEN # PAT with public_repo scope
|
||||
|
||||
# Supabase
|
||||
wrangler secret put SUPABASE_URL # https://xxx.supabase.co
|
||||
wrangler secret put SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
# Discord
|
||||
wrangler secret put DISCORD_BOT_TOKEN
|
||||
wrangler secret put DISCORD_GUILD_ID
|
||||
|
||||
# Vercel
|
||||
wrangler secret put VERCEL_TOKEN # Personal access token
|
||||
wrangler secret put VERCEL_PROJECT_ID # aitmpl project ID
|
||||
|
||||
# Manual trigger auth
|
||||
wrangler secret put TRIGGER_SECRET
|
||||
|
||||
# Optional — Google Analytics (add later)
|
||||
wrangler secret put GA_PROPERTY_ID
|
||||
wrangler secret put GA_SERVICE_ACCOUNT_JSON # Base64-encoded service account JSON
|
||||
```
|
||||
|
||||
### 3. Deploy
|
||||
|
||||
```bash
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Automatic (Cron)
|
||||
|
||||
Runs every Sunday at 14:00 UTC (11:00 AM Chile). No action needed after deploy.
|
||||
|
||||
### Manual trigger
|
||||
|
||||
```bash
|
||||
# Full report
|
||||
curl -X POST https://pulse-weekly-report.YOUR_SUBDOMAIN.workers.dev/trigger \
|
||||
-H "Authorization: Bearer YOUR_TRIGGER_SECRET"
|
||||
|
||||
# Single source only
|
||||
curl -X POST "https://pulse-weekly-report.YOUR_SUBDOMAIN.workers.dev/trigger?source=github" \
|
||||
-H "Authorization: Bearer YOUR_TRIGGER_SECRET"
|
||||
|
||||
# Dry run (don't send to Telegram)
|
||||
curl -X POST "https://pulse-weekly-report.YOUR_SUBDOMAIN.workers.dev/trigger?send=false" \
|
||||
-H "Authorization: Bearer YOUR_TRIGGER_SECRET"
|
||||
```
|
||||
|
||||
### Status
|
||||
|
||||
```bash
|
||||
curl https://pulse-weekly-report.YOUR_SUBDOMAIN.workers.dev/status
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm run dev # Start local dev server
|
||||
npm run test # Test cron trigger locally
|
||||
```
|
||||
|
||||
## Report format
|
||||
|
||||
```
|
||||
📊 PULSE — Weekly Report
|
||||
📅 Jan 25, 2026 - Jan 31, 2026
|
||||
|
||||
⭐ GITHUB
|
||||
├ Stars: 1,234 (+45)
|
||||
├ Forks: 156 (+8)
|
||||
├ Issues: 12 open (3 new, 2 closed)
|
||||
└ PRs: 5 opened, 4 merged
|
||||
|
||||
💬 DISCORD
|
||||
├ Members: 890 (+23)
|
||||
├ Active: ~145
|
||||
└ Messages: 312
|
||||
|
||||
📦 DOWNLOADS
|
||||
├ Total: 45,678 (+1,234)
|
||||
├ Top: frontend-developer (89)
|
||||
├ By type: agents 65% | commands 20% | settings 15%
|
||||
└ Countries: US 40% | DE 15% | BR 10% | UK 8% | CL 5%
|
||||
|
||||
🚀 VERCEL
|
||||
├ Deploys: 12 (11 ✅ 1 ❌)
|
||||
└ Latest: 2h ago ✅
|
||||
|
||||
📈 ANALYTICS
|
||||
├ Visitors: 3,456
|
||||
├ Pageviews: 12,345
|
||||
├ Top: / (4,567) | /agents (2,345) | /commands (1,234)
|
||||
└ Referrers: google (45%) | github (30%) | direct (15%)
|
||||
```
|
||||
|
||||
Sources that fail gracefully show `⚠️ Unavailable` instead.
|
||||
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* Cloudflare Worker: Pulse — Weekly KPI Report
|
||||
*
|
||||
* Collects metrics from GitHub, Discord, Supabase, npm, and Google Analytics,
|
||||
* then sends a consolidated report via Telegram every Sunday at 14:00 UTC.
|
||||
*
|
||||
* All source collectors are in this single file (no npm dependencies).
|
||||
*
|
||||
* Error tracking: set SENTRY_DSN (wrangler secret put SENTRY_DSN) to report
|
||||
* failed collectors to the "aitmpl-workers" Sentry project. Optional — the
|
||||
* worker degrades gracefully (console.error only) if it's not configured.
|
||||
*/
|
||||
|
||||
import { reportError, checkIn } from './sentry.js';
|
||||
|
||||
const REPO = 'davila7/claude-code-templates';
|
||||
const NPM_PACKAGE = 'claude-code-templates';
|
||||
const GITHUB_API = 'https://api.github.com';
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
const NPM_API = 'https://api.npmjs.org';
|
||||
const GA4_API = 'https://analyticsdata.googleapis.com/v1beta';
|
||||
const TELEGRAM_API = 'https://api.telegram.org';
|
||||
const MAX_MESSAGE_LENGTH = 4096;
|
||||
|
||||
// ─── Entry Points ────────────────────────────────────────────────────────────
|
||||
|
||||
export default {
|
||||
async scheduled(event, env, ctx) {
|
||||
console.log('📊 Pulse: Starting weekly report (cron)...');
|
||||
await runReport(env);
|
||||
},
|
||||
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Manual trigger
|
||||
if (url.pathname === '/trigger' && request.method === 'POST') {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!env.TRIGGER_SECRET || authHeader !== `Bearer ${env.TRIGGER_SECRET}`) {
|
||||
return jsonResponse({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const onlySource = url.searchParams.get('source');
|
||||
const sendTelegram = url.searchParams.get('send') !== 'false';
|
||||
|
||||
const result = await runReport(env, { onlySource, sendTelegram });
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
// Status endpoint
|
||||
if (url.pathname === '/status') {
|
||||
return jsonResponse({
|
||||
status: 'running',
|
||||
worker: 'pulse-weekly-report',
|
||||
schedule: 'Sundays 14:00 UTC',
|
||||
sources: ['github', 'discord', 'downloads', 'npm', 'analytics']
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
'Pulse — Weekly KPI Report Worker\n\nEndpoints:\n- POST /trigger (requires auth)\n- GET /status',
|
||||
{ headers: { 'Content-Type': 'text/plain' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Report Runner ───────────────────────────────────────────────────────────
|
||||
|
||||
async function runReport(env, opts = {}) {
|
||||
const { onlySource, sendTelegram = true } = opts;
|
||||
|
||||
const checkInId = await checkIn(env, 'pulse-weekly-report', 'in_progress');
|
||||
|
||||
let results = {};
|
||||
|
||||
if (onlySource) {
|
||||
const collectors = {
|
||||
github: collectGitHub,
|
||||
discord: collectDiscord,
|
||||
downloads: collectDownloads,
|
||||
npm: collectNpm,
|
||||
analytics: collectAnalytics
|
||||
};
|
||||
const collector = collectors[onlySource];
|
||||
if (!collector) {
|
||||
return { error: `Unknown source: ${onlySource}`, available: Object.keys(collectors) };
|
||||
}
|
||||
results[onlySource] = await collector(env);
|
||||
} else {
|
||||
const [github, discord, downloads, npm] = await Promise.all([
|
||||
collectGitHub(env),
|
||||
collectDiscord(env),
|
||||
collectDownloads(env),
|
||||
collectNpm(env)
|
||||
]);
|
||||
results = { github, discord, downloads, npm };
|
||||
}
|
||||
|
||||
// Report any failed collectors to Sentry (they still degrade gracefully
|
||||
// into "Unavailable" in the rendered report — this just makes failures visible).
|
||||
const failedSources = Object.entries(results).filter(([, v]) => v && v.error);
|
||||
for (const [source, value] of failedSources) {
|
||||
await reportError(env, `pulse collector "${source}" failed: ${value.error}`, {
|
||||
worker: 'pulse-weekly-report',
|
||||
source
|
||||
});
|
||||
}
|
||||
|
||||
const reportText = formatReport(results);
|
||||
|
||||
let telegramResult = null;
|
||||
if (sendTelegram) {
|
||||
telegramResult = await sendToTelegram(env, reportText);
|
||||
}
|
||||
|
||||
await checkIn(env, 'pulse-weekly-report', failedSources.length > 0 ? 'error' : 'ok', checkInId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sources: Object.fromEntries(
|
||||
Object.entries(results).map(([k, v]) => [k, v.error ? 'error' : 'ok'])
|
||||
),
|
||||
report: reportText,
|
||||
telegram: sendTelegram ? telegramResult : 'skipped'
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Source: GitHub ──────────────────────────────────────────────────────────
|
||||
|
||||
async function collectGitHub(env) {
|
||||
try {
|
||||
if (!env.GITHUB_TOKEN) return { error: 'GITHUB_TOKEN not configured' };
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `Bearer ${env.GITHUB_TOKEN}`,
|
||||
'User-Agent': 'Pulse-Weekly-Report/1.0'
|
||||
};
|
||||
|
||||
const since = weekAgo();
|
||||
const sinceISO = since.toISOString();
|
||||
|
||||
const [repoRes, issuesRes, prsRes] = await Promise.all([
|
||||
fetchJSON(`${GITHUB_API}/repos/${REPO}`, { headers }),
|
||||
fetchJSON(`${GITHUB_API}/repos/${REPO}/issues?state=all&since=${sinceISO}&per_page=100`, { headers }),
|
||||
fetchJSON(`${GITHUB_API}/repos/${REPO}/pulls?state=all&sort=updated&direction=desc&per_page=100`, { headers })
|
||||
]);
|
||||
|
||||
const repo = repoRes;
|
||||
|
||||
// Filter issues (exclude PRs) created this week
|
||||
const weekIssues = issuesRes.filter(
|
||||
i => !i.pull_request && new Date(i.created_at) >= since
|
||||
);
|
||||
const closedThisWeek = issuesRes.filter(
|
||||
i => !i.pull_request && i.state === 'closed' && i.closed_at && new Date(i.closed_at) >= since
|
||||
);
|
||||
|
||||
// Filter PRs from this week
|
||||
const weekPRs = prsRes.filter(pr => new Date(pr.created_at) >= since);
|
||||
const mergedPRs = prsRes.filter(pr => pr.merged_at && new Date(pr.merged_at) >= since);
|
||||
|
||||
// Count new stars this week — paginate to the last page to get most recent
|
||||
let starsWeek = null;
|
||||
try {
|
||||
const starHeaders = { ...headers, Accept: 'application/vnd.github.v3.star+json' };
|
||||
// HEAD request to get total pages from Link header
|
||||
const headRes = await fetch(`${GITHUB_API}/repos/${REPO}/stargazers?per_page=100`, {
|
||||
method: 'HEAD',
|
||||
headers: starHeaders
|
||||
});
|
||||
const linkHeader = headRes.headers.get('Link') || '';
|
||||
const lastMatch = linkHeader.match(/page=(\d+)>; rel="last"/);
|
||||
const lastPage = lastMatch ? parseInt(lastMatch[1]) : 1;
|
||||
|
||||
// Fetch last few pages to find stars from this week
|
||||
let recentStars = [];
|
||||
for (let page = lastPage; page >= Math.max(1, lastPage - 2); page--) {
|
||||
const pageRes = await fetchJSON(
|
||||
`${GITHUB_API}/repos/${REPO}/stargazers?per_page=100&page=${page}`,
|
||||
{ headers: starHeaders }
|
||||
);
|
||||
recentStars = recentStars.concat(pageRes);
|
||||
}
|
||||
starsWeek = recentStars.filter(s => new Date(s.starred_at) >= since).length;
|
||||
} catch { /* stargazers with timestamps may not always work */ }
|
||||
|
||||
// Estimate new forks this week
|
||||
let forksWeek = null;
|
||||
try {
|
||||
const forkRes = await fetchJSON(
|
||||
`${GITHUB_API}/repos/${REPO}/forks?sort=newest&per_page=100`,
|
||||
{ headers }
|
||||
);
|
||||
forksWeek = forkRes.filter(f => new Date(f.created_at) >= since).length;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return {
|
||||
stars: repo.stargazers_count,
|
||||
starsWeek,
|
||||
forks: repo.forks_count,
|
||||
forksWeek,
|
||||
openIssues: repo.open_issues_count - weekPRs.filter(pr => pr.state === 'open').length,
|
||||
issuesOpenedWeek: weekIssues.length,
|
||||
issuesClosedWeek: closedThisWeek.length,
|
||||
prsOpenedWeek: weekPRs.length,
|
||||
prsMergedWeek: mergedPRs.length
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('GitHub source error:', error.message);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Source: Discord ─────────────────────────────────────────────────────────
|
||||
|
||||
async function collectDiscord(env) {
|
||||
try {
|
||||
if (!env.DISCORD_BOT_TOKEN || !env.DISCORD_GUILD_ID) {
|
||||
return { error: 'DISCORD_BOT_TOKEN or DISCORD_GUILD_ID not configured' };
|
||||
}
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
const guildId = env.DISCORD_GUILD_ID;
|
||||
|
||||
// Fetch guild info with approximate counts
|
||||
const guild = await fetchJSON(
|
||||
`${DISCORD_API}/guilds/${guildId}?with_counts=true`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
const totalMembers = guild.approximate_member_count || 0;
|
||||
const activeMembers = guild.approximate_presence_count || 0;
|
||||
|
||||
// Estimate new members this week from audit log
|
||||
let newMembersWeek = null;
|
||||
try {
|
||||
const since = weekAgo();
|
||||
const auditRes = await fetchJSON(
|
||||
`${DISCORD_API}/guilds/${guildId}/audit-logs?action_type=1&limit=100`,
|
||||
{ headers }
|
||||
);
|
||||
const entries = auditRes.audit_log_entries || [];
|
||||
newMembersWeek = entries.filter(e => {
|
||||
const timestamp = Number(BigInt(e.id) >> 22n) + 1420070400000;
|
||||
return timestamp >= since.getTime();
|
||||
}).length;
|
||||
} catch { /* Audit log access may not be available */ }
|
||||
|
||||
// Count messages in last 7 days across text channels
|
||||
let messagesWeek = null;
|
||||
try {
|
||||
const channels = await fetchJSON(
|
||||
`${DISCORD_API}/guilds/${guildId}/channels`,
|
||||
{ headers }
|
||||
);
|
||||
const textChannels = channels.filter(c => c.type === 0);
|
||||
|
||||
const since = weekAgo();
|
||||
const sinceSnowflake = String((BigInt(since.getTime()) - 1420070400000n) << 22n);
|
||||
|
||||
let total = 0;
|
||||
// Sample up to 10 channels to avoid rate limits
|
||||
for (const ch of textChannels.slice(0, 10)) {
|
||||
try {
|
||||
const msgs = await fetchJSON(
|
||||
`${DISCORD_API}/channels/${ch.id}/messages?after=${sinceSnowflake}&limit=100`,
|
||||
{ headers }
|
||||
);
|
||||
total += msgs.length;
|
||||
} catch { /* Channel may not be accessible */ }
|
||||
}
|
||||
messagesWeek = total;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return { totalMembers, activeMembers, newMembersWeek, messagesWeek };
|
||||
} catch (error) {
|
||||
console.error('Discord source error:', error.message);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Source: Supabase Downloads (PostgREST) ──────────────────────────────────
|
||||
|
||||
async function collectDownloads(env) {
|
||||
try {
|
||||
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
|
||||
return { error: 'SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not configured' };
|
||||
}
|
||||
|
||||
const baseUrl = `${env.SUPABASE_URL}/rest/v1`;
|
||||
const headers = {
|
||||
apikey: env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'count=exact'
|
||||
};
|
||||
|
||||
const since = weekAgo();
|
||||
const sinceISO = since.toISOString();
|
||||
|
||||
// Total downloads all-time (HEAD request with count)
|
||||
const totalRes = await fetch(`${baseUrl}/component_downloads?select=*`, {
|
||||
method: 'HEAD',
|
||||
headers
|
||||
});
|
||||
const totalAllTime = parseInt(totalRes.headers.get('content-range')?.split('/')[1]) || 0;
|
||||
|
||||
// Downloads this week (HEAD request with count + filter)
|
||||
const weekRes = await fetch(
|
||||
`${baseUrl}/component_downloads?select=*&download_timestamp=gte.${sinceISO}`,
|
||||
{ method: 'HEAD', headers }
|
||||
);
|
||||
const totalWeek = parseInt(weekRes.headers.get('content-range')?.split('/')[1]) || 0;
|
||||
|
||||
// Get weekly downloads with component info for top components + type breakdown
|
||||
const weekDownloads = await fetchJSON(
|
||||
`${baseUrl}/component_downloads?select=component_name,component_type&download_timestamp=gte.${sinceISO}&limit=10000`,
|
||||
{ headers: { ...headers, Prefer: '' } }
|
||||
);
|
||||
|
||||
let topComponents = [];
|
||||
let byType = {};
|
||||
if (weekDownloads && weekDownloads.length > 0) {
|
||||
const componentCounts = {};
|
||||
const typeCounts = {};
|
||||
|
||||
for (const row of weekDownloads) {
|
||||
componentCounts[row.component_name] = (componentCounts[row.component_name] || 0) + 1;
|
||||
const t = row.component_type || 'unknown';
|
||||
typeCounts[t] = (typeCounts[t] || 0) + 1;
|
||||
}
|
||||
|
||||
topComponents = Object.entries(componentCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([name, count]) => ({ name, count }));
|
||||
|
||||
const total = weekDownloads.length || 1;
|
||||
for (const [type, count] of Object.entries(typeCounts)) {
|
||||
byType[type] = (count / total) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
// Top 5 countries this week
|
||||
let topCountries = [];
|
||||
const countryData = await fetchJSON(
|
||||
`${baseUrl}/component_downloads?select=country&download_timestamp=gte.${sinceISO}&country=not.is.null&limit=10000`,
|
||||
{ headers: { ...headers, Prefer: '' } }
|
||||
);
|
||||
|
||||
if (countryData && countryData.length > 0) {
|
||||
const countryCounts = {};
|
||||
for (const row of countryData) {
|
||||
countryCounts[row.country] = (countryCounts[row.country] || 0) + 1;
|
||||
}
|
||||
const total = countryData.length || 1;
|
||||
topCountries = Object.entries(countryCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([country, count]) => ({ country, pct: (count / total) * 100 }));
|
||||
}
|
||||
|
||||
return { totalAllTime, totalWeek, topComponents, byType, topCountries };
|
||||
} catch (error) {
|
||||
console.error('Supabase downloads source error:', error.message);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Source: npm Downloads ───────────────────────────────────────────────────
|
||||
|
||||
async function collectNpm(env) {
|
||||
try {
|
||||
const headers = { 'User-Agent': 'Pulse-Weekly-Report/1.0' };
|
||||
|
||||
// Weekly downloads (last 7 days)
|
||||
const weekData = await fetchJSON(
|
||||
`${NPM_API}/downloads/point/last-week/${NPM_PACKAGE}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
// Monthly downloads (last 30 days) for context
|
||||
const monthData = await fetchJSON(
|
||||
`${NPM_API}/downloads/point/last-month/${NPM_PACKAGE}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
// Daily breakdown for the last week (to show trend)
|
||||
const end = new Date();
|
||||
const start = weekAgo();
|
||||
const startStr = start.toISOString().split('T')[0];
|
||||
const endStr = end.toISOString().split('T')[0];
|
||||
const rangeData = await fetchJSON(
|
||||
`${NPM_API}/downloads/range/${startStr}:${endStr}/${NPM_PACKAGE}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
// Find peak day
|
||||
let peakDay = null;
|
||||
let peakDownloads = 0;
|
||||
if (rangeData.downloads) {
|
||||
for (const day of rangeData.downloads) {
|
||||
if (day.downloads > peakDownloads) {
|
||||
peakDownloads = day.downloads;
|
||||
peakDay = day.day;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate daily average
|
||||
const dailyAvg = weekData.downloads > 0
|
||||
? Math.round(weekData.downloads / 7)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
weeklyDownloads: weekData.downloads || 0,
|
||||
monthlyDownloads: monthData.downloads || 0,
|
||||
dailyAvg,
|
||||
peakDay,
|
||||
peakDownloads
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('npm source error:', error.message);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Source: Google Analytics (GA4) ──────────────────────────────────────────
|
||||
|
||||
async function collectAnalytics(env) {
|
||||
try {
|
||||
if (!env.GA_PROPERTY_ID || !env.GA_SERVICE_ACCOUNT_JSON) {
|
||||
return { error: 'GA_PROPERTY_ID or GA_SERVICE_ACCOUNT_JSON not configured' };
|
||||
}
|
||||
|
||||
const accessToken = await getGAAccessToken(env);
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const since = weekAgo();
|
||||
const startDate = since.toISOString().split('T')[0];
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const propertyId = env.GA_PROPERTY_ID;
|
||||
|
||||
const [summaryRes, pagesRes, referrersRes] = await Promise.all([
|
||||
fetchJSON(`${GA4_API}/properties/${propertyId}:runReport`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
dateRanges: [{ startDate, endDate }],
|
||||
metrics: [{ name: 'activeUsers' }, { name: 'screenPageViews' }]
|
||||
})
|
||||
}),
|
||||
fetchJSON(`${GA4_API}/properties/${propertyId}:runReport`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
dateRanges: [{ startDate, endDate }],
|
||||
dimensions: [{ name: 'pagePath' }],
|
||||
metrics: [{ name: 'screenPageViews' }],
|
||||
orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
|
||||
limit: 10
|
||||
})
|
||||
}),
|
||||
fetchJSON(`${GA4_API}/properties/${propertyId}:runReport`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
dateRanges: [{ startDate, endDate }],
|
||||
dimensions: [{ name: 'sessionSource' }],
|
||||
metrics: [{ name: 'sessions' }],
|
||||
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
||||
limit: 10
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
const summaryRow = summaryRes.rows?.[0];
|
||||
const visitors = summaryRow ? parseInt(summaryRow.metricValues[0].value) : 0;
|
||||
const pageviews = summaryRow ? parseInt(summaryRow.metricValues[1].value) : 0;
|
||||
|
||||
const topPages = (pagesRes.rows || []).map(row => ({
|
||||
path: row.dimensionValues[0].value,
|
||||
views: parseInt(row.metricValues[0].value)
|
||||
}));
|
||||
|
||||
const referrerRows = referrersRes.rows || [];
|
||||
const totalSessions = referrerRows.reduce((sum, r) => sum + parseInt(r.metricValues[0].value), 0) || 1;
|
||||
const topReferrers = referrerRows.map(row => ({
|
||||
source: row.dimensionValues[0].value,
|
||||
sessions: parseInt(row.metricValues[0].value),
|
||||
pct: (parseInt(row.metricValues[0].value) / totalSessions) * 100
|
||||
}));
|
||||
|
||||
return { visitors, pageviews, topPages, topReferrers };
|
||||
} catch (error) {
|
||||
console.error('Google Analytics source error:', error.message);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// GA4 JWT auth — build and sign JWT for service account
|
||||
async function getGAAccessToken(env) {
|
||||
const saJson = atob(env.GA_SERVICE_ACCOUNT_JSON);
|
||||
const sa = JSON.parse(saJson);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const headerB64 = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||
const payloadB64 = base64url(JSON.stringify({
|
||||
iss: sa.client_email,
|
||||
scope: 'https://www.googleapis.com/auth/analytics.readonly',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
iat: now,
|
||||
exp: now + 3600
|
||||
}));
|
||||
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
|
||||
// Import RSA private key and sign
|
||||
const pemContents = sa.private_key
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\n/g, '');
|
||||
const keyData = Uint8Array.from(atob(pemContents), c => c.charCodeAt(0));
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyData.buffer,
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', cryptoKey, new TextEncoder().encode(signingInput));
|
||||
const signatureB64 = base64url(signatureBuffer);
|
||||
|
||||
const jwt = `${signingInput}.${signatureB64}`;
|
||||
|
||||
// Exchange JWT for access token
|
||||
const tokenRes = await fetchJSON('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`
|
||||
});
|
||||
|
||||
return tokenRes.access_token;
|
||||
}
|
||||
|
||||
// ─── Report Formatter ────────────────────────────────────────────────────────
|
||||
|
||||
function formatReport(results) {
|
||||
const sections = [];
|
||||
|
||||
sections.push(`📊 Claude Code Templates - Weekly Report\n📅 ${getWeekRange()}`);
|
||||
|
||||
// Downloads
|
||||
if (results.downloads?.error) {
|
||||
sections.push(`DOWNLOADS\n└ Unavailable: ${results.downloads.error}`);
|
||||
} else if (results.downloads) {
|
||||
const dl = results.downloads;
|
||||
const lines = [
|
||||
`DOWNLOADS (aitmpl.com)`,
|
||||
`├ Total: ${fmt(dl.totalAllTime)}${fmtDelta(dl.totalWeek)}`
|
||||
];
|
||||
if (dl.topComponents?.length > 0) {
|
||||
lines.push(`├ Top: ${dl.topComponents[0].name} (${fmt(dl.topComponents[0].count)})`);
|
||||
}
|
||||
if (dl.byType && Object.keys(dl.byType).length > 0) {
|
||||
const types = Object.entries(dl.byType).sort((a, b) => b[1] - a[1]).filter(([, pct]) => pct >= 1);
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
const prefix = (i === types.length - 1 && dl.topComponents?.length === 0) ? '└' : '├';
|
||||
lines.push(`${prefix} ${types[i][0]}: ${fmtPct(types[i][1])}`);
|
||||
}
|
||||
}
|
||||
// fix last item to └
|
||||
if (lines.length > 1) {
|
||||
lines[lines.length - 1] = lines[lines.length - 1].replace('├', '└');
|
||||
}
|
||||
sections.push(lines.join('\n'));
|
||||
}
|
||||
|
||||
// npm
|
||||
if (results.npm?.error) {
|
||||
sections.push(`NPM (npmjs.com)\n└ ⚠️ Unavailable: ${results.npm.error}`);
|
||||
} else if (results.npm) {
|
||||
const n = results.npm;
|
||||
const lines = [
|
||||
`NPM (npmjs.com)`,
|
||||
`├ Weekly: ${fmt(n.weeklyDownloads)}`,
|
||||
`├ Monthly: ${fmt(n.monthlyDownloads)}`,
|
||||
`├ Daily avg: ${fmt(n.dailyAvg)}`
|
||||
];
|
||||
if (n.peakDay) {
|
||||
const peakDate = new Date(n.peakDay);
|
||||
const dayName = peakDate.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
lines.push(`└ Peak: ${dayName} (${fmt(n.peakDownloads)})`);
|
||||
} else {
|
||||
lines[lines.length - 1] = lines[lines.length - 1].replace('├', '└');
|
||||
}
|
||||
sections.push(lines.join('\n'));
|
||||
}
|
||||
|
||||
// GitHub
|
||||
if (results.github?.error) {
|
||||
sections.push(`GITHUB\n└ Unavailable: ${results.github.error}`);
|
||||
} else if (results.github) {
|
||||
const g = results.github;
|
||||
sections.push([
|
||||
`GITHUB`,
|
||||
`├ Stars: ${fmt(g.stars)}${fmtDelta(g.starsWeek)}`,
|
||||
`├ Forks: ${fmt(g.forks)}${fmtDelta(g.forksWeek)}`,
|
||||
`├ Issues: ${fmt(g.openIssues)} open (${fmt(g.issuesOpenedWeek)} new, ${fmt(g.issuesClosedWeek)} closed)`,
|
||||
`└ PRs: ${fmt(g.prsOpenedWeek)} opened, ${fmt(g.prsMergedWeek)} merged`
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
// Discord
|
||||
if (results.discord?.error) {
|
||||
sections.push(`DISCORD\n└ Unavailable: ${results.discord.error}`);
|
||||
} else if (results.discord) {
|
||||
const d = results.discord;
|
||||
sections.push([
|
||||
`DISCORD`,
|
||||
`├ Members: ${fmt(d.totalMembers)}${fmtDelta(d.newMembersWeek)}`,
|
||||
`├ Active: ~${fmt(d.activeMembers)}`,
|
||||
`└ Messages: ${fmt(d.messagesWeek)}`
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
// ─── Telegram Sender ─────────────────────────────────────────────────────────
|
||||
|
||||
async function sendToTelegram(env, text) {
|
||||
const botToken = env.TELEGRAM_BOT_TOKEN;
|
||||
const chatId = env.TELEGRAM_CHAT_ID;
|
||||
|
||||
if (!botToken || !chatId) {
|
||||
throw new Error('Missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID');
|
||||
}
|
||||
|
||||
const messages = splitMessage(text);
|
||||
let sent = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
const response = await fetch(`${TELEGRAM_API}/bot${botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: msg,
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.ok) {
|
||||
console.error('Telegram API error:', JSON.stringify(result));
|
||||
return { success: false, error: result.description };
|
||||
}
|
||||
sent++;
|
||||
}
|
||||
|
||||
console.log(`✅ Telegram: sent ${sent} message(s)`);
|
||||
return { success: true, messagesSent: sent };
|
||||
}
|
||||
|
||||
function splitMessage(text) {
|
||||
if (text.length <= MAX_MESSAGE_LENGTH) return [text];
|
||||
|
||||
const messages = [];
|
||||
const sections = text.split('\n\n');
|
||||
let current = '';
|
||||
|
||||
for (const section of sections) {
|
||||
if ((current + '\n\n' + section).length > MAX_MESSAGE_LENGTH) {
|
||||
if (current) messages.push(current.trim());
|
||||
current = section;
|
||||
} else {
|
||||
current = current ? current + '\n\n' + section : section;
|
||||
}
|
||||
}
|
||||
if (current) messages.push(current.trim());
|
||||
return messages;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchJSON(url, opts = {}) {
|
||||
const response = await fetch(url, opts);
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${body.substring(0, 200)}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function jsonResponse(data, status = 200) {
|
||||
return new Response(JSON.stringify(data, null, 2), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function weekAgo() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 7);
|
||||
return d;
|
||||
}
|
||||
|
||||
function getWeekRange() {
|
||||
const end = new Date();
|
||||
const start = weekAgo();
|
||||
const opts = { month: 'short', day: 'numeric', year: 'numeric' };
|
||||
return `${start.toLocaleDateString('en-US', opts)} - ${end.toLocaleDateString('en-US', opts)}`;
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
if (n == null) return '—';
|
||||
return Number(n).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function fmtDelta(n) {
|
||||
if (n == null) return '';
|
||||
const sign = n >= 0 ? '+' : '';
|
||||
return ` (${sign}${fmt(n)})`;
|
||||
}
|
||||
|
||||
function fmtPct(n) {
|
||||
if (n == null) return '—';
|
||||
return `${Math.round(n)}%`;
|
||||
}
|
||||
|
||||
function base64url(input) {
|
||||
let str;
|
||||
if (input instanceof ArrayBuffer) {
|
||||
str = btoa(String.fromCharCode(...new Uint8Array(input)));
|
||||
} else {
|
||||
str = btoa(input);
|
||||
}
|
||||
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
+1584
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "pulse-weekly-report",
|
||||
"version": "1.0.0",
|
||||
"description": "Cloudflare Worker — Weekly KPI report sent via Telegram",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"tail": "wrangler tail",
|
||||
"test": "wrangler dev --test-scheduled",
|
||||
"secret:telegram-bot": "wrangler secret put TELEGRAM_BOT_TOKEN",
|
||||
"secret:telegram-chat": "wrangler secret put TELEGRAM_CHAT_ID",
|
||||
"secret:github": "wrangler secret put GITHUB_TOKEN",
|
||||
"secret:supabase-url": "wrangler secret put SUPABASE_URL",
|
||||
"secret:supabase-key": "wrangler secret put SUPABASE_SERVICE_ROLE_KEY",
|
||||
"secret:discord-bot": "wrangler secret put DISCORD_BOT_TOKEN",
|
||||
"secret:discord-guild": "wrangler secret put DISCORD_GUILD_ID",
|
||||
"secret:vercel-token": "wrangler secret put VERCEL_TOKEN",
|
||||
"secret:vercel-project": "wrangler secret put VERCEL_PROJECT_ID",
|
||||
"secret:trigger": "wrangler secret put TRIGGER_SECRET"
|
||||
},
|
||||
"keywords": [
|
||||
"cloudflare-workers",
|
||||
"telegram",
|
||||
"kpi",
|
||||
"analytics",
|
||||
"weekly-report"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.91.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Minimal Sentry error reporter for Cloudflare Workers (no npm dependency).
|
||||
*
|
||||
* Sends errors directly to the Sentry envelope API via fetch(), matching this
|
||||
* project's convention of single-file, zero-dependency Workers.
|
||||
*
|
||||
* Secret required (wrangler secret put SENTRY_DSN):
|
||||
* SENTRY_DSN — DSN from the "aitmpl-workers" Sentry project
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a Sentry DSN into the pieces needed to build the envelope endpoint.
|
||||
* DSN format: https://<publicKey>@<host>/<projectId>
|
||||
*/
|
||||
function parseDsn(dsn) {
|
||||
try {
|
||||
const url = new URL(dsn);
|
||||
return {
|
||||
publicKey: url.username,
|
||||
host: url.host,
|
||||
projectId: url.pathname.replace(/^\//, ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an error to Sentry. Safe to call even if SENTRY_DSN is not configured
|
||||
* (no-op) or if the request to Sentry itself fails (never throws).
|
||||
*
|
||||
* @param {object} env - Worker env bindings (needs env.SENTRY_DSN)
|
||||
* @param {Error|string} error - The error (or message) to report
|
||||
* @param {object} [context] - Extra tags/context, e.g. { cron, endpoint, status }
|
||||
*/
|
||||
async function reportError(env, error, context = {}) {
|
||||
const dsn = env && env.SENTRY_DSN;
|
||||
if (!dsn) {
|
||||
console.error('[sentry] SENTRY_DSN not configured, skipping report:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseDsn(dsn);
|
||||
if (!parsed) {
|
||||
console.error('[sentry] invalid SENTRY_DSN, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stacktrace = error instanceof Error && error.stack ? error.stack : undefined;
|
||||
const eventId = crypto.randomUUID().replace(/-/g, '');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const event = {
|
||||
event_id: eventId,
|
||||
timestamp,
|
||||
platform: 'javascript',
|
||||
logger: 'cloudflare-worker',
|
||||
environment: 'production',
|
||||
tags: { worker: context.worker || 'unknown', ...context.tags },
|
||||
extra: context,
|
||||
exception: {
|
||||
values: [
|
||||
{
|
||||
type: error instanceof Error ? error.name || 'Error' : 'Error',
|
||||
value: message,
|
||||
stacktrace: stacktrace ? { frames: [{ filename: 'worker', function: 'anonymous', context_line: stacktrace }] } : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const envelopeHeader = JSON.stringify({ event_id: eventId, sent_at: timestamp });
|
||||
const itemHeader = JSON.stringify({ type: 'event' });
|
||||
const body = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(event)}\n`;
|
||||
|
||||
const endpoint = `https://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
||||
const authHeader = `Sentry sentry_version=7, sentry_client=aitmpl-worker/1.0, sentry_key=${parsed.publicKey}`;
|
||||
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
'X-Sentry-Auth': authHeader,
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch (sendError) {
|
||||
// Never let error reporting break the worker itself
|
||||
console.error('[sentry] failed to send report:', sendError.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Sentry Cron Monitor check-in.
|
||||
* @param {object} env - Worker env bindings (needs env.SENTRY_DSN)
|
||||
* @param {string} monitorSlug - Slug configured in Sentry (Crons > Add Monitor)
|
||||
* @param {'in_progress'|'ok'|'error'} status
|
||||
* @param {string} [checkInId] - Pass the id returned from the 'in_progress' call
|
||||
* so 'ok'/'error' updates the same check-in.
|
||||
* @returns {Promise<string|undefined>} checkInId
|
||||
*/
|
||||
async function checkIn(env, monitorSlug, status, checkInId) {
|
||||
const dsn = env && env.SENTRY_DSN;
|
||||
if (!dsn) return undefined;
|
||||
|
||||
const parsed = parseDsn(dsn);
|
||||
if (!parsed) return undefined;
|
||||
|
||||
const id = checkInId || crypto.randomUUID().replace(/-/g, '');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const envelopeHeader = JSON.stringify({ sent_at: timestamp });
|
||||
const itemPayload = { check_in_id: id, monitor_slug: monitorSlug, status };
|
||||
const itemHeader = JSON.stringify({ type: 'check_in' });
|
||||
const body = `${envelopeHeader}\n${itemHeader}\n${JSON.stringify(itemPayload)}\n`;
|
||||
|
||||
const endpoint = `https://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
||||
const authHeader = `Sentry sentry_version=7, sentry_client=aitmpl-worker/1.0, sentry_key=${parsed.publicKey}`;
|
||||
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-sentry-envelope',
|
||||
'X-Sentry-Auth': authHeader,
|
||||
},
|
||||
body,
|
||||
});
|
||||
} catch (sendError) {
|
||||
console.error('[sentry] failed to send check-in:', sendError.message);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export { reportError, checkIn };
|
||||
@@ -0,0 +1,32 @@
|
||||
# Cloudflare Worker Configuration
|
||||
# Pulse — Weekly KPI Report
|
||||
|
||||
name = "pulse-weekly-report"
|
||||
main = "index.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
# Cron Trigger - Every Sunday at 14:00 UTC (11:00 AM Chile)
|
||||
[triggers]
|
||||
crons = ["0 14 * * SUN"]
|
||||
|
||||
# Secrets (set via: wrangler secret put <KEY>)
|
||||
# Required:
|
||||
# - TELEGRAM_BOT_TOKEN: Telegram bot token (same as docs-monitor)
|
||||
# - TELEGRAM_CHAT_ID: Telegram chat ID (same as docs-monitor)
|
||||
# - GITHUB_TOKEN: GitHub PAT (public_repo scope)
|
||||
# - SUPABASE_URL: Supabase project URL
|
||||
# - SUPABASE_SERVICE_ROLE_KEY: Supabase service role key
|
||||
# - DISCORD_BOT_TOKEN: Discord bot token
|
||||
# - DISCORD_GUILD_ID: Discord server ID
|
||||
# - VERCEL_TOKEN: Vercel personal access token
|
||||
# - VERCEL_PROJECT_ID: Vercel project ID
|
||||
# - TRIGGER_SECRET: Secret for manual /trigger endpoint
|
||||
#
|
||||
# Optional (add later):
|
||||
# - GA_PROPERTY_ID: GA4 property ID
|
||||
# - GA_SERVICE_ACCOUNT_JSON: Base64-encoded service account JSON
|
||||
#
|
||||
# Optional (error tracking):
|
||||
# - SENTRY_DSN: DSN from the "aitmpl-workers" Sentry project. Also configure a
|
||||
# Cron Monitor in Sentry with slug "pulse-weekly-report" to get alerted if
|
||||
# the weekly report stops running entirely.
|
||||
Reference in New Issue
Block a user