From 53fe568a702d74769399ac60828977ec256a133c Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:06:16 +0300 Subject: [PATCH 1/5] feat: add schedules --- .github/workflows/migrations.yml | 36 + apps/operator/drizzle.config.ts | 8 + .../migrations/0000_acoustic_shape.sql | 21 + apps/operator/migrations/0001_cool_thanos.sql | 7 + .../migrations/meta/0000_snapshot.json | 157 +++++ .../migrations/meta/0001_snapshot.json | 202 ++++++ apps/operator/migrations/meta/_journal.json | 20 + apps/operator/package.json | 9 +- apps/operator/src/db/schema.ts | 43 ++ apps/operator/src/index.ts | 6 +- .../src/modules/telegram/controller.ts | 209 +++++- .../src/modules/telegram/routes.test.ts | 18 + apps/operator/src/scheduled.ts | 112 +++ apps/operator/src/services/openai.test.ts | 188 +++++ apps/operator/src/services/openai.ts | 168 ++++- apps/operator/src/services/pending-action.ts | 87 +++ apps/operator/src/services/schedule.test.ts | 325 +++++++++ apps/operator/src/services/schedule.ts | 433 ++++++++++++ apps/operator/src/types/env.ts | 8 +- apps/operator/src/utils/message.ts | 24 + apps/operator/src/utils/url-validator.test.ts | 128 ++++ apps/operator/src/utils/url-validator.ts | 69 ++ apps/operator/wrangler.jsonc | 11 + pnpm-lock.yaml | 651 ++++++++++++++++++ 24 files changed, 2911 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/migrations.yml create mode 100644 apps/operator/drizzle.config.ts create mode 100644 apps/operator/migrations/0000_acoustic_shape.sql create mode 100644 apps/operator/migrations/0001_cool_thanos.sql create mode 100644 apps/operator/migrations/meta/0000_snapshot.json create mode 100644 apps/operator/migrations/meta/0001_snapshot.json create mode 100644 apps/operator/migrations/meta/_journal.json create mode 100644 apps/operator/src/db/schema.ts create mode 100644 apps/operator/src/scheduled.ts create mode 100644 apps/operator/src/services/pending-action.ts create mode 100644 apps/operator/src/services/schedule.test.ts create mode 100644 apps/operator/src/services/schedule.ts create mode 100644 apps/operator/src/utils/message.ts create mode 100644 apps/operator/src/utils/url-validator.test.ts create mode 100644 apps/operator/src/utils/url-validator.ts diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml new file mode 100644 index 0000000..77d4790 --- /dev/null +++ b/.github/workflows/migrations.yml @@ -0,0 +1,36 @@ +name: D1 Migrations + +on: + push: + branches: [main] + paths: + - "apps/operator/migrations/**" + +permissions: {} + +concurrency: + group: d1-migrations-${{ github.ref }} + cancel-in-progress: false + +jobs: + migrate: + name: apply-migrations + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: production + permissions: + contents: read + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Setup + uses: ./.github/actions/setup + + - name: Apply D1 migrations + run: pnpm --filter @repo/operator exec wrangler d1 migrations apply switch-operator-db --remote + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/apps/operator/drizzle.config.ts b/apps/operator/drizzle.config.ts new file mode 100644 index 0000000..da45899 --- /dev/null +++ b/apps/operator/drizzle.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "drizzle-kit"; + +// eslint-disable-next-line import/no-default-export +export default defineConfig({ + out: "./migrations", + schema: "./src/db/schema.ts", + dialect: "sqlite", +}); diff --git a/apps/operator/migrations/0000_acoustic_shape.sql b/apps/operator/migrations/0000_acoustic_shape.sql new file mode 100644 index 0000000..77b400e --- /dev/null +++ b/apps/operator/migrations/0000_acoustic_shape.sql @@ -0,0 +1,21 @@ +CREATE TABLE `schedules` ( + `id` text PRIMARY KEY NOT NULL, + `chat_id` integer NOT NULL, + `schedule_type` text NOT NULL, + `hour` integer, + `minute` integer DEFAULT 0, + `day_of_week` integer, + `day_of_month` integer, + `timezone` text DEFAULT 'UTC' NOT NULL, + `fixed_message` text, + `message_prompt` text, + `source_url` text, + `state_json` text, + `description` text NOT NULL, + `active` integer DEFAULT true NOT NULL, + `next_run_at` text NOT NULL, + `retry_count` integer DEFAULT 0 NOT NULL, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_schedules_next_run` ON `schedules` (`active`,`next_run_at`); \ No newline at end of file diff --git a/apps/operator/migrations/0001_cool_thanos.sql b/apps/operator/migrations/0001_cool_thanos.sql new file mode 100644 index 0000000..4aebd01 --- /dev/null +++ b/apps/operator/migrations/0001_cool_thanos.sql @@ -0,0 +1,7 @@ +CREATE TABLE `pending_actions` ( + `chat_id` integer PRIMARY KEY NOT NULL, + `action_type` text NOT NULL, + `payload` text NOT NULL, + `description` text NOT NULL, + `expires_at` text NOT NULL +); diff --git a/apps/operator/migrations/meta/0000_snapshot.json b/apps/operator/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..f15d0df --- /dev/null +++ b/apps/operator/migrations/meta/0000_snapshot.json @@ -0,0 +1,157 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c0ba48da-50d8-497b-9a7b-721b8aa7b578", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "schedules": { + "name": "schedules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hour": { + "name": "hour", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "minute": { + "name": "minute", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "day_of_month": { + "name": "day_of_month", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "fixed_message": { + "name": "fixed_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_prompt": { + "name": "message_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_json": { + "name": "state_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_schedules_next_run": { + "name": "idx_schedules_next_run", + "columns": ["active", "next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/operator/migrations/meta/0001_snapshot.json b/apps/operator/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..4bdd7c1 --- /dev/null +++ b/apps/operator/migrations/meta/0001_snapshot.json @@ -0,0 +1,202 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dda87aa5-3f97-4c0f-a0a6-dc0c2d9a75bb", + "prevId": "c0ba48da-50d8-497b-9a7b-721b8aa7b578", + "tables": { + "pending_actions": { + "name": "pending_actions", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hour": { + "name": "hour", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "minute": { + "name": "minute", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "day_of_month": { + "name": "day_of_month", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "fixed_message": { + "name": "fixed_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_prompt": { + "name": "message_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_json": { + "name": "state_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_schedules_next_run": { + "name": "idx_schedules_next_run", + "columns": ["active", "next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/operator/migrations/meta/_journal.json b/apps/operator/migrations/meta/_journal.json new file mode 100644 index 0000000..4b548fa --- /dev/null +++ b/apps/operator/migrations/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1775369189820, + "tag": "0000_acoustic_shape", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1775370212742, + "tag": "0001_cool_thanos", + "breakpoints": true + } + ] +} diff --git a/apps/operator/package.json b/apps/operator/package.json index 375e3d9..c3cb05b 100644 --- a/apps/operator/package.json +++ b/apps/operator/package.json @@ -3,17 +3,21 @@ "private": true, "type": "module", "scripts": { - "dev": "wrangler dev", + "dev": "wrangler dev --test-scheduled", "deploy": "wrangler deploy", "typecheck": "tsc", "lint": "eslint .", "test": "vitest run --passWithNoTests", - "set-webhook": "tsx scripts/set-webhook.ts" + "set-webhook": "tsx scripts/set-webhook.ts", + "db:generate": "drizzle-kit generate", + "db:migrate:local": "wrangler d1 migrations apply switch-operator-db --local", + "db:migrate:remote": "wrangler d1 migrations apply switch-operator-db --remote" }, "dependencies": { "@hono/zod-validator": "0.7.6", "@repo/http-client": "workspace:*", "@repo/logger": "workspace:*", + "drizzle-orm": "0.45.2", "hono": "4.12.9", "openai": "6.33.0", "zod": "4.3.6" @@ -24,6 +28,7 @@ "@repo/prettier": "workspace:*", "@repo/typescript": "workspace:*", "@types/node": "25.2.3", + "drizzle-kit": "0.31.10", "eslint": "9.39.1", "prettier": "3.8.1", "tsx": "4.21.0", diff --git a/apps/operator/src/db/schema.ts b/apps/operator/src/db/schema.ts new file mode 100644 index 0000000..3072aac --- /dev/null +++ b/apps/operator/src/db/schema.ts @@ -0,0 +1,43 @@ +import { index, int, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +const schedules = sqliteTable( + "schedules", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + chatId: int("chat_id").notNull(), + scheduleType: text("schedule_type", { + enum: ["hourly", "daily", "weekly", "monthly"], + }).notNull(), + hour: int("hour"), + minute: int("minute").default(0), + dayOfWeek: int("day_of_week"), + dayOfMonth: int("day_of_month"), + timezone: text("timezone").notNull().default("UTC"), + fixedMessage: text("fixed_message"), + messagePrompt: text("message_prompt"), + sourceUrl: text("source_url"), + stateJson: text("state_json"), + description: text("description").notNull(), + active: int("active", { mode: "boolean" }).notNull().default(true), + nextRunAt: text("next_run_at").notNull(), + retryCount: int("retry_count").notNull().default(0), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), + }, + (table) => [index("idx_schedules_next_run").on(table.active, table.nextRunAt)] +); + +const pendingActions = sqliteTable("pending_actions", { + chatId: int("chat_id").primaryKey(), + actionType: text("action_type", { + enum: ["create_schedule", "delete_schedule"], + }).notNull(), + payload: text("payload").notNull(), + description: text("description").notNull(), + expiresAt: text("expires_at").notNull(), +}); + +export { pendingActions, schedules }; diff --git a/apps/operator/src/index.ts b/apps/operator/src/index.ts index f3ac213..650ddc7 100644 --- a/apps/operator/src/index.ts +++ b/apps/operator/src/index.ts @@ -7,6 +7,7 @@ import { loggerMiddleware } from "./middleware/logger"; import { secureHeadersMiddleware } from "./middleware/secure-headers"; import { healthRoutes } from "./modules/health/routes"; import { telegramRoutes } from "./modules/telegram/routes"; +import { createScheduledHandler } from "./scheduled"; import type { AppEnv } from "./types/env"; const app = new Hono(); @@ -23,4 +24,7 @@ app.route("/", telegramRoutes); app.notFound(notFoundHandler); // eslint-disable-next-line import/no-default-export -export default app; +export default { + fetch: app.fetch, + scheduled: createScheduledHandler(), +}; diff --git a/apps/operator/src/modules/telegram/controller.ts b/apps/operator/src/modules/telegram/controller.ts index ef21a50..a51438a 100644 --- a/apps/operator/src/modules/telegram/controller.ts +++ b/apps/operator/src/modules/telegram/controller.ts @@ -1,31 +1,89 @@ import type { Context } from "hono"; import { OpenAiService } from "../../services/openai"; +import type { ToolExecutor, ToolResult } from "../../services/openai"; +import { PendingActionService } from "../../services/pending-action"; +import type { PendingAction } from "../../services/pending-action"; +import { + createScheduleSchema, + MAX_ACTIVE_SCHEDULES, + ScheduleService, +} from "../../services/schedule"; +import type { CreateScheduleInput } from "../../services/schedule"; import { TelegramService } from "../../services/telegram"; import type { AppEnv } from "../../types/env"; import type { TelegramUpdate } from "../../types/telegram"; +import { splitMessage } from "../../utils/message"; -const TELEGRAM_MAX_MESSAGE_LENGTH = 4096; +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; -const splitMessage = (text: string): string[] => { - if (text.length <= TELEGRAM_MAX_MESSAGE_LENGTH) { - return [text]; +const formatScheduleDescription = ( + type: string, + args: Record +): string => { + const schedType = + typeof args.schedule_type === "string" ? args.schedule_type : type; + const parts: string[] = [`${schedType} schedule`]; + if (args.hour != null) { + const h = typeof args.hour === "number" ? String(args.hour) : "0"; + const m = + typeof args.minute === "number" + ? String(args.minute).padStart(2, "0") + : "00"; + parts.push(`at ${h}:${m}`); } - const chunks: string[] = []; - let remaining = text; - while (remaining.length > 0) { - if (remaining.length <= TELEGRAM_MAX_MESSAGE_LENGTH) { - chunks.push(remaining); - break; - } - let splitAt = remaining.lastIndexOf("\n", TELEGRAM_MAX_MESSAGE_LENGTH); - if (splitAt <= 0) { - splitAt = TELEGRAM_MAX_MESSAGE_LENGTH; - } - chunks.push(remaining.slice(0, splitAt)); - remaining = remaining.slice(splitAt).replace(/^\n/, ""); + if (typeof args.day_of_week === "number") { + parts.push(`on ${DAYS[args.day_of_week] ?? "?"}`); + } + if (typeof args.day_of_month === "number") { + parts.push(`on day ${String(args.day_of_month)}`); + } + const tz = typeof args.timezone === "string" ? args.timezone : "UTC"; + parts.push(`(${tz})`); + if (typeof args.description === "string") { + parts.push(`— "${args.description}"`); + } + return parts.join(" "); +}; + +const mapToolArgsToInput = ( + args: Record +): CreateScheduleInput => ({ + scheduleType: args.schedule_type as CreateScheduleInput["scheduleType"], + hour: args.hour as number | undefined, + minute: args.minute as number | undefined, + dayOfWeek: args.day_of_week as number | undefined, + dayOfMonth: args.day_of_month as number | undefined, + timezone: (args.timezone as string | undefined) ?? "Europe/Helsinki", + fixedMessage: args.fixed_message as string | undefined, + messagePrompt: args.message_prompt as string | undefined, + sourceUrl: args.source_url as string | undefined, + description: (args.description as string | undefined) ?? "", +}); + +const executePendingAction = async ( + action: PendingAction, + chatId: number, + db: D1Database, + logger: { info: (msg: string, meta?: Record) => void } +): Promise => { + const scheduleService = new ScheduleService(db); + + if (action.type === "create_schedule") { + const input = action.payload as unknown as CreateScheduleInput; + const row = await scheduleService.create(chatId, input); + logger.info("schedule created", { scheduleId: row.id }); + return `Schedule created: ${action.description}\nID: ${row.id}\nNext run: ${row.nextRunAt}`; } - return chunks; + + // delete_schedule + const id = action.payload.id as string; + const deleted = await scheduleService.remove(id, chatId); + if (deleted) { + logger.info("schedule deleted", { scheduleId: id }); + return `Schedule ${id} has been deleted.`; + } + return `Schedule ${id} not found or already deleted.`; }; type WebhookInput = { @@ -65,11 +123,124 @@ const handleWebhook = async (c: Context) => { textLength: message.text.length, }); const telegram = new TelegramService(c.env.TELEGRAM_BOT_TOKEN, logger); + const pendingService = new PendingActionService(c.env.DB); + + // Check for pending confirmation + const pending = await pendingService.get(chatId); + if (pending) { + await pendingService.clear(chatId); + const confirmed = message.text.trim().toUpperCase() === "YES"; + + if (confirmed) { + const result = await executePendingAction( + pending, + chatId, + c.env.DB, + logger + ); + for (const chunk of splitMessage(result)) { + await telegram.sendMessage({ chat_id: chatId, text: chunk }); + } + return c.json({ ok: true }); + } + + await telegram.sendMessage({ + chat_id: chatId, + text: "Action cancelled.", + }); + // Fall through to process the message normally if it wasn't just "YES"/"NO" + const isSimpleResponse = + message.text.trim().length <= 3 || + message.text.trim().toUpperCase() === "NO"; + if (isSimpleResponse) { + return c.json({ ok: true }); + } + } + + const scheduleService = new ScheduleService(c.env.DB); + + const toolExecutor: ToolExecutor = async ( + name: string, + args: Record + ): Promise => { + logger.debug("tool call received", { tool: name }); + + if (name === "list_schedules") { + const list = await scheduleService.list(chatId); + return { + result: JSON.stringify( + list.map((s) => ({ + id: s.id, + type: s.scheduleType, + description: s.description, + hour: s.hour, + minute: s.minute, + dayOfWeek: s.dayOfWeek, + dayOfMonth: s.dayOfMonth, + timezone: s.timezone, + sourceUrl: s.sourceUrl, + nextRunAt: s.nextRunAt, + })) + ), + }; + } + + if (name === "create_schedule") { + const count = await scheduleService.countActive(chatId); + if (count >= MAX_ACTIVE_SCHEDULES) { + return { + error: `Quota exceeded: maximum ${String(MAX_ACTIVE_SCHEDULES)} active schedules`, + }; + } + + const input = mapToolArgsToInput(args); + const validation = createScheduleSchema.safeParse(input); + if (!validation.success) { + return { error: validation.error.message }; + } + + // Monitor support (source_url) is not yet implemented — reject + if (input.sourceUrl) { + return { error: "URL monitors are not yet supported" }; + } + + // Store as pending in D1 — require user confirmation + await pendingService.set(chatId, { + type: "create_schedule", + payload: input as unknown as Record, + description: formatScheduleDescription("create", args), + }); + + return { + result: `Confirmation required. I've asked the user to confirm: "${formatScheduleDescription("create", args)}". Tell the user to reply YES to confirm.`, + }; + } + + if (name === "delete_schedule") { + const id = args.id as string | undefined; + if (!id) { + return { error: "Missing schedule ID" }; + } + + // Store as pending in D1 — require user confirmation + await pendingService.set(chatId, { + type: "delete_schedule", + payload: { id }, + description: `Delete schedule ${id}`, + }); + + return { + result: `Confirmation required. I've asked the user to confirm deletion of schedule ${id}. Tell the user to reply YES to confirm.`, + }; + } + + return { error: `Unknown tool: ${name}` }; + }; let reply: string; try { const openai = new OpenAiService(c.env.OPENAI_API_KEY, logger); - reply = await openai.reply(message.text); + reply = await openai.replyWithTools(message.text, toolExecutor); } catch (error) { logger.error("openai request failed", { error: error instanceof Error ? error.message : String(error), diff --git a/apps/operator/src/modules/telegram/routes.test.ts b/apps/operator/src/modules/telegram/routes.test.ts index ad220aa..0d7c07f 100644 --- a/apps/operator/src/modules/telegram/routes.test.ts +++ b/apps/operator/src/modules/telegram/routes.test.ts @@ -19,11 +19,28 @@ import { TELEGRAM_WEBHOOK_MAX_BODY_BYTES, telegramRoutes } from "./routes"; const mockFetch = vi.fn(); globalThis.fetch = mockFetch; +const createMockD1 = () => { + const mockStatement = { + bind: vi.fn().mockReturnThis(), + all: vi.fn().mockResolvedValue({ results: [], meta: {} }), + run: vi.fn().mockResolvedValue({ meta: { changes: 0 } }), + first: vi.fn().mockResolvedValue(null), + raw: vi.fn().mockResolvedValue([]), + }; + return { + prepare: vi.fn().mockReturnValue(mockStatement), + batch: vi.fn().mockResolvedValue([]), + exec: vi.fn().mockResolvedValue({}), + dump: vi.fn().mockResolvedValue(new ArrayBuffer(0)), + } as unknown as D1Database; +}; + const ENV = { TELEGRAM_BOT_TOKEN: "test-token", TELEGRAM_WEBHOOK_SECRET: "test-secret-that-is-at-least-32-chars!", ALLOWED_CHAT_ID: "12345", OPENAI_API_KEY: "test-openai-key", + DB: createMockD1(), }; const validUpdate = { @@ -61,6 +78,7 @@ const sendRequest = (body: unknown, headers: Record = {}) => describe("POST /webhook/telegram", () => { beforeEach(() => { mockFetch.mockReset(); + ENV.DB = createMockD1(); mockFetch.mockResolvedValue( new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, diff --git a/apps/operator/src/scheduled.ts b/apps/operator/src/scheduled.ts new file mode 100644 index 0000000..ab6a589 --- /dev/null +++ b/apps/operator/src/scheduled.ts @@ -0,0 +1,112 @@ +import { Logger } from "@repo/logger"; + +import { OpenAiService } from "./services/openai"; +import { ScheduleService } from "./services/schedule"; +import { TelegramService } from "./services/telegram"; +import type { AppEnv } from "./types/env"; +import { splitMessage } from "./utils/message"; + +type Env = AppEnv["Bindings"]; + +const RESPONSE_SIZE_LIMIT = 1024 * 1024; // 1MB + +const handleScheduled = async ( + _event: ScheduledEvent, + env: Env, + _ctx: ExecutionContext +) => { + const logger = new Logger({ context: "scheduled", level: "info" }); + const now = new Date(); + + const scheduleService = new ScheduleService(env.DB); + const telegram = new TelegramService(env.TELEGRAM_BOT_TOKEN, logger); + + let claimed; + try { + claimed = await scheduleService.claimDueSchedules(now, env.ALLOWED_CHAT_ID); + } catch (error) { + logger.error("failed to claim due schedules", { + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + if (claimed.length === 0) { + return; + } + + logger.info("claimed schedules", { count: claimed.length }); + + const chatId = Number(env.ALLOWED_CHAT_ID); + + const results = await Promise.allSettled( + claimed.map(async (schedule) => { + // Monitor path (Stage 2 — stub) + if (schedule.sourceUrl) { + logger.info("monitor execution not yet implemented, skipping", { + scheduleId: schedule.id, + }); + return; + } + + // Reminder path + let text: string; + if (schedule.fixedMessage) { + text = schedule.fixedMessage; + } else if (schedule.messagePrompt) { + const openai = new OpenAiService(env.OPENAI_API_KEY, logger); + text = await openai.reply(schedule.messagePrompt); + if (text.length > RESPONSE_SIZE_LIMIT) { + text = text.slice(0, RESPONSE_SIZE_LIMIT); + } + } else { + logger.warn("schedule has no message content", { + scheduleId: schedule.id, + }); + return; + } + + for (const chunk of splitMessage(text)) { + await telegram.sendMessage({ chat_id: chatId, text: chunk }); + } + + logger.info("scheduled message sent", { + scheduleId: schedule.id, + type: schedule.scheduleType, + }); + }) + ); + + // Handle failures — mark for retry + for (let i = 0; i < results.length; i++) { + const result = results[i]; + const schedule = claimed[i]; + if (result.status === "rejected") { + logger.error("scheduled message failed", { + scheduleId: schedule.id, + error: + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + }); + + const { deadLettered } = await scheduleService.markFailed( + schedule.id, + schedule.retryCount + ); + if (deadLettered) { + logger.error("schedule dead-lettered after max retries", { + scheduleId: schedule.id, + }); + } + } + } +}; + +const createScheduledHandler = () => { + return (event: ScheduledEvent, env: Env, ctx: ExecutionContext) => { + ctx.waitUntil(handleScheduled(event, env, ctx)); + }; +}; + +export { createScheduledHandler, handleScheduled }; diff --git a/apps/operator/src/services/openai.test.ts b/apps/operator/src/services/openai.test.ts index 7a1eb71..edf6ce8 100644 --- a/apps/operator/src/services/openai.test.ts +++ b/apps/operator/src/services/openai.test.ts @@ -2,6 +2,7 @@ import type { Logger } from "@repo/logger"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { OpenAiService } from "./openai"; +import type { ToolExecutor } from "./openai"; const createMock = vi.fn(); const constructorMock = vi.fn(); @@ -92,4 +93,191 @@ describe("OpenAiService", () => { ); }); }); + + describe("replyWithTools", () => { + it("returns text when no tool calls", async () => { + createMock.mockResolvedValueOnce({ + choices: [ + { + finish_reason: "stop", + message: { content: "Here is your answer", tool_calls: undefined }, + }, + ], + }); + + const executor = vi.fn(); + const result = await service.replyWithTools("hello", executor); + + expect(result).toBe("Here is your answer"); + expect(executor).not.toHaveBeenCalled(); + }); + + it("executes tool calls and returns final text", async () => { + createMock + .mockResolvedValueOnce({ + choices: [ + { + finish_reason: "tool_calls", + message: { + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "list_schedules", + arguments: "{}", + }, + }, + ], + }, + }, + ], + }) + .mockResolvedValueOnce({ + choices: [ + { + finish_reason: "stop", + message: { + content: "You have 2 schedules.", + tool_calls: undefined, + }, + }, + ], + }); + + const executor: ToolExecutor = vi.fn().mockResolvedValueOnce({ + result: JSON.stringify([{ id: "1" }, { id: "2" }]), + }); + + const result = await service.replyWithTools( + "list my schedules", + executor + ); + + expect(result).toBe("You have 2 schedules."); + expect(executor).toHaveBeenCalledWith("list_schedules", {}); + }); + + it("handles multiple tool calls in one response", async () => { + createMock + .mockResolvedValueOnce({ + choices: [ + { + finish_reason: "tool_calls", + message: { + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "list_schedules", + arguments: "{}", + }, + }, + { + id: "call_2", + type: "function", + function: { + name: "list_schedules", + arguments: "{}", + }, + }, + ], + }, + }, + ], + }) + .mockResolvedValueOnce({ + choices: [ + { + finish_reason: "stop", + message: { content: "Done.", tool_calls: undefined }, + }, + ], + }); + + const executor: ToolExecutor = vi + .fn() + .mockResolvedValue({ result: "[]" }); + + await service.replyWithTools("test", executor); + + expect(executor).toHaveBeenCalledTimes(2); + }); + + it("throws after max iterations", async () => { + // Always return tool calls to exhaust iterations + createMock.mockResolvedValue({ + choices: [ + { + finish_reason: "tool_calls", + message: { + content: null, + tool_calls: [ + { + id: "call_loop", + type: "function", + function: { + name: "list_schedules", + arguments: "{}", + }, + }, + ], + }, + }, + ], + }); + + const executor: ToolExecutor = vi + .fn() + .mockResolvedValue({ result: "[]" }); + + await expect( + service.replyWithTools("infinite loop", executor) + ).rejects.toThrow("Tool calling exceeded maximum iterations"); + }); + + it("throws when no choices returned", async () => { + createMock.mockResolvedValueOnce({ choices: [] }); + + await expect(service.replyWithTools("test", vi.fn())).rejects.toThrow(); + }); + + it("skips non-function tool calls", async () => { + createMock + .mockResolvedValueOnce({ + choices: [ + { + finish_reason: "tool_calls", + message: { + content: null, + tool_calls: [ + { + id: "call_custom", + type: "custom", + custom: { name: "something", input: "{}" }, + }, + ], + }, + }, + ], + }) + .mockResolvedValueOnce({ + choices: [ + { + finish_reason: "stop", + message: { content: "Skipped.", tool_calls: undefined }, + }, + ], + }); + + const executor = vi.fn(); + const result = await service.replyWithTools("test", executor); + + expect(result).toBe("Skipped."); + expect(executor).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/operator/src/services/openai.ts b/apps/operator/src/services/openai.ts index 29dda28..d17f5ea 100644 --- a/apps/operator/src/services/openai.ts +++ b/apps/operator/src/services/openai.ts @@ -1,8 +1,106 @@ import type { Logger } from "@repo/logger"; import OpenAI from "openai"; +import type { ChatCompletionTool } from "openai/resources/chat/completions"; -const SYSTEM_PROMPT = - "You are a helpful personal assistant called Switch Operator. Be concise and helpful."; +const SYSTEM_PROMPT = `You are a helpful personal assistant called Switch Operator. Be concise and helpful. + +You can create, list, and delete scheduled messages for the user. +When the user asks to be reminded or wants something scheduled, use the create_schedule tool. +When creating schedules, infer the timezone from context or default to Europe/Helsinki. + +Schedule types: +- hourly: runs every hour at the specified minute +- daily: runs every day at the specified hour:minute +- weekly: runs every week on the specified day at hour:minute +- monthly: runs every month on the specified day at hour:minute + +Use fixedMessage for exact text or messagePrompt for AI-generated content.`; + +const SCHEDULE_TOOLS: ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "create_schedule", + description: + "Create a new scheduled message. Use fixed_message for exact text or message_prompt for AI-generated content.", + parameters: { + type: "object", + properties: { + schedule_type: { + type: "string", + enum: ["hourly", "daily", "weekly", "monthly"], + }, + hour: { + type: "number", + description: "Hour (0-23). Required for daily/weekly/monthly.", + }, + minute: { + type: "number", + description: "Minute (0-59). Defaults to 0.", + }, + day_of_week: { + type: "number", + description: "Day of week (0=Sun, 6=Sat). Required for weekly.", + }, + day_of_month: { + type: "number", + description: "Day of month (1-28). Required for monthly.", + }, + timezone: { + type: "string", + description: + "IANA timezone (e.g. Europe/Helsinki). Defaults to Europe/Helsinki.", + }, + fixed_message: { + type: "string", + description: + "Exact message to send. Mutually exclusive with message_prompt.", + }, + message_prompt: { + type: "string", + description: + "Prompt for AI-generated message. Mutually exclusive with fixed_message.", + }, + description: { + type: "string", + description: "Short description of this schedule (max 200 chars).", + }, + }, + required: ["schedule_type", "timezone", "description"], + }, + }, + }, + { + type: "function", + function: { + name: "list_schedules", + description: "List all active schedules for the user.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "delete_schedule", + description: "Delete (deactivate) a schedule by its ID.", + parameters: { + type: "object", + properties: { + id: { type: "string", description: "The schedule ID to delete." }, + }, + required: ["id"], + }, + }, + }, +]; + +type ToolResult = { result: string } | { error: string }; +type ToolExecutor = ( + name: string, + args: Record +) => Promise; + +const MAX_TOOL_ITERATIONS = 5; class OpenAiService { private readonly client: OpenAI; @@ -38,6 +136,70 @@ class OpenAiService { return content; } + + async replyWithTools( + userMessage: string, + toolExecutor: ToolExecutor + ): Promise { + this.logger.debug("sending chat completion with tools", { + messageLength: userMessage.length, + }); + + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userMessage }, + ]; + + for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { + const response = await this.client.chat.completions.create({ + model: "gpt-5.4-mini", + max_completion_tokens: 2048, + messages, + tools: SCHEDULE_TOOLS, + }); + + const choice = response.choices[0]; + const message = choice.message; + messages.push(message); + + if ( + choice.finish_reason !== "tool_calls" || + !message.tool_calls?.length + ) { + const content = message.content; + if (!content) { + throw new Error("OpenAI returned empty response"); + } + return content; + } + + for (const toolCall of message.tool_calls) { + if (toolCall.type !== "function") { + continue; + } + + this.logger.debug("executing tool call", { + tool: toolCall.function.name, + iteration: i, + }); + + const args = JSON.parse(toolCall.function.arguments) as Record< + string, + unknown + >; + const result = await toolExecutor(toolCall.function.name, args); + + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify(result), + }); + } + } + + throw new Error("Tool calling exceeded maximum iterations"); + } } -export { OpenAiService }; +export { MAX_TOOL_ITERATIONS, OpenAiService, SCHEDULE_TOOLS }; +export type { ToolExecutor, ToolResult }; diff --git a/apps/operator/src/services/pending-action.ts b/apps/operator/src/services/pending-action.ts new file mode 100644 index 0000000..806c6c3 --- /dev/null +++ b/apps/operator/src/services/pending-action.ts @@ -0,0 +1,87 @@ +import { eq, lte } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/d1"; +import type { DrizzleD1Database } from "drizzle-orm/d1"; + +import { pendingActions } from "../db/schema"; + +type PendingActionType = "create_schedule" | "delete_schedule"; + +type PendingAction = { + type: PendingActionType; + payload: Record; + description: string; +}; + +const TTL_MS = 2 * 60 * 1000; + +class PendingActionService { + private readonly db: DrizzleD1Database; + + constructor(d1: D1Database) { + this.db = drizzle(d1); + } + + async set(chatId: number, action: PendingAction): Promise { + const expiresAt = new Date(Date.now() + TTL_MS).toISOString(); + await this.db + .insert(pendingActions) + .values({ + chatId, + actionType: action.type, + payload: JSON.stringify(action.payload), + description: action.description, + expiresAt, + }) + .onConflictDoUpdate({ + target: pendingActions.chatId, + set: { + actionType: action.type, + payload: JSON.stringify(action.payload), + description: action.description, + expiresAt, + }, + }); + } + + async get(chatId: number): Promise { + const rows = await this.db + .select() + .from(pendingActions) + .where(eq(pendingActions.chatId, chatId)) + .limit(1); + + if (rows.length === 0) { + return undefined; + } + + const row = rows[0]; + + // Check expiry + if (new Date(row.expiresAt) <= new Date()) { + await this.clear(chatId); + return undefined; + } + + return { + type: row.actionType, + payload: JSON.parse(row.payload) as Record, + description: row.description, + }; + } + + async clear(chatId: number): Promise { + await this.db + .delete(pendingActions) + .where(eq(pendingActions.chatId, chatId)); + } + + async clearExpired(): Promise { + const now = new Date().toISOString(); + await this.db + .delete(pendingActions) + .where(lte(pendingActions.expiresAt, now)); + } +} + +export { PendingActionService, TTL_MS }; +export type { PendingAction, PendingActionType }; diff --git a/apps/operator/src/services/schedule.test.ts b/apps/operator/src/services/schedule.test.ts new file mode 100644 index 0000000..b36ee92 --- /dev/null +++ b/apps/operator/src/services/schedule.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from "vitest"; + +import { computeNextRun, createScheduleSchema } from "./schedule"; + +describe("computeNextRun", () => { + describe("hourly", () => { + it("returns current hour if minute hasn't passed", () => { + const from = new Date("2026-04-05T10:15:00Z"); + const result = computeNextRun("hourly", "UTC", from, { minute: 30 }); + expect(result.toISOString()).toBe("2026-04-05T10:30:00.000Z"); + }); + + it("returns next hour if minute has passed", () => { + const from = new Date("2026-04-05T10:45:00Z"); + const result = computeNextRun("hourly", "UTC", from, { minute: 30 }); + expect(result.toISOString()).toBe("2026-04-05T11:30:00.000Z"); + }); + + it("defaults to minute 0", () => { + const from = new Date("2026-04-05T10:01:00Z"); + const result = computeNextRun("hourly", "UTC", from, {}); + expect(result.toISOString()).toBe("2026-04-05T11:00:00.000Z"); + }); + + it("handles midnight rollover", () => { + const from = new Date("2026-04-05T23:45:00Z"); + const result = computeNextRun("hourly", "UTC", from, { minute: 30 }); + expect(result.toISOString()).toBe("2026-04-06T00:30:00.000Z"); + }); + }); + + describe("daily", () => { + it("returns today if time hasn't passed", () => { + const from = new Date("2026-04-05T06:00:00Z"); + const result = computeNextRun("daily", "UTC", from, { + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2026-04-05T09:00:00.000Z"); + }); + + it("returns tomorrow if time has passed", () => { + const from = new Date("2026-04-05T10:00:00Z"); + const result = computeNextRun("daily", "UTC", from, { + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2026-04-06T09:00:00.000Z"); + }); + + it("handles timezone offset (Europe/Helsinki = UTC+3 in summer)", () => { + // 9:00 Helsinki = 6:00 UTC (EEST = UTC+3) + const from = new Date("2026-04-05T05:00:00Z"); // 8:00 Helsinki + const result = computeNextRun("daily", "Europe/Helsinki", from, { + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2026-04-05T06:00:00.000Z"); + }); + }); + + describe("weekly", () => { + it("returns this week if day hasn't passed", () => { + // 2026-04-05 is a Sunday (dayOfWeek=0) + const from = new Date("2026-04-05T10:00:00Z"); + const result = computeNextRun("weekly", "UTC", from, { + dayOfWeek: 1, // Monday + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2026-04-06T09:00:00.000Z"); + }); + + it("returns next week if same day but time has passed", () => { + // 2026-04-06 is Monday + const from = new Date("2026-04-06T10:00:00Z"); + const result = computeNextRun("weekly", "UTC", from, { + dayOfWeek: 1, // Monday + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2026-04-13T09:00:00.000Z"); + }); + + it("returns same day if time hasn't passed", () => { + // 2026-04-06 is Monday + const from = new Date("2026-04-06T08:00:00Z"); + const result = computeNextRun("weekly", "UTC", from, { + dayOfWeek: 1, + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2026-04-06T09:00:00.000Z"); + }); + }); + + describe("monthly", () => { + it("returns this month if day hasn't passed", () => { + const from = new Date("2026-04-03T10:00:00Z"); + const result = computeNextRun("monthly", "UTC", from, { + dayOfMonth: 15, + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2026-04-15T09:00:00.000Z"); + }); + + it("returns next month if day has passed", () => { + const from = new Date("2026-04-20T10:00:00Z"); + const result = computeNextRun("monthly", "UTC", from, { + dayOfMonth: 15, + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2026-05-15T09:00:00.000Z"); + }); + + it("returns next month if same day but time has passed", () => { + const from = new Date("2026-04-15T10:00:00Z"); + const result = computeNextRun("monthly", "UTC", from, { + dayOfMonth: 15, + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2026-05-15T09:00:00.000Z"); + }); + + it("handles year rollover", () => { + const from = new Date("2026-12-20T10:00:00Z"); + const result = computeNextRun("monthly", "UTC", from, { + dayOfMonth: 15, + hour: 9, + minute: 0, + }); + expect(result.toISOString()).toBe("2027-01-15T09:00:00.000Z"); + }); + }); + + describe("DST transitions", () => { + it("handles spring-forward (Europe/Helsinki)", () => { + // 2026 EEST starts last Sunday in March = 2026-03-29 + // Clocks spring forward from 3:00 to 4:00 + // Scheduling at 3:30 should land on 4:30 (or next valid time) + const from = new Date("2026-03-29T00:00:00Z"); // Before DST + const result = computeNextRun("daily", "Europe/Helsinki", from, { + hour: 3, + minute: 30, + }); + // 3:30 doesn't exist due to spring-forward, should resolve to a valid time + expect(result.getTime()).toBeGreaterThan(from.getTime()); + }); + + it("handles fall-back (Europe/Helsinki)", () => { + // 2026 EET starts last Sunday in October = 2026-10-25 + // Clocks fall back from 4:00 to 3:00 + const from = new Date("2026-10-24T20:00:00Z"); + const result = computeNextRun("daily", "Europe/Helsinki", from, { + hour: 2, + minute: 30, + }); + expect(result.getTime()).toBeGreaterThan(from.getTime()); + // Verify the result is on Oct 25 + expect(result.toISOString()).toMatch(/2026-10-25/); + }); + }); +}); + +describe("createScheduleSchema", () => { + const base = { + scheduleType: "daily" as const, + hour: 9, + minute: 0, + timezone: "UTC", + description: "test", + }; + + it("accepts valid reminder with fixedMessage", () => { + const result = createScheduleSchema.safeParse({ + ...base, + fixedMessage: "Hello!", + }); + expect(result.success).toBe(true); + }); + + it("accepts valid reminder with messagePrompt", () => { + const result = createScheduleSchema.safeParse({ + ...base, + messagePrompt: "Generate a greeting", + }); + expect(result.success).toBe(true); + }); + + it("accepts valid monitor with sourceUrl + messagePrompt", () => { + const result = createScheduleSchema.safeParse({ + ...base, + sourceUrl: "https://example.com", + messagePrompt: "Summarize changes", + }); + expect(result.success).toBe(true); + }); + + it("rejects monitor with sourceUrl + fixedMessage", () => { + const result = createScheduleSchema.safeParse({ + ...base, + sourceUrl: "https://example.com", + fixedMessage: "Hello", + }); + expect(result.success).toBe(false); + }); + + it("rejects when both fixedMessage and messagePrompt set (no sourceUrl)", () => { + const result = createScheduleSchema.safeParse({ + ...base, + fixedMessage: "Hello", + messagePrompt: "Generate", + }); + expect(result.success).toBe(false); + }); + + it("rejects when neither fixedMessage nor messagePrompt set", () => { + const result = createScheduleSchema.safeParse(base); + expect(result.success).toBe(false); + }); + + it("rejects daily without hour", () => { + const result = createScheduleSchema.safeParse({ + ...base, + hour: undefined, + fixedMessage: "Hello", + }); + expect(result.success).toBe(false); + }); + + it("rejects weekly without dayOfWeek", () => { + const result = createScheduleSchema.safeParse({ + ...base, + scheduleType: "weekly", + fixedMessage: "Hello", + }); + expect(result.success).toBe(false); + }); + + it("rejects monthly without dayOfMonth", () => { + const result = createScheduleSchema.safeParse({ + ...base, + scheduleType: "monthly", + fixedMessage: "Hello", + }); + expect(result.success).toBe(false); + }); + + it("accepts weekly with dayOfWeek", () => { + const result = createScheduleSchema.safeParse({ + ...base, + scheduleType: "weekly", + dayOfWeek: 1, + fixedMessage: "Hello", + }); + expect(result.success).toBe(true); + }); + + it("accepts monthly with dayOfMonth", () => { + const result = createScheduleSchema.safeParse({ + ...base, + scheduleType: "monthly", + dayOfMonth: 15, + fixedMessage: "Hello", + }); + expect(result.success).toBe(true); + }); + + it("accepts hourly without hour", () => { + const result = createScheduleSchema.safeParse({ + ...base, + scheduleType: "hourly", + hour: undefined, + minute: 30, + fixedMessage: "Hello", + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid timezone", () => { + const result = createScheduleSchema.safeParse({ + ...base, + timezone: "Not/A/Timezone", + fixedMessage: "Hello", + }); + expect(result.success).toBe(false); + }); + + it("rejects description over 200 chars", () => { + const result = createScheduleSchema.safeParse({ + ...base, + description: "x".repeat(201), + fixedMessage: "Hello", + }); + expect(result.success).toBe(false); + }); + + it("rejects fixedMessage over 4000 chars", () => { + const result = createScheduleSchema.safeParse({ + ...base, + fixedMessage: "x".repeat(4001), + }); + expect(result.success).toBe(false); + }); + + it("rejects messagePrompt over 500 chars", () => { + const result = createScheduleSchema.safeParse({ + ...base, + messagePrompt: "x".repeat(501), + }); + expect(result.success).toBe(false); + }); + + it("rejects sourceUrl over 2048 chars", () => { + const result = createScheduleSchema.safeParse({ + ...base, + sourceUrl: "https://example.com/" + "x".repeat(2048), + messagePrompt: "test", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/apps/operator/src/services/schedule.ts b/apps/operator/src/services/schedule.ts new file mode 100644 index 0000000..4ba25e2 --- /dev/null +++ b/apps/operator/src/services/schedule.ts @@ -0,0 +1,433 @@ +import { and, eq, lte, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/d1"; +import type { DrizzleD1Database } from "drizzle-orm/d1"; +import { z } from "zod"; + +import { schedules } from "../db/schema"; + +const SCHEDULE_TYPES = ["hourly", "daily", "weekly", "monthly"] as const; +type ScheduleType = (typeof SCHEDULE_TYPES)[number]; + +const MAX_ACTIVE_SCHEDULES = 20; + +const createScheduleSchema = z + .object({ + scheduleType: z.enum(SCHEDULE_TYPES), + hour: z.number().int().min(0).max(23).optional(), + minute: z.number().int().min(0).max(59).optional(), + dayOfWeek: z.number().int().min(0).max(6).optional(), + dayOfMonth: z.number().int().min(1).max(28).optional(), + timezone: z.string().refine( + (tz) => { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } + }, + { message: "Invalid timezone" } + ), + fixedMessage: z.string().max(4000).optional(), + messagePrompt: z.string().max(500).optional(), + sourceUrl: z.string().max(2048).optional(), + description: z.string().max(200), + }) + .refine( + (d) => { + if (d.sourceUrl) { + return d.messagePrompt != null && d.fixedMessage == null; + } + return (d.fixedMessage != null) !== (d.messagePrompt != null); + }, + { + message: + "Reminders need exactly one of fixedMessage/messagePrompt. Monitors need sourceUrl + messagePrompt, no fixedMessage.", + } + ) + .refine((d) => d.scheduleType === "hourly" || d.hour != null, { + message: "hour is required for daily/weekly/monthly schedules", + }) + .refine((d) => d.scheduleType !== "weekly" || d.dayOfWeek != null, { + message: "dayOfWeek is required for weekly schedules", + }) + .refine((d) => d.scheduleType !== "monthly" || d.dayOfMonth != null, { + message: "dayOfMonth is required for monthly schedules", + }); + +type CreateScheduleInput = z.infer; + +const WEEKDAY_MAP: Record = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, +}; + +/** + * Get the current local parts (hour, minute, day-of-week, day-of-month, etc.) + * for a given Date in a given timezone. + */ +const getLocalParts = (date: Date, timezone: string) => { + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + weekday: "short", + hour12: false, + }); + const parts = Object.fromEntries( + fmt.formatToParts(date).map((p) => [p.type, p.value]) + ); + + return { + year: Number(parts.year), + month: Number(parts.month), + day: Number(parts.day), + hour: Number(parts.hour === "24" ? "0" : parts.hour), + minute: Number(parts.minute), + second: Number(parts.second), + dayOfWeek: WEEKDAY_MAP[parts.weekday] ?? 0, + }; +}; + +/** + * Build a UTC Date from local parts in a given timezone. + * Uses the timezone offset to convert local -> UTC. + */ +const localToUtc = ( + timezone: string, + year: number, + month: number, + day: number, + hour: number, + minute: number +): Date => { + // Start with a guess: treat local parts as UTC + const guess = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0)); + + // Find the offset by checking what local time the guess maps to + const localParts = getLocalParts(guess, timezone); + const localAsUtc = new Date( + Date.UTC( + localParts.year, + localParts.month - 1, + localParts.day, + localParts.hour, + localParts.minute, + localParts.second, + 0 + ) + ); + + const offsetMs = localAsUtc.getTime() - guess.getTime(); + const result = new Date(guess.getTime() - offsetMs); + + // Verify by round-tripping: the result, viewed in the target timezone, + // should show the desired hour. If DST caused a shift (spring-forward), + // the hour won't match — advance by 1 hour. + const verify = getLocalParts(result, timezone); + if (verify.hour !== hour) { + // Spring-forward: the target hour doesn't exist. Advance to the next valid hour. + const diff = ((hour - verify.hour + 24) % 24) * 60 * 60 * 1000; + return new Date(result.getTime() + diff); + } + + return result; +}; + +const computeNextRun = ( + scheduleType: ScheduleType, + timezone: string, + from: Date, + opts: { + hour?: number; + minute?: number; + dayOfWeek?: number; + dayOfMonth?: number; + } +): Date => { + const minute = opts.minute ?? 0; + + if (scheduleType === "hourly") { + // Next occurrence of :MM after `from` + const local = getLocalParts(from, timezone); + if (local.minute < minute) { + // Still in the current hour + return localToUtc( + timezone, + local.year, + local.month, + local.day, + local.hour, + minute + ); + } + // Next hour + const next = new Date(from.getTime() + 60 * 60 * 1000); + const nextLocal = getLocalParts(next, timezone); + return localToUtc( + timezone, + nextLocal.year, + nextLocal.month, + nextLocal.day, + nextLocal.hour, + minute + ); + } + + const hour = opts.hour ?? 0; + + if (scheduleType === "daily") { + const local = getLocalParts(from, timezone); + // Try today + const candidate = localToUtc( + timezone, + local.year, + local.month, + local.day, + hour, + minute + ); + if (candidate.getTime() > from.getTime()) { + return candidate; + } + // Tomorrow + const tomorrow = new Date(from.getTime() + 24 * 60 * 60 * 1000); + const tLocal = getLocalParts(tomorrow, timezone); + return localToUtc( + timezone, + tLocal.year, + tLocal.month, + tLocal.day, + hour, + minute + ); + } + + if (scheduleType === "weekly") { + const targetDay = opts.dayOfWeek ?? 0; + const local = getLocalParts(from, timezone); + + // Try this week + let daysAhead = (targetDay - local.dayOfWeek + 7) % 7; + if (daysAhead === 0) { + // Same day — check if time hasn't passed + const candidate = localToUtc( + timezone, + local.year, + local.month, + local.day, + hour, + minute + ); + if (candidate.getTime() > from.getTime()) { + return candidate; + } + daysAhead = 7; + } + + const target = new Date(from.getTime() + daysAhead * 24 * 60 * 60 * 1000); + const tLocal = getLocalParts(target, timezone); + return localToUtc( + timezone, + tLocal.year, + tLocal.month, + tLocal.day, + hour, + minute + ); + } + + // monthly + const targetDom = opts.dayOfMonth ?? 1; + const local = getLocalParts(from, timezone); + + // Try this month + if (local.day <= targetDom) { + const candidate = localToUtc( + timezone, + local.year, + local.month, + targetDom, + hour, + minute + ); + if (candidate.getTime() > from.getTime()) { + return candidate; + } + } + + // Next month + let nextMonth = local.month + 1; + let nextYear = local.year; + if (nextMonth > 12) { + nextMonth = 1; + nextYear++; + } + return localToUtc(timezone, nextYear, nextMonth, targetDom, hour, minute); +}; + +class ScheduleService { + private readonly db: DrizzleD1Database; + + constructor(d1: D1Database) { + this.db = drizzle(d1); + } + + async create(chatId: number, input: CreateScheduleInput) { + const validated = createScheduleSchema.parse(input); + const now = new Date(); + const nextRunAt = computeNextRun( + validated.scheduleType, + validated.timezone, + now, + { + hour: validated.hour, + minute: validated.minute, + dayOfWeek: validated.dayOfWeek, + dayOfMonth: validated.dayOfMonth, + } + ); + + const [row] = await this.db + .insert(schedules) + .values({ + chatId, + scheduleType: validated.scheduleType, + hour: validated.hour, + minute: validated.minute ?? 0, + dayOfWeek: validated.dayOfWeek, + dayOfMonth: validated.dayOfMonth, + timezone: validated.timezone, + fixedMessage: validated.fixedMessage, + messagePrompt: validated.messagePrompt, + sourceUrl: validated.sourceUrl, + description: validated.description, + nextRunAt: nextRunAt.toISOString(), + }) + .returning(); + + return row; + } + + async list(chatId: number) { + return this.db + .select() + .from(schedules) + .where(and(eq(schedules.chatId, chatId), eq(schedules.active, true))); + } + + async countActive(chatId: number): Promise { + const [result] = await this.db + .select({ count: sql`count(*)` }) + .from(schedules) + .where(and(eq(schedules.chatId, chatId), eq(schedules.active, true))); + return result.count; + } + + async remove(id: string, chatId: number): Promise { + const result = await this.db + .update(schedules) + .set({ active: false }) + .where(and(eq(schedules.id, id), eq(schedules.chatId, chatId))); + return result.meta.changes > 0; + } + + /** + * Claim due schedules for a specific chat. Returns claimed rows + * with next_run_at already advanced to the next occurrence. + */ + async claimDueSchedules(now: Date, allowedChatId: string) { + const nowIso = now.toISOString(); + const chatIdNum = Number(allowedChatId); + + const dueRows = await this.db + .select() + .from(schedules) + .where( + and( + eq(schedules.active, true), + lte(schedules.nextRunAt, nowIso), + eq(schedules.chatId, chatIdNum) + ) + ); + + if (dueRows.length === 0) { + return []; + } + + // Claim each row by advancing next_run_at. + // The WHERE matches the row's actual stored nextRunAt so a concurrent + // cron invocation that already advanced it will get 0 changes. + const claimed: (typeof dueRows)[number][] = []; + for (const row of dueRows) { + const nextRun = computeNextRun(row.scheduleType, row.timezone, now, { + hour: row.hour ?? undefined, + minute: row.minute ?? undefined, + dayOfWeek: row.dayOfWeek ?? undefined, + dayOfMonth: row.dayOfMonth ?? undefined, + }); + const result = await this.db + .update(schedules) + .set({ nextRunAt: nextRun.toISOString() }) + .where( + and(eq(schedules.id, row.id), eq(schedules.nextRunAt, row.nextRunAt)) + ); + if (result.meta.changes > 0) { + claimed.push(row); + } + } + + return claimed; + } + + async markFailed(id: string, currentRetryCount: number) { + const newCount = currentRetryCount + 1; + if (newCount >= 5) { + await this.db + .update(schedules) + .set({ active: false, retryCount: newCount }) + .where(eq(schedules.id, id)); + return { deadLettered: true }; + } + + const backoffMs = newCount * 2 * 60 * 1000; + const nextRetry = new Date(Date.now() + backoffMs); + await this.db + .update(schedules) + .set({ + retryCount: newCount, + nextRunAt: nextRetry.toISOString(), + }) + .where(eq(schedules.id, id)); + return { deadLettered: false }; + } + + async updateState(id: string, stateJson: string) { + const STATE_MAX_BYTES = 100 * 1024; + if (new TextEncoder().encode(stateJson).byteLength > STATE_MAX_BYTES) { + throw new Error("stateJson exceeds 100KB limit"); + } + await this.db + .update(schedules) + .set({ stateJson }) + .where(eq(schedules.id, id)); + } +} + +export { + computeNextRun, + createScheduleSchema, + MAX_ACTIVE_SCHEDULES, + SCHEDULE_TYPES, + ScheduleService, +}; +export type { CreateScheduleInput, ScheduleType }; diff --git a/apps/operator/src/types/env.ts b/apps/operator/src/types/env.ts index 7866e01..93b0b84 100644 --- a/apps/operator/src/types/env.ts +++ b/apps/operator/src/types/env.ts @@ -8,17 +8,17 @@ const envSchema = z.object({ OPENAI_API_KEY: z.string().min(1), }); -type Env = z.infer; - type AppEnv = { - Bindings: Env; + Bindings: z.infer & { + DB: D1Database; + }; Variables: { logger: Logger; requestId: string; }; }; -const parseEnv = (env: unknown): Env => { +const parseEnv = (env: unknown): z.infer => { return envSchema.parse(env); }; diff --git a/apps/operator/src/utils/message.ts b/apps/operator/src/utils/message.ts new file mode 100644 index 0000000..fc19985 --- /dev/null +++ b/apps/operator/src/utils/message.ts @@ -0,0 +1,24 @@ +const TELEGRAM_MAX_MESSAGE_LENGTH = 4096; + +const splitMessage = (text: string): string[] => { + if (text.length <= TELEGRAM_MAX_MESSAGE_LENGTH) { + return [text]; + } + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + if (remaining.length <= TELEGRAM_MAX_MESSAGE_LENGTH) { + chunks.push(remaining); + break; + } + let splitAt = remaining.lastIndexOf("\n", TELEGRAM_MAX_MESSAGE_LENGTH); + if (splitAt <= 0) { + splitAt = TELEGRAM_MAX_MESSAGE_LENGTH; + } + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n/, ""); + } + return chunks; +}; + +export { splitMessage, TELEGRAM_MAX_MESSAGE_LENGTH }; diff --git a/apps/operator/src/utils/url-validator.test.ts b/apps/operator/src/utils/url-validator.test.ts new file mode 100644 index 0000000..0d8a9f8 --- /dev/null +++ b/apps/operator/src/utils/url-validator.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { parseAllowedDomains, validateSourceUrl } from "./url-validator"; + +const DOMAINS = ["telsu.fi", "blackrock.com"]; + +describe("validateSourceUrl", () => { + it("accepts valid HTTPS URL on allowed domain", () => { + expect(validateSourceUrl("https://www.telsu.fi/", DOMAINS)).toEqual({ + valid: true, + }); + }); + + it("accepts subdomain of allowed domain", () => { + expect( + validateSourceUrl( + "https://www.blackrock.com/us/individual/insights", + DOMAINS + ) + ).toEqual({ valid: true }); + }); + + it("accepts exact domain match", () => { + expect(validateSourceUrl("https://telsu.fi/page", DOMAINS)).toEqual({ + valid: true, + }); + }); + + it("rejects HTTP URLs", () => { + const result = validateSourceUrl("http://www.telsu.fi/", DOMAINS); + expect(result).toEqual({ + valid: false, + reason: "Only HTTPS URLs are allowed", + }); + }); + + it("rejects non-allowlisted domains", () => { + const result = validateSourceUrl("https://evil.com/", DOMAINS); + expect(result.valid).toBe(false); + expect(result).toHaveProperty("reason"); + }); + + it("rejects localhost", () => { + const result = validateSourceUrl("https://localhost/", DOMAINS); + expect(result.valid).toBe(false); + }); + + it("rejects 127.0.0.1", () => { + const result = validateSourceUrl("https://127.0.0.1/", DOMAINS); + expect(result.valid).toBe(false); + }); + + it("rejects [::1]", () => { + const result = validateSourceUrl("https://[::1]/", DOMAINS); + expect(result.valid).toBe(false); + }); + + it("rejects URLs with credentials", () => { + const result = validateSourceUrl("https://user:pass@telsu.fi/", DOMAINS); + expect(result).toEqual({ + valid: false, + reason: "URLs with credentials are not allowed", + }); + }); + + it("rejects URLs over 2048 chars", () => { + const result = validateSourceUrl( + "https://telsu.fi/" + "a".repeat(2048), + DOMAINS + ); + expect(result).toEqual({ + valid: false, + reason: "URL exceeds 2048 character limit", + }); + }); + + it("rejects invalid URLs", () => { + const result = validateSourceUrl("not-a-url", DOMAINS); + expect(result).toEqual({ valid: false, reason: "Invalid URL" }); + }); + + it("rejects FTP URLs", () => { + const result = validateSourceUrl("ftp://telsu.fi/", DOMAINS); + expect(result).toEqual({ + valid: false, + reason: "Only HTTPS URLs are allowed", + }); + }); + + it("rejects file URLs", () => { + const result = validateSourceUrl("file:///etc/passwd", DOMAINS); + expect(result.valid).toBe(false); + }); + + it("rejects when allowed domains list is empty", () => { + const result = validateSourceUrl("https://telsu.fi/", []); + expect(result.valid).toBe(false); + }); + + it("prevents domain suffix attacks (eviltelsu.fi)", () => { + const result = validateSourceUrl("https://eviltelsu.fi/", DOMAINS); + expect(result.valid).toBe(false); + }); +}); + +describe("parseAllowedDomains", () => { + it("parses comma-separated domains", () => { + expect(parseAllowedDomains("telsu.fi,blackrock.com")).toEqual([ + "telsu.fi", + "blackrock.com", + ]); + }); + + it("trims whitespace", () => { + expect(parseAllowedDomains(" telsu.fi , blackrock.com ")).toEqual([ + "telsu.fi", + "blackrock.com", + ]); + }); + + it("returns empty array for undefined", () => { + expect(parseAllowedDomains(undefined)).toEqual([]); + }); + + it("returns empty array for empty string", () => { + expect(parseAllowedDomains("")).toEqual([]); + }); +}); diff --git a/apps/operator/src/utils/url-validator.ts b/apps/operator/src/utils/url-validator.ts new file mode 100644 index 0000000..6f19fde --- /dev/null +++ b/apps/operator/src/utils/url-validator.ts @@ -0,0 +1,69 @@ +/** + * Validates a source URL against the SSRF safety policy: + * - HTTPS only + * - Hostname must be in the allowed domains list + * - Max 2048 characters + * + * DNS-level private IP checks are deferred to the fetch layer + * (Cloudflare Workers already block fetches to private IPs). + */ +const validateSourceUrl = ( + url: string, + allowedDomains: string[] +): { valid: true } | { valid: false; reason: string } => { + if (url.length > 2048) { + return { valid: false, reason: "URL exceeds 2048 character limit" }; + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { valid: false, reason: "Invalid URL" }; + } + + if (parsed.protocol !== "https:") { + return { valid: false, reason: "Only HTTPS URLs are allowed" }; + } + + if (parsed.username || parsed.password) { + return { valid: false, reason: "URLs with credentials are not allowed" }; + } + + const hostname = parsed.hostname.toLowerCase(); + + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "[::1]" || + hostname === "0.0.0.0" + ) { + return { valid: false, reason: "Localhost URLs are not allowed" }; + } + + const isAllowed = allowedDomains.some((domain) => { + const d = domain.toLowerCase(); + return hostname === d || hostname.endsWith(`.${d}`); + }); + + if (!isAllowed) { + return { + valid: false, + reason: `Domain "${hostname}" is not in the allowed list`, + }; + } + + return { valid: true }; +}; + +const parseAllowedDomains = (envValue: string | undefined): string[] => { + if (!envValue) { + return []; + } + return envValue + .split(",") + .map((d) => d.trim()) + .filter((d) => d.length > 0); +}; + +export { parseAllowedDomains, validateSourceUrl }; diff --git a/apps/operator/wrangler.jsonc b/apps/operator/wrangler.jsonc index e455bc8..660c641 100644 --- a/apps/operator/wrangler.jsonc +++ b/apps/operator/wrangler.jsonc @@ -10,4 +10,15 @@ "invocation_logs": true, }, }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "switch-operator-db", + "database_id": "", + "migrations_dir": "migrations", + }, + ], + "triggers": { + "crons": ["* * * * *"], + }, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70eff85..59580f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@repo/logger': specifier: workspace:* version: link:../../packages/logger + drizzle-orm: + specifier: 0.45.2 + version: 0.45.2(@cloudflare/workers-types@4.20260317.1) hono: specifier: 4.12.9 version: 4.12.9 @@ -57,6 +60,9 @@ importers: '@types/node': specifier: 25.2.3 version: 25.2.3 + drizzle-kit: + specifier: 0.31.10 + version: 0.31.10 eslint: specifier: 9.39.1 version: 9.39.1(jiti@2.6.1) @@ -316,6 +322,9 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -325,156 +334,452 @@ packages: '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -1046,6 +1351,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1142,6 +1450,102 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1187,6 +1591,16 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -1997,6 +2411,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2421,6 +2842,8 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -2437,81 +2860,235 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true @@ -3070,6 +3647,8 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-from@1.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -3162,6 +3741,17 @@ snapshots: dependencies: esutils: 2.0.3 + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.21.0 + + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1): + optionalDependencies: + '@cloudflare/workers-types': 4.20260317.1 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3276,6 +3866,60 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -4163,6 +4807,13 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + stackback@0.0.2: {} std-env@4.0.0: {} From 96f3c8b71ba01bb648596cff358ac728278d69d1 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:21:54 +0300 Subject: [PATCH 2/5] fix: standardize argument naming in OpenAiService and improve error handling --- .../src/modules/telegram/controller.ts | 3 ++- apps/operator/src/services/openai.ts | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/operator/src/modules/telegram/controller.ts b/apps/operator/src/modules/telegram/controller.ts index a51438a..2f7f047 100644 --- a/apps/operator/src/modules/telegram/controller.ts +++ b/apps/operator/src/modules/telegram/controller.ts @@ -128,7 +128,6 @@ const handleWebhook = async (c: Context) => { // Check for pending confirmation const pending = await pendingService.get(chatId); if (pending) { - await pendingService.clear(chatId); const confirmed = message.text.trim().toUpperCase() === "YES"; if (confirmed) { @@ -138,12 +137,14 @@ const handleWebhook = async (c: Context) => { c.env.DB, logger ); + await pendingService.clear(chatId); for (const chunk of splitMessage(result)) { await telegram.sendMessage({ chat_id: chatId, text: chunk }); } return c.json({ ok: true }); } + await pendingService.clear(chatId); await telegram.sendMessage({ chat_id: chatId, text: "Action cancelled.", diff --git a/apps/operator/src/services/openai.ts b/apps/operator/src/services/openai.ts index d17f5ea..e48e7f0 100644 --- a/apps/operator/src/services/openai.ts +++ b/apps/operator/src/services/openai.ts @@ -14,7 +14,7 @@ Schedule types: - weekly: runs every week on the specified day at hour:minute - monthly: runs every month on the specified day at hour:minute -Use fixedMessage for exact text or messagePrompt for AI-generated content.`; +Use fixed_message for exact text or message_prompt for AI-generated content.`; const SCHEDULE_TOOLS: ChatCompletionTool[] = [ { @@ -183,10 +183,24 @@ class OpenAiService { iteration: i, }); - const args = JSON.parse(toolCall.function.arguments) as Record< - string, - unknown - >; + let args: Record; + try { + args = JSON.parse(toolCall.function.arguments) as Record< + string, + unknown + >; + } catch { + this.logger.error("failed to parse tool call arguments", { + tool: toolCall.function.name, + arguments: toolCall.function.arguments, + }); + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify({ error: "Invalid tool arguments" }), + }); + continue; + } const result = await toolExecutor(toolCall.function.name, args); messages.push({ From c33d395622bd4676c7492d1bc926f834d3090581 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:26:44 +0300 Subject: [PATCH 3/5] chore: update database_id in wrangler.jsonc --- apps/operator/wrangler.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/operator/wrangler.jsonc b/apps/operator/wrangler.jsonc index 660c641..87760ea 100644 --- a/apps/operator/wrangler.jsonc +++ b/apps/operator/wrangler.jsonc @@ -14,7 +14,7 @@ { "binding": "DB", "database_name": "switch-operator-db", - "database_id": "", + "database_id": "4624e5e2-045d-48a8-a6ac-a70c8a274146", "migrations_dir": "migrations", }, ], From ab328318fc17b0116b1f3c2f7a7770ac4db923f5 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:40:41 +0300 Subject: [PATCH 4/5] feat: add success marking for scheduled messages --- apps/operator/src/modules/telegram/controller.ts | 16 +++++++++++----- apps/operator/src/scheduled.ts | 4 ++++ apps/operator/src/services/schedule.ts | 7 +++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/operator/src/modules/telegram/controller.ts b/apps/operator/src/modules/telegram/controller.ts index 2f7f047..ef51ec7 100644 --- a/apps/operator/src/modules/telegram/controller.ts +++ b/apps/operator/src/modules/telegram/controller.ts @@ -248,13 +248,19 @@ const handleWebhook = async (c: Context) => { }); reply = "Something went wrong while generating a response. Please try again."; + await pendingService.clear(chatId); } - for (const chunk of splitMessage(reply)) { - await telegram.sendMessage({ - chat_id: chatId, - text: chunk, - }); + try { + for (const chunk of splitMessage(reply)) { + await telegram.sendMessage({ + chat_id: chatId, + text: chunk, + }); + } + } catch (error) { + await pendingService.clear(chatId); + throw error; } return c.json({ ok: true }); diff --git a/apps/operator/src/scheduled.ts b/apps/operator/src/scheduled.ts index ab6a589..ebea7c3 100644 --- a/apps/operator/src/scheduled.ts +++ b/apps/operator/src/scheduled.ts @@ -81,6 +81,10 @@ const handleScheduled = async ( for (let i = 0; i < results.length; i++) { const result = results[i]; const schedule = claimed[i]; + if (result.status === "fulfilled") { + await scheduleService.markSuccess(schedule.id); + } + if (result.status === "rejected") { logger.error("scheduled message failed", { scheduleId: schedule.id, diff --git a/apps/operator/src/services/schedule.ts b/apps/operator/src/services/schedule.ts index 4ba25e2..66bc7e4 100644 --- a/apps/operator/src/services/schedule.ts +++ b/apps/operator/src/services/schedule.ts @@ -411,6 +411,13 @@ class ScheduleService { return { deadLettered: false }; } + async markSuccess(id: string) { + await this.db + .update(schedules) + .set({ retryCount: 0 }) + .where(eq(schedules.id, id)); + } + async updateState(id: string, stateJson: string) { const STATE_MAX_BYTES = 100 * 1024; if (new TextEncoder().encode(stateJson).byteLength > STATE_MAX_BYTES) { From 35de10dc6766a207c0ce27831610f457dd4a9780 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sun, 5 Apr 2026 16:49:18 +0300 Subject: [PATCH 5/5] docs: update system prompt with schedule listing and deletion details --- apps/operator/src/services/openai.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/operator/src/services/openai.ts b/apps/operator/src/services/openai.ts index e48e7f0..759453d 100644 --- a/apps/operator/src/services/openai.ts +++ b/apps/operator/src/services/openai.ts @@ -14,7 +14,10 @@ Schedule types: - weekly: runs every week on the specified day at hour:minute - monthly: runs every month on the specified day at hour:minute -Use fixed_message for exact text or message_prompt for AI-generated content.`; +Use fixed_message for exact text or message_prompt for AI-generated content. + +When listing schedules, format them as a numbered list (1, 2, 3...) with key details like description, type, time, and next run. +When the user asks to delete a schedule by number, first call list_schedules to get the current list, then use the ID from the matching position to call delete_schedule.`; const SCHEDULE_TOOLS: ChatCompletionTool[] = [ {