diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 93068a2..e66298e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index aa063ed..d256544 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`) diff --git a/apps/alerts/README.md b/apps/alerts/README.md new file mode 100644 index 0000000..e86fffb --- /dev/null +++ b/apps/alerts/README.md @@ -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/.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`). diff --git a/apps/alerts/eslint.config.ts b/apps/alerts/eslint.config.ts new file mode 100644 index 0000000..f57754c --- /dev/null +++ b/apps/alerts/eslint.config.ts @@ -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); diff --git a/apps/alerts/package.json b/apps/alerts/package.json new file mode 100644 index 0000000..413e393 --- /dev/null +++ b/apps/alerts/package.json @@ -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" + } +} diff --git a/apps/alerts/src/alerts/healthcheck.ts b/apps/alerts/src/alerts/healthcheck.ts new file mode 100644 index 0000000..2407011 --- /dev/null +++ b/apps/alerts/src/alerts/healthcheck.ts @@ -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 }; diff --git a/apps/alerts/src/alerts/index.ts b/apps/alerts/src/alerts/index.ts new file mode 100644 index 0000000..214f408 --- /dev/null +++ b/apps/alerts/src/alerts/index.ts @@ -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 }; diff --git a/apps/alerts/src/alerts/types.ts b/apps/alerts/src/alerts/types.ts new file mode 100644 index 0000000..d5721e4 --- /dev/null +++ b/apps/alerts/src/alerts/types.ts @@ -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; +}; + +export type { Alert, AlertContext }; diff --git a/apps/alerts/src/index.ts b/apps/alerts/src/index.ts new file mode 100644 index 0000000..7b3fc4b --- /dev/null +++ b/apps/alerts/src/index.ts @@ -0,0 +1,6 @@ +import { createScheduledHandler } from "./scheduled"; + +// eslint-disable-next-line import/no-default-export +export default { + scheduled: createScheduledHandler(), +}; diff --git a/apps/alerts/src/scheduled.test.ts b/apps/alerts/src/scheduled.test.ts new file mode 100644 index 0000000..a973af9 --- /dev/null +++ b/apps/alerts/src/scheduled.test.ts @@ -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>(); + +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(); + }); +}); diff --git a/apps/alerts/src/scheduled.ts b/apps/alerts/src/scheduled.ts new file mode 100644 index 0000000..fa45c4d --- /dev/null +++ b/apps/alerts/src/scheduled.ts @@ -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 }; diff --git a/apps/alerts/src/types/env.ts b/apps/alerts/src/types/env.ts new file mode 100644 index 0000000..1ea69c3 --- /dev/null +++ b/apps/alerts/src/types/env.ts @@ -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), +}); + +type Env = z.infer; + +const parseEnv = (env: unknown): Env => { + return envSchema.parse(env); +}; + +export { envSchema, parseEnv }; +export type { Env }; diff --git a/apps/alerts/tsconfig.json b/apps/alerts/tsconfig.json new file mode 100644 index 0000000..1eed9fe --- /dev/null +++ b/apps/alerts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@repo/typescript/base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/apps/alerts/wrangler.jsonc b/apps/alerts/wrangler.jsonc new file mode 100644 index 0000000..163f71f --- /dev/null +++ b/apps/alerts/wrangler.jsonc @@ -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 * * *"], + }, +} diff --git a/apps/operator/package.json b/apps/operator/package.json index c46c674..ea961dc 100644 --- a/apps/operator/package.json +++ b/apps/operator/package.json @@ -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", diff --git a/apps/operator/src/middleware/__tests__/logger.test.ts b/apps/operator/src/middleware/__tests__/logger.test.ts index 4583e7d..32e7bdf 100644 --- a/apps/operator/src/middleware/__tests__/logger.test.ts +++ b/apps/operator/src/middleware/__tests__/logger.test.ts @@ -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"; diff --git a/apps/operator/src/modules/telegram/controller.ts b/apps/operator/src/modules/telegram/controller.ts index 9fb8d55..ee86554 100644 --- a/apps/operator/src/modules/telegram/controller.ts +++ b/apps/operator/src/modules/telegram/controller.ts @@ -1,4 +1,5 @@ import type { Logger } from "@repo/logger"; +import { TelegramService } from "@repo/telegram"; import type { Context } from "hono"; import { PendingActionService } from "../../services/pending-action"; @@ -6,7 +7,6 @@ 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, diff --git a/apps/operator/src/modules/telegram/conversation-runner.ts b/apps/operator/src/modules/telegram/conversation-runner.ts index 02d53a1..bf80320 100644 --- a/apps/operator/src/modules/telegram/conversation-runner.ts +++ b/apps/operator/src/modules/telegram/conversation-runner.ts @@ -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"; @@ -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 { diff --git a/apps/operator/src/modules/telegram/ui.ts b/apps/operator/src/modules/telegram/ui.ts index 2530b6d..2010bbb 100644 --- a/apps/operator/src/modules/telegram/ui.ts +++ b/apps/operator/src/modules/telegram/ui.ts @@ -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; diff --git a/apps/operator/src/scheduled.ts b/apps/operator/src/scheduled.ts index 1ca3564..3d50125 100644 --- a/apps/operator/src/scheduled.ts +++ b/apps/operator/src/scheduled.ts @@ -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, diff --git a/apps/operator/src/types/telegram.ts b/apps/operator/src/types/telegram.ts index 167c0b4..c0fed92 100644 --- a/apps/operator/src/types/telegram.ts +++ b/apps/operator/src/types/telegram.ts @@ -40,45 +40,7 @@ const telegramUpdateSchema = z }) .loose(); -const inlineKeyboardButtonSchema = z.object({ - text: z.string(), - callback_data: z.string().max(64), -}); - -const inlineKeyboardMarkupSchema = z.object({ - inline_keyboard: z.array(z.array(inlineKeyboardButtonSchema)), -}); - -const sendMessageParamsSchema = z.object({ - chat_id: z.number(), - text: z.string(), - parse_mode: z.enum(["HTML", "MarkdownV2"]).optional(), - reply_markup: inlineKeyboardMarkupSchema.optional(), -}); - -const answerCallbackQueryParamsSchema = z.object({ - callback_query_id: z.string(), - text: z.string().optional(), -}); - -const editMessageReplyMarkupParamsSchema = z.object({ - chat_id: z.number(), - message_id: z.number(), -}); - -const telegramApiResponseSchema = z - .object({ - ok: z.boolean(), - description: z.string().optional(), - }) - .loose(); - export { - answerCallbackQueryParamsSchema, - editMessageReplyMarkupParamsSchema, - inlineKeyboardMarkupSchema, - sendMessageParamsSchema, - telegramApiResponseSchema, telegramCallbackQuerySchema, telegramMessageSchema, telegramUpdateSchema, @@ -87,11 +49,3 @@ export { export type TelegramUpdate = z.infer; export type TelegramMessage = z.infer; export type TelegramCallbackQuery = z.infer; -export type SendMessageParams = z.infer; -export type AnswerCallbackQueryParams = z.infer< - typeof answerCallbackQueryParamsSchema ->; -export type EditMessageReplyMarkupParams = z.infer< - typeof editMessageReplyMarkupParamsSchema ->; -export type InlineKeyboardMarkup = z.infer; diff --git a/packages/http-client/package.json b/packages/http-client/package.json index ca1076b..243a0cf 100644 --- a/packages/http-client/package.json +++ b/packages/http-client/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/http-client.ts" + ".": "./src/http-client.ts", + "./types": "./src/types.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/http-client/src/http-client.ts b/packages/http-client/src/http-client.ts index 9ab6a98..007eefd 100644 --- a/packages/http-client/src/http-client.ts +++ b/packages/http-client/src/http-client.ts @@ -3,8 +3,6 @@ import type { z } from "zod"; import type { HttpClientConfig, PostOptions, RequestOptions } from "./types"; -export type { HttpClientConfig, PostOptions, RequestOptions } from "./types"; - class HttpClientError extends Error { readonly status: number; readonly body: unknown; diff --git a/packages/logger/package.json b/packages/logger/package.json index 12f2a61..d364a7a 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/logger.ts" + ".": "./src/logger.ts", + "./types": "./src/types.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts index a827047..ecabeb8 100644 --- a/packages/logger/src/logger.ts +++ b/packages/logger/src/logger.ts @@ -1,7 +1,5 @@ import type { LogEntry, LoggerConfig, LogLevel, LogMetadata } from "./types"; -export type { LogEntry, LoggerConfig, LogLevel, LogMetadata } from "./types"; - const LOG_LEVEL_PRIORITY: Record = { debug: 0, info: 1, diff --git a/packages/telegram/eslint.config.ts b/packages/telegram/eslint.config.ts new file mode 100644 index 0000000..f57754c --- /dev/null +++ b/packages/telegram/eslint.config.ts @@ -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); diff --git a/packages/telegram/package.json b/packages/telegram/package.json new file mode 100644 index 0000000..efaf8dc --- /dev/null +++ b/packages/telegram/package.json @@ -0,0 +1,31 @@ +{ + "name": "@repo/telegram", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/telegram.ts", + "./types": "./src/types.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run --passWithNoTests", + "format": "prettier --check . --ignore-path ../../.gitignore" + }, + "dependencies": { + "@repo/http-client": "workspace:*", + "@repo/logger": "workspace:*", + "zod": "4.3.6" + }, + "devDependencies": { + "@repo/eslint": "workspace:*", + "@repo/prettier": "workspace:*", + "@repo/typescript": "workspace:*", + "eslint": "9.39.1", + "prettier": "3.8.2", + "typescript": "6.0.2", + "vitest": "4.1.4" + }, + "prettier": "@repo/prettier" +} diff --git a/apps/operator/src/services/telegram.test.ts b/packages/telegram/src/telegram.test.ts similarity index 100% rename from apps/operator/src/services/telegram.test.ts rename to packages/telegram/src/telegram.test.ts diff --git a/apps/operator/src/services/telegram.ts b/packages/telegram/src/telegram.ts similarity index 93% rename from apps/operator/src/services/telegram.ts rename to packages/telegram/src/telegram.ts index 1913f12..c9acf71 100644 --- a/apps/operator/src/services/telegram.ts +++ b/packages/telegram/src/telegram.ts @@ -5,8 +5,8 @@ import type { AnswerCallbackQueryParams, EditMessageReplyMarkupParams, SendMessageParams, -} from "../types/telegram"; -import { telegramApiResponseSchema } from "../types/telegram"; +} from "./types"; +import { telegramApiResponseSchema } from "./types"; class TelegramService { private readonly client: HttpClient; diff --git a/packages/telegram/src/types.ts b/packages/telegram/src/types.ts new file mode 100644 index 0000000..cf28f6f --- /dev/null +++ b/packages/telegram/src/types.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +const inlineKeyboardButtonSchema = z.object({ + text: z.string(), + callback_data: z.string().max(64), +}); + +const inlineKeyboardMarkupSchema = z.object({ + inline_keyboard: z.array(z.array(inlineKeyboardButtonSchema)), +}); + +const sendMessageParamsSchema = z.object({ + chat_id: z.number(), + text: z.string(), + parse_mode: z.enum(["HTML", "MarkdownV2"]).optional(), + reply_markup: inlineKeyboardMarkupSchema.optional(), +}); + +const answerCallbackQueryParamsSchema = z.object({ + callback_query_id: z.string(), + text: z.string().optional(), +}); + +const editMessageReplyMarkupParamsSchema = z.object({ + chat_id: z.number(), + message_id: z.number(), +}); + +const telegramApiResponseSchema = z + .object({ + ok: z.boolean(), + description: z.string().optional(), + }) + .loose(); + +export { + answerCallbackQueryParamsSchema, + editMessageReplyMarkupParamsSchema, + inlineKeyboardMarkupSchema, + sendMessageParamsSchema, + telegramApiResponseSchema, +}; + +export type SendMessageParams = z.infer; +export type AnswerCallbackQueryParams = z.infer< + typeof answerCallbackQueryParamsSchema +>; +export type EditMessageReplyMarkupParams = z.infer< + typeof editMessageReplyMarkupParamsSchema +>; +export type InlineKeyboardMarkup = z.infer; diff --git a/packages/telegram/tsconfig.json b/packages/telegram/tsconfig.json new file mode 100644 index 0000000..7b05454 --- /dev/null +++ b/packages/telegram/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@repo/typescript/base.json", + "compilerOptions": { + "types": ["vitest/globals"] + }, + "include": ["src", "*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/telegram/vitest.config.ts b/packages/telegram/vitest.config.ts new file mode 100644 index 0000000..ab8f85c --- /dev/null +++ b/packages/telegram/vitest.config.ts @@ -0,0 +1,8 @@ +/* eslint-disable import/no-default-export */ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1d83e0..14cfa9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,49 @@ importers: specifier: 0.7.2 version: 0.7.2(@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.8.2))(prettier@3.8.2) + apps/alerts: + dependencies: + '@repo/logger': + specifier: workspace:* + version: link:../../packages/logger + '@repo/telegram': + specifier: workspace:* + version: link:../../packages/telegram + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@cloudflare/workers-types': + specifier: 4.20260412.1 + version: 4.20260412.1 + '@repo/eslint': + specifier: workspace:* + version: link:../../tooling/eslint + '@repo/prettier': + specifier: workspace:* + version: link:../../tooling/prettier + '@repo/typescript': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 25.6.0 + version: 25.6.0 + eslint: + specifier: 9.39.1 + version: 9.39.1(jiti@2.6.1) + prettier: + specifier: 3.8.2 + version: 3.8.2 + typescript: + specifier: 6.0.2 + version: 6.0.2 + vitest: + specifier: 4.1.4 + version: 4.1.4(@types/node@25.6.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2)(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) + wrangler: + specifier: 4.81.1 + version: 4.81.1(@cloudflare/workers-types@4.20260412.1) + apps/browser-scraper: dependencies: '@cloudflare/playwright': @@ -83,6 +126,9 @@ importers: '@repo/logger': specifier: workspace:* version: link:../../packages/logger + '@repo/telegram': + specifier: workspace:* + version: link:../../packages/telegram '@repo/url-validator': specifier: workspace:* version: link:../../packages/url-validator @@ -194,6 +240,40 @@ importers: specifier: 4.1.4 version: 4.1.4(@types/node@25.6.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2)(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) + packages/telegram: + dependencies: + '@repo/http-client': + specifier: workspace:* + version: link:../http-client + '@repo/logger': + specifier: workspace:* + version: link:../logger + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@repo/eslint': + specifier: workspace:* + version: link:../../tooling/eslint + '@repo/prettier': + specifier: workspace:* + version: link:../../tooling/prettier + '@repo/typescript': + specifier: workspace:* + version: link:../../tooling/typescript + eslint: + specifier: 9.39.1 + version: 9.39.1(jiti@2.6.1) + prettier: + specifier: 3.8.2 + version: 3.8.2 + typescript: + specifier: 6.0.2 + version: 6.0.2 + vitest: + specifier: 4.1.4 + version: 4.1.4(@types/node@25.6.0)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2)(@types/node@25.6.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)) + packages/url-validator: devDependencies: '@repo/eslint': diff --git a/tooling/eslint/base.ts b/tooling/eslint/base.ts index 17276d6..9cec1e5 100644 --- a/tooling/eslint/base.ts +++ b/tooling/eslint/base.ts @@ -76,6 +76,21 @@ export const baseConfig = defineConfig( curly: ["error", "all"], "import/no-default-export": "error", "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + // No barrel re-exports: export from the defining module and expose it via + // a package.json subpath, so consumers import from a stable, explicit path. + "no-restricted-syntax": [ + "error", + { + selector: "ExportAllDeclaration", + message: + "No barrel re-exports — export from the defining module and expose it via a package.json subpath.", + }, + { + selector: "ExportNamedDeclaration[source]", + message: + "No barrel re-exports — export from the defining module and expose it via a package.json subpath.", + }, + ], }, }, {