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 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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user