From 279134a30555757290c9ee756468c1159d00da9c Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Wed, 23 Sep 2026 10:20:48 +0000 Subject: [PATCH] Add /connect_calendar and /calendar_done bot commands --- src/telegram/bridge.test.ts | 168 +++++++++++++++++++++++++++++++++++- src/telegram/bridge.ts | 81 +++++++++++++++++ 2 files changed, 247 insertions(+), 2 deletions(-) diff --git a/src/telegram/bridge.test.ts b/src/telegram/bridge.test.ts index f671366..58ae12c 100644 --- a/src/telegram/bridge.test.ts +++ b/src/telegram/bridge.test.ts @@ -4,7 +4,9 @@ import { describe, expect, it, vi } from "vitest"; import type { AgentHostEvent, AgentInput } from "../agent/events.js"; import { createLogger } from "../log.js"; import type { JobView } from "../scheduler/scheduler.js"; +import type { GoogleTokenStore } from "../tools/google-token-store.js"; import { type HostLike, registerBridge, type SchedulerLike } from "./bridge.js"; +import type { CompleteResult, DeviceCode, DeviceFlowClient } from "./google-device-flow.js"; import { Outbound } from "./outbound.js"; const OWNER = 4242; @@ -55,7 +57,48 @@ class FakeScheduler implements SchedulerLike { } } -function harness(overrides: { debounceMs?: number; transcriber?: import("../stt/openai.js").Transcriber } = {}) { +class FakeDeviceFlow implements DeviceFlowClient { + startResult: DeviceCode | Error = { + deviceCode: "dev-1", + userCode: "ABCD-EFGH", + verificationUrl: "https://google.com/device", + expiresAt: Date.now() + 900_000, + interval: 5, + }; + completeQueue: CompleteResult[] = []; + startCalls = 0; + completeCalls: string[] = []; + async start() { + this.startCalls++; + if (this.startResult instanceof Error) throw this.startResult; + return this.startResult; + } + async complete(deviceCode: string) { + this.completeCalls.push(deviceCode); + const next = this.completeQueue.shift(); + if (!next) throw new Error(`unexpected complete() call with ${deviceCode}`); + return next; + } +} + +class FakeTokenStore implements GoogleTokenStore { + current: string | null = null; + writeError: Error | null = null; + read() { + return this.current; + } + write(t: string) { + if (this.writeError) throw this.writeError; + this.current = t; + } +} + +function harness(overrides: { + debounceMs?: number; + transcriber?: import("../stt/openai.js").Transcriber; + deviceFlow?: DeviceFlowClient | null; + tokenStore?: GoogleTokenStore | null; +} = {}) { const botInfo: UserFromGetMe = { id: 1, is_bot: true, first_name: "sec", username: "sec_bot", can_join_groups: false, can_read_all_group_messages: false, supports_inline_queries: false, can_connect_to_business: false, has_main_web_app: false, @@ -75,6 +118,8 @@ function harness(overrides: { debounceMs?: number; transcriber?: import("../stt/ const host = new FakeHost(); const scheduler = new FakeScheduler(); const outbound = new Outbound(bot.api, OWNER, logger, { editThrottleMs: 1, typingIntervalMs: 60_000 }); + const deviceFlow = overrides.deviceFlow === null ? null : (overrides.deviceFlow ?? new FakeDeviceFlow()); + const tokenStore = overrides.tokenStore === null ? null : (overrides.tokenStore ?? new FakeTokenStore()); registerBridge({ bot, config: { telegramBotToken: "1:TEST", telegramOwnerId: OWNER, workspaceDir: process.cwd(), timezone: "UTC" }, @@ -85,9 +130,11 @@ function harness(overrides: { debounceMs?: number; transcriber?: import("../stt/ transcriber: overrides.transcriber, fetchFn: async () => new Response(new Uint8Array([0x4f, 0x67, 0x67, 0x53])), debounceMs: overrides.debounceMs ?? 5, + deviceFlow, + tokenStore, }); const texts = () => calls.filter((c) => c.method === "sendMessage").map((c) => c.payload.text as string); - return { bot, calls, host, scheduler, texts }; + return { bot, calls, host, scheduler, texts, deviceFlow, tokenStore }; } let updateId = 1; @@ -283,4 +330,121 @@ describe("registerBridge", () => { await vi.waitFor(() => expect(h.calls.filter((c) => c.method === "deleteMessage")).toHaveLength(3)); expect(h.texts().at(-1)).toBe("⚙️ Working…"); }); + + it("/connect_calendar without google config replies 'not configured'", async () => { + const h = harness({ deviceFlow: null, tokenStore: null }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + expect(h.texts()[0]).toMatch(/isn't configured/); + }); + + it("/calendar_done without google config replies 'not configured'", async () => { + const h = harness({ deviceFlow: null, tokenStore: null }); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[0]).toMatch(/isn't configured/); + }); + + it("/connect_calendar shows the URL and code and stores pending state", async () => { + const h = harness(); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + const msg = h.texts()[0]; + expect(msg).toContain("https://google.com/device"); + expect(msg).toContain("ABCD-EFGH"); + expect(msg).toContain("/calendar_done"); + }); + + it("/connect_calendar twice overwrites the pending state — second code wins", async () => { + const flow = new FakeDeviceFlow(); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + flow.startResult = { + deviceCode: "dev-2", + userCode: "WXYZ-1234", + verificationUrl: "https://google.com/device", + expiresAt: Date.now() + 900_000, + interval: 5, + }; + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-2" }); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(flow.completeCalls).toEqual(["dev-2"]); + expect((h.tokenStore as FakeTokenStore).current).toBe("rt-2"); + }); + + it("/calendar_done with no pending flow replies 'no pending'", async () => { + const h = harness(); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[0]).toMatch(/No pending calendar connection/); + }); + + it("/calendar_done after expiry clears state and asks to restart", async () => { + const flow = new FakeDeviceFlow(); + flow.startResult = { ...flow.startResult as DeviceCode, expiresAt: Date.now() - 1 }; + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/expired/); + // A second /calendar_done should now say 'no pending' because state was cleared. + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[2]).toMatch(/No pending calendar connection/); + expect(flow.completeCalls).toEqual([]); + }); + + it("/calendar_done success writes the token and reports connected", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-good" }); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect((h.tokenStore as FakeTokenStore).current).toBe("rt-good"); + expect(h.texts()[1]).toMatch(/Calendar connected/); + }); + + it("/calendar_done pending keeps state and prompts to approve", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "pending" }); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-late" }); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/haven't approved/); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect((h.tokenStore as FakeTokenStore).current).toBe("rt-late"); + }); + + it("/calendar_done denied clears state", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "denied" }); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/denied/); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[2]).toMatch(/No pending calendar connection/); + }); + + it("/calendar_done error keeps state so the owner can retry", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "error", message: "network broken" }); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-retry" }); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/network broken/); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect((h.tokenStore as FakeTokenStore).current).toBe("rt-retry"); + }); + + it("/calendar_done reports a save failure and clears state", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-drop" }); + const store = new FakeTokenStore(); + store.writeError = new Error("disk full"); + const h = harness({ deviceFlow: flow, tokenStore: store }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/failed to save/); + // State should be cleared: next /calendar_done says no pending. + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[2]).toMatch(/No pending calendar connection/); + }); }); diff --git a/src/telegram/bridge.ts b/src/telegram/bridge.ts index 2c6f2e7..c3e7fca 100644 --- a/src/telegram/bridge.ts +++ b/src/telegram/bridge.ts @@ -6,8 +6,10 @@ import type { Config } from "../config.js"; import type { Logger } from "../log.js"; import type { Scheduler } from "../scheduler/scheduler.js"; import type { Transcriber } from "../stt/openai.js"; +import type { GoogleTokenStore } from "../tools/google-token-store.js"; import { describeJob } from "../tools/schedule.js"; import { InputDebouncer } from "./debouncer.js"; +import type { DeviceCode, DeviceFlowClient } from "./google-device-flow.js"; import { normalizeMessage } from "./inbound.js"; import type { Outbound } from "./outbound.js"; @@ -28,6 +30,10 @@ export interface BridgeDeps { fetchFn?: typeof fetch; /** Debounce window for merging rapid-succession messages (e.g. forward + comment). Defaults to 600ms. */ debounceMs?: number; + /** Google device-authorization flow client. Null when GOOGLE_CLIENT_ID/SECRET aren't set. */ + deviceFlow: DeviceFlowClient | null; + /** Refresh-token store. Null when GOOGLE_CLIENT_ID/SECRET aren't set. */ + tokenStore: GoogleTokenStore | null; } export const BOT_COMMANDS = [ @@ -37,11 +43,14 @@ export const BOT_COMMANDS = [ { command: "jobs", description: "List scheduled jobs" }, { command: "cancel", description: "Cancel a job: /cancel " }, { command: "compact", description: "Compact the conversation context" }, + { command: "connect_calendar", description: "Link your Google Calendar" }, + { command: "calendar_done", description: "Finish linking Google Calendar" }, ]; export function registerBridge(deps: BridgeDeps): void { const { bot, config, host, scheduler, outbound, logger } = deps; const warned = new Set(); + let pendingDevice: DeviceCode | null = null; const debouncer = new InputDebouncer({ windowMs: deps.debounceMs ?? DEFAULT_DEBOUNCE_MS, onFlush: (input) => { @@ -107,6 +116,78 @@ export function registerBridge(deps: BridgeDeps): void { } }); + bot.command("connect_calendar", async (ctx) => { + if (!deps.deviceFlow) { + await ctx.reply( + "Google Calendar isn't configured on this bot. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the environment first.", + ); + return; + } + try { + const code = await deps.deviceFlow.start(); + pendingDevice = code; + const mins = Math.max(1, Math.round((code.expiresAt - Date.now()) / 60_000)); + await ctx.reply( + `Go to ${code.verificationUrl} and enter code ${code.userCode}.\n\n` + + `When you've approved it there, send /calendar_done here and I'll finish the connection.\n\n` + + `Code expires in ${mins} minute${mins === 1 ? "" : "s"}.`, + ); + } catch (err) { + await ctx.reply(`Couldn't start Google device flow: ${errorText(err)}`); + } + }); + + bot.command("calendar_done", async (ctx) => { + if (!deps.deviceFlow || !deps.tokenStore) { + await ctx.reply( + "Google Calendar isn't configured on this bot. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the environment first.", + ); + return; + } + if (!pendingDevice) { + await ctx.reply("No pending calendar connection. Run /connect_calendar first."); + return; + } + if (pendingDevice.expiresAt <= Date.now()) { + pendingDevice = null; + await ctx.reply("That code expired. Run /connect_calendar to start over."); + return; + } + const active = pendingDevice; + const result = await deps.deviceFlow.complete(active.deviceCode); + switch (result.kind) { + case "success": { + try { + deps.tokenStore.write(result.refreshToken); + } catch (err) { + pendingDevice = null; + await ctx.reply(`Got a refresh token but failed to save it: ${errorText(err)}`); + return; + } + pendingDevice = null; + await ctx.reply("✅ Calendar connected."); + return; + } + case "pending": + case "slow_down": + await ctx.reply( + `Google says you haven't approved it yet. Approve at ${active.verificationUrl} (code ${active.userCode}), then send /calendar_done again.`, + ); + return; + case "denied": + pendingDevice = null; + await ctx.reply("You denied the request. Run /connect_calendar to try again."); + return; + case "expired": + pendingDevice = null; + await ctx.reply("That code expired. Run /connect_calendar to start over."); + return; + case "error": + await ctx.reply(`Couldn't finish Google connection: ${result.message}`); + return; + } + }); + bot.on("message", async (ctx) => { const isCommand = ctx.message.entities?.some((e) => e.type === "bot_command" && e.offset === 0) ?? false; if (isCommand) {