chore: import upstream snapshot with attribution
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:58 +08:00
commit bb5c75ce05
8824 changed files with 1946442 additions and 0 deletions
+246
View File
@@ -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**
+91
View File
@@ -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 });
},
};
+138
View File
@@ -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 };
+19
View File
@@ -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]`
+320
View File
@@ -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**
+279
View File
@@ -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;
}
}
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"
}
}
+138
View File
@@ -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 };
+106
View File
@@ -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"
+83
View File
@@ -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.
+502
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 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"
}
}
+145
View File
@@ -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".
+15
View File
@@ -0,0 +1,15 @@
# Wrangler
.wrangler/
dist/
node_modules/
# Environment
.env
.dev.vars
# Logs
*.log
# OS
.DS_Store
Thumbs.db
+121
View File
@@ -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.
+754
View File
@@ -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(/=+$/, '');
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
+138
View File
@@ -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 };
+32
View File
@@ -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.