diff --git a/.env.example b/.env.example index 60c03960..3be872b7 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ PORT=8080 #SLACK_BOT_TOKEN=xoxb-... #SLACK_APP_TOKEN=xapp-... +#TELEGRAM_BOT_TOKEN=123456:ABC-... +#TELEGRAM_ALLOWED_CHAT_IDS=123456,789012 + CORE_SIGNING_SECRET= CAPABILITY_SECRET= PORTAL_IDENTITY_SECRET= diff --git a/package-lock.json b/package-lock.json index be8f50f3..2c0908fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,8 @@ "pg-boss": "^12.26.1", "tar-stream": "^3.2.0", "typebox": "^1.1.38", - "zod": "4.4.3" + "zod": "4.4.3", + "grammy": "^1.45.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -7796,6 +7797,79 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/grammy": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.45.1.tgz", + "integrity": "sha512-Y4VL/hqJMZZxwlUr5ZgM68CFu2iIeEkNLR1cY3+Ww68CIvWARDsoXFix7+31rmyC0+7L85ZI+Pq3E5JSG5+nLQ==", + "dependencies": { + "@grammyjs/types": "4.0.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^18.20.0 || >=20.6.0" + } + }, + "node_modules/@grammyjs/types": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-4.0.0.tgz", + "integrity": "sha512-Z8lDLTvOlo12e5Vnly/vQh3JC9ppaitS1dGZ3w068gNitOd/y8tTSiib+Xm38aBGbtYGmUZwOc6afYrLs2CSTg==" + }, + "node_modules/grammy/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orX0xfREivs3C9tG+BTKBvZGcjJDGz5mdNTEgdDT1Xy6n3vO4XZShkAc2ZDTvTyLYVYg==", + "license": "BSD-2-Clause" } } } diff --git a/package.json b/package.json index 17bc5dfc..4166c460 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "croner": "^10.0.1", "emoji-datasource": "^16.0.0", "fastify": "^5.10.0", + "grammy": "^1.45.1", "jose": "^6.2.3", "lru-cache": "^11.5.2", "opencode-ai": "1.17.18", diff --git a/src/api/core-bridge.ts b/src/api/core-bridge.ts new file mode 100644 index 00000000..be1c1c9b --- /dev/null +++ b/src/api/core-bridge.ts @@ -0,0 +1,181 @@ +import { swallow } from "../util/errors.ts"; +import type { CoreClient } from "./core-client.ts"; +import type { TaskStatus } from "../tasks/task-store.ts"; +import type { TurnRequest, TurnResult } from "../types.ts"; + +export type CoreTurnBody = Omit; + +export interface CoreCallHooks { + onQueued?: (runId: string) => void; + onSteered?: (runId: string) => void; + onFirstBlock?: (text: string) => void; + onSurfacePosted?: () => void; + onTasks?: (tasks: Array<{ id: string; title: string; status: TaskStatus }>) => void | Promise; +} + +export interface CoreBridge { + callCore(body: CoreTurnBody, hooks?: CoreCallHooks): Promise; + inFlightRuns: { add(runId: string): void; delete(runId: string): void; has(runId: string): boolean }; + inFlightRunByThread: { + set(threadRef: string, runId: string): void; + get(threadRef: string): string | undefined; + clear(threadRef: string, runId: string): void; + }; + signalRunAbort(runId: string): Promise; + fetchActiveRunForThread(threadRef: string): Promise; + ackRunDeliveryWithRetry(runId: string): void; + reportTurnMetrics(runId: string, patch: { deliverMs?: number; slackInflightMs?: number }): void; + checkpointRunEditRef(runId: string, editRef: string): Promise; + reportRunEditRef(runId: string, editRef: string): void; + stageBlobInCore(bytes: Uint8Array): Promise<{ blobId: string; sizeBytes: number }>; + fetchBlobFromCore(blobId: string): Promise; + fetchFileArtifactFromCore(artifactId: string, viewerId: string): Promise; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createCoreBridge(core: CoreClient, surface = "slack"): CoreBridge { + const stageBlobInCore = async (bytes: Uint8Array): Promise<{ blobId: string; sizeBytes: number }> => { + try { + return await core.stageBlob(bytes); + } catch (err) { + if ((err as Error)?.name === "BlobTooLargeError") + throw new Error("that request was too large — try fewer or smaller files", { cause: err }); + throw err; + } + }; + const fetchBlobFromCore = (blobId: string): Promise => core.readBlob(blobId); + const fetchFileArtifactFromCore = (artifactId: string, viewerId: string): Promise => + core.readFileArtifact(artifactId, viewerId); + + const inFlightRunPins = new Map(); + const inFlightRuns = { + add: (runId: string): void => void inFlightRunPins.set(runId, (inFlightRunPins.get(runId) ?? 0) + 1), + delete: (runId: string): void => { + const held = inFlightRunPins.get(runId) ?? 0; + if (held <= 1) inFlightRunPins.delete(runId); + else inFlightRunPins.set(runId, held - 1); + }, + has: (runId: string): boolean => inFlightRunPins.has(runId), + }; + + const inFlightRunByThread = new Map(); + + const signalRunAbort = (runId: string): Promise => core.signalRunAbort(runId); + + const fetchActiveRunForThread = (threadRef: string): Promise => + core.activeRunForThread(threadRef); + + const ackRunDelivery = (runId: string): Promise => core.ackRunDelivery(runId); + + const ACK_RETRY_DELAYS_MS = [2_000, 5_000, 15_000, 30_000]; + function ackRunDeliveryWithRetry(runId: string): void { + void (async () => { + for (let attempt = 0; ; attempt++) { + try { + await ackRunDelivery(runId); + return; + } catch (err) { + if (attempt >= ACK_RETRY_DELAYS_MS.length) { + console.error( + `[${surface}-plugin] recovery-copy ack failed for run ${runId} (giving up — the poller may re-deliver):`, + (err as Error).message, + ); + return; + } + await sleep(ACK_RETRY_DELAYS_MS[attempt]!); + } + } + })().finally(() => inFlightRuns.delete(runId)); + } + + function reportTurnMetrics(runId: string, patch: { deliverMs?: number; slackInflightMs?: number }): void { + if (patch.deliverMs === undefined && patch.slackInflightMs === undefined) return; + void core + .reportTurnMetrics(runId, patch) + .catch((err) => + console.error(`[${surface}-plugin] turn-metrics report failed for run ${runId}:`, (err as Error).message), + ); + } + + async function checkpointRunEditRef(runId: string, editRef: string): Promise { + await core.reportRunEditRef(runId, editRef); + } + + function reportRunEditRef(runId: string, editRef: string): void { + void checkpointRunEditRef(runId, editRef).catch((err) => + console.error(`[${surface}-plugin] delivery-state checkpoint failed for run ${runId}:`, (err as Error).message), + ); + } + + function coreFailure(err: unknown): Error { + swallow(`${surface}: core call`, err); + if ((err as { code?: string })?.code === "run_stalled") { + return new Error( + "this request is taking unusually long — I'm still on it and will post the result here as soon as it finishes", + { cause: err }, + ); + } + return new Error("I couldn't reach the agent core — it may be busy or deploying; please try again in a moment", { + cause: err, + }); + } + + async function callCore(body: CoreTurnBody, hooks: CoreCallHooks = {}): Promise { + let queued: TurnResult; + try { + queued = await core.submitTurn({ async: true, ...body }); + } catch (err) { + throw coreFailure(err); + } + if (queued.status !== "queued" || !queued.runId) return queued; + if (queued.steered) { + hooks.onSteered?.(queued.runId); + return { status: "silent", steered: true }; + } + hooks.onQueued?.(queued.runId); + return pollRun(queued.runId, hooks); + } + + async function pollRun(runId: string, hooks: CoreCallHooks = {}): Promise { + inFlightRuns.add(runId); + let result: TurnResult | null; + try { + result = await core.waitRun(runId, { + ...(hooks.onFirstBlock ? { onFirstBlock: hooks.onFirstBlock } : {}), + ...(hooks.onSurfacePosted ? { onSurfacePosted: hooks.onSurfacePosted } : {}), + ...(hooks.onTasks ? { onTasks: hooks.onTasks } : {}), + }); + } catch (err) { + inFlightRuns.delete(runId); + throw coreFailure(err); + } + if (result?.status === "refused" && result.refusalKind === "security_quarantine") { + return result; + } + if (result && (result.status === "ok" || result.status === "refused" || result.status === "failed")) { + ackRunDeliveryWithRetry(runId); + } else { + inFlightRuns.delete(runId); + } + if (result) return result; + throw new Error("the agent finished without producing a reply"); + } + + return { + callCore, + inFlightRuns, + inFlightRunByThread, + signalRunAbort, + fetchActiveRunForThread, + ackRunDeliveryWithRetry, + reportTurnMetrics, + checkpointRunEditRef, + reportRunEditRef, + stageBlobInCore, + fetchBlobFromCore, + fetchFileArtifactFromCore, + }; +} diff --git a/src/api/slack-core-client.ts b/src/api/core-client.ts similarity index 95% rename from src/api/slack-core-client.ts rename to src/api/core-client.ts index 67f88aa9..6944ec27 100644 --- a/src/api/slack-core-client.ts +++ b/src/api/core-client.ts @@ -27,7 +27,7 @@ import { swallowAs } from "../util/errors.ts"; import { resolveRuntimeChoiceDurable, type RuntimeChoice } from "../harness/harness-router.ts"; import { modelDisplayName } from "../model/pi-models.ts"; -interface SlackRunHooks { +interface RunHooks { onFirstBlock?(text: string): void; onSurfacePosted?(): void; onTasks?(tasks: Array<{ id: string; title: string; status: TaskStatus }>): void | Promise; @@ -53,7 +53,7 @@ interface DirectoryPush { groupsSyncedAt?: number; } -export interface SlackCoreClient { +export interface CoreClient { externalSlackParticipants(): Promise; surfaceHeaderFacts(scope: ScopeId): Promise<{ agentLabel?: string; modelName: string }>; onScopeModelChanged(listener: (scope: ScopeId) => void): void; @@ -62,7 +62,7 @@ export interface SlackCoreClient { readFileArtifact(artifactId: string, viewerId: string): Promise; ingestSurfaceEvents(events: IngestEvent[], self?: { name?: string; mentionId?: string }): Promise; submitTurn(body: Omit): Promise; - waitRun(runId: string, hooks?: SlackRunHooks): Promise; + waitRun(runId: string, hooks?: RunHooks): Promise; activeRunForThread(threadRef: string): Promise; signalRunAbort(runId: string): Promise; ackRunDelivery(runId: string): Promise; @@ -93,7 +93,7 @@ type AckPickInput = { export type { SurfaceContextRequest }; -export interface SlackCoreClientDeps { +export interface CoreClientDeps { app: App; config: ScopedConfigStore; runtimeFallback: RuntimeChoice; @@ -116,7 +116,7 @@ function agentLabelFrom(raw: string | undefined): string | undefined { const RUN_FALLBACK_POLL_MS = 1_000; const RUN_STALL_BUDGET_MS = 300_000; -export function createSlackCoreClient(deps: SlackCoreClientDeps): SlackCoreClient { +export function createCoreClient(deps: CoreClientDeps, surface = "slack"): CoreClient { const orgScope: ScopeId = scopeId("org", configOrgId()); const terminalWaiters = new Map void>>(); deps.runs.onTerminal((run) => { @@ -168,7 +168,7 @@ export function createSlackCoreClient(deps: SlackCoreClientDeps): SlackCoreClien }, submitTurn(body) { - return deps.app.turn({ ...body, surface: "slack" }); + return deps.app.turn({ ...body, surface }); }, async waitRun(runId, hooks = {}) { @@ -220,7 +220,7 @@ export function createSlackCoreClient(deps: SlackCoreClientDeps): SlackCoreClien if (deps.turnStream.surfacePosted(runId)) signalSurface(); if (isTerminal(run.status)) { const view = await deps.app.getRun(runId); - await emitTasks().catch(swallowAs("slack-core-client: terminal task refresh", undefined)); + await emitTasks().catch(swallowAs("core-client: terminal task refresh", undefined)); if (view?.surfacePosted) signalSurface(); return (view?.result as TurnResult | null | undefined) ?? null; } @@ -311,12 +311,12 @@ export function createSlackCoreClient(deps: SlackCoreClientDeps): SlackCoreClien }, pendingContextRequests() { - return deps.app.pendingContextRequests("slack"); + return deps.app.pendingContextRequests(surface); }, onContextRequest(listener) { return deps.app.onContextRequestCreated((request) => { - if (request.source === "slack") listener(request); + if (request.source === surface) listener(request); }); }, @@ -329,7 +329,7 @@ export function createSlackCoreClient(deps: SlackCoreClientDeps): SlackCoreClien const ackModel = deps.ackModelId?.(); await deps.ackPicks .record({ - surface: "slack", + surface, channel: pick.channel, ts: pick.ts, outcome: pick.outcome, @@ -350,7 +350,7 @@ export function createSlackCoreClient(deps: SlackCoreClientDeps): SlackCoreClien .then((ok) => { if (!ok) return; }) - .catch(swallowAs("slack-core-client: fulfill context request", undefined)); + .catch(swallowAs("core-client: fulfill context request", undefined)); }, }; } diff --git a/src/index.ts b/src/index.ts index fbd390c3..0bb1d460 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { errMessage } from "./util/errors.ts"; import { defaultModelForHarness, modelProviderAvailabilityFor } from "./model/pi-models.ts"; import { effectiveEgressEnforcement } from "./sandbox/sandbox.ts"; import { slackPluginConfigFromEnv, startSlackPlugin } from "./slack/index.ts"; +import { telegramPluginConfigFromEnv, startTelegramPlugin } from "./telegram/index.ts"; import { createSlackRuntimeReconciler } from "./surfaces/slack-runtime.ts"; const config = loadConfig(); @@ -131,12 +132,26 @@ const slackRuntime = createSlackRuntimeReconciler({ }); slackRuntime.start(); +const telegramConfig = telegramPluginConfigFromEnv(process.env); +let telegramPlugin: { stop(): Promise } | null = null; +if (telegramConfig) { + try { + telegramPlugin = await startTelegramPlugin(telegramConfig, built.telegramCore); + console.log("[qm] telegram plugin started"); + } catch (error) { + console.error(`[qm] telegram plugin failed to start: ${errMessage(error)}`); + } +} + let shuttingDown = false; function shutdown(signal: string): void { if (shuttingDown) return; shuttingDown = true; console.log(`[qm] ${signal} received, shutting down`); void slackRuntime.stop().catch((e: unknown) => console.error("[qm] slack plugin stop failed:", errMessage(e))); + if (telegramPlugin) { + void telegramPlugin.stop().catch((e: unknown) => console.error("[qm] telegram plugin stop failed:", errMessage(e))); + } built.scheduler.stop(); built.deploymentLayerRefresh.stop(); server.close(); diff --git a/src/slack/ack-emoji.ts b/src/slack/ack-emoji.ts index 5ce084c7..14cbcdf3 100644 --- a/src/slack/ack-emoji.ts +++ b/src/slack/ack-emoji.ts @@ -1,7 +1,7 @@ import { swallowAs } from "../util/errors.ts"; import { CURATED_ACK_EMOJI, DEFAULT_ACK_REACTIONS } from "./lib.ts"; import { EMOJI_NAME_BY_CHAR } from "./emoji-map.ts"; -import type { SlackCoreClient } from "../api/slack-core-client.ts"; +import type { CoreClient } from "../api/core-client.ts"; export interface AckEmojiPicker { refreshAckEmoji(client: any): void; @@ -30,7 +30,7 @@ function sample(items: readonly T[], n: number): T[] { return pool.slice(0, n); } -export function createAckEmojiPicker(core: SlackCoreClient): AckEmojiPicker { +export function createAckEmojiPicker(core: CoreClient): AckEmojiPicker { let ackEmojiCache: { custom: string[]; urls: Record; at: number } | null = null; let ackEmojiInFlight: Promise | null = null; function refreshAckEmoji(client: any): void { diff --git a/src/slack/approvals.ts b/src/slack/approvals.ts index 851e363c..be33ce2e 100644 --- a/src/slack/approvals.ts +++ b/src/slack/approvals.ts @@ -26,7 +26,7 @@ import { uploadFailureNote, } from "./lib.ts"; import { resolveAgentRequestTarget } from "./approval-context.ts"; -import type { SlackCoreClient } from "../api/slack-core-client.ts"; +import type { CoreClient } from "../api/core-client.ts"; import type { TurnResult } from "../types.ts"; import type { CoreBridge, CoreTurnBody } from "./core-bridge.ts"; import type { Directory } from "./directory.ts"; @@ -118,7 +118,7 @@ export interface Approvals { } export function createApprovals(deps: { - core: SlackCoreClient; + core: CoreClient; bridge: CoreBridge; directory: Directory; threads: ReturnType; diff --git a/src/slack/core-bridge.ts b/src/slack/core-bridge.ts index 5946cb8b..25ffd7a5 100644 --- a/src/slack/core-bridge.ts +++ b/src/slack/core-bridge.ts @@ -1,177 +1 @@ -import { swallow } from "../util/errors.ts"; -import { sleep, createInFlightThreadMap, type RunTaskView } from "./lib.ts"; -import type { SlackCoreClient } from "../api/slack-core-client.ts"; -import type { TurnRequest, TurnResult } from "../types.ts"; - -export type CoreTurnBody = Omit; - -interface CoreCallHooks { - onQueued?: (runId: string) => void; - /** The turn was folded into a run that was ALREADY live (a mid-turn steer), so this handler - * owns nothing: the envelope is durably accepted, but the reply belongs to the run's owner. */ - onSteered?: (runId: string) => void; - onFirstBlock?: (text: string) => void; - onSurfacePosted?: () => void; - onTasks?: (tasks: RunTaskView[]) => void; -} - -export interface CoreBridge { - callCore(body: CoreTurnBody, hooks?: CoreCallHooks): Promise; - inFlightRuns: { add(runId: string): void; delete(runId: string): void; has(runId: string): boolean }; - inFlightRunByThread: ReturnType; - signalRunAbort(runId: string): Promise; - fetchActiveRunForThread(threadRef: string): Promise; - ackRunDeliveryWithRetry(runId: string): void; - reportTurnMetrics(runId: string, patch: { deliverMs?: number; slackInflightMs?: number }): void; - checkpointRunEditRef(runId: string, editRef: string): Promise; - reportRunEditRef(runId: string, editRef: string): void; - stageBlobInCore(bytes: Uint8Array): Promise<{ blobId: string; sizeBytes: number }>; - fetchBlobFromCore(blobId: string): Promise; - fetchFileArtifactFromCore(artifactId: string, viewerId: string): Promise; -} - -export function createCoreBridge(core: SlackCoreClient): CoreBridge { - const stageBlobInCore = async (bytes: Uint8Array): Promise<{ blobId: string; sizeBytes: number }> => { - try { - return await core.stageBlob(bytes); - } catch (err) { - if ((err as Error)?.name === "BlobTooLargeError") - throw new Error("that request was too large — try fewer or smaller files", { cause: err }); - throw err; - } - }; - const fetchBlobFromCore = (blobId: string): Promise => core.readBlob(blobId); - const fetchFileArtifactFromCore = (artifactId: string, viewerId: string): Promise => - core.readFileArtifact(artifactId, viewerId); - - const inFlightRunPins = new Map(); - const inFlightRuns = { - add: (runId: string): void => void inFlightRunPins.set(runId, (inFlightRunPins.get(runId) ?? 0) + 1), - delete: (runId: string): void => { - const held = inFlightRunPins.get(runId) ?? 0; - if (held <= 1) inFlightRunPins.delete(runId); - else inFlightRunPins.set(runId, held - 1); - }, - has: (runId: string): boolean => inFlightRunPins.has(runId), - }; - - const inFlightRunByThread = createInFlightThreadMap(); - - const signalRunAbort = (runId: string): Promise => core.signalRunAbort(runId); - - const fetchActiveRunForThread = (threadRef: string): Promise => - core.activeRunForThread(threadRef); - - const ackRunDelivery = (runId: string): Promise => core.ackRunDelivery(runId); - - const ACK_RETRY_DELAYS_MS = [2_000, 5_000, 15_000, 30_000]; - function ackRunDeliveryWithRetry(runId: string): void { - void (async () => { - for (let attempt = 0; ; attempt++) { - try { - await ackRunDelivery(runId); - return; - } catch (err) { - if (attempt >= ACK_RETRY_DELAYS_MS.length) { - console.error( - `[slack-plugin] recovery-copy ack failed for run ${runId} (giving up — the poller may re-deliver):`, - (err as Error).message, - ); - return; - } - await sleep(ACK_RETRY_DELAYS_MS[attempt]!); - } - } - })().finally(() => inFlightRuns.delete(runId)); - } - - function reportTurnMetrics(runId: string, patch: { deliverMs?: number; slackInflightMs?: number }): void { - if (patch.deliverMs === undefined && patch.slackInflightMs === undefined) return; - void core - .reportTurnMetrics(runId, patch) - .catch((err) => - console.error(`[slack-plugin] turn-metrics report failed for run ${runId}:`, (err as Error).message), - ); - } - - async function checkpointRunEditRef(runId: string, editRef: string): Promise { - await core.reportRunEditRef(runId, editRef); - } - - function reportRunEditRef(runId: string, editRef: string): void { - void checkpointRunEditRef(runId, editRef).catch((err) => - console.error(`[slack-plugin] delivery-state checkpoint failed for run ${runId}:`, (err as Error).message), - ); - } - - function coreFailure(err: unknown): Error { - swallow("slack: core call", err); - if ((err as { code?: string })?.code === "run_stalled") { - return new Error( - "this request is taking unusually long — I'm still on it and will post the result here as soon as it finishes", - { cause: err }, - ); - } - return new Error("I couldn't reach the agent core — it may be busy or deploying; please try again in a moment", { - cause: err, - }); - } - - async function callCore(body: CoreTurnBody, hooks: CoreCallHooks = {}): Promise { - let queued: TurnResult; - try { - queued = await core.submitTurn({ async: true, ...body }); - } catch (err) { - throw coreFailure(err); - } - if (queued.status !== "queued" || !queued.runId) return queued; - // A steered turn JOINED a run another handler started; core hands back that LIVE run's id. - // Polling it here would resolve the same result in both handlers and post the reply twice. - if (queued.steered) { - hooks.onSteered?.(queued.runId); - return { status: "silent", steered: true }; - } - hooks.onQueued?.(queued.runId); - return pollRun(queued.runId, hooks); - } - - async function pollRun(runId: string, hooks: CoreCallHooks = {}): Promise { - inFlightRuns.add(runId); - let result: TurnResult | null; - try { - result = await core.waitRun(runId, { - ...(hooks.onFirstBlock ? { onFirstBlock: hooks.onFirstBlock } : {}), - ...(hooks.onSurfacePosted ? { onSurfacePosted: hooks.onSurfacePosted } : {}), - ...(hooks.onTasks ? { onTasks: hooks.onTasks } : {}), - }); - } catch (err) { - inFlightRuns.delete(runId); - throw coreFailure(err); - } - if (result?.status === "refused" && result.refusalKind === "security_quarantine") { - return result; - } - if (result && (result.status === "ok" || result.status === "refused" || result.status === "failed")) { - ackRunDeliveryWithRetry(runId); - } else { - inFlightRuns.delete(runId); - } - if (result) return result; - throw new Error("the agent finished without producing a reply"); - } - - return { - callCore, - inFlightRuns, - inFlightRunByThread, - signalRunAbort, - fetchActiveRunForThread, - ackRunDeliveryWithRetry, - reportTurnMetrics, - checkpointRunEditRef, - reportRunEditRef, - stageBlobInCore, - fetchBlobFromCore, - fetchFileArtifactFromCore, - }; -} +export { createCoreBridge, type CoreBridge, type CoreTurnBody, type CoreCallHooks } from "../api/core-bridge.ts"; diff --git a/src/slack/deliveries.ts b/src/slack/deliveries.ts index c331b8bf..8883b68d 100644 --- a/src/slack/deliveries.ts +++ b/src/slack/deliveries.ts @@ -18,7 +18,7 @@ import { uploadFailureNote, applyReactions, } from "./lib.ts"; -import type { SlackCoreClient } from "../api/slack-core-client.ts"; +import type { CoreClient } from "../api/core-client.ts"; import type { Delivery } from "../types.ts"; import type { CoreBridge } from "./core-bridge.ts"; import type { Mirror } from "./mirror.ts"; @@ -36,7 +36,7 @@ function mergeSlackApiMs(body: unknown, slackApiMs: number | undefined): unknown } export function createDeliveryPoller(deps: { - core: SlackCoreClient; + core: CoreClient; bridge: CoreBridge; mirror: Mirror; threads: ReturnType; diff --git a/src/slack/directory.ts b/src/slack/directory.ts index a58ada0b..7f9e968b 100644 --- a/src/slack/directory.ts +++ b/src/slack/directory.ts @@ -17,7 +17,7 @@ import { sleep, } from "./lib.ts"; import { errMessage, swallowAs } from "../util/errors.ts"; -import type { SlackCoreClient } from "../api/slack-core-client.ts"; +import type { CoreClient } from "../api/core-client.ts"; export interface BotIdentity { ownTeamId: string; @@ -86,7 +86,7 @@ export interface Directory { } export function createDirectory(deps: { - core: SlackCoreClient; + core: CoreClient; ids: BotIdentity; userSnapshotTtlMs?: number; channelMembersTtlMs?: number; diff --git a/src/slack/index.ts b/src/slack/index.ts index 6160d655..13b34dce 100644 --- a/src/slack/index.ts +++ b/src/slack/index.ts @@ -17,16 +17,16 @@ import { createSurfaceContextFulfiller } from "./surface-context.ts"; import { createDeliveryPoller } from "./deliveries.ts"; import { createDeferredAckReceiver } from "./deferred-ack.ts"; import { createHttpEventsReceiver } from "./http-events.ts"; -import type { SlackCoreClient, SurfaceContextRequest } from "../api/slack-core-client.ts"; +import type { CoreClient, SurfaceContextRequest } from "../api/core-client.ts"; const { App, LogLevel } = bolt; -export type { SlackCoreClient }; +export type { CoreClient }; export type { SlackPluginConfig }; export { normalizeSlackApiUrl, slackPluginConfigFromEnv }; export async function startSlackPlugin( cfg: SlackPluginConfig, - core: SlackCoreClient, + core: CoreClient, ): Promise<{ stop(): Promise }> { const EVENTS_MODE = cfg.eventsMode ?? "socket"; if (!cfg.botToken) { diff --git a/src/slack/mirror.ts b/src/slack/mirror.ts index 323f665f..8c3bdfb3 100644 --- a/src/slack/mirror.ts +++ b/src/slack/mirror.ts @@ -1,6 +1,6 @@ import { swallow } from "../util/errors.ts"; import { decodeSlackEntities, mentionsBot, resolveMentionsInText } from "./lib.ts"; -import type { SlackCoreClient } from "../api/slack-core-client.ts"; +import type { CoreClient } from "../api/core-client.ts"; import type { IngestEvent } from "../surface-cache/surface-cache.ts"; import type { BotIdentity, Directory } from "./directory.ts"; import { MAX_NAME_LOOKUPS } from "./conversation-view.ts"; @@ -22,7 +22,7 @@ export interface Mirror { } export function createMirror(deps: { - core: SlackCoreClient; + core: CoreClient; ids: BotIdentity; directory: Directory; externalParticipantsEnabled(): Promise; diff --git a/src/slack/surface-context.ts b/src/slack/surface-context.ts index 7d5c1a8a..86921c39 100644 --- a/src/slack/surface-context.ts +++ b/src/slack/surface-context.ts @@ -11,7 +11,7 @@ import { oversizeMsg, parseDeliveryTarget, } from "./lib.ts"; -import type { SlackCoreClient, SurfaceContextRequest } from "../api/slack-core-client.ts"; +import type { CoreClient, SurfaceContextRequest } from "../api/core-client.ts"; import type { CoreBridge } from "./core-bridge.ts"; import type { Directory } from "./directory.ts"; import { @@ -22,7 +22,7 @@ import { } from "./conversation-view.ts"; export function createSurfaceContextFulfiller(deps: { - core: SlackCoreClient; + core: CoreClient; bridge: CoreBridge; directory: Directory; serializer: ConversationSerializer; diff --git a/src/telegram/README.md b/src/telegram/README.md new file mode 100644 index 00000000..6d956b7f --- /dev/null +++ b/src/telegram/README.md @@ -0,0 +1,63 @@ +# Telegram surface (long polling) + +The Telegram surface, run **in-process with the agent core**: core boots it when +`TELEGRAM_BOT_TOKEN` is present in its env and hands it a direct client into +core's services. **Long polling** means no public URL, ingress, domain, or TLS — +the plugin polls `getUpdates` over an outbound HTTPS connection, so you can run +it from a laptop or any box with internet. + +``` +Telegram ⇄ (HTTPS long poll) telegram surface (in core) ── direct calls ──▶ core services +``` + +## 1. Create the bot (one paste) + +1. Open BotFather in Telegram: → **/newbot**. +2. Name it, copy the token (`123456:ABC-…`) → `TELEGRAM_BOT_TOKEN`. +3. Optionally restrict the bot to specific chats with + `TELEGRAM_ALLOWED_CHAT_IDS=123456,789012` (comma-separated chat ids). + +## 2. Run + +One process — core boots the Telegram surface itself when the token is in its env: + +```bash +cd ~/qm +HARNESS=pi ORG_ID=acme ANTHROPIC_API_KEY=… \ +TELEGRAM_BOT_TOKEN=123456:ABC-… \ +npm start +``` + +(Or put the token in the repo-root `.env` — `npm start` loads it via +`node --env-file-if-exists`.) It logs `[qm] telegram plugin started` when live; +without the token, core simply runs without Telegram. + +## 3. Use it + +- **DM the bot** anything → it replies in the DM (one continuous session per DM, + keyed on `dm:`; replying to one of the bot's messages continues that + thread as `dm::`). +- Replies longer than 4096 characters are split across multiple messages. +- **Background deliveries** (cron reports, monitors, agent-initiated posts) are + claimed by the plugin as `type: "telegram"` deliveries and sent to the chat + that originated the turn. +- Files the agent produces are uploaded back into the chat as documents. +- **Group chats aren't handled yet** — the bot stays silent there. + +## How it maps to the core + +| Telegram | Core | +| ---------------------------------------- | ------------------------------------------------- | +| `message` update in a private chat | `POST /v1/turns` with `surface: "telegram"` | +| chat id (+ optional reply-to message id) | `threadRef` = `dm:[:]` | +| `TurnResult.reply` | sent back to the same chat | +| pending `type: "telegram"` deliveries | claimed from `/v1/deliveries`, posted, then acked | + +## Notes / next + +- The bot token never leaves the plugin. The plugin talks to Telegram over the + Bot API (`api.telegram.org`, overridable with `TELEGRAM_API_URL` for tests or + self-hosted proxies). +- `@mention` handling in group chats, approval buttons, and reaction support are + future work; the core's surface machinery (`surfaceTools`, delivery routing) + already speaks `telegram` as a first-class surface name. diff --git a/src/telegram/bot-api.ts b/src/telegram/bot-api.ts new file mode 100644 index 00000000..add980d8 --- /dev/null +++ b/src/telegram/bot-api.ts @@ -0,0 +1,111 @@ +export interface TelegramMessage { + messageId: number; + chatId: string; + chatType: string; + userId: string; + userName?: string; + text?: string; + replyToMessageId?: number; + messageThreadId?: number; + isBot?: boolean; +} + +export interface TelegramUpdate { + updateId: number; + message?: TelegramMessage; +} + +export interface TelegramApi { + getUpdates(offset: number, timeoutSec: number): Promise; + sendMessage(chatId: string, text: string, opts?: { replyToMessageId?: number }): Promise; + sendDocument(chatId: string, fileName: string, bytes: Uint8Array): Promise; + stop(): Promise; +} + +function userNameOf(from: { username?: string; first_name?: string }): string | undefined { + if (from.username) return from.username; + return from.first_name; +} + +export function createTelegramApi(deps: { botToken: string; apiUrl?: string; fetchImpl?: typeof fetch }): TelegramApi { + const apiUrl = (deps.apiUrl ?? "https://api.telegram.org").replace(/\/+$/, ""); + const fetchImpl = deps.fetchImpl ?? fetch; + let stopped = false; + + async function call(method: string, body: Record): Promise { + if (stopped && method !== "getUpdates") throw new Error("telegram api stopped"); + const res = await fetchImpl(`${apiUrl}/bot${deps.botToken}/${method}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`telegram api ${method} failed: HTTP ${res.status} ${text.slice(0, 200)}`); + } + const json = (await res.json()) as { ok: boolean; result?: unknown; description?: string }; + if (!json.ok) throw new Error(`telegram api ${method} failed: ${json.description ?? "unknown error"}`); + return json.result; + } + + return { + async getUpdates(offset, timeoutSec) { + const result = await call("getUpdates", { + offset, + timeout: timeoutSec, + allowed_updates: ["message"], + }); + return ( + result as Array<{ + update_id: number; + message?: { + message_id: number; + chat: { id: number; type: string }; + from: { id: number; first_name?: string; username?: string; is_bot?: boolean }; + text?: string; + reply_to_message?: { message_id?: number }; + message_thread_id?: number; + }; + }> + ).map((u) => ({ + updateId: u.update_id, + ...(u.message + ? { + message: { + messageId: u.message.message_id, + chatId: String(u.message.chat.id), + chatType: u.message.chat.type, + userId: String(u.message.from.id), + ...(userNameOf(u.message.from) ? { userName: userNameOf(u.message.from) } : {}), + ...(u.message.text !== undefined ? { text: u.message.text } : {}), + ...(u.message.reply_to_message?.message_id !== undefined + ? { replyToMessageId: u.message.reply_to_message.message_id } + : {}), + ...(u.message.message_thread_id !== undefined ? { messageThreadId: u.message.message_thread_id } : {}), + ...(u.message.from.is_bot ? { isBot: u.message.from.is_bot } : {}), + }, + } + : {}), + })); + }, + async sendMessage(chatId, text, opts) { + await call("sendMessage", { + chat_id: chatId, + text, + ...(opts?.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {}), + }); + }, + async sendDocument(chatId, fileName, bytes) { + const form = new FormData(); + form.append("chat_id", chatId); + form.append("document", new Blob([bytes]), fileName); + const res = await fetchImpl(`${apiUrl}/bot${deps.botToken}/sendDocument`, { method: "POST", body: form }); + if (!res.ok) throw new Error(`telegram api sendDocument failed: HTTP ${res.status}`); + const json = (await res.json()) as { ok: boolean; description?: string }; + if (!json.ok) throw new Error(`telegram api sendDocument failed: ${json.description ?? "unknown error"}`); + }, + async stop() { + stopped = true; + }, + }; +} diff --git a/src/telegram/config.ts b/src/telegram/config.ts new file mode 100644 index 00000000..04445244 --- /dev/null +++ b/src/telegram/config.ts @@ -0,0 +1,35 @@ +export interface TelegramPluginConfig { + botToken: string; + apiUrl?: string; + allowedChatIds?: string[]; + pollingTimeoutSec?: number; + fetchImpl?: typeof fetch; +} + +export function telegramPluginConfigFromEnv(env: Record): TelegramPluginConfig | null { + if (!env.TELEGRAM_BOT_TOKEN) return null; + const num = (v: string | undefined): number | undefined => { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : undefined; + }; + const opt = ( + key: K, + value: TelegramPluginConfig[K] | undefined, + ): Partial => (value === undefined ? {} : ({ [key]: value } as Partial)); + return { + botToken: env.TELEGRAM_BOT_TOKEN, + ...opt("apiUrl", env.TELEGRAM_API_URL), + ...(() => { + const raw = (env.TELEGRAM_ALLOWED_CHAT_IDS ?? "").trim(); + return raw + ? { + allowedChatIds: raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + } + : {}; + })(), + ...opt("pollingTimeoutSec", num(env.TELEGRAM_POLLING_TIMEOUT_SEC)), + }; +} diff --git a/src/telegram/deliveries.ts b/src/telegram/deliveries.ts new file mode 100644 index 00000000..edaf762a --- /dev/null +++ b/src/telegram/deliveries.ts @@ -0,0 +1,62 @@ +import { swallow } from "../util/errors.ts"; +import type { CoreClient } from "../api/core-client.ts"; +import type { CoreBridge } from "../api/core-bridge.ts"; +import type { TelegramApi } from "./bot-api.ts"; +import type { Delivery } from "../types.ts"; + +const DELIVERY_MAX_ATTEMPTS = 5; + +export function createDeliveryPoller(deps: { core: CoreClient; bridge: CoreBridge; api: TelegramApi }): { + pollDeliveries(): Promise; +} { + const { core, bridge, api } = deps; + const { inFlightRuns, fetchBlobFromCore, fetchFileArtifactFromCore } = bridge; + const failures = new Map(); + const dead = new Set(); + + async function fetchDeliveries(): Promise { + try { + return await core.claimDeliveries("telegram", 15_000); + } catch { + return []; + } + } + + async function pollDeliveries(): Promise { + for (const d of await fetchDeliveries()) { + if (dead.has(d.id)) continue; + const runId = d.idempotencyKey?.startsWith("run:") ? d.idempotencyKey.slice("run:".length) : undefined; + if (runId && inFlightRuns.has(runId)) continue; + const chatId = d.destination.target; + if (!chatId) { + await core.ackDelivery(d.id); + continue; + } + try { + if (d.text?.trim()) { + await api.sendMessage(chatId, d.text); + } + for (const attachment of d.attachments ?? []) { + const bytes = attachment.artifactId + ? await fetchFileArtifactFromCore(attachment.artifactId, attachment.artifactViewerId!) + : await fetchBlobFromCore(attachment.blobId); + await api.sendDocument(chatId, attachment.name, bytes); + } + await core.ackDelivery(d.id); + failures.delete(d.id); + } catch (err) { + swallow("telegram: delivery", err); + const count = (failures.get(d.id) ?? 0) + 1; + if (count >= DELIVERY_MAX_ATTEMPTS) { + failures.delete(d.id); + dead.add(d.id); + console.error(`[telegram-plugin] delivery ${d.id} failed permanently (giving up)`); + } else { + failures.set(d.id, count); + } + } + } + } + + return { pollDeliveries }; +} diff --git a/src/telegram/index.ts b/src/telegram/index.ts new file mode 100644 index 00000000..e56dd521 --- /dev/null +++ b/src/telegram/index.ts @@ -0,0 +1,115 @@ +import { swallowAs } from "../util/errors.ts"; +import { createCoreBridge } from "../api/core-bridge.ts"; +import type { CoreClient } from "../api/core-client.ts"; +import { createTelegramApi, type TelegramUpdate } from "./bot-api.ts"; +import { telegramPluginConfigFromEnv, type TelegramPluginConfig } from "./config.ts"; +import { createTurnHandler, type TelegramIncoming, type TurnHandler } from "./turn-handler.ts"; +import { createDeliveryPoller } from "./deliveries.ts"; + +export type { TelegramPluginConfig }; +export { telegramPluginConfigFromEnv }; + +const POLLING_DEFAULT_TIMEOUT_SEC = 25; +const POLLING_RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000]; +const POLLING_EMPTY_BATCH_DELAY_MS = 200; +const DELIVERY_POLL_MS = 5_000; + +export async function startTelegramPlugin( + cfg: TelegramPluginConfig, + core: CoreClient, +): Promise<{ stop(): Promise }> { + const api = createTelegramApi({ + botToken: cfg.botToken, + ...(cfg.apiUrl ? { apiUrl: cfg.apiUrl } : {}), + ...(cfg.fetchImpl ? { fetchImpl: cfg.fetchImpl } : {}), + }); + const allowed = new Set(cfg.allowedChatIds ?? []); + const pollingTimeoutSec = cfg.pollingTimeoutSec ?? POLLING_DEFAULT_TIMEOUT_SEC; + let stopped = false; + + const bridge = createCoreBridge(core, "telegram"); + const handler: TurnHandler = createTurnHandler({ bridge, api }); + + const deliveries = createDeliveryPoller({ core, bridge, api }); + + async function handleUpdate(update: TelegramUpdate): Promise { + const msg = update.message; + if (!msg) return; + if (msg.isBot) return; + if (allowed.size > 0 && !allowed.has(msg.chatId)) { + console.error(`[telegram-plugin] ignoring message from unlisted chat ${msg.chatId}`); + return; + } + const inc: TelegramIncoming = { + chatId: msg.chatId, + userId: msg.userId, + ...(msg.userName ? { userName: msg.userName } : {}), + text: msg.text ?? "", + messageId: msg.messageId, + ...(msg.replyToMessageId ? { replyToMessageId: msg.replyToMessageId } : {}), + isGroup: msg.chatType !== "private", + isBotCommand: false, + }; + if (inc.isGroup) { + console.error(`[telegram-plugin] group chats aren't supported yet — ignoring chat ${msg.chatId}`); + return; + } + await handler.handleIncoming(inc); + } + + async function poll(): Promise { + let offset = 0; + let failureStreak = 0; + while (!stopped) { + try { + const updates = await api.getUpdates(offset, pollingTimeoutSec); + failureStreak = 0; + for (const update of updates) { + if (update.updateId >= offset) offset = update.updateId + 1; + await handleUpdate(update).catch(swallowAs("telegram: update handling", undefined)); + } + if (updates.length === 0) await new Promise((resolve) => setTimeout(resolve, POLLING_EMPTY_BATCH_DELAY_MS)); + } catch (err) { + swallowAs("telegram: polling", undefined)(err); + if (stopped) return; + const delay = POLLING_RETRY_DELAYS_MS[Math.min(failureStreak, POLLING_RETRY_DELAYS_MS.length - 1)]!; + failureStreak++; + console.error(`[telegram-plugin] polling error (retry in ${delay}ms): ${(err as Error).message}`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + let deliveriesPollInFlight = false; + let deliveriesPollAgain = false; + const drainDeliveries = (): void => { + if (stopped) return; + if (deliveriesPollInFlight) { + deliveriesPollAgain = true; + return; + } + deliveriesPollInFlight = true; + void deliveries.pollDeliveries().finally(() => { + deliveriesPollInFlight = false; + if (deliveriesPollAgain) { + deliveriesPollAgain = false; + drainDeliveries(); + } + }); + }; + const unsubscribeDeliveries = core.onDeliveryEnqueued(drainDeliveries); + const deliveriesTimer = setInterval(drainDeliveries, DELIVERY_POLL_MS); + deliveriesTimer.unref(); + + void poll(); + + return { + async stop(): Promise { + if (stopped) return; + stopped = true; + clearInterval(deliveriesTimer); + unsubscribeDeliveries(); + await api.stop().catch(swallowAs("telegram: api stop", undefined)); + }, + }; +} diff --git a/src/telegram/turn-handler.ts b/src/telegram/turn-handler.ts new file mode 100644 index 00000000..41d73358 --- /dev/null +++ b/src/telegram/turn-handler.ts @@ -0,0 +1,125 @@ +import { errMessage } from "../util/errors.ts"; +import type { ActorAssertion, ConversationKind, TurnResult } from "../types.ts"; +import type { CoreBridge, CoreTurnBody } from "../api/core-bridge.ts"; +import type { TelegramApi } from "./bot-api.ts"; + +export interface TelegramIncoming { + chatId: string; + userId: string; + userName?: string; + text: string; + messageId: number; + replyToMessageId?: number; + isGroup: boolean; + isBotCommand: boolean; +} + +export interface TurnHandlerDeps { + bridge: CoreBridge; + api: TelegramApi; +} + +export interface TurnHandler { + handleIncoming(inc: TelegramIncoming): Promise; +} + +export function telegramThreadRef(chatId: string, replyToMessageId?: number): string { + return replyToMessageId ? `dm:${chatId}:${replyToMessageId}` : `dm:${chatId}`; +} + +export function createTurnHandler(deps: TurnHandlerDeps): TurnHandler { + const { bridge, api } = deps; + const { callCore, inFlightRunByThread } = bridge; + + const postReply = async (chatId: string, text: string, replyToMessageId?: number): Promise => { + const chunks = splitText(text); + for (let i = 0; i < chunks.length; i++) { + await api.sendMessage(chatId, chunks[i]!, { + ...(i === 0 && replyToMessageId ? { replyToMessageId } : {}), + }); + } + }; + + async function handleIncoming(inc: TelegramIncoming): Promise { + const text = inc.text.trim(); + if (!text) return; + + const actor: ActorAssertion = { + externalId: inc.userId, + ...(inc.userName ? { displayName: inc.userName } : {}), + }; + + const kind: ConversationKind = inc.isGroup ? "channel" : "dm"; + const threadRef = telegramThreadRef(inc.chatId, inc.replyToMessageId); + + const turn: CoreTurnBody = { + actor, + conversation: { + kind, + threadRef, + ...(inc.isGroup ? { channelRef: inc.chatId } : {}), + audience: [actor], + }, + deliveryTarget: inc.chatId, + text, + gatewayContext: { + location: inc.isGroup ? `a group chat (${inc.chatId})` : "a direct message with the user", + details: { + chat_id: inc.chatId, + ...(inc.isGroup ? { group: "true" } : {}), + }, + }, + liveActor: true, + triggerTs: String(inc.messageId), + }; + + let result: TurnResult; + let queuedRunId: string | undefined; + try { + result = await callCore(turn, { + onQueued: (runId) => { + queuedRunId = runId; + inFlightRunByThread.set(threadRef, runId); + }, + }); + } catch (err) { + console.error(`[telegram-plugin] turn error chat=${inc.chatId}: ${errMessage(err)}`); + await api.sendMessage(inc.chatId, `⚠️ ${errMessage(err)}`, { + ...(inc.replyToMessageId ? { replyToMessageId: inc.replyToMessageId } : {}), + }); + return; + } finally { + if (queuedRunId) inFlightRunByThread.clear(threadRef, queuedRunId); + } + + if (result.status === "silent") { + console.error(`[telegram-plugin] turn.silent (no reply) chat=${inc.chatId}`); + return; + } + + if (result.status === "ok" || result.status === "refused") { + const reply = result.reply?.trim(); + if (reply) { + await postReply(inc.chatId, reply, inc.replyToMessageId); + } + } else { + console.error(`[telegram-plugin] turn ${result.status} chat=${inc.chatId}: ${result.reason ?? "refused"}`); + } + } + + return { handleIncoming }; +} + +export function splitText(text: string, maxLen = 4096): string[] { + const out: string[] = []; + let rest = text; + while (rest.length > maxLen) { + let cut = rest.lastIndexOf("\n", maxLen); + if (cut <= 0) cut = rest.lastIndexOf(" ", maxLen); + if (cut <= 0) cut = maxLen; + out.push(rest.slice(0, cut).trimEnd()); + rest = rest.slice(cut).trimStart(); + } + if (rest) out.push(rest); + return out; +} diff --git a/src/wiring.ts b/src/wiring.ts index f2a93ade..d8eef995 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -208,7 +208,7 @@ import { createPostgresSessionStateBus } from "./runs/postgres-session-state-bus import { createMemoryRunActivityStore, type RunActivityStore } from "./runs/run-activity-store.ts"; import { createPostgresRunActivityStore } from "./runs/postgres-run-activity-store.ts"; import { createApp, type App } from "./api/app.ts"; -import { createSlackCoreClient, type SlackCoreClient } from "./api/slack-core-client.ts"; +import { createCoreClient, type CoreClient } from "./api/core-client.ts"; import { createSurfaceContextPuller } from "./api/surface-context-puller.ts"; import { createEngagedRegistry } from "./wake/engaged-registry.ts"; import { createWakeSweep, type WakeSweep } from "./wake/sweep.ts"; @@ -358,7 +358,8 @@ export interface BuiltApp { ackEmojiPicks?: AckEmojiPickStore; channelPolicy: ChannelPolicyStore; skillSyncEngine: SkillSyncEngine; - slackCore: SlackCoreClient; + slackCore: CoreClient; + telegramCore: CoreClient; } export function buildApp( @@ -1088,7 +1089,7 @@ export function buildApp( modelProviders: modelProviderAvailabilityFor(config.harness, providerKeys), runWaitMs: config.runWaitMs, }); - const slackCore = createSlackCoreClient({ + const slackCore = createCoreClient({ app, config: configStore, runtimeFallback: fallback, @@ -1103,6 +1104,24 @@ export function buildApp( ...(config.brandingDefault ? { brandingDefault: config.brandingDefault } : {}), ...(harness.models.pickAckEmoji ? { pickAckEmoji: (t, c) => harness.models.pickAckEmoji!(t, c) } : {}), }); + const telegramCore = createCoreClient( + { + app, + config: configStore, + runtimeFallback: fallback, + blobTransfer, + deliveries, + metrics, + runs, + turnStream, + tasks, + ackPicks: ackEmojiPicks, + ackModelId: () => auxiliaryModelForProvider("anthropic"), + ...(config.brandingDefault ? { brandingDefault: config.brandingDefault } : {}), + ...(harness.models.pickAckEmoji ? { pickAckEmoji: (t, c) => harness.models.pickAckEmoji!(t, c) } : {}), + }, + "telegram", + ); runs.onTerminal((run) => { void runs .activeForThread(run.sessionId) @@ -1424,5 +1443,6 @@ export function buildApp( channelPolicy, skillSyncEngine, slackCore, + telegramCore, }; } diff --git a/test/slack-index.integration.test.ts b/test/slack-index.integration.test.ts index a5bc2a65..720d6160 100644 --- a/test/slack-index.integration.test.ts +++ b/test/slack-index.integration.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { mock, test } from "node:test"; -import type { SlackCoreClient } from "../src/slack/index.ts"; +import type { CoreClient } from "../src/slack/index.ts"; import type { TurnResult } from "../src/types.ts"; type Handler = (args: any) => Promise; @@ -183,7 +183,7 @@ mock.module("@slack/web-api", { namedExports: { WebClient: class {} } }); const { slackPluginConfigFromEnv, startSlackPlugin } = await import("../src/slack/index.ts"); -class FakeCore implements SlackCoreClient { +class FakeCore implements CoreClient { readonly turns: any[] = []; readonly ingests: any[][] = []; readonly directories: any[] = []; diff --git a/test/telegram-plugin.test.ts b/test/telegram-plugin.test.ts new file mode 100644 index 00000000..2c6a2f26 --- /dev/null +++ b/test/telegram-plugin.test.ts @@ -0,0 +1,292 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { CoreClient } from "../src/api/core-client.ts"; +import type { TurnResult } from "../src/types.ts"; + +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +class FakeTelegramServer { + readonly sent: Array<{ method: string; body: any }> = []; + readonly pendingUpdates: any[] = []; + getUpdatesCalls = 0; + stopCalled = false; + + async handle(url: string, init: any): Promise { + const method = url.slice(url.lastIndexOf("/") + 1); + const body = init?.body ? JSON.parse(init.body) : {}; + this.sent.push({ method, body }); + if (method === "getUpdates") { + this.getUpdatesCalls++; + const batch = this.pendingUpdates.splice(0); + return jsonResponse({ ok: true, result: batch }); + } + if (method === "sendMessage" || method === "sendDocument") { + return jsonResponse({ ok: true, result: { message_id: 1 } }); + } + throw new Error(`unexpected method ${method}`); + } + + emitMessage(overrides: Record): void { + this.pendingUpdates.push({ + update_id: this.pendingUpdates.length + 1, + message: { + message_id: 100 + this.pendingUpdates.length, + chat: { id: 42, type: "private" }, + from: { id: 7, first_name: "Alice", username: "alice" }, + text: "hello", + ...overrides, + }, + }); + } +} + +class FakeCore implements CoreClient { + readonly turns: any[] = []; + readonly acked: string[] = []; + readonly deliveries: any[] = []; + readonly enqueueListeners: Array<() => void> = []; + enqueue(delivery: any): void { + this.deliveries.push(delivery); + for (const l of this.enqueueListeners) l(); + } + result: TurnResult = { status: "ok", reply: "agent reply" }; + queuedRunId: string | undefined; + private heldRunClaimed = false; + private runGate: Promise | undefined; + private releaseRun: (() => void) | undefined; + + async externalSlackParticipants(): Promise { + return false; + } + async surfaceHeaderFacts(): Promise<{ agentLabel?: string; modelName: string }> { + return { modelName: "test-model" }; + } + onScopeModelChanged(): void {} + async stageBlob(bytes: Uint8Array): Promise<{ blobId: string; sizeBytes: number }> { + return { blobId: "blob-1", sizeBytes: bytes.byteLength }; + } + async readBlob(): Promise { + return Buffer.alloc(0); + } + async readFileArtifact(): Promise { + return Buffer.alloc(0); + } + async ingestSurfaceEvents(): Promise {} + async submitTurn(body: any): Promise { + // The real core-client injects the surface (createCoreClient(deps, "telegram")); + // simulate that here so the turn body reflects the full path. + this.turns.push({ ...body, surface: "telegram" }); + if (this.queuedRunId) { + const steered = this.heldRunClaimed; + this.heldRunClaimed = true; + return { status: "queued", runId: this.queuedRunId, ...(steered ? { steered: true as const } : {}) }; + } + return this.result; + } + async waitRun(): Promise { + if (this.runGate) await this.runGate; + return this.result; + } + holdRun(runId: string): void { + this.queuedRunId = runId; + this.heldRunClaimed = false; + this.runGate = new Promise((resolve) => (this.releaseRun = resolve)); + } + finishRun(result: TurnResult): void { + this.result = result; + this.releaseRun?.(); + } + async activeRunForThread(): Promise { + return undefined; + } + async signalRunAbort(): Promise {} + async ackRunDelivery(): Promise {} + async reportTurnMetrics(): Promise {} + async reportRunEditRef(): Promise {} + async getApproval(): Promise { + return null; + } + async pushDirectory(): Promise {} + async claimDeliveries(type: string): Promise { + const rows = this.deliveries.filter((d) => d.destination.type === type); + return rows; + } + async ackDelivery(id: string): Promise { + this.acked.push(id); + } + onDeliveryEnqueued(listener: () => void): () => void { + this.enqueueListeners.push(listener); + return () => {}; + } + async pendingContextRequests(): Promise<[]> { + return []; + } + onContextRequest(): () => void { + return () => {}; + } + async fulfillContextRequest(): Promise {} + async pickAckEmoji(): Promise { + return undefined; + } + async recordAckPick(): Promise {} +} + +import { startTelegramPlugin } from "../src/telegram/index.ts"; + +async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > deadline) throw new Error("timed out waiting for condition"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +test("telegram plugin submits a turn and posts the reply", async () => { + const server = new FakeTelegramServer(); + const core = new FakeCore(); + const plugin = await startTelegramPlugin( + { botToken: "test-token", apiUrl: "http://fake", fetchImpl: server.handle.bind(server) as any }, + core, + ); + try { + server.emitMessage({}); + await waitFor(() => core.turns.length > 0); + assert.equal(core.turns[0]!.surface, "telegram"); + assert.equal(core.turns[0]!.text, "hello"); + assert.equal(core.turns[0]!.conversation.kind, "dm"); + assert.equal(core.turns[0]!.conversation.threadRef, "dm:42"); + assert.equal(core.turns[0]!.actor.externalId, "7"); + assert.equal(core.turns[0]!.deliveryTarget, "42"); + await waitFor(() => server.sent.some((s) => s.method === "sendMessage")); + const send = server.sent.find((s) => s.method === "sendMessage")!; + assert.equal(send.body.chat_id, "42"); + assert.equal(send.body.text, "agent reply"); + } finally { + await plugin.stop(); + } +}); + +test("telegram plugin splits long replies into multiple messages", async () => { + const server = new FakeTelegramServer(); + const core = new FakeCore(); + core.result = { status: "ok", reply: "x".repeat(9000) }; + const plugin = await startTelegramPlugin( + { botToken: "test-token", apiUrl: "http://fake", fetchImpl: server.handle.bind(server) as any }, + core, + ); + try { + server.emitMessage({}); + await waitFor(() => server.sent.filter((s) => s.method === "sendMessage").length >= 3); + const sends = server.sent.filter((s) => s.method === "sendMessage"); + assert.equal(sends.length, 3); + assert.ok(sends.every((s) => s.body.text.length <= 4096)); + } finally { + await plugin.stop(); + } +}); + +test("telegram plugin replies in a thread when replying to the bot", async () => { + const server = new FakeTelegramServer(); + const core = new FakeCore(); + const plugin = await startTelegramPlugin( + { botToken: "test-token", apiUrl: "http://fake", fetchImpl: server.handle.bind(server) as any }, + core, + ); + try { + server.emitMessage({ reply_to_message: { message_id: 55 } }); + await waitFor(() => core.turns.length > 0); + assert.equal(core.turns[0]!.conversation.threadRef, "dm:42:55"); + await waitFor(() => server.sent.some((s) => s.method === "sendMessage")); + const send = server.sent.find((s) => s.method === "sendMessage")!; + assert.equal(send.body.reply_to_message_id, 55); + } finally { + await plugin.stop(); + } +}); + +test("telegram plugin ignores bot messages and unlisted chats", async () => { + const server = new FakeTelegramServer(); + const core = new FakeCore(); + const plugin = await startTelegramPlugin( + { + botToken: "test-token", + apiUrl: "http://fake", + allowedChatIds: ["99"], + fetchImpl: server.handle.bind(server) as any, + }, + core, + ); + try { + server.emitMessage({}); + server.emitMessage({ chat: { id: 42, type: "private" }, from: { id: 7, is_bot: true } }); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(core.turns.length, 0); + } finally { + await plugin.stop(); + } +}); + +test("telegram plugin claims and delivers type=telegram deliveries", async () => { + const server = new FakeTelegramServer(); + const core = new FakeCore(); + const plugin = await startTelegramPlugin( + { botToken: "test-token", apiUrl: "http://fake", fetchImpl: server.handle.bind(server) as any }, + core, + ); + try { + core.enqueue({ + id: "del-1", + destination: { type: "telegram", target: "42" }, + text: "cron report", + idempotencyKey: "cron:1", + createdAt: Date.now(), + deliveredAt: null, + }); + await waitFor(() => server.sent.some((s) => s.method === "sendMessage" && s.body.text === "cron report")); + await waitFor(() => core.acked.includes("del-1")); + } finally { + await plugin.stop(); + } +}); + +test("createCoreClient(deps, 'telegram') injects the telegram surface into turns", async () => { + const bodies: any[] = []; + const app = { + turn: async (body: any) => { + bodies.push(body); + return { status: "ok", reply: "hi" }; + }, + }; + const deps: any = { + app, + config: { + onRuntimeSelectionChanged: () => {}, + getExternalSlackParticipantsDurable: async () => false, + getBrandingDurable: async () => ({}), + }, + runtimeFallback: { harnessId: "pi", modelId: "pi" }, + blobTransfer: { put: async () => ({ blobId: "b", sizeBytes: 1 }), open: async () => null }, + deliveries: { onEnqueue: () => () => {} }, + metrics: { updateByRunId: async () => {} }, + runs: { onTerminal: () => {}, get: async () => null, activeForThread: async () => null }, + turnStream: { subscribe: () => () => {}, surfacePosted: () => false, firstBlock: () => undefined }, + tasks: { list: async () => [] }, + }; + const { createCoreClient } = await import("../src/api/core-client.ts"); + const turnBody = { + actor: { externalId: "7" }, + conversation: { kind: "dm" as const, threadRef: "dm:42", audience: [{ externalId: "7" }] }, + text: "hello", + }; + const telegramCore = createCoreClient(deps, "telegram"); + await telegramCore.submitTurn(turnBody); + assert.equal(bodies[0]!.surface, "telegram"); + const slackCore = createCoreClient(deps); + await slackCore.submitTurn(turnBody); + assert.equal(bodies[1]!.surface, "slack"); +});