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
6 changes: 6 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,9 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

- name: Deploy alerts to Cloudflare Workers
run: pnpm --filter @repo/alerts run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ Use it for project orientation, quality gates, and safe editing workflow.

- Project: switch-operator
- Workspace: pnpm workspaces (`apps/`, `packages/`, `tooling/`)
- App workspaces: `apps/operator` (Cloudflare Worker with Hono)
- Package workspaces: `packages/http-client`, `packages/logger`
- App workspaces: `apps/operator` (Cloudflare Worker with Hono), `apps/browser-scraper` (Cloudflare Worker), `apps/alerts` (scheduled Cloudflare Worker)
- Package workspaces: `packages/http-client`, `packages/logger`, `packages/telegram`, `packages/url-validator`
- Shared tooling configs: `tooling/eslint`, `tooling/prettier`, `tooling/typescript`
- Package manager: `pnpm` (lockfile: `pnpm-lock.yaml`)
- Required Node version: `24.12.0` (from `.nvmrc`)
Expand Down
60 changes: 60 additions & 0 deletions apps/alerts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# @repo/alerts

A non-public, cron-scheduled Cloudflare Worker that runs recurring "alert" tasks
and pushes messages to the user over Telegram (reusing the operator's bot).

Stage 1 ships a single **healthcheck** alert. The worker is built around an
**array of alerts**, each declaring its own cron cadence.

## How it works

- `src/alerts/` holds the alerts. Each implements the `Alert` contract
(`src/alerts/types.ts`): a `name`, a `cron` string, and an async `run(ctx)`
that returns the message to send — or `null` to send nothing this run.
- `src/alerts/index.ts` exports the `alerts` array (the registry).
- `src/scheduled.ts` is the cron handler. On each fire it runs only the alerts
whose `cron` matches `event.cron`, then sends any returned messages to
`ALLOWED_CHAT_ID` via `@repo/telegram`. A failing alert is logged and does not
block the others.
- The worker has no public `fetch` handler and sets `"workers_dev": false`.

## Adding an alert

1. Create `src/alerts/<name>.ts` implementing `Alert`.
2. Add it to the `alerts` array in `src/alerts/index.ts`.
3. **Add its `cron` to `wrangler.jsonc` `triggers.crons`** (the union of all
alert crons). This is the one manual invariant — an alert whose cron is not
registered will never fire.

## Local development

```sh
# from repo root
pnpm --filter @repo/alerts dev # runs `wrangler dev --test-scheduled`
```

`wrangler dev --test-scheduled` exposes a `/__scheduled` endpoint. Trigger a
specific cron manually:

```sh
curl "http://localhost:8787/__scheduled?cron=0+8+*+*+*"
```

Local secrets live in `apps/alerts/.dev.vars` (gitignored):

```
TELEGRAM_BOT_TOKEN=...
ALLOWED_CHAT_ID=...
```

## Secrets & deploy

Production secrets are **not** in `wrangler.jsonc`. Set them once per Cloudflare
environment:

```sh
pnpm --filter @repo/alerts exec wrangler secret put TELEGRAM_BOT_TOKEN
pnpm --filter @repo/alerts exec wrangler secret put ALLOWED_CHAT_ID
```

Deploys run automatically on push to `main` (see `.github/workflows/main.yml`).
5 changes: 5 additions & 0 deletions apps/alerts/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/* eslint-disable import/no-default-export */
import { baseConfig } from "@repo/eslint/base";
import { defineConfig } from "eslint/config";

export default defineConfig(baseConfig);
29 changes: 29 additions & 0 deletions apps/alerts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@repo/alerts",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev --test-scheduled",
"deploy": "wrangler deploy",
"typecheck": "tsc",
"lint": "eslint .",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@repo/logger": "workspace:*",
"@repo/telegram": "workspace:*",
"zod": "4.3.6"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20260412.1",
"@repo/eslint": "workspace:*",
"@repo/prettier": "workspace:*",
"@repo/typescript": "workspace:*",
"@types/node": "25.6.0",
"eslint": "9.39.1",
"prettier": "3.8.2",
"typescript": "6.0.2",
"vitest": "4.1.4",
"wrangler": "4.81.1"
}
}
16 changes: 16 additions & 0 deletions apps/alerts/src/alerts/healthcheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Alert } from "./types";

/**
* Stage-1 alert: a simple daily heartbeat confirming the alerts worker runs on
* schedule. Serves as the reference implementation for future alerts.
*/
const healthcheck: Alert = {
name: "healthcheck",
cron: "0 8 * * *",
run: () => {
const now = new Date().toISOString();
return Promise.resolve(`✅ alerts worker healthy — ${now}`);
},
};

export { healthcheck };
10 changes: 10 additions & 0 deletions apps/alerts/src/alerts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { healthcheck } from "./healthcheck";
import type { Alert } from "./types";

/**
* The registry of alerts. Add new alerts here; remember to also add each
* alert's `cron` to `wrangler.jsonc` `triggers.crons`.
*/
const alerts: Alert[] = [healthcheck];

export { alerts };
26 changes: 26 additions & 0 deletions apps/alerts/src/alerts/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Logger } from "@repo/logger";

import type { Env } from "../types/env";

type AlertContext = {
env: Env;
logger: Logger;
};

type Alert = {
/** Human-readable identifier, used in logs. */
name: string;
/**
* The cron expression this alert runs on. Must also appear in
* `wrangler.jsonc` `triggers.crons`; the scheduled handler only runs alerts
* whose `cron` matches the fired `event.cron`.
*/
cron: string;
/**
* Produce the message to send, or `null` to send nothing this run (e.g. a
* monitor with no change to report).
*/
run: (ctx: AlertContext) => Promise<string | null>;
};

export type { Alert, AlertContext };
6 changes: 6 additions & 0 deletions apps/alerts/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createScheduledHandler } from "./scheduled";

// eslint-disable-next-line import/no-default-export
export default {
scheduled: createScheduledHandler(),
};
48 changes: 48 additions & 0 deletions apps/alerts/src/scheduled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { SendMessageParams } from "@repo/telegram/types";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { handleScheduled } from "./scheduled";

const sendMessage = vi.fn<(params: SendMessageParams) => Promise<unknown>>();

vi.mock("@repo/telegram", () => ({
TelegramService: class {
sendMessage = sendMessage;
},
}));

const env = {
TELEGRAM_BOT_TOKEN: "test-token",
ALLOWED_CHAT_ID: "12345",
};

const ctx = {
waitUntil: vi.fn(),
passThroughOnException: vi.fn(),
} as unknown as ExecutionContext;

const makeEvent = (cron: string): ScheduledEvent =>
({ cron, scheduledTime: 0 }) as unknown as ScheduledEvent;

describe("handleScheduled", () => {
beforeEach(() => {
sendMessage.mockReset();
sendMessage.mockResolvedValue({ ok: true });
});

it("sends the healthcheck message when its cron fires", async () => {
await handleScheduled(makeEvent("0 8 * * *"), env, ctx);

expect(sendMessage).toHaveBeenCalledTimes(1);
const params = sendMessage.mock.calls[0][0];
expect(params.chat_id).toBe(12345);
expect(params.text).toContain("alerts worker healthy");
expect(params.parse_mode).toBe("HTML");
});

it("does not send anything when no alert matches the fired cron", async () => {
await handleScheduled(makeEvent("*/5 * * * *"), env, ctx);

expect(sendMessage).not.toHaveBeenCalled();
});
});
61 changes: 61 additions & 0 deletions apps/alerts/src/scheduled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Logger } from "@repo/logger";
import { TelegramService } from "@repo/telegram";

import { alerts } from "./alerts";
import { parseEnv } from "./types/env";

const handleScheduled = async (
event: ScheduledEvent,
env: unknown,
_ctx: ExecutionContext
) => {
const logger = new Logger({ context: "alerts", level: "info" });

const parsed = parseEnv(env);
const telegram = new TelegramService(parsed.TELEGRAM_BOT_TOKEN, logger);
const chatId = Number(parsed.ALLOWED_CHAT_ID);

const due = alerts.filter((alert) => alert.cron === event.cron);
if (due.length === 0) {
logger.info("no alerts for cron", { cron: event.cron });
return;
}

logger.info("running alerts", { cron: event.cron, count: due.length });

const results = await Promise.allSettled(
due.map(async (alert) => {
const message = await alert.run({ env: parsed, logger });
if (message === null) {
logger.info("alert produced no message", { alert: alert.name });
return;
}
await telegram.sendMessage({
chat_id: chatId,
text: message,
parse_mode: "HTML",
});
logger.info("alert message sent", { alert: alert.name });
})
);

results.forEach((result, i) => {
if (result.status === "rejected") {
logger.error("alert failed", {
alert: due[i].name,
error:
result.reason instanceof Error
? result.reason.message
: String(result.reason),
});
}
});
};

const createScheduledHandler = () => {
return (event: ScheduledEvent, env: unknown, ctx: ExecutionContext) => {
ctx.waitUntil(handleScheduled(event, env, ctx));
};
};

export { createScheduledHandler, handleScheduled };
15 changes: 15 additions & 0 deletions apps/alerts/src/types/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from "zod";

const envSchema = z.object({
TELEGRAM_BOT_TOKEN: z.string().min(1),
ALLOWED_CHAT_ID: z.string().min(1),
});
Comment on lines +3 to +6

type Env = z.infer<typeof envSchema>;

const parseEnv = (env: unknown): Env => {
return envSchema.parse(env);
};

export { envSchema, parseEnv };
export type { Env };
8 changes: 8 additions & 0 deletions apps/alerts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "@repo/typescript/base.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types"]
},
"include": ["src"],
"exclude": ["node_modules"]
}
19 changes: 19 additions & 0 deletions apps/alerts/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "switch-operator-alerts",
"main": "src/index.ts",
"compatibility_date": "2026-03-29",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": false,
"observability": {
"logs": {
"enabled": true,
"invocation_logs": true,
},
},
// The union of every alert's `cron` (see src/alerts). Each alert runs only
// when the fired cron matches its declared `cron` string.
"triggers": {
"crons": ["0 8 * * *"],
},
}
1 change: 1 addition & 0 deletions apps/operator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@hono/zod-validator": "0.7.6",
"@repo/http-client": "workspace:*",
"@repo/logger": "workspace:*",
"@repo/telegram": "workspace:*",
"@repo/url-validator": "workspace:*",
"drizzle-orm": "0.45.2",
"hono": "4.12.18",
Expand Down
2 changes: 1 addition & 1 deletion apps/operator/src/middleware/__tests__/logger.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LogEntry } from "@repo/logger";
import type { LogEntry } from "@repo/logger/types";
import { Hono } from "hono";
import type { MockInstance } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down
2 changes: 1 addition & 1 deletion apps/operator/src/modules/telegram/controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Logger } from "@repo/logger";
import { TelegramService } from "@repo/telegram";
import type { Context } from "hono";

import { PendingActionService } from "../../services/pending-action";
import type { PendingAction } from "../../services/pending-action";
import { PendingConversationService } from "../../services/pending-conversation";
import { ScheduleService } from "../../services/schedule";
import type { CreateScheduleInput } from "../../services/schedule";
import { TelegramService } from "../../services/telegram";
import type { AppEnv } from "../../types/env";
import type {
TelegramCallbackQuery,
Expand Down
2 changes: 1 addition & 1 deletion apps/operator/src/modules/telegram/conversation-runner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Logger } from "@repo/logger";
import type { TelegramService } from "@repo/telegram";
import { validateSourceUrl } from "@repo/url-validator";

import { buildInitialMessages, OpenAiService } from "../../services/openai";
Expand All @@ -16,7 +17,6 @@ import {
MAX_ACTIVE_SCHEDULES,
ScheduleService,
} from "../../services/schedule";
import type { TelegramService } from "../../services/telegram";
import type { AppEnv } from "../../types/env";
import { markdownToTelegramHtml } from "../../utils/markdown-to-html";
import {
Expand Down
3 changes: 2 additions & 1 deletion apps/operator/src/modules/telegram/ui.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { InlineKeyboardMarkup } from "@repo/telegram/types";

import type { QuestionOption } from "../../services/pending-conversation";
import type { CreateScheduleInput } from "../../services/schedule";
import type { InlineKeyboardMarkup } from "../../types/telegram";

const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const QUESTION_BUTTON_LABEL_MAX = 32;
Expand Down
2 changes: 1 addition & 1 deletion apps/operator/src/scheduled.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Logger } from "@repo/logger";
import { TelegramService } from "@repo/telegram";

import { OpenAiService } from "./services/openai";
import { MAX_RETRIES, ScheduleService } from "./services/schedule";
import { scrapeUrl } from "./services/scrape";
import { TelegramService } from "./services/telegram";
import type { AppEnv } from "./types/env";
import {
extractWindows,
Expand Down
Loading