Add /connect_calendar and /calendar_done bot commands

This commit is contained in:
EugeneTes
2026-09-23 10:20:48 +00:00
parent 2d294995fa
commit 279134a305
2 changed files with 247 additions and 2 deletions

View File

@@ -4,7 +4,9 @@ import { describe, expect, it, vi } from "vitest";
import type { AgentHostEvent, AgentInput } from "../agent/events.js"; import type { AgentHostEvent, AgentInput } from "../agent/events.js";
import { createLogger } from "../log.js"; import { createLogger } from "../log.js";
import type { JobView } from "../scheduler/scheduler.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 HostLike, registerBridge, type SchedulerLike } from "./bridge.js";
import type { CompleteResult, DeviceCode, DeviceFlowClient } from "./google-device-flow.js";
import { Outbound } from "./outbound.js"; import { Outbound } from "./outbound.js";
const OWNER = 4242; 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 = { const botInfo: UserFromGetMe = {
id: 1, is_bot: true, first_name: "sec", username: "sec_bot", can_join_groups: false, 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, 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 host = new FakeHost();
const scheduler = new FakeScheduler(); const scheduler = new FakeScheduler();
const outbound = new Outbound(bot.api, OWNER, logger, { editThrottleMs: 1, typingIntervalMs: 60_000 }); 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({ registerBridge({
bot, bot,
config: { telegramBotToken: "1:TEST", telegramOwnerId: OWNER, workspaceDir: process.cwd(), timezone: "UTC" }, 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, transcriber: overrides.transcriber,
fetchFn: async () => new Response(new Uint8Array([0x4f, 0x67, 0x67, 0x53])), fetchFn: async () => new Response(new Uint8Array([0x4f, 0x67, 0x67, 0x53])),
debounceMs: overrides.debounceMs ?? 5, debounceMs: overrides.debounceMs ?? 5,
deviceFlow,
tokenStore,
}); });
const texts = () => calls.filter((c) => c.method === "sendMessage").map((c) => c.payload.text as string); 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; let updateId = 1;
@@ -283,4 +330,121 @@ describe("registerBridge", () => {
await vi.waitFor(() => expect(h.calls.filter((c) => c.method === "deleteMessage")).toHaveLength(3)); await vi.waitFor(() => expect(h.calls.filter((c) => c.method === "deleteMessage")).toHaveLength(3));
expect(h.texts().at(-1)).toBe("⚙️ Working…"); 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/);
});
}); });

View File

@@ -6,8 +6,10 @@ import type { Config } from "../config.js";
import type { Logger } from "../log.js"; import type { Logger } from "../log.js";
import type { Scheduler } from "../scheduler/scheduler.js"; import type { Scheduler } from "../scheduler/scheduler.js";
import type { Transcriber } from "../stt/openai.js"; import type { Transcriber } from "../stt/openai.js";
import type { GoogleTokenStore } from "../tools/google-token-store.js";
import { describeJob } from "../tools/schedule.js"; import { describeJob } from "../tools/schedule.js";
import { InputDebouncer } from "./debouncer.js"; import { InputDebouncer } from "./debouncer.js";
import type { DeviceCode, DeviceFlowClient } from "./google-device-flow.js";
import { normalizeMessage } from "./inbound.js"; import { normalizeMessage } from "./inbound.js";
import type { Outbound } from "./outbound.js"; import type { Outbound } from "./outbound.js";
@@ -28,6 +30,10 @@ export interface BridgeDeps {
fetchFn?: typeof fetch; fetchFn?: typeof fetch;
/** Debounce window for merging rapid-succession messages (e.g. forward + comment). Defaults to 600ms. */ /** Debounce window for merging rapid-succession messages (e.g. forward + comment). Defaults to 600ms. */
debounceMs?: number; 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 = [ export const BOT_COMMANDS = [
@@ -37,11 +43,14 @@ export const BOT_COMMANDS = [
{ command: "jobs", description: "List scheduled jobs" }, { command: "jobs", description: "List scheduled jobs" },
{ command: "cancel", description: "Cancel a job: /cancel <id>" }, { command: "cancel", description: "Cancel a job: /cancel <id>" },
{ command: "compact", description: "Compact the conversation context" }, { 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 { export function registerBridge(deps: BridgeDeps): void {
const { bot, config, host, scheduler, outbound, logger } = deps; const { bot, config, host, scheduler, outbound, logger } = deps;
const warned = new Set<number>(); const warned = new Set<number>();
let pendingDevice: DeviceCode | null = null;
const debouncer = new InputDebouncer({ const debouncer = new InputDebouncer({
windowMs: deps.debounceMs ?? DEFAULT_DEBOUNCE_MS, windowMs: deps.debounceMs ?? DEFAULT_DEBOUNCE_MS,
onFlush: (input) => { 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) => { bot.on("message", async (ctx) => {
const isCommand = ctx.message.entities?.some((e) => e.type === "bot_command" && e.offset === 0) ?? false; const isCommand = ctx.message.entities?.some((e) => e.type === "bot_command" && e.offset === 0) ?? false;
if (isCommand) { if (isCommand) {