Add /connect_calendar and /calendar_done bot commands
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <id>" },
|
||||
{ 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<number>();
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user