Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# These are Cloudflare Worker secrets, not a dotenv file.
# Set each one with: npx wrangler secret put <NAME>
# For local development, put the same keys in a .dev.vars file.

TELEGRAM_BOT_TOKEN=
MAIN_CHAT_ID=
NOTIFICATIONS_CHAT_ID=
TRIGGER_SECRET=
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Test
run: npm test
69 changes: 62 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,68 @@
Run locally
# MHMIC Telegram Cron

`npm run dev1`
A Cloudflare Worker that watches the [Fajr Reminders](https://mhmic.org/fajrreminders)
category on mhmic.org and posts each new reminder's audio to a Telegram chat.

and then curl the worker
## How it works

`curl --url http://localhost:8787/`
A cron trigger runs every 10 minutes. Each run fetches the latest post in the
category and compares its publish time against the last one sent, which is kept
in KV under `fr:last-sent-at`. When the post is newer, the worker scrapes the
audio URL from the post page, downloads it, reads its metadata (title, duration,
performer) and uploads it to the chat via the Telegram Bot API. The KV timestamp
is written only after the upload succeeds, so a failed send is retried on the
next tick instead of being silently skipped.

Note: your port might be different
On the very first run against an empty KV namespace the worker records the
current position without sending, so deploying does not re-post a reminder that
has already gone out.

Get telegram chat ID
## Configuration

https://api.telegram.org/bot${BOT_TOKEN}/getUpdates
All four values are Cloudflare secrets — there is no `.env` file in production.
See `.env.example` for the list.

```
npx wrangler secret put TELEGRAM_BOT_TOKEN
npx wrangler secret put MAIN_CHAT_ID
npx wrangler secret put NOTIFICATIONS_CHAT_ID
npx wrangler secret put TRIGGER_SECRET
```

| Secret | Purpose |
| ----------------------- | ------------------------------------------------------------------------- |
| `TELEGRAM_BOT_TOKEN` | Bot token from BotFather |
| `MAIN_CHAT_ID` | Chat that receives the audio |
| `NOTIFICATIONS_CHAT_ID` | Chat that receives error alerts (optional; alerts are skipped when unset) |
| `TRIGGER_SECRET` | Shared token required by the manual HTTP trigger |

To find a chat ID: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates`

For local development, put the same keys in a `.dev.vars` file (git-ignored).

## Development

```
npm install
npm run dev # local worker with scheduled events enabled
npm test # unit tests (vitest, workers pool)
npm run typecheck
npm run deploy
```

`npm run dev` prints the local port; it is usually 8787.

## Manual trigger

The HTTP entrypoint sends the latest reminder on demand. It requires a `POST`
and the shared secret — an unauthenticated request would let anyone post to the
chat and pull a full audio download through the worker.

```
curl -X POST http://localhost:8787/ \
-H "Authorization: Bearer ${TRIGGER_SECRET}"
```

Responses: `200` on success, `401` for a bad or missing token, `405` for a
non-`POST` request, `503` when `TRIGGER_SECRET` is not configured, `500` when the
send itself fails.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"start": "wrangler dev --test-scheduled",
"cf-typegen": "wrangler types",
"build": "wrangler build",
"test": "vitest run"
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.9.1",
Expand Down
128 changes: 77 additions & 51 deletions src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,106 @@
import { getLatestPost, getCountFromWordpress } from './wordpress';
import { env } from 'cloudflare:workers';
import { getLatestPost, getPublishedAt, WordPressPost } from './wordpress';
import { sendTelegramAudio, sendErrorNotification } from './telegram';
import { getHTML, getAudioUrl } from './audio';
import { getCountFromKV, updateKVCount } from './storage';
import { getLastSentAt, setLastSentAt } from './storage';
import { secretsMatch } from './utils';

export const send = async () => {
try {
console.log('Starting send operation');
const post = await getLatestPost('FR');
const html = await getHTML(post.slug);
const audioSrc = getAudioUrl(html);

if (audioSrc) {
await sendTelegramAudio(audioSrc);
} else {
throw new Error('No audio source found');
}
const CATEGORY = 'FR';
const STORAGE_CATEGORY = 'fr';

console.log('Send operation completed successfully');
} catch (error) {
const errorMessage = `Error in send operation: ${error instanceof Error ? error.message : 'Unknown error'}`;
console.error(errorMessage);
await sendErrorNotification(errorMessage);
throw error;
}
const jsonResponse = (body: Record<string, unknown>, status: number) =>
Response.json({ ...body, timestamp: new Date().toISOString() }, { status });

const describeError = (error: unknown) => (error instanceof Error ? error.message : 'Unknown error');

/**
* Sends the audio attached to a post. Errors propagate to the caller, which owns
* error notification, so a single failure is only reported once.
*/
export const send = async (post: WordPressPost) => {
console.log(`Starting send operation for post: ${post.slug}`);

const html = await getHTML(post.slug);
const audioSrc = getAudioUrl(html);

await sendTelegramAudio(audioSrc);

console.log('Send operation completed successfully');
};

export const scheduledHandler = async (event: ScheduledController): Promise<void> => {
try {
console.log(`CRON triggered at ${event.cron}`);
let wasSuccessful = 'NA';

const wordpressCount = await getCountFromWordpress('FR');
const kvCount = await getCountFromKV('fr');
const post = await getLatestPost(CATEGORY);
const publishedAt = getPublishedAt(post);
const lastSentAt = await getLastSentAt(STORAGE_CATEGORY);

console.log(`WordPress count: ${wordpressCount}, KV count: ${kvCount}`);
if (lastSentAt === null) {
// First run against an empty namespace. Record the current position
// rather than sending a reminder that has almost certainly gone out
// already.
await setLastSentAt(STORAGE_CATEGORY, publishedAt);
console.log(`CRON fired at ${event.cron}: seeded last sent timestamp with ${post.slug} (${publishedAt})`);
return;
}

if (wordpressCount > kvCount) {
await send();
// Comparing publish times rather than post counts means a deleted post
// cannot make an older reminder look new.
if (publishedAt <= lastSentAt) {
console.log(`CRON fired at ${event.cron}: no new content (latest is ${post.slug})`);
return;
}

const resp = await updateKVCount('fr', wordpressCount);
wasSuccessful = resp.ok ? 'success' : 'fail';
await send(post);

console.log(`CRON Fired and message sent ${event.cron}`);
} else {
console.log(`CRON Fired and message was NOT sent ${event.cron}`);
wasSuccessful = 'no_new_content';
}
// Recorded only after a successful send, so a failed send is retried on
// the next tick instead of being marked as delivered.
await setLastSentAt(STORAGE_CATEGORY, publishedAt);

console.log(`Trigger fired at ${event.cron}: ${wasSuccessful}`);
console.log(`CRON fired at ${event.cron}: sent ${post.slug}`);
} catch (error) {
const errorMessage = `Error in scheduled handler: ${error instanceof Error ? error.message : 'Unknown error'}`;
const errorMessage = `Error in scheduled handler: ${describeError(error)}`;
console.error(errorMessage);
await sendErrorNotification(errorMessage);
throw error;
}
};

export const fetchHandler = async (request: Request) => {
/**
* Manual trigger. Guarded by a shared secret because an unauthenticated request
* would let anyone post to the chat and pull a full audio download through the
* worker.
*/
export const fetchHandler = async (request: Request): Promise<Response> => {
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405);
}

if (!env.TRIGGER_SECRET) {
console.error('TRIGGER_SECRET is not configured, refusing manual trigger');
return jsonResponse({ error: 'Manual trigger is not configured' }, 503);
}

const provided = (request.headers.get('authorization') || '').replace(/^Bearer\s+/i, '');

if (!secretsMatch(provided, env.TRIGGER_SECRET)) {
return jsonResponse({ error: 'Unauthorized' }, 401);
}

try {
await send();
return Response.json({
message: 'Sent',
timestamp: new Date().toISOString(),
});
const post = await getLatestPost(CATEGORY);
const publishedAt = getPublishedAt(post);

await send(post);
await setLastSentAt(STORAGE_CATEGORY, publishedAt);

return jsonResponse({ message: 'Sent', slug: post.slug }, 200);
} catch (error) {
const errorMessage = `Error in fetch handler: ${error instanceof Error ? error.message : 'Unknown error'}`;
const errorMessage = `Error in fetch handler: ${describeError(error)}`;
console.error(errorMessage);
await sendErrorNotification(errorMessage);

return Response.json(
{
error: 'Failed to process request',
message: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
},
{ status: 500 }
);
return jsonResponse({ error: 'Failed to process request', message: describeError(error) }, 500);
}
};
10 changes: 5 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
/**
* MHMIC Telegram Cron Bot
*
*
* A Cloudflare Worker that automatically sends new Fajr Reminder audio posts
* from the MHMIC website to a Telegram chat when new content is available.
*
*
* Features:
* - Scheduled CRON job to check for new posts
* - Manual trigger via HTTP fetch
* - Manual trigger via authenticated HTTP POST
* - Error notifications to dedicated error chat
* - Modular architecture for maintainability
*/

import { scheduledHandler, fetchHandler } from './handlers';

export default {
// The scheduled handler is invoked at the interval set in our wrangler.toml's
// [[triggers]] configuration.
// The scheduled handler is invoked at the interval set in wrangler.jsonc's
// "triggers.crons" configuration.
async scheduled(event): Promise<void> {
await scheduledHandler(event);
},
Expand Down
65 changes: 37 additions & 28 deletions src/storage.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
import { env } from 'cloudflare:workers';

export const getCountFromKV = async (category: 'fr' | 'jk') => {
try {
console.log(`Fetching count from KV for category: ${category}`);
const countStr = await env.MHMIC_TELEGRAM_BOT.get(category);

if (countStr === null) {
console.log(`No count found for ${category}, initializing to 0`);
await env.MHMIC_TELEGRAM_BOT.put(category, '0');
return parseInt('0');
}

const count = parseInt(countStr);
console.log(`Successfully fetched count from KV for ${category}: ${count}`);
return count;
} catch (error) {
console.error(`Error fetching count from KV for category ${category}:`, error);
return parseInt('0');
export type StorageCategory = 'fr' | 'jk';

const lastSentKey = (category: StorageCategory) => `${category}:last-sent-at`;

/**
* Returns the publish time (epoch ms) of the most recently sent post, or null
* when nothing has been recorded for the category yet.
*
* Read failures propagate instead of defaulting to 0. A transient KV error must
* not look like "nothing has ever been sent", which would re-send the latest
* audio on every cron tick until KV recovered.
*/
export const getLastSentAt = async (category: StorageCategory): Promise<number | null> => {
const key = lastSentKey(category);
console.log(`Fetching last sent timestamp from KV for category: ${category}`);

const stored = await env.MHMIC_TELEGRAM_BOT.get(key);

if (stored === null) {
console.log(`No last sent timestamp found for ${category}`);
return null;
}
};

export const updateKVCount = async (category: 'fr' | 'jk', count: number) => {
try {
console.log(`Updating KV count for ${category} to: ${count}`);
await env.MHMIC_TELEGRAM_BOT.put(category, count.toString());
console.log(`Successfully updated KV count for ${category}`);
return new Response(`count: ${count}`, { status: 200 });
} catch (error) {
console.error(`Error updating KV count for category ${category}:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return new Response(errorMessage, { status: 500 });
const lastSentAt = Number.parseInt(stored, 10);

if (Number.isNaN(lastSentAt)) {
throw new Error(`Corrupt value in KV for ${key}: "${stored}"`);
}

console.log(`Last sent timestamp for ${category}: ${lastSentAt}`);
return lastSentAt;
};

/**
* Records the publish time (epoch ms) of the post that was just sent.
*/
export const setLastSentAt = async (category: StorageCategory, publishedAt: number): Promise<void> => {
console.log(`Updating last sent timestamp for ${category} to: ${publishedAt}`);
await env.MHMIC_TELEGRAM_BOT.put(lastSentKey(category), publishedAt.toString());
console.log(`Successfully updated last sent timestamp for ${category}`);
};
Loading
Loading