diff --git a/AGENTS.md b/AGENTS.md index 52bf1e94..7185ff3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Ode is a Slack bot that bridges messages to OpenCode for AI-assisted coding. - Bot replies in threads once mentioned - Status updates include phases, tool progress, and elapsed time - Status messages are preserved as an operation record -- When capturing screenshots, save images to the system temp folder and upload them to Slack as soon as possible +- When capturing screenshots, save images to the system temp folder and upload them with `ode send file` to the current thread as soon as possible - When merging PRs, do not delete the branch if the current worktree is on that branch ## Commands @@ -36,6 +36,34 @@ Ode is a Slack bot that bridges messages to OpenCode for AI-assisted coding. - Persistence: SQLite at `~/.config/ode/inbox.db` (table `tasks`); scheduler polls every 10s and uses `UPDATE ... WHERE status='pending'` for cross-process idempotency. - HTTP API mirrors the CLI under `/api/tasks*`; the Web UI lives at Settings → Tasks. +## Recurring Cron Jobs (`ode cron`) +- A cron job is a recurring scheduled prompt (5-field cron expression) that re-runs the same prompt as a fresh agent turn on schedule. +- CLI: + - `ode cron create --schedule "" --channel --message "" [--title ] [--disabled] [--run-now]` + - `ode cron list [--enabled | --disabled] [--json]` / `ode cron show <id> [--json]` + - `ode cron update <id> [--schedule ...] [--channel ...] [--message ...] [--title ...] [--enabled | --disabled]` + - `ode cron enable <id>` / `ode cron disable <id>` / `ode cron run <id>` / `ode cron delete <id>` +- Every run creates a fresh session + worktree (see `packages/core/cron/scheduler.ts`) so jobs start clean. +- Persistence: same `~/.config/ode/inbox.db` (table `cron_jobs`); scheduler polls every 15s and claims runs at the SQL level. +- HTTP API mirrors the CLI under `/api/cron-jobs*`; the Web UI lives at Settings → Cron. + +## Sending Files / Images (`ode send`) +- `ode send file <path> --channel <channelId> [--thread <threadId>] [--filename <name>] [--title <title>] [--comment <text>]` uploads any file to a chat channel. +- The command resolves platform (Slack / Discord / Lark) from the channel's configured workspace; agents don't need to know the underlying SDK. +- Visual testing workflows should save screenshots to `os.tmpdir()` and upload them directly into the current thread. + +## Fetching Messages (`ode messages`) +- `ode messages get <threadId> --channel <channelId> [--limit N] [--json]` returns the replies in a thread. +- Use it to re-read the current thread (for example, to pick up a follow-up comment posted while you were running tools) or to inspect another thread by its root id. + +## Reactions (`ode reaction`) +- `ode reaction add <messageId> --channel <channelId> --emoji <thumbsup|eyes|ok_hand> [--thread <threadId>]` reacts to a message. +- Useful acks: `eyes` = "I'm on it", `thumbsup` = "done", `ok_hand` = "acknowledged". + +## Platform APIs +- Ode no longer exposes a generic `/api/action` bridge; agents must use the dedicated `ode <verb>` CLIs above instead of calling Slack/Discord/Lark APIs directly. +- Adding a new platform-facing capability means adding (or extending) an `ode` subcommand plus a matching daemon route. + ## Bun conventions - Use Bun instead of Node.js - Run: `bun run src/index.ts` diff --git a/packages/agents/shared.ts b/packages/agents/shared.ts index 3315f426..61c61f2c 100644 --- a/packages/agents/shared.ts +++ b/packages/agents/shared.ts @@ -1,5 +1,4 @@ import type { OpenCodeMessageContext, OpenCodeOptions, PromptPart, SlackContext } from "./types"; -import { getSlackActionApiUrl } from "@/config"; export function buildSystemPrompt(slack?: SlackContext): string { const platform = slack?.platform === "discord" @@ -39,32 +38,6 @@ export function buildSystemPrompt(slack?: SlackContext): string { lines.push(`- GitHub token available: ${slack.hasGitHubToken ? "yes" : "no"}`); } - lines.push(""); - lines.push(`${platformLabel.toUpperCase()} ACTIONS:`); - const baseUrl = slack.odeSlackApiUrl ?? getSlackActionApiUrl(); - lines.push("- Use bash + curl to call the Ode action API."); - lines.push(`- Endpoint: ${baseUrl}/action`); - lines.push( - platform === "discord" - ? "- Payload: {\"platform\":\"discord\",\"action\":\"post_message\",\"channelId\":\"...\",\"messageId\":\"...\",\"text\":\"...\"}" - : platform === "lark" - ? "- Payload: {\"platform\":\"lark\",\"action\":\"post_message\",\"channelId\":\"...\",\"threadId\":\"...\",\"text\":\"...\"}" - : "- Payload: {\"action\":\"post_message\",\"channelId\":\"...\",\"threadId\":\"...\",\"messageId\":\"...\",\"text\":\"...\"}" - ); - if (platform === "discord") { - lines.push("- Supported actions: get_guilds, get_channels, post_message, update_message, create_thread_from_message, get_thread_messages, ask_user, add_reaction, get_user_info, upload_file."); - lines.push("- Required fields: channelId for message/reaction/question/upload actions; threadId for get_thread_messages; messageId + emoji for reactions; userId (or \"@me\") for get_user_info; filePath for upload_file."); - lines.push("- add_reaction schema: { platform: \"discord\", action: \"add_reaction\", channelId: string, messageId: string, emoji: \"thumbsup\" | \"eyes\" | \"ok_hand\" }"); - } else if (platform === "lark") { - lines.push("- Supported actions: get_channels, post_message, update_message, get_thread_messages, ask_user, add_reaction, get_user_info, upload_file."); - lines.push("- Required fields: channelId for post_message/ask_user/upload_file; messageId + text for update_message; threadId for get_thread_messages; messageId + emoji for add_reaction; userId for get_user_info; filePath for upload_file."); - lines.push("- post_message schema: { platform: \"lark\", action: \"post_message\", channelId: string, threadId?: string, text: string }"); - } else { - lines.push("- Supported actions: post_message, add_reaction, get_thread_messages, ask_user, get_user_info, upload_file."); - lines.push("- Required fields: channelId; threadId for thread actions; messageId + emoji for reactions; userId for get_user_info."); - lines.push("- add_reaction schema: { action: \"add_reaction\", channelId: string, messageId: string, emoji: \"thumbsup\" | \"eyes\" | \"ok_hand\" }"); - } - lines.push("- You can use any tool available via bash, curl"); lines.push(""); lines.push(`IMPORTANT: Your text output is automatically posted to ${platformLabel}.`); lines.push("- Only output text OR use a messaging tool, never both."); @@ -85,13 +58,44 @@ export function buildSystemPrompt(slack?: SlackContext): string { lines.push("- Use four states: * not started, ♻️ in progress, ✅ done, 🚫 cancelled"); lines.push("- If you include a task list, keep the tasks you have done at the top of the response"); lines.push(""); - lines.push("ONE-TIME SCHEDULED TASKS:"); - lines.push("- Ode provides a one-shot task scheduler for follow-ups that need to fire at a specific time."); - lines.push("- Use it when you need to wait on something that may take minutes, hours, or days (deploys, nightly builds, external approvals). Schedule a task and return instead of blocking the conversation."); - lines.push("- Create via CLI: `ode task create --time <ISO8601> --channel <channelId> [--thread <threadId>] --message \"<prompt>\" [--agent <agentId>]`."); - lines.push("- `--time` accepts ISO 8601 (e.g. `2026-04-19T09:00:00+08:00`). `--thread` is optional; when set, the task reuses this thread's session to keep context; when omitted, the task posts as a new channel message."); - lines.push("- When scheduling a follow-up for the current conversation, pass the current channel and thread so the agent wakes up with the same session history."); - lines.push("- Manage tasks with `ode task list`, `ode task show <id>`, `ode task cancel <id>`, `ode task delete <id>`. Tasks persist across restarts."); + lines.push("ODE CLI:"); + lines.push("- The `ode` binary is how you interact with this chat platform outside of plain text output."); + lines.push("- All commands below auto-detect platform (Slack / Discord / Lark) from the `--channel` value;"); + lines.push(" you never need to call Slack/Discord/Lark APIs directly."); + lines.push("- `--channel` accepts either a raw channel id or a `workspaceId::channelId` pair for disambiguation."); + lines.push(""); + lines.push("ODE CLI - one-time scheduled tasks (`ode task`):"); + lines.push("- Use when you need to wait on something that takes minutes / hours / days (deploys, nightly builds,"); + lines.push(" external approvals). Schedule the follow-up and return instead of blocking the conversation."); + lines.push("- `ode task create --time <ISO8601> --channel <channelId> [--thread <threadId>] --message \"<prompt>\" [--agent <agentId>] [--run-now]`"); + lines.push("- Manage: `ode task list`, `ode task show <id>`, `ode task cancel <id>`, `ode task run <id>`, `ode task delete <id>`."); + lines.push("- Pass the current `--thread` so the task wakes up inside the same conversation; omit it to post as a fresh channel message."); + lines.push(""); + lines.push("ODE CLI - recurring cron jobs (`ode cron`):"); + lines.push("- Use for schedules (heartbeats, daily digests, periodic checks). Each run starts a fresh agent session."); + lines.push("- `ode cron create --schedule \"<5-field cron>\" --channel <channelId> --message \"<prompt>\" [--title <title>] [--disabled] [--run-now]`"); + lines.push("- `--schedule` is standard 5-field cron (minute hour day month weekday), e.g. `*/30 * * * *`."); + lines.push("- Manage: `ode cron list`, `ode cron show <id>`, `ode cron update <id>`, `ode cron enable|disable <id>`, `ode cron run <id>`, `ode cron delete <id>`."); + lines.push(""); + lines.push("ODE CLI - send files / images (`ode send`):"); + lines.push("- `ode send file <path> --channel <channelId> [--thread <threadId>] [--comment \"...\"] [--filename <name>] [--title <text>]`"); + lines.push("- Save files to `$(mktemp -d)` or `os.tmpdir()` first, then upload them with this command."); + lines.push(`- Example: \`ode send file /tmp/screenshot.png --channel ${slack.channelId} --thread ${slack.threadId} --comment \"layout after fix\"\`.`); + lines.push(""); + lines.push("ODE CLI - fetch thread messages (`ode messages`):"); + lines.push("- `ode messages get <threadId> --channel <channelId> [--limit N] [--json]`"); + lines.push("- Use when you need to re-read prior messages in the current thread or another thread."); + lines.push(`- Example: \`ode messages get ${slack.threadId} --channel ${slack.channelId} --limit 40\`.`); + lines.push(""); + lines.push("ODE CLI - react to a message (`ode reaction`):"); + lines.push("- `ode reaction add <messageId> --channel <channelId> --emoji <thumbsup|eyes|ok_hand> [--thread <threadId>]`"); + lines.push("- Useful to acknowledge a request (`eyes` = \"I'm on it\", `thumbsup` = \"done\", `ok_hand` = \"ack\")."); + lines.push(""); + lines.push("VISUAL TESTING:"); + lines.push("- Whenever you work on UI / layout / design tasks, capture the result and upload it to the current"); + lines.push(" thread with `ode send file` so the user can see it immediately."); + lines.push("- Prefer real screenshots over describing the UI in text. A single screenshot is usually worth paragraphs."); + lines.push("- For before/after comparisons, upload both screenshots with a short `--comment` explaining what changed."); const channelSystemMessage = slack.channelSystemMessage?.trim(); if (channelSystemMessage) { diff --git a/packages/agents/test/cli-command.test.ts b/packages/agents/test/cli-command.test.ts index cd5f32b6..dc0a2416 100644 --- a/packages/agents/test/cli-command.test.ts +++ b/packages/agents/test/cli-command.test.ts @@ -63,7 +63,7 @@ describe("agent cli command formatting", () => { expect(systemPrompt).toContain("Preferred branch format before PR: `feat/<short-slug>-<threadShortId>`"); }); - it("builds Discord action instructions for Discord context", () => { + it("builds Discord-specific formatting guidance for Discord context", () => { const systemPrompt = buildSystemPrompt({ platform: "discord", channelId: "C123", @@ -72,12 +72,13 @@ describe("agent cli command formatting", () => { }); expect(systemPrompt).toContain("DISCORD CONTEXT:"); - expect(systemPrompt).toContain("DISCORD ACTIONS:"); - expect(systemPrompt).toContain('"platform":"discord"'); - expect(systemPrompt).toContain("Supported actions: get_guilds, get_channels, post_message"); + expect(systemPrompt).toContain("Discord supports markdown like **bold**, _italic_, and code fences."); + expect(systemPrompt).toContain("ODE CLI:"); + expect(systemPrompt).not.toContain("DISCORD ACTIONS:"); + expect(systemPrompt).not.toContain("/api/action"); }); - it("builds Lark action instructions for Lark context", () => { + it("builds Lark-specific formatting guidance for Lark context", () => { const systemPrompt = buildSystemPrompt({ platform: "lark", channelId: "oc_123", @@ -86,10 +87,26 @@ describe("agent cli command formatting", () => { }); expect(systemPrompt).toContain("LARK CONTEXT:"); - expect(systemPrompt).toContain("LARK ACTIONS:"); - expect(systemPrompt).toContain('"platform":"lark"'); - expect(systemPrompt).toContain("Supported actions: get_channels, post_message, update_message, get_thread_messages, ask_user, add_reaction, get_user_info, upload_file."); expect(systemPrompt).toContain("Lark output should be plain text for now; do not rely on markdown styling."); + expect(systemPrompt).toContain("ODE CLI:"); + expect(systemPrompt).not.toContain("LARK ACTIONS:"); + expect(systemPrompt).not.toContain("Supported actions:"); + }); + + it("recommends Ode CLI commands with the current channel/thread baked in", () => { + const systemPrompt = buildSystemPrompt({ + platform: "slack", + channelId: "C9XXX", + threadId: "1700000000.000001", + userId: "U42", + }); + + expect(systemPrompt).toContain("ode task create"); + expect(systemPrompt).toContain("ode cron create"); + expect(systemPrompt).toContain("ode send file /tmp/screenshot.png --channel C9XXX --thread 1700000000.000001"); + expect(systemPrompt).toContain("ode messages get 1700000000.000001 --channel C9XXX"); + expect(systemPrompt).toContain("ode reaction add"); + expect(systemPrompt).toContain("VISUAL TESTING:"); }); it("builds the OpenCode curl command", () => { diff --git a/packages/agents/types.ts b/packages/agents/types.ts index 6cfa9f6d..c0a330be 100644 --- a/packages/agents/types.ts +++ b/packages/agents/types.ts @@ -18,7 +18,6 @@ export interface PlatformContext { threadId: string; userId: string; threadHistory?: string; - odeSlackApiUrl?: string; hasGitHubToken?: boolean; channelSystemMessage?: string; } diff --git a/packages/config/index.ts b/packages/config/index.ts index d7031d2a..5a51ca59 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -95,6 +95,6 @@ export { type StatusMessageFrequencyValue, } from "./status-message-frequency"; -export { getSlackActionApiUrl, getWebHost, getWebPort } from "./network"; +export { getWebHost, getWebPort } from "./network"; export * as local from "./local"; diff --git a/packages/config/local/cron-jobs.ts b/packages/config/local/cron-jobs.ts index 7c878987..816b787a 100644 --- a/packages/config/local/cron-jobs.ts +++ b/packages/config/local/cron-jobs.ts @@ -45,6 +45,14 @@ export type CreateCronJobParams = { enabled?: boolean; }; +export type PatchCronJobParams = { + title?: string; + cronExpression?: string; + channelId?: string; + messageText?: string; + enabled?: boolean; +}; + type CronJobRow = { id: string; title: string; @@ -353,6 +361,29 @@ export function deleteCronJob(id: string): void { db.query("DELETE FROM cron_jobs WHERE id = ?").run(id); } +/** + * Apply a partial update to an existing cron job. Only the provided fields + * are changed; omitting a key preserves the current value. This powers the + * `ode cron update` / `ode cron enable` / `ode cron disable` CLI flows where + * callers typically want to flip a single attribute without re-specifying the + * whole record. + */ +export function patchCronJob(id: string, params: PatchCronJobParams): CronJobRecord { + const existing = getCronJobById(id); + if (!existing) { + throw new Error("Cron job not found"); + } + + const merged: CreateCronJobParams = { + title: params.title ?? existing.title, + cronExpression: params.cronExpression ?? existing.cronExpression, + channelId: params.channelId ?? existing.channelId, + messageText: params.messageText ?? existing.messageText, + enabled: params.enabled ?? existing.enabled, + }; + return updateCronJob(id, merged); +} + export function markCronJobTriggered(id: string, minuteStartMs: number): boolean { const db = getDatabase(); const result = db.query(` diff --git a/packages/config/network.ts b/packages/config/network.ts index 250aa442..6d956f4e 100644 --- a/packages/config/network.ts +++ b/packages/config/network.ts @@ -15,7 +15,3 @@ export function getWebHost(): string { export function getWebPort(): number { return parsePort(process.env.ODE_WEB_PORT?.trim(), DEFAULT_WEB_PORT); } - -export function getSlackActionApiUrl(): string { - return `http://${getWebHost()}:${getWebPort()}/api`; -} diff --git a/packages/core/cli-handlers/cron.ts b/packages/core/cli-handlers/cron.ts new file mode 100644 index 00000000..7711026a --- /dev/null +++ b/packages/core/cli-handlers/cron.ts @@ -0,0 +1,325 @@ +import { getWebHost, getWebPort } from "@/config"; +import type { CronJobRecord } from "@/config/local/cron-jobs"; + +type CliArgs = string[]; + +type FlagSpec = Record<string, boolean>; // name -> whether it takes a value + +function parseFlags(args: CliArgs, specs: FlagSpec): { flags: Record<string, string | boolean>; positional: string[] } { + const flags: Record<string, string | boolean> = {}; + const positional: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg.startsWith("--")) { + const eqIdx = arg.indexOf("="); + let name: string; + let value: string | undefined; + if (eqIdx >= 0) { + name = arg.slice(2, eqIdx); + value = arg.slice(eqIdx + 1); + } else { + name = arg.slice(2); + } + const takesValue = specs[name]; + if (takesValue === undefined) { + throw new Error(`Unknown flag: --${name}`); + } + if (!takesValue) { + flags[name] = true; + continue; + } + if (value === undefined) { + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + throw new Error(`Flag --${name} requires a value`); + } + value = next; + i += 1; + } + flags[name] = value; + } else { + positional.push(arg); + } + } + return { flags, positional }; +} + +function apiBase(): string { + return `http://${getWebHost()}:${getWebPort()}`; +} + +type ApiResponse<T> = { ok?: boolean; error?: string; result?: T }; + +async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { + const url = `${apiBase()}${path}`; + let response: Response; + try { + response = await fetch(url, init); + } catch (error) { + throw new Error( + `Failed to reach Ode daemon at ${url}. Is the daemon running? (Try \`ode status\` / \`ode start\`.) ${String(error)}`, + ); + } + const payload = (await response.json().catch(() => ({}))) as ApiResponse<T>; + if (!response.ok || payload.ok === false) { + throw new Error(payload.error || `Request failed with status ${response.status}`); + } + if (payload.result === undefined) { + throw new Error("Empty response from Ode daemon"); + } + return payload.result; +} + +function formatTimestamp(value: number | null | undefined): string { + if (!value || !Number.isFinite(value)) return "n/a"; + return new Date(value).toISOString(); +} + +function printJobRow(job: CronJobRecord): void { + const workspace = job.workspaceName || job.workspaceId || "-"; + const channel = job.channelName || job.channelId; + const enabled = job.enabled ? "on " : "off"; + console.log( + [ + job.id, + enabled, + job.lastRunStatus.padEnd(8), + job.cronExpression.padEnd(18), + `${workspace}/${channel}`, + job.title, + ].join(" "), + ); +} + +function printJobDetail(job: CronJobRecord): void { + console.log(`id: ${job.id}`); + console.log(`title: ${job.title}`); + console.log(`cronExpression: ${job.cronExpression}`); + console.log(`enabled: ${job.enabled ? "yes" : "no"}`); + console.log(`platform: ${job.platform}`); + console.log(`workspace: ${job.workspaceName || job.workspaceId || "-"}`); + console.log(`channel: ${job.channelName || job.channelId} (${job.channelId})`); + console.log(`lastRunStatus: ${job.lastRunStatus}`); + console.log(`lastTriggered: ${formatTimestamp(job.lastTriggeredAt)}`); + console.log(`lastCompleted: ${formatTimestamp(job.lastCompletedAt)}`); + console.log(`createdAt: ${formatTimestamp(job.createdAt)}`); + console.log(`updatedAt: ${formatTimestamp(job.updatedAt)}`); + if (job.lastError) { + console.log(`lastError: ${job.lastError}`); + } + console.log("--- message ---"); + console.log(job.messageText); +} + +function printCronHelp(): void { + console.log( + [ + "ode cron - recurring scheduled jobs (cron)", + "", + "Usage:", + " ode cron create --schedule <cron> --channel <channelId> --message <text> [--title <title>] [--disabled] [--run-now]", + " ode cron list [--enabled | --disabled] [--json]", + " ode cron show <id> [--json]", + " ode cron update <id> [--schedule <cron>] [--channel <channelId>] [--message <text>] [--title <title>] [--enabled | --disabled]", + " ode cron enable <id>", + " ode cron disable <id>", + " ode cron run <id>", + " ode cron delete <id>", + "", + "Notes:", + " --schedule uses 5-field cron syntax: `minute hour day month weekday`.", + " --channel accepts either a raw channel id or a \"workspaceId::channelId\" value.", + " Creating a job defaults to enabled; use --disabled to create a paused job.", + " `update` only changes the fields you pass; omit a flag to keep the current value.", + ].join("\n"), + ); +} + +async function handleCreate(args: CliArgs): Promise<void> { + const { flags } = parseFlags(args, { + schedule: true, + channel: true, + message: true, + title: true, + disabled: false, + "run-now": false, + }); + + const schedule = flags.schedule as string | undefined; + const channel = flags.channel as string | undefined; + const message = flags.message as string | undefined; + const disabled = flags.disabled === true; + const runNow = flags["run-now"] === true; + + if (!schedule) throw new Error("--schedule is required"); + if (!channel) throw new Error("--channel is required"); + if (!message) throw new Error("--message is required"); + + const title = (flags.title as string | undefined)?.trim() + || message.split("\n")[0]!.slice(0, 80) + || "Cron job"; + + const result = await apiFetch<{ job: CronJobRecord }>("/api/cron-jobs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title, + cronExpression: schedule, + channelId: channel, + messageText: message, + enabled: !disabled, + runImmediately: runNow, + }), + }); + printJobDetail(result.job); +} + +async function handleList(args: CliArgs): Promise<void> { + const { flags } = parseFlags(args, { enabled: false, disabled: false, json: false }); + const result = await apiFetch<{ jobs: CronJobRecord[] }>("/api/cron-jobs"); + let jobs = result.jobs; + if (flags.enabled && !flags.disabled) { + jobs = jobs.filter((job) => job.enabled); + } else if (flags.disabled && !flags.enabled) { + jobs = jobs.filter((job) => !job.enabled); + } + if (flags.json) { + console.log(JSON.stringify(jobs, null, 2)); + return; + } + if (jobs.length === 0) { + console.log("No cron jobs."); + return; + } + console.log("id state last_status cron_expression workspace/channel title"); + console.log("-".repeat(80)); + for (const job of jobs) { + printJobRow(job); + } +} + +async function handleShow(args: CliArgs): Promise<void> { + const { flags, positional } = parseFlags(args, { json: false }); + const id = positional[0]; + if (!id) throw new Error("Cron job id is required: ode cron show <id>"); + const result = await apiFetch<{ job: CronJobRecord }>(`/api/cron-jobs/${encodeURIComponent(id)}`); + if (flags.json) { + console.log(JSON.stringify(result.job, null, 2)); + return; + } + printJobDetail(result.job); +} + +async function handleUpdate(args: CliArgs): Promise<void> { + const { flags, positional } = parseFlags(args, { + schedule: true, + channel: true, + message: true, + title: true, + enabled: false, + disabled: false, + }); + const id = positional[0]; + if (!id) throw new Error("Cron job id is required: ode cron update <id>"); + + if (flags.enabled && flags.disabled) { + throw new Error("--enabled and --disabled cannot be combined"); + } + + const body: Record<string, unknown> = {}; + if (flags.schedule !== undefined) body.cronExpression = flags.schedule; + if (flags.channel !== undefined) body.channelId = flags.channel; + if (flags.message !== undefined) body.messageText = flags.message; + if (flags.title !== undefined) body.title = flags.title; + if (flags.enabled) body.enabled = true; + if (flags.disabled) body.enabled = false; + + if (Object.keys(body).length === 0) { + throw new Error("No update fields provided"); + } + + const result = await apiFetch<{ job: CronJobRecord }>(`/api/cron-jobs/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + printJobDetail(result.job); +} + +async function handleToggle(args: CliArgs, enabled: boolean): Promise<void> { + const { positional } = parseFlags(args, {}); + const id = positional[0]; + if (!id) throw new Error(`Cron job id is required: ode cron ${enabled ? "enable" : "disable"} <id>`); + const result = await apiFetch<{ job: CronJobRecord }>(`/api/cron-jobs/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + console.log(`Cron job ${id} ${enabled ? "enabled" : "disabled"}.`); + printJobDetail(result.job); +} + +async function handleDelete(args: CliArgs): Promise<void> { + const { positional } = parseFlags(args, {}); + const id = positional[0]; + if (!id) throw new Error("Cron job id is required: ode cron delete <id>"); + await apiFetch(`/api/cron-jobs/${encodeURIComponent(id)}`, { method: "DELETE" }); + console.log(`Cron job ${id} deleted.`); +} + +async function handleRunNow(args: CliArgs): Promise<void> { + const { positional } = parseFlags(args, {}); + const id = positional[0]; + if (!id) throw new Error("Cron job id is required: ode cron run <id>"); + await apiFetch(`/api/cron-jobs/${encodeURIComponent(id)}/run`, { method: "POST" }); + console.log(`Cron job ${id} triggered.`); +} + +export async function handleCronCommand(args: CliArgs): Promise<number> { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + printCronHelp(); + return 0; + } + try { + const rest = args.slice(1); + if (sub === "create") { + await handleCreate(rest); + return 0; + } + if (sub === "list" || sub === "ls") { + await handleList(rest); + return 0; + } + if (sub === "show" || sub === "get") { + await handleShow(rest); + return 0; + } + if (sub === "update") { + await handleUpdate(rest); + return 0; + } + if (sub === "enable") { + await handleToggle(rest, true); + return 0; + } + if (sub === "disable") { + await handleToggle(rest, false); + return 0; + } + if (sub === "delete" || sub === "rm") { + await handleDelete(rest); + return 0; + } + if (sub === "run") { + await handleRunNow(rest); + return 0; + } + console.error(`Unknown cron subcommand: ${sub}`); + printCronHelp(); + return 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/packages/core/cli-handlers/messages.ts b/packages/core/cli-handlers/messages.ts new file mode 100644 index 00000000..b92d472b --- /dev/null +++ b/packages/core/cli-handlers/messages.ts @@ -0,0 +1,137 @@ +import { getWebHost, getWebPort } from "@/config"; + +type CliArgs = string[]; + +type FlagSpec = Record<string, boolean>; + +function parseFlags(args: CliArgs, specs: FlagSpec): { flags: Record<string, string | boolean>; positional: string[] } { + const flags: Record<string, string | boolean> = {}; + const positional: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg.startsWith("--")) { + const eqIdx = arg.indexOf("="); + let name: string; + let value: string | undefined; + if (eqIdx >= 0) { + name = arg.slice(2, eqIdx); + value = arg.slice(eqIdx + 1); + } else { + name = arg.slice(2); + } + const takesValue = specs[name]; + if (takesValue === undefined) { + throw new Error(`Unknown flag: --${name}`); + } + if (!takesValue) { + flags[name] = true; + continue; + } + if (value === undefined) { + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + throw new Error(`Flag --${name} requires a value`); + } + value = next; + i += 1; + } + flags[name] = value; + } else { + positional.push(arg); + } + } + return { flags, positional }; +} + +function apiBase(): string { + return `http://${getWebHost()}:${getWebPort()}`; +} + +type ApiResponse<T> = { ok?: boolean; error?: string; result?: T }; + +async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { + const url = `${apiBase()}${path}`; + let response: Response; + try { + response = await fetch(url, init); + } catch (error) { + throw new Error( + `Failed to reach Ode daemon at ${url}. Is the daemon running? (Try \`ode status\` / \`ode start\`.) ${String(error)}`, + ); + } + const payload = (await response.json().catch(() => ({}))) as ApiResponse<T>; + if (!response.ok || payload.ok === false) { + throw new Error(payload.error || `Request failed with status ${response.status}`); + } + if (payload.result === undefined) { + throw new Error("Empty response from Ode daemon"); + } + return payload.result; +} + +function printMessagesHelp(): void { + console.log( + [ + "ode messages - fetch messages from a chat thread/channel", + "", + "Usage:", + " ode messages get <threadId> --channel <channelId> [--limit N] [--json]", + "", + "Notes:", + " <threadId> is the thread root id (Slack `thread_ts`, Lark message id, Discord channel/thread id).", + " --limit caps how many replies to return (default 20).", + " --channel accepts either a raw channel id or a \"workspaceId::channelId\" value.", + " Ode auto-detects the platform (Slack / Discord / Lark) from the channel.", + ].join("\n"), + ); +} + +async function handleMessagesGet(args: CliArgs): Promise<void> { + const { flags, positional } = parseFlags(args, { channel: true, limit: true, json: false }); + const threadId = positional[0]; + if (!threadId) throw new Error("Thread id is required: ode messages get <threadId> --channel <channelId>"); + const channel = flags.channel as string | undefined; + if (!channel) throw new Error("--channel is required"); + const limitRaw = flags.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : undefined; + if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) { + throw new Error("--limit must be a positive number"); + } + + const body: Record<string, unknown> = { channelId: channel, threadId }; + if (limit !== undefined) body.limit = limit; + + const result = await apiFetch<{ platform: string; messages: unknown[] }>("/api/messages/thread", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (flags.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`platform: ${result.platform} count: ${result.messages.length}`); + console.log("--- messages ---"); + console.log(JSON.stringify(result.messages, null, 2)); +} + +export async function handleMessagesCommand(args: CliArgs): Promise<number> { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + printMessagesHelp(); + return 0; + } + try { + const rest = args.slice(1); + if (sub === "get") { + await handleMessagesGet(rest); + return 0; + } + console.error(`Unknown messages subcommand: ${sub}`); + printMessagesHelp(); + return 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/packages/core/cli-handlers/reaction.ts b/packages/core/cli-handlers/reaction.ts new file mode 100644 index 00000000..e93563cd --- /dev/null +++ b/packages/core/cli-handlers/reaction.ts @@ -0,0 +1,127 @@ +import { getWebHost, getWebPort } from "@/config"; + +type CliArgs = string[]; + +type FlagSpec = Record<string, boolean>; + +function parseFlags(args: CliArgs, specs: FlagSpec): { flags: Record<string, string | boolean>; positional: string[] } { + const flags: Record<string, string | boolean> = {}; + const positional: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg.startsWith("--")) { + const eqIdx = arg.indexOf("="); + let name: string; + let value: string | undefined; + if (eqIdx >= 0) { + name = arg.slice(2, eqIdx); + value = arg.slice(eqIdx + 1); + } else { + name = arg.slice(2); + } + const takesValue = specs[name]; + if (takesValue === undefined) { + throw new Error(`Unknown flag: --${name}`); + } + if (!takesValue) { + flags[name] = true; + continue; + } + if (value === undefined) { + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + throw new Error(`Flag --${name} requires a value`); + } + value = next; + i += 1; + } + flags[name] = value; + } else { + positional.push(arg); + } + } + return { flags, positional }; +} + +function apiBase(): string { + return `http://${getWebHost()}:${getWebPort()}`; +} + +type ApiResponse<T> = { ok?: boolean; error?: string; result?: T }; + +async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { + const url = `${apiBase()}${path}`; + let response: Response; + try { + response = await fetch(url, init); + } catch (error) { + throw new Error( + `Failed to reach Ode daemon at ${url}. Is the daemon running? (Try \`ode status\` / \`ode start\`.) ${String(error)}`, + ); + } + const payload = (await response.json().catch(() => ({}))) as ApiResponse<T>; + if (!response.ok || payload.ok === false) { + throw new Error(payload.error || `Request failed with status ${response.status}`); + } + if (payload.result === undefined) { + throw new Error("Empty response from Ode daemon"); + } + return payload.result; +} + +function printReactionHelp(): void { + console.log( + [ + "ode reaction - add reactions to chat messages", + "", + "Usage:", + " ode reaction add <messageId> --channel <channelId> --emoji <name> [--thread <threadId>]", + "", + "Notes:", + " Supported --emoji values: thumbsup, eyes, ok_hand (aliases: thumbup, ok).", + " --channel accepts either a raw channel id or a \"workspaceId::channelId\" value.", + " --thread is optional; Slack accepts it to scope the reaction to the right session.", + ].join("\n"), + ); +} + +async function handleReactionAdd(args: CliArgs): Promise<void> { + const { flags, positional } = parseFlags(args, { channel: true, emoji: true, thread: true }); + const messageId = positional[0]; + if (!messageId) throw new Error("Message id is required: ode reaction add <messageId> --channel ... --emoji ..."); + const channel = flags.channel as string | undefined; + if (!channel) throw new Error("--channel is required"); + const emoji = flags.emoji as string | undefined; + if (!emoji) throw new Error("--emoji is required"); + + const body: Record<string, unknown> = { channelId: channel, messageId, emoji }; + if (typeof flags.thread === "string") body.threadId = flags.thread; + + const result = await apiFetch<Record<string, unknown>>("/api/reactions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + console.log(JSON.stringify(result, null, 2)); +} + +export async function handleReactionCommand(args: CliArgs): Promise<number> { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + printReactionHelp(); + return 0; + } + try { + const rest = args.slice(1); + if (sub === "add") { + await handleReactionAdd(rest); + return 0; + } + console.error(`Unknown reaction subcommand: ${sub}`); + printReactionHelp(); + return 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/packages/core/cli-handlers/send.ts b/packages/core/cli-handlers/send.ts new file mode 100644 index 00000000..fac82993 --- /dev/null +++ b/packages/core/cli-handlers/send.ts @@ -0,0 +1,151 @@ +import { getWebHost, getWebPort } from "@/config"; + +type CliArgs = string[]; + +type FlagSpec = Record<string, boolean>; + +function parseFlags(args: CliArgs, specs: FlagSpec): { flags: Record<string, string | boolean>; positional: string[] } { + const flags: Record<string, string | boolean> = {}; + const positional: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + if (arg.startsWith("--")) { + const eqIdx = arg.indexOf("="); + let name: string; + let value: string | undefined; + if (eqIdx >= 0) { + name = arg.slice(2, eqIdx); + value = arg.slice(eqIdx + 1); + } else { + name = arg.slice(2); + } + const takesValue = specs[name]; + if (takesValue === undefined) { + throw new Error(`Unknown flag: --${name}`); + } + if (!takesValue) { + flags[name] = true; + continue; + } + if (value === undefined) { + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + throw new Error(`Flag --${name} requires a value`); + } + value = next; + i += 1; + } + flags[name] = value; + } else { + positional.push(arg); + } + } + return { flags, positional }; +} + +function apiBase(): string { + return `http://${getWebHost()}:${getWebPort()}`; +} + +type ApiResponse<T> = { ok?: boolean; error?: string; result?: T }; + +async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { + const url = `${apiBase()}${path}`; + let response: Response; + try { + response = await fetch(url, init); + } catch (error) { + throw new Error( + `Failed to reach Ode daemon at ${url}. Is the daemon running? (Try \`ode status\` / \`ode start\`.) ${String(error)}`, + ); + } + const payload = (await response.json().catch(() => ({}))) as ApiResponse<T>; + if (!response.ok || payload.ok === false) { + throw new Error(payload.error || `Request failed with status ${response.status}`); + } + if (payload.result === undefined) { + throw new Error("Empty response from Ode daemon"); + } + return payload.result; +} + +function printSendHelp(): void { + console.log( + [ + "ode send - upload files/images to a chat channel", + "", + "Usage:", + " ode send file <path> --channel <channelId> [--thread <threadId>] [--filename <name>] [--title <title>] [--comment <text>]", + "", + "Notes:", + " Ode auto-detects the platform (Slack / Discord / Lark) from the channel.", + " --channel accepts either a raw channel id or a \"workspaceId::channelId\" value.", + " --thread is optional; when set, the upload lands in that thread.", + " --comment adds an initial message alongside the file.", + " Use this command to post screenshots, rendered designs, or any binary asset.", + " For visual checks (layout diffs, screenshots of running UI), prefer uploading the", + " artifact directly into the current thread so reviewers can see it inline.", + ].join("\n"), + ); +} + +async function handleSendFile(args: CliArgs): Promise<void> { + const { flags, positional } = parseFlags(args, { + channel: true, + thread: true, + filename: true, + title: true, + comment: true, + }); + + const filePath = positional[0]; + if (!filePath) throw new Error("File path is required: ode send file <path> --channel <channelId>"); + const channel = flags.channel as string | undefined; + if (!channel) throw new Error("--channel is required"); + + // Resolve to absolute path so the daemon — which may run from a different + // cwd — can still find the file. Bun provides `path.resolve` via node:path. + const { resolve: resolvePath } = await import("path"); + const absolutePath = resolvePath(process.cwd(), filePath); + const file = Bun.file(absolutePath); + if (!(await file.exists())) { + throw new Error(`File not found: ${absolutePath}`); + } + + const body: Record<string, unknown> = { + channelId: channel, + filePath: absolutePath, + }; + if (typeof flags.thread === "string") body.threadId = flags.thread; + if (typeof flags.filename === "string") body.filename = flags.filename; + if (typeof flags.title === "string") body.title = flags.title; + if (typeof flags.comment === "string") body.initialComment = flags.comment; + + const result = await apiFetch<Record<string, unknown>>("/api/send/file", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + console.log(JSON.stringify(result, null, 2)); +} + +export async function handleSendCommand(args: CliArgs): Promise<number> { + const sub = args[0]; + if (!sub || sub === "help" || sub === "--help" || sub === "-h") { + printSendHelp(); + return 0; + } + try { + const rest = args.slice(1); + if (sub === "file") { + await handleSendFile(rest); + return 0; + } + console.error(`Unknown send subcommand: ${sub}`); + printSendHelp(); + return 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/packages/core/cli.ts b/packages/core/cli.ts index 5b80dbdc..98bcedd3 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -9,6 +9,10 @@ import { getDaemonLogPath } from "@/core/daemon/paths"; import { isProcessAlive, readDaemonState, type DaemonState } from "@/core/daemon/state"; import { runOnboarding } from "@/core/onboarding"; import { handleTaskCommand } from "@/core/cli-handlers/task"; +import { handleCronCommand } from "@/core/cli-handlers/cron"; +import { handleSendCommand } from "@/core/cli-handlers/send"; +import { handleMessagesCommand } from "@/core/cli-handlers/messages"; +import { handleReactionCommand } from "@/core/cli-handlers/reaction"; import { isInstalledBinary, performUpgrade } from "@/core/upgrade"; const rawArgs = process.argv.slice(2); @@ -47,6 +51,10 @@ function printHelp(): void { " ode onboarding", " ode config", " ode task <subcommand> # manage one-time scheduled tasks", + " ode cron <subcommand> # manage recurring cron jobs", + " ode send <subcommand> # upload files/images to a chat channel", + " ode messages <subcommand> # fetch thread messages", + " ode reaction <subcommand> # add reactions to messages", " ode upgrade", " ode --version", "", @@ -60,6 +68,11 @@ function printHelp(): void { " ode onboard", " ode task create --time 2026-04-19T09:00:00+08:00 --channel C123 --message \"check deploy\"", " ode task list", + " ode cron create --schedule \"*/30 * * * *\" --channel C123 --message \"heartbeat\"", + " ode cron list", + " ode send file ./screenshot.png --channel C123 --thread 1700000000.000001 --comment \"layout diff\"", + " ode messages get 1700000000.000001 --channel C123", + " ode reaction add 1700000000.000001 --channel C123 --emoji thumbsup", " ode --foreground", " ODE_WEB_HOST=0.0.0.0 ode #run ode process and expose setting UI", ].join("\n"), @@ -497,6 +510,26 @@ if (command === "task") { process.exit(code); } +if (command === "cron") { + const code = await handleCronCommand(args.slice(1)); + process.exit(code); +} + +if (command === "send") { + const code = await handleSendCommand(args.slice(1)); + process.exit(code); +} + +if (command === "messages") { + const code = await handleMessagesCommand(args.slice(1)); + process.exit(code); +} + +if (command === "reaction") { + const code = await handleReactionCommand(args.slice(1)); + process.exit(code); +} + if (foregroundRequested) { await import("./index"); await new Promise(() => {}); diff --git a/packages/core/web/app.ts b/packages/core/web/app.ts index 890e422c..b4a29a84 100644 --- a/packages/core/web/app.ts +++ b/packages/core/web/app.ts @@ -5,10 +5,12 @@ import { registerWorkspaceRoutes } from "./routes/workspaces"; import { registerLarkRoutes } from "./routes/lark"; import { registerAgentCheckRoutes } from "./routes/agent-check"; import { registerSessionRoutes } from "./routes/sessions"; -import { registerActionRoutes } from "./routes/action"; import { registerInboxRoutes } from "./routes/inbox"; import { registerCronJobRoutes } from "./routes/cron-jobs"; import { registerTaskRoutes } from "./routes/tasks"; +import { registerSendRoutes } from "./routes/send"; +import { registerMessagesRoutes } from "./routes/messages"; +import { registerReactionsRoutes } from "./routes/reactions"; export function createWebApp(): Elysia { const app = new Elysia(); @@ -32,10 +34,12 @@ export function createWebApp(): Elysia { registerLarkRoutes(app); registerAgentCheckRoutes(app); registerSessionRoutes(app); - registerActionRoutes(app); registerInboxRoutes(app); registerCronJobRoutes(app); registerTaskRoutes(app); + registerSendRoutes(app); + registerMessagesRoutes(app); + registerReactionsRoutes(app); app.all("*", async ({ request }: { request: Request }) => { return serveStaticAsset(request); diff --git a/packages/core/web/routes/action.ts b/packages/core/web/routes/action.ts deleted file mode 100644 index e412213c..00000000 --- a/packages/core/web/routes/action.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Elysia } from "elysia"; -import { handleDiscordActionPayload, handleLarkActionPayload, handleSlackActionPayload } from "@/ims"; -import { attachDiscordBotToken, attachLarkCredentials } from "../config-validation"; -import { jsonResponse, readJsonBody, runRoute } from "../http"; - -export function registerActionRoutes(app: Elysia): void { - app.post("/api/action", async ({ request }: { request: Request }) => { - return runRoute( - async () => { - const payload = await readJsonBody(request); - const platform = payload && typeof payload === "object" && "platform" in payload - ? String((payload as { platform?: unknown }).platform ?? "slack").toLowerCase() - : "slack"; - - if (platform === "discord") { - attachDiscordBotToken(payload); - } else if (platform === "lark") { - attachLarkCredentials(payload); - } - - const response = platform === "discord" - ? await handleDiscordActionPayload(payload) - : platform === "lark" - ? await handleLarkActionPayload(payload) - : await handleSlackActionPayload(payload); - return response; - }, - (response) => jsonResponse(response.ok ? 200 : 400, response), - { - fallbackMessage: "Invalid JSON payload", - resolveStatus: (message) => (message === "Invalid JSON payload" ? 400 : 500), - } - ); - }); -} diff --git a/packages/core/web/routes/channel-resolver.ts b/packages/core/web/routes/channel-resolver.ts new file mode 100644 index 00000000..b76be74c --- /dev/null +++ b/packages/core/web/routes/channel-resolver.ts @@ -0,0 +1,49 @@ +import { loadOdeConfig } from "@/config/local/ode-store"; + +// Shared channel resolution helper used by the Ode CLI-facing routes +// (`/api/send/file`, `/api/messages/thread`, `/api/reactions`) to translate +// a user-supplied channel locator into a concrete `(platform, workspaceId, +// channelId)` tuple backed by the persisted Ode config. + +export type ResolvedChannelPlatform = "slack" | "discord" | "lark"; + +export type ResolvedChannel = { + platform: ResolvedChannelPlatform; + workspaceId: string; + workspaceName: string; + channelId: string; +}; + +/** + * Resolve a channel locator to a concrete workspace + channel. Accepts either + * a raw channel id (e.g. `C123`) or a `"workspaceId::channelId"` pair for + * ambiguity resolution — the same convention `ode task` / `ode cron` use. + */ +export function resolveChannelLocator(input: string): ResolvedChannel { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("channelId is required"); + } + + const delimiterIndex = trimmed.lastIndexOf("::"); + const workspaceHint = delimiterIndex >= 0 ? trimmed.slice(0, delimiterIndex) : ""; + const rawChannelId = delimiterIndex >= 0 ? trimmed.slice(delimiterIndex + 2).trim() : trimmed; + if (!rawChannelId) { + throw new Error("channelId is required"); + } + + const config = loadOdeConfig(); + for (const workspace of config.workspaces) { + if (workspaceHint && workspace.id !== workspaceHint) continue; + const channel = workspace.channelDetails.find((entry) => entry.id === rawChannelId); + if (channel) { + return { + platform: workspace.type, + workspaceId: workspace.id, + workspaceName: workspace.name || workspace.id, + channelId: channel.id, + }; + } + } + throw new Error("Channel not found in configured workspaces"); +} diff --git a/packages/core/web/routes/cron-jobs.ts b/packages/core/web/routes/cron-jobs.ts index 47bbde02..648fbc4d 100644 --- a/packages/core/web/routes/cron-jobs.ts +++ b/packages/core/web/routes/cron-jobs.ts @@ -2,9 +2,12 @@ import type { Elysia } from "elysia"; import { createCronJob, deleteCronJob, + getCronJobById, listCronJobChannelOptions, listCronJobs, + patchCronJob, updateCronJob, + type PatchCronJobParams, } from "@/config/local/cron-jobs"; import { CronJobAlreadyRunningError, @@ -34,6 +37,19 @@ function parseCronJobPayload(payload: Record<string, unknown>) { }; } +function parseCronJobPatchPayload(payload: Record<string, unknown>): PatchCronJobParams { + const patch: PatchCronJobParams = {}; + if ("title" in payload && typeof payload.title === "string") patch.title = payload.title; + if ("cronExpression" in payload && typeof payload.cronExpression === "string") { + patch.cronExpression = payload.cronExpression; + } + if ("channelId" in payload && typeof payload.channelId === "string") patch.channelId = payload.channelId; + if ("messageText" in payload && typeof payload.messageText === "string") patch.messageText = payload.messageText; + const enabled = getBoolean(payload, "enabled"); + if (enabled !== undefined) patch.enabled = enabled; + return patch; +} + export function registerCronJobRoutes(app: Elysia): void { app.get("/api/cron-jobs", async () => { return runRoute( @@ -46,6 +62,27 @@ export function registerCronJobRoutes(app: Elysia): void { ); }); + app.get("/api/cron-jobs/:id", async ({ params }: { params: { id?: string } }) => { + return runRoute( + async () => { + const id = params.id?.trim(); + if (!id) throw new Error("Missing cron job id"); + const job = getCronJobById(id); + if (!job) throw new Error("Cron job not found"); + return { job }; + }, + (result) => jsonResponse(200, { ok: true, result }), + { + fallbackMessage: "Failed to load cron job", + resolveStatus: (message) => { + if (message === "Missing cron job id") return 400; + if (message === "Cron job not found") return 404; + return 500; + }, + } + ); + }); + app.post("/api/cron-jobs", async ({ request }: { request: Request }) => { return runRoute( async () => { @@ -115,6 +152,33 @@ export function registerCronJobRoutes(app: Elysia): void { ); }); + app.patch("/api/cron-jobs/:id", async ({ params, request }: { params: { id?: string }; request: Request }) => { + return runRoute( + async () => { + const id = params.id?.trim(); + if (!id) { + throw new Error("Missing cron job id"); + } + const patch = parseCronJobPatchPayload(await readJsonBody(request)); + const job = patchCronJob(id, patch); + return { + job, + jobs: listCronJobs(), + channels: listCronJobChannelOptions(), + }; + }, + (result) => jsonResponse(200, { ok: true, result }), + { + fallbackMessage: "Invalid cron job payload", + resolveStatus: (message) => { + if (message === "Missing cron job id") return 400; + if (message === "Cron job not found") return 404; + return 400; + }, + } + ); + }); + app.delete("/api/cron-jobs/:id", async ({ params }: { params: { id?: string } }) => { return runRoute( async () => { diff --git a/packages/core/web/routes/messages.ts b/packages/core/web/routes/messages.ts new file mode 100644 index 00000000..14590cc0 --- /dev/null +++ b/packages/core/web/routes/messages.ts @@ -0,0 +1,114 @@ +import type { Elysia } from "elysia"; +import { getDiscordThreadMessages, getLarkThreadMessages, getSlackThreadMessages } from "@/ims"; +import { attachDiscordBotToken, attachLarkCredentials } from "../config-validation"; +import { jsonResponse, readJsonBody, runRoute } from "../http"; +import { resolveChannelLocator } from "./channel-resolver"; + +function getString(payload: Record<string, unknown>, key: string): string { + const value = payload[key]; + return typeof value === "string" ? value : ""; +} + +function getOptionalString(payload: Record<string, unknown>, key: string): string | undefined { + const value = payload[key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function getOptionalNumber(payload: Record<string, unknown>, key: string): number | undefined { + const value = payload[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +export function registerMessagesRoutes(app: Elysia): void { + /** + * Fetch messages from a thread / channel. Powers `ode messages get`. The + * server resolves which messaging platform owns the channel and calls the + * dedicated per-platform helper — callers don't need to know whether the + * channel is Slack / Discord / Lark. + */ + app.post("/api/messages/thread", async ({ request }: { request: Request }) => { + return runRoute( + async () => { + const body = await readJsonBody(request); + const channelIdRaw = getString(body, "channelId"); + if (!channelIdRaw) { + throw new Error("channelId is required"); + } + const threadId = getOptionalString(body, "threadId"); + const limit = getOptionalNumber(body, "limit"); + + const resolved = resolveChannelLocator(channelIdRaw); + + if (resolved.platform === "slack") { + if (!threadId) { + throw new Error("threadId is required"); + } + const result = await getSlackThreadMessages({ + channelId: resolved.channelId, + threadId, + limit, + }); + return { platform: resolved.platform, ...result }; + } + + if (resolved.platform === "discord") { + const discordPayload: Record<string, unknown> = { channelId: resolved.channelId }; + attachDiscordBotToken(discordPayload); + const botToken = typeof discordPayload.botToken === "string" ? discordPayload.botToken : ""; + if (!botToken) { + throw new Error("Discord bot token not configured"); + } + const result = await getDiscordThreadMessages({ + botToken, + channelId: resolved.channelId, + threadId, + limit, + }); + return { platform: resolved.platform, ...result }; + } + + if (resolved.platform === "lark") { + if (!threadId) { + throw new Error("threadId is required"); + } + const larkPayload: Record<string, unknown> = { + channelId: resolved.channelId, + workspaceId: resolved.workspaceId, + }; + attachLarkCredentials(larkPayload); + const appId = typeof larkPayload.appId === "string" ? larkPayload.appId : ""; + const appSecret = typeof larkPayload.appSecret === "string" ? larkPayload.appSecret : ""; + if (!appId || !appSecret) { + throw new Error("Lark app credentials not configured"); + } + const result = await getLarkThreadMessages({ + appId, + appSecret, + channelId: resolved.channelId, + threadId, + limit, + }); + return { platform: resolved.platform, ...result }; + } + + throw new Error(`Unsupported platform: ${resolved.platform}`); + }, + (result) => jsonResponse(200, { ok: true, result }), + { + fallbackMessage: "Failed to fetch messages", + resolveStatus: (message) => { + if (message === "channelId is required") return 400; + if (message === "threadId is required") return 400; + if (message === "Channel not found in configured workspaces") return 404; + if (message.includes("not configured")) return 400; + return 500; + }, + }, + ); + }); +} diff --git a/packages/core/web/routes/reactions.ts b/packages/core/web/routes/reactions.ts new file mode 100644 index 00000000..472574a0 --- /dev/null +++ b/packages/core/web/routes/reactions.ts @@ -0,0 +1,101 @@ +import type { Elysia } from "elysia"; +import { addDiscordReaction, addLarkReaction, addSlackReaction } from "@/ims"; +import { attachDiscordBotToken, attachLarkCredentials } from "../config-validation"; +import { jsonResponse, readJsonBody, runRoute } from "../http"; +import { resolveChannelLocator } from "./channel-resolver"; + +function getString(payload: Record<string, unknown>, key: string): string { + const value = payload[key]; + return typeof value === "string" ? value : ""; +} + +function getOptionalString(payload: Record<string, unknown>, key: string): string | undefined { + const value = payload[key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +export function registerReactionsRoutes(app: Elysia): void { + /** + * Add a reaction emoji to a message. Powers `ode reaction add`. Accepts + * the short reaction names (`thumbsup`, `eyes`, `ok_hand`) and dispatches + * to the platform-specific helper based on the channel's configured + * workspace type. + */ + app.post("/api/reactions", async ({ request }: { request: Request }) => { + return runRoute( + async () => { + const body = await readJsonBody(request); + const channelIdRaw = getString(body, "channelId"); + const messageId = getString(body, "messageId"); + const emoji = getString(body, "emoji"); + if (!channelIdRaw) throw new Error("channelId is required"); + if (!messageId) throw new Error("messageId is required"); + if (!emoji) throw new Error("emoji is required"); + + const threadId = getOptionalString(body, "threadId"); + const resolved = resolveChannelLocator(channelIdRaw); + + if (resolved.platform === "slack") { + const result = await addSlackReaction({ + channelId: resolved.channelId, + messageId, + emoji, + threadId, + }); + return { platform: resolved.platform, ...result }; + } + + if (resolved.platform === "discord") { + const discordPayload: Record<string, unknown> = { channelId: resolved.channelId }; + attachDiscordBotToken(discordPayload); + const botToken = typeof discordPayload.botToken === "string" ? discordPayload.botToken : ""; + if (!botToken) { + throw new Error("Discord bot token not configured"); + } + const result = await addDiscordReaction({ + botToken, + channelId: resolved.channelId, + messageId, + emoji, + }); + return { platform: resolved.platform, ...result }; + } + + if (resolved.platform === "lark") { + const larkPayload: Record<string, unknown> = { + channelId: resolved.channelId, + workspaceId: resolved.workspaceId, + }; + attachLarkCredentials(larkPayload); + const appId = typeof larkPayload.appId === "string" ? larkPayload.appId : ""; + const appSecret = typeof larkPayload.appSecret === "string" ? larkPayload.appSecret : ""; + if (!appId || !appSecret) { + throw new Error("Lark app credentials not configured"); + } + const result = await addLarkReaction({ + appId, + appSecret, + messageId, + emoji, + }); + return { platform: resolved.platform, ...result }; + } + + throw new Error(`Unsupported platform: ${resolved.platform}`); + }, + (result) => jsonResponse(200, { ok: true, result }), + { + fallbackMessage: "Failed to add reaction", + resolveStatus: (message) => { + if (message === "channelId is required") return 400; + if (message === "messageId is required") return 400; + if (message === "emoji is required") return 400; + if (message === "Channel not found in configured workspaces") return 404; + if (message.includes("not configured")) return 400; + if (message.startsWith("emoji must be one of")) return 400; + return 500; + }, + }, + ); + }); +} diff --git a/packages/core/web/routes/send.ts b/packages/core/web/routes/send.ts new file mode 100644 index 00000000..9c49e725 --- /dev/null +++ b/packages/core/web/routes/send.ts @@ -0,0 +1,111 @@ +import type { Elysia } from "elysia"; +import { uploadDiscordFile, uploadLarkFile, uploadSlackFile } from "@/ims"; +import { attachDiscordBotToken, attachLarkCredentials } from "../config-validation"; +import { jsonResponse, readJsonBody, runRoute } from "../http"; +import { resolveChannelLocator } from "./channel-resolver"; + +function getString(payload: Record<string, unknown>, key: string): string { + const value = payload[key]; + return typeof value === "string" ? value : ""; +} + +function getOptionalString(payload: Record<string, unknown>, key: string): string | undefined { + const value = payload[key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +export function registerSendRoutes(app: Elysia): void { + /** + * Unified file upload endpoint powering `ode send file`. Callers don't need + * to know which messaging provider is behind the channel; the server + * resolves the platform from the channel's configured workspace and calls + * the matching SDK helper directly. + */ + app.post("/api/send/file", async ({ request }: { request: Request }) => { + return runRoute( + async () => { + const body = await readJsonBody(request); + const channelIdRaw = getString(body, "channelId"); + if (!channelIdRaw) { + throw new Error("channelId is required"); + } + const filePath = getString(body, "filePath"); + if (!filePath) { + throw new Error("filePath is required"); + } + + const resolved = resolveChannelLocator(channelIdRaw); + const threadId = getOptionalString(body, "threadId"); + const filename = getOptionalString(body, "filename"); + const title = getOptionalString(body, "title"); + const initialComment = getOptionalString(body, "initialComment"); + + if (resolved.platform === "slack") { + const result = await uploadSlackFile({ + channelId: resolved.channelId, + threadId, + filePath, + filename, + title, + initialComment, + }); + return { platform: resolved.platform, result }; + } + + if (resolved.platform === "discord") { + const discordPayload: Record<string, unknown> = { channelId: resolved.channelId }; + attachDiscordBotToken(discordPayload); + const botToken = typeof discordPayload.botToken === "string" ? discordPayload.botToken : ""; + if (!botToken) { + throw new Error("Discord bot token not configured"); + } + const result = await uploadDiscordFile({ + botToken, + channelId: resolved.channelId, + filePath, + filename, + initialComment, + }); + return { platform: resolved.platform, result }; + } + + if (resolved.platform === "lark") { + const larkPayload: Record<string, unknown> = { + channelId: resolved.channelId, + workspaceId: resolved.workspaceId, + }; + attachLarkCredentials(larkPayload); + const appId = typeof larkPayload.appId === "string" ? larkPayload.appId : ""; + const appSecret = typeof larkPayload.appSecret === "string" ? larkPayload.appSecret : ""; + if (!appId || !appSecret) { + throw new Error("Lark app credentials not configured"); + } + const result = await uploadLarkFile({ + appId, + appSecret, + channelId: resolved.channelId, + threadId, + filePath, + filename, + initialComment, + }); + return { platform: resolved.platform, result }; + } + + throw new Error(`Unsupported platform: ${resolved.platform}`); + }, + (result) => jsonResponse(200, { ok: true, result }), + { + fallbackMessage: "Failed to upload file", + resolveStatus: (message) => { + if (message === "channelId is required") return 400; + if (message === "filePath is required") return 400; + if (message === "Channel not found in configured workspaces") return 404; + if (message.includes("not configured")) return 400; + if (message.startsWith("File not found")) return 400; + return 500; + }, + }, + ); + }); +} diff --git a/packages/ims/discord/api.test.ts b/packages/ims/discord/api.test.ts index 31ddb557..3efe2f8f 100644 --- a/packages/ims/discord/api.test.ts +++ b/packages/ims/discord/api.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import { rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { handleDiscordActionPayload } from "./api"; +import { addDiscordReaction, getDiscordThreadMessages, uploadDiscordFile } from "./api"; const ORIGINAL_FETCH = globalThis.fetch; @@ -11,91 +11,8 @@ afterEach(() => { mock.restore(); }); -describe("handleDiscordActionPayload", () => { - it("returns validation error when token is missing", async () => { - const result = await handleDiscordActionPayload({ action: "get_guilds" }); - expect(result.ok).toBe(false); - expect(result.error).toContain("Discord bot token missing"); - }); - - it("posts a message via Discord API", async () => { - globalThis.fetch = mock(async () => { - return new Response( - JSON.stringify({ id: "m1", channel_id: "c1", content: "hello" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }) as unknown as typeof fetch; - - const result = await handleDiscordActionPayload({ - action: "post_message", - botToken: "token", - channelId: "c1", - text: "hello", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ messageId: "m1", channelId: "c1", content: "hello" }); - }); - - it("adds a reaction via Discord API", async () => { - const fetchMock = mock(async (_url: string, init?: RequestInit) => { - expect(init?.method).toBe("PUT"); - return new Response(null, { status: 204 }); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const result = await handleDiscordActionPayload({ - action: "add_reaction", - botToken: "token", - channelId: "c1", - messageId: "m1", - emoji: "thumbsup", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ status: "reaction_added" }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("fetches user info via Discord API", async () => { - globalThis.fetch = mock(async () => { - return new Response( - JSON.stringify({ id: "u1", username: "tester", global_name: "Test User", bot: false }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }) as unknown as typeof fetch; - - const result = await handleDiscordActionPayload({ - action: "get_user_info", - botToken: "token", - userId: "<@u1>", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ id: "u1", username: "tester", global_name: "Test User", bot: false }); - }); - - it("fetches current bot user info with @me", async () => { - const fetchMock = mock(async (url: string) => { - expect(url).toContain("/users/@me"); - return new Response( - JSON.stringify({ id: "bot1", username: "ode-bot", bot: true }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const result = await handleDiscordActionPayload({ - action: "get_user_info", - botToken: "token", - userId: "@me", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ id: "bot1", username: "ode-bot", bot: true }); - }); - - it("uploads a file via Discord API", async () => { +describe("discord api helpers", () => { + it("uploads a file via uploadDiscordFile helper", async () => { const tempFilePath = join(tmpdir(), `ode-discord-upload-${Date.now()}.txt`); await Bun.write(tempFilePath, "hello file"); @@ -111,8 +28,7 @@ describe("handleDiscordActionPayload", () => { ); }) as unknown as typeof fetch; - const result = await handleDiscordActionPayload({ - action: "upload_file", + const result = await uploadDiscordFile({ botToken: "token", channelId: "c1", filePath: tempFilePath, @@ -120,8 +36,7 @@ describe("handleDiscordActionPayload", () => { initialComment: "file uploaded", }); - expect(result.ok).toBe(true); - expect(result.result).toEqual({ + expect(result).toEqual({ status: "file_uploaded", messageId: "m-upload", channelId: "c1", @@ -131,4 +46,57 @@ describe("handleDiscordActionPayload", () => { rmSync(tempFilePath, { force: true }); } }); + + it("fetches thread messages via getDiscordThreadMessages", async () => { + const fetchMock = mock(async (url: string) => { + expect(url).toContain("/channels/t1/messages?limit=5"); + return new Response( + JSON.stringify([ + { id: "m1", content: "hi", author: { username: "alice" } }, + { id: "m2", content: "there", author: { username: "bob" } }, + ]), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const result = await getDiscordThreadMessages({ + botToken: "token", + channelId: "c1", + threadId: "t1", + limit: 5, + }); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0]!.id).toBe("m1"); + }); + + it("adds a reaction via addDiscordReaction", async () => { + const fetchMock = mock(async (_url: string, init?: RequestInit) => { + expect(init?.method).toBe("PUT"); + return new Response(null, { status: 204 }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const result = await addDiscordReaction({ + botToken: "token", + channelId: "c1", + messageId: "m1", + emoji: "thumbsup", + }); + + expect(result).toEqual({ status: "reaction_added" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects unsupported emoji names", async () => { + await expect( + addDiscordReaction({ + botToken: "token", + channelId: "c1", + messageId: "m1", + emoji: "fire", + }) + ).rejects.toThrow("emoji must be one of"); + }); }); diff --git a/packages/ims/discord/api.ts b/packages/ims/discord/api.ts index bbec93e6..ba5d2a80 100644 --- a/packages/ims/discord/api.ts +++ b/packages/ims/discord/api.ts @@ -1,43 +1,12 @@ import { basename } from "path"; -export type DiscordActionName = - | "get_guilds" - | "get_channels" - | "post_message" - | "update_message" - | "create_thread_from_message" - | "get_thread_messages" - | "ask_user" - | "add_reaction" - | "get_user_info" - | "upload_file"; - -export type DiscordActionRequest = { - action: DiscordActionName; - botToken?: string; - guildId?: string; - channelId?: string; - threadId?: string; - messageId?: string; - name?: string; - text?: string; - emoji?: string; - question?: string; - options?: unknown[]; - userId?: string; - filePath?: string; - filename?: string; - title?: string; - initialComment?: string; - limit?: number; - autoArchiveDuration?: 60 | 1440 | 4320 | 10080; -}; - -export type DiscordApiResponse = { - ok: boolean; - result?: unknown; - error?: string; -}; +// --------------------------------------------------------------------------- +// Discord IM helper module. +// +// The legacy `/api/action` dispatcher (`handleDiscordActionPayload`) has been +// retired. This module now only exposes the helpers that back the dedicated +// `ode send file` / `ode messages get` / `ode reaction add` CLIs. +// --------------------------------------------------------------------------- function requireString(value: unknown, label: string): string { if (!value || typeof value !== "string") { @@ -46,17 +15,6 @@ function requireString(value: unknown, label: string): string { return value; } -function normalizeOptionLabel(option: unknown): string { - if (typeof option === "string") return option; - if (option && typeof option === "object") { - const record = option as Record<string, unknown>; - if (typeof record.label === "string") return record.label; - if (typeof record.text === "string") return record.text; - if (typeof record.value === "string") return record.value; - } - return String(option ?? ""); -} - const REACTION_ALIASES: Record<string, string> = { thumbup: "thumbsup", ok: "ok_hand", @@ -82,25 +40,6 @@ function normalizeDiscordEmoji(emoji: string): string { return resolved; } -function normalizeDiscordUserId(userId: string): string { - const trimmed = userId.trim(); - if (trimmed === "@me" || trimmed.toLowerCase() === "me") { - return "@me"; - } - if ((trimmed.startsWith("<@") || trimmed.startsWith("<@!")) && trimmed.endsWith(">")) { - return trimmed.replace(/^<@!?/, "").slice(0, -1); - } - return trimmed; -} - -function getDiscordBotToken(payload: DiscordActionRequest): string { - const token = payload.botToken?.trim(); - if (!token) { - throw new Error("Discord bot token missing"); - } - return token; -} - async function discordApiCall<T>( token: string, method: "GET" | "POST" | "PATCH" | "PUT", @@ -172,199 +111,113 @@ async function discordMultipartCall<T>( return response.json() as Promise<T>; } -async function handleDiscordAction(payload: DiscordActionRequest): Promise<unknown> { - const token = getDiscordBotToken(payload); - - switch (payload.action) { - case "get_guilds": { - const guilds = await discordApiCall<Array<{ - id: string; - name: string; - owner?: boolean; - }>>(token, "GET", "/users/@me/guilds"); - return { guilds }; - } - - case "get_channels": { - const guildId = requireString(payload.guildId, "guildId"); - const channels = await discordApiCall<Array<{ - id: string; - type: number; - name?: string; - parent_id?: string | null; - }>>(token, "GET", `/guilds/${guildId}/channels`); - return { channels }; - } - - case "post_message": { - const channelId = requireString(payload.channelId, "channelId"); - const text = requireString(payload.text, "text"); - const message = await discordApiCall<{ id: string; channel_id: string; content: string }>( - token, - "POST", - `/channels/${channelId}/messages`, - { content: text } - ); - return { - messageId: message.id, - channelId: message.channel_id, - content: message.content, - }; - } - - case "update_message": { - const channelId = requireString(payload.channelId, "channelId"); - const messageId = requireString(payload.messageId, "messageId"); - const text = requireString(payload.text, "text"); - const message = await discordApiCall<{ id: string; channel_id: string; content: string }>( - token, - "PATCH", - `/channels/${channelId}/messages/${messageId}`, - { content: text } - ); - return { - messageId: message.id, - channelId: message.channel_id, - content: message.content, - }; - } - - case "create_thread_from_message": { - const channelId = requireString(payload.channelId, "channelId"); - const messageId = requireString(payload.messageId, "messageId"); - const name = requireString(payload.name, "name"); - const thread = await discordApiCall<{ id: string; parent_id: string; name: string; type: number }>( - token, - "POST", - `/channels/${channelId}/messages/${messageId}/threads`, - { - name, - ...(payload.autoArchiveDuration ? { auto_archive_duration: payload.autoArchiveDuration } : {}), - } - ); - return { - threadId: thread.id, - parentId: thread.parent_id, - name: thread.name, - type: thread.type, - }; - } - - case "get_thread_messages": { - const threadId = requireString(payload.threadId, "threadId"); - const limit = Math.min(Math.max(payload.limit ?? 20, 1), 100); - const messages = await discordApiCall<Array<{ id: string; content: string; author?: { username?: string } }>>( - token, - "GET", - `/channels/${threadId}/messages?limit=${limit}` - ); - return { messages }; - } - - case "ask_user": { - const channelId = requireString(payload.channelId, "channelId"); - const question = requireString(payload.question, "question"); - const options = Array.isArray(payload.options) - ? payload.options.map(normalizeOptionLabel).filter((opt) => opt.trim().length > 0) - : []; - if (options.length < 2 || options.length > 5) { - throw new Error("options must have 2-5 items"); - } - - const optionLines = options.map((option, index) => `${index + 1}. ${option}`).join("\n"); - const prompt = `${question}\n\nOptions:\n${optionLines}\n\nReply with the option text or number.`; - - const message = await discordApiCall<{ id: string; channel_id: string; content: string }>( - token, - "POST", - `/channels/${channelId}/messages`, - { content: prompt } - ); - - return { - status: "question_posted", - messageId: message.id, - channelId: message.channel_id, - }; - } - - case "add_reaction": { - const channelId = requireString(payload.channelId, "channelId"); - const messageId = requireString(payload.messageId, "messageId"); - const emoji = normalizeDiscordEmoji(requireString(payload.emoji, "emoji")); - - await discordApiCall<void>( - token, - "PUT", - `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me` - ); - - return { status: "reaction_added" }; - } - - case "get_user_info": { - const userId = normalizeDiscordUserId(requireString(payload.userId, "userId")); - const userPath = userId === "@me" ? "/users/@me" : `/users/${userId}`; - const user = await discordApiCall<{ - id: string; - username: string; - global_name?: string | null; - bot?: boolean; - }>(token, "GET", userPath); - return user; - } - - case "upload_file": { - const channelId = requireString(payload.channelId, "channelId"); - const filePath = requireString(payload.filePath, "filePath"); - const filename = payload.filename?.trim() || basename(filePath); - const initialComment = payload.initialComment?.trim(); - - const file = Bun.file(filePath); - if (!(await file.exists())) { - throw new Error(`File not found: ${filePath}`); - } - - const formData = new FormData(); - formData.append("files[0]", file, filename); - formData.append("payload_json", JSON.stringify({ - content: initialComment && initialComment.length > 0 ? initialComment : undefined, - })); - - const message = await discordMultipartCall<{ - id: string; - channel_id: string; - attachments?: Array<{ id: string; filename: string; url: string }>; - }>( - token, - "POST", - `/channels/${channelId}/messages`, - formData - ); +/** + * Upload a file to a Discord channel via the standard multipart message + * endpoint. Powers the `ode send file` CLI on Discord-configured channels. + */ +export async function uploadDiscordFile(args: { + botToken: string; + channelId: string; + filePath: string; + filename?: string; + initialComment?: string; +}): Promise<{ + status: "file_uploaded"; + messageId: string; + channelId: string; + attachments: Array<{ id: string; filename: string; url: string }>; +}> { + const token = args.botToken.trim(); + if (!token) { + throw new Error("Discord bot token missing"); + } + const channelId = requireString(args.channelId, "channelId"); + const filePath = requireString(args.filePath, "filePath"); + const filename = args.filename?.trim() || basename(filePath); + const initialComment = args.initialComment?.trim(); + + const file = Bun.file(filePath); + if (!(await file.exists())) { + throw new Error(`File not found: ${filePath}`); + } - return { - status: "file_uploaded", - messageId: message.id, - channelId: message.channel_id, - attachments: message.attachments ?? [], - }; - } + const formData = new FormData(); + formData.append("files[0]", file, filename); + formData.append("payload_json", JSON.stringify({ + content: initialComment && initialComment.length > 0 ? initialComment : undefined, + })); + + const message = await discordMultipartCall<{ + id: string; + channel_id: string; + attachments?: Array<{ id: string; filename: string; url: string }>; + }>( + token, + "POST", + `/channels/${channelId}/messages`, + formData + ); + + return { + status: "file_uploaded", + messageId: message.id, + channelId: message.channel_id, + attachments: message.attachments ?? [], + }; +} - default: - throw new Error(`Unknown Discord action: ${payload.action}`); +/** + * Fetch recent messages from a Discord channel / thread. Powers + * `ode messages get` on Discord-configured channels. Discord does not have + * a native Slack-style "thread"; we resolve `threadId` as a channel id (which + * also matches Discord's thread channels) and fall back to the provided + * `channelId` when `threadId` is absent. + */ +export async function getDiscordThreadMessages(args: { + botToken: string; + channelId: string; + threadId?: string; + limit?: number; +}): Promise<{ messages: Array<{ id: string; content: string; author?: { username?: string } }> }> { + const token = args.botToken.trim(); + if (!token) { + throw new Error("Discord bot token missing"); + } + const channelOrThread = args.threadId?.trim() || args.channelId.trim(); + if (!channelOrThread) { + throw new Error("channelId or threadId is required"); } + const limit = Math.min(Math.max(args.limit ?? 20, 1), 100); + const messages = await discordApiCall<Array<{ id: string; content: string; author?: { username?: string } }>>( + token, + "GET", + `/channels/${channelOrThread}/messages?limit=${limit}` + ); + return { messages }; } -export async function handleDiscordActionPayload(payload: unknown): Promise<DiscordApiResponse> { - if (!payload || typeof payload !== "object") { - return { ok: false, error: "Invalid payload" }; +/** + * Add a reaction to a Discord message. Powers `ode reaction add` on Discord. + */ +export async function addDiscordReaction(args: { + botToken: string; + channelId: string; + messageId: string; + emoji: string; +}): Promise<{ status: "reaction_added" }> { + const token = args.botToken.trim(); + if (!token) { + throw new Error("Discord bot token missing"); } + const channelId = requireString(args.channelId, "channelId"); + const messageId = requireString(args.messageId, "messageId"); + const emoji = normalizeDiscordEmoji(requireString(args.emoji, "emoji")); - try { - const result = await handleDiscordAction(payload as DiscordActionRequest); - return { ok: true, result }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: message }; - } + await discordApiCall<void>( + token, + "PUT", + `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me` + ); + + return { status: "reaction_added" }; } diff --git a/packages/ims/lark/api.test.ts b/packages/ims/lark/api.test.ts index 98bb7856..9b108815 100644 --- a/packages/ims/lark/api.test.ts +++ b/packages/ims/lark/api.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import { rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { handleLarkActionPayload } from "./api"; +import { addLarkReaction, getLarkThreadMessages, uploadLarkFile } from "./api"; const ORIGINAL_FETCH = globalThis.fetch; @@ -11,206 +11,8 @@ afterEach(() => { mock.restore(); }); -describe("handleLarkActionPayload", () => { - it("returns validation error when credentials are missing", async () => { - const result = await handleLarkActionPayload({ action: "post_message", channelId: "oc_x", text: "hello" }); - expect(result.ok).toBe(false); - expect(result.error).toContain("Lark app credentials missing"); - }); - - it("posts a message via Lark API", async () => { - const fetchMock = mock(async (url: string, init?: RequestInit) => { - if (url.includes("tenant_access_token")) { - return new Response( - JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : {}; - expect(body.msg_type).toBe("post"); - const content = typeof body.content === "string" ? JSON.parse(body.content) as Record<string, unknown> : {}; - const zh = content.zh_cn as { content?: Array<Array<{ tag?: string; text?: string }>> }; - expect(zh.content?.[0]?.[0]?.tag).toBe("md"); - - return new Response( - JSON.stringify({ code: 0, data: { message_id: "om_xxx" } }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const result = await handleLarkActionPayload({ - action: "post_message", - appId: "cli_app", - appSecret: "secret", - channelId: "oc_123", - text: "hello", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ messageId: "om_xxx", channelId: "oc_123" }); - }); - - it("posts a thread reply via reply endpoint", async () => { - const fetchMock = mock(async (url: string, init?: RequestInit) => { - if (url.includes("tenant_access_token")) { - return new Response( - JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - expect(url).toContain("/im/v1/messages/om_root/reply"); - const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : {}; - expect(body.msg_type).toBe("post"); - expect(body.reply_in_thread).toBe(true); - const content = typeof body.content === "string" ? JSON.parse(body.content) as Record<string, unknown> : {}; - const zh = content.zh_cn as { content?: Array<Array<{ tag?: string; text?: string }>> }; - expect(zh.content?.[0]?.[0]?.tag).toBe("md"); - return new Response( - JSON.stringify({ code: 0, data: { message_id: "om_reply" } }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const result = await handleLarkActionPayload({ - action: "post_message", - appId: "cli_app", - appSecret: "secret", - channelId: "oc_123", - threadId: "om_root", - text: "reply", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ messageId: "om_reply", channelId: "oc_123" }); - }); - - it("updates a message via Lark API", async () => { - const fetchMock = mock(async (url: string, init?: RequestInit) => { - if (url.includes("tenant_access_token")) { - return new Response( - JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - expect(init?.method).toBe("PATCH"); - const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : {}; - expect(body.msg_type).toBe("post"); - return new Response( - JSON.stringify({ code: 0, data: {} }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const result = await handleLarkActionPayload({ - action: "update_message", - appId: "cli_app", - appSecret: "secret", - messageId: "om_123", - text: "updated", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ status: "message_updated", messageId: "om_123" }); - }); - - it("adds a reaction via Lark API", async () => { - const fetchMock = mock(async (url: string) => { - if (url.includes("tenant_access_token")) { - return new Response( - JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - return new Response( - JSON.stringify({ code: 0, data: {} }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const result = await handleLarkActionPayload({ - action: "add_reaction", - appId: "cli_app", - appSecret: "secret", - messageId: "om_123", - emoji: "thumbsup", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ status: "reaction_added", messageId: "om_123" }); - }); - - it("lists channels via Lark API", async () => { - const fetchMock = mock(async (url: string) => { - if (url.includes("tenant_access_token")) { - return new Response( - JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - return new Response( - JSON.stringify({ code: 0, data: { items: [{ chat_id: "oc_1", name: "dev" }] } }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const result = await handleLarkActionPayload({ - action: "get_channels", - appId: "cli_app", - appSecret: "secret", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ channels: [{ chat_id: "oc_1", name: "dev" }] }); - }); - - it("filters thread messages from chat list", async () => { - const fetchMock = mock(async (url: string) => { - if (url.includes("tenant_access_token")) { - return new Response( - JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - return new Response( - JSON.stringify({ - code: 0, - data: { - items: [ - { message_id: "om_root", root_id: "" }, - { message_id: "om_reply", root_id: "om_root" }, - { message_id: "om_other", root_id: "om_other" }, - ], - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const result = await handleLarkActionPayload({ - action: "get_thread_messages", - appId: "cli_app", - appSecret: "secret", - channelId: "oc_123", - threadId: "om_root", - }); - - expect(result.ok).toBe(true); - expect(result.result).toEqual({ - messages: [ - { message_id: "om_root", root_id: "" }, - { message_id: "om_reply", root_id: "om_root" }, - ], - }); - }); - - it("uploads a file via Lark API", async () => { +describe("lark api helpers", () => { + it("uploads a file via uploadLarkFile helper", async () => { const tempFilePath = join(tmpdir(), `ode-lark-upload-${Date.now()}.txt`); await Bun.write(tempFilePath, "hello lark file"); @@ -244,8 +46,7 @@ describe("handleLarkActionPayload", () => { }); globalThis.fetch = fetchMock as unknown as typeof fetch; - const result = await handleLarkActionPayload({ - action: "upload_file", + const result = await uploadLarkFile({ appId: "cli_app", appSecret: "secret", channelId: "oc_123", @@ -255,8 +56,7 @@ describe("handleLarkActionPayload", () => { initialComment: "uploading file", }); - expect(result.ok).toBe(true); - expect(result.result).toEqual({ + expect(result).toEqual({ status: "file_uploaded", messageId: "om_file", channelId: "oc_123", @@ -266,4 +66,93 @@ describe("handleLarkActionPayload", () => { rmSync(tempFilePath, { force: true }); } }); + + it("fetches thread messages by filtering channel history", async () => { + const fetchMock = mock(async (url: string) => { + if (url.includes("tenant_access_token")) { + return new Response( + JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url.includes("/im/v1/messages/") && !url.includes("/reactions")) { + // root message lookup — provide a thread_id so the filter branches to + // "match by thread_id". + return new Response( + JSON.stringify({ + code: 0, + data: { + items: [{ message_id: "om_root", thread_id: "thr_1" }], + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url.includes("/im/v1/messages?container_id_type=chat")) { + return new Response( + JSON.stringify({ + code: 0, + data: { + items: [ + { message_id: "om_root", thread_id: "thr_1" }, + { message_id: "om_reply", thread_id: "thr_1" }, + { message_id: "om_other", thread_id: "thr_2" }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response(JSON.stringify({ code: 0, data: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const result = await getLarkThreadMessages({ + appId: "cli_app", + appSecret: "secret", + channelId: "oc_123", + threadId: "om_root", + }); + + expect(result.messages.map((m) => m.message_id)).toEqual(["om_root", "om_reply"]); + }); + + it("adds a reaction via addLarkReaction", async () => { + const fetchMock = mock(async (url: string, init?: RequestInit) => { + if (url.includes("tenant_access_token")) { + return new Response( + JSON.stringify({ code: 0, tenant_access_token: "tenant_token" }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url.includes("/reactions")) { + expect(init?.method).toBe("POST"); + const body = typeof init?.body === "string" + ? JSON.parse(init.body) as { reaction_type?: { emoji_type?: string } } + : {}; + expect(body.reaction_type?.emoji_type).toBe("THUMBSUP"); + return new Response( + JSON.stringify({ code: 0, data: {} }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return new Response(JSON.stringify({ code: 0, data: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const result = await addLarkReaction({ + appId: "cli_app", + appSecret: "secret", + messageId: "om_root", + emoji: "thumbsup", + }); + + expect(result).toEqual({ status: "reaction_added", messageId: "om_root" }); + }); }); diff --git a/packages/ims/lark/api.ts b/packages/ims/lark/api.ts index 43517534..f85b0212 100644 --- a/packages/ims/lark/api.ts +++ b/packages/ims/lark/api.ts @@ -1,36 +1,13 @@ -export type LarkActionName = - | "get_channels" - | "post_message" - | "update_message" - | "get_thread_messages" - | "ask_user" - | "get_user_info" - | "add_reaction" - | "upload_file"; - -export type LarkActionRequest = { - action: LarkActionName; - appId?: string; - appSecret?: string; - channelId?: string; - threadId?: string; - messageId?: string; - text?: string; - question?: string; - options?: unknown[]; - userId?: string; - limit?: number; - emoji?: string; - filePath?: string; - filename?: string; - initialComment?: string; -}; - -export type LarkApiResponse = { - ok: boolean; - result?: unknown; - error?: string; -}; +// --------------------------------------------------------------------------- +// Lark IM helper module. +// +// The legacy `/api/action` dispatcher (`handleLarkActionPayload`) has been +// retired. This module now only exposes the helpers that back the dedicated +// `ode send file` / `ode messages get` / `ode reaction add` CLIs. +// +// Internal helpers (`larkRequest`, `getTenantAccessToken`, `postTextMessage`, +// `threadMessageCache`) stay as implementation details. +// --------------------------------------------------------------------------- type LarkResponse<T> = { code?: number; @@ -56,17 +33,6 @@ function requireString(value: unknown, label: string): string { return value; } -function normalizeOptionLabel(option: unknown): string { - if (typeof option === "string") return option; - if (option && typeof option === "object") { - const record = option as Record<string, unknown>; - if (typeof record.label === "string") return record.label; - if (typeof record.text === "string") return record.text; - if (typeof record.value === "string") return record.value; - } - return String(option ?? ""); -} - const REACTION_ALIASES: Record<string, string> = { thumbup: "thumbsup", ok: "ok_hand", @@ -90,15 +56,6 @@ function normalizeLarkReactionEmoji(emoji: string): string { return resolved; } -function getLarkCredentials(payload: LarkActionRequest): { appId: string; appSecret: string } { - const appId = payload.appId?.trim() || ""; - const appSecret = payload.appSecret?.trim() || ""; - if (!appId || !appSecret) { - throw new Error("Lark app credentials missing"); - } - return { appId, appSecret }; -} - async function larkRequest<T>( method: "GET" | "POST" | "PATCH" | "PUT", path: string, @@ -216,279 +173,209 @@ async function getMessageById(token: string, messageId: string): Promise<Record< return item ?? null; } -async function handleLarkAction(payload: LarkActionRequest): Promise<unknown> { - const { appId, appSecret } = getLarkCredentials(payload); - const token = await getTenantAccessToken(appId, appSecret); - - switch (payload.action) { - case "get_channels": { - const data = await larkRequest<{ - items?: Array<{ - chat_id?: string; - name?: string; - description?: string; - }>; - }>( - "GET", - "/open-apis/im/v1/chats?page_size=100", - token - ); - return { channels: data.items ?? [] }; - } - - case "post_message": { - const channelId = requireString(payload.channelId, "channelId"); - const text = requireString(payload.text, "text"); - const message = await postTextMessage({ - token, - channelId, - text, - threadId: payload.threadId, - }); - if (payload.threadId && message.messageId) { - rememberThreadMessage(payload.threadId, message.messageId); - } - return message; - } - - case "update_message": { - const messageId = requireString(payload.messageId, "messageId"); - const text = requireString(payload.text, "text"); - const body = { - msg_type: "post", - content: JSON.stringify(buildLarkPostContent(text)), - }; - try { - await larkRequest<Record<string, unknown>>( - "PATCH", - `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`, - token, - body - ); - } catch (patchError) { - const patchMessage = patchError instanceof Error ? patchError.message : String(patchError); - if (!patchMessage.includes("400")) { - throw patchError; - } - - await larkRequest<Record<string, unknown>>( - "PUT", - `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`, - token, - body - ); - } - return { - status: "message_updated", - messageId, - }; - } - - case "ask_user": { - const channelId = requireString(payload.channelId, "channelId"); - const question = requireString(payload.question, "question"); - const options = Array.isArray(payload.options) - ? payload.options.map(normalizeOptionLabel).filter((opt) => opt.trim().length > 0) - : []; - if (options.length < 2 || options.length > 5) { - throw new Error("options must have 2-5 items"); - } - const lines = options.map((option, index) => `${index + 1}. ${option}`).join("\n"); - const text = `${question}\n\n${lines}\n\nReply with option text or number.`; - return postTextMessage({ - token, - channelId, - text, - threadId: payload.threadId, - }); - } +/** + * Upload a file to a Lark chat. Internally fetches a tenant access token from + * the provided app credentials, pushes the bytes through `/open-apis/im/v1/files`, + * and then posts a `file` message into the channel (or thread reply). Powers + * the `ode send file` CLI on Lark-configured channels. + */ +export async function uploadLarkFile(args: { + appId: string; + appSecret: string; + channelId: string; + threadId?: string; + filePath: string; + filename?: string; + initialComment?: string; +}): Promise<{ + status: "file_uploaded"; + messageId: string; + channelId: string; + fileKey: string; +}> { + const appId = args.appId.trim(); + const appSecret = args.appSecret.trim(); + if (!appId || !appSecret) { + throw new Error("Lark app credentials missing"); + } + const channelId = requireString(args.channelId, "channelId"); + const filePath = requireString(args.filePath, "filePath"); - case "get_thread_messages": { - const threadId = requireString(payload.threadId, "threadId"); - const channelId = payload.channelId?.trim(); - const limit = Math.min(Math.max(payload.limit ?? 20, 1), 50); + const file = Bun.file(filePath); + if (!(await file.exists())) { + throw new Error(`File not found: ${filePath}`); + } - let threadConversationId = ""; - try { - const root = await getMessageById(token, threadId); - threadConversationId = typeof root?.thread_id === "string" ? root.thread_id : ""; - } catch { - threadConversationId = ""; - } + const token = await getTenantAccessToken(appId, appSecret); + const filename = args.filename?.trim() || file.name || "upload.bin"; + const formData = new FormData(); + formData.append("file_name", filename); + formData.append("file_type", "stream"); + formData.append("file", file, filename); - if (channelId) { - const data = await larkRequest<{ - items?: Array<Record<string, unknown>>; - }>( - "GET", - `/open-apis/im/v1/messages?container_id_type=chat&container_id=${encodeURIComponent(channelId)}&page_size=50`, - token - ); - const messages = (data.items ?? []) - .filter((item) => { - const messageId = typeof item.message_id === "string" ? item.message_id : ""; - const rootId = typeof item.root_id === "string" ? item.root_id : ""; - const parentId = typeof item.parent_id === "string" ? item.parent_id : ""; - const itemThreadId = typeof item.thread_id === "string" ? item.thread_id : ""; - if (threadConversationId) { - return itemThreadId === threadConversationId; - } - return messageId === threadId || rootId === threadId || parentId === threadId; - }) - .slice(-limit); - if (messages.length > 0) { - return { messages }; - } - } + const uploadResponse = await fetch("https://open.feishu.cn/open-apis/im/v1/files", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + if (!uploadResponse.ok) { + throw new Error(`Lark file upload API ${uploadResponse.status} ${uploadResponse.statusText}`); + } + const uploadPayload = await uploadResponse.json() as { + code?: number; + msg?: string; + data?: { file_key?: string }; + }; + if ((uploadPayload.code ?? -1) !== 0 || !uploadPayload.data?.file_key) { + throw new Error(uploadPayload.msg || "Failed to upload file to Lark"); + } - const cachedIds = threadMessageCache.get(threadId) ?? []; - const uniqueIds = [threadId, ...cachedIds].filter((id, index, arr) => arr.indexOf(id) === index); - const messages: Array<Record<string, unknown>> = []; - for (const id of uniqueIds.slice(-limit)) { - try { - const item = await getMessageById(token, id); - if (item) messages.push(item); - } catch { - // ignore single message lookup failures - } - } - return { messages }; + const threadId = args.threadId?.trim(); + if (args.initialComment?.trim()) { + const comment = await postTextMessage({ + token, + channelId, + text: args.initialComment.trim(), + threadId, + }); + if (threadId && comment.messageId) { + rememberThreadMessage(threadId, comment.messageId); } + } - case "get_user_info": { - const userId = requireString(payload.userId, "userId").trim(); - if (userId === "@me" || userId.toLowerCase() === "me") { - const me = await larkRequest<Record<string, unknown>>( - "GET", - "/open-apis/contact/v3/users/me?user_id_type=open_id", - token - ); - return me; + const message = await larkRequest<{ message_id?: string }>( + "POST", + threadId + ? `/open-apis/im/v1/messages/${encodeURIComponent(threadId)}/reply` + : "/open-apis/im/v1/messages?receive_id_type=chat_id", + token, + threadId + ? { + msg_type: "file", + content: JSON.stringify({ file_key: uploadPayload.data.file_key }), + reply_in_thread: true, } - const normalized = userId.replace(/^<@/, "").replace(/>$/, "").trim(); - const user = await larkRequest<Record<string, unknown>>( - "GET", - `/open-apis/contact/v3/users/${encodeURIComponent(normalized)}?user_id_type=open_id`, - token - ); - return user; - } - - case "add_reaction": { - const messageId = requireString(payload.messageId, "messageId"); - const emoji = normalizeLarkReactionEmoji(requireString(payload.emoji, "emoji")); - await larkRequest<Record<string, unknown>>( - "POST", - `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`, - token, - { - reaction_type: { - emoji_type: emoji, - }, - } - ); - return { - status: "reaction_added", - messageId, - }; - } - - case "upload_file": { - const channelId = requireString(payload.channelId, "channelId"); - const filePath = requireString(payload.filePath, "filePath"); - const file = Bun.file(filePath); - if (!(await file.exists())) { - throw new Error(`File not found: ${filePath}`); + : { + receive_id: channelId, + msg_type: "file", + content: JSON.stringify({ file_key: uploadPayload.data.file_key }), } + ); - const filename = payload.filename?.trim() || file.name || "upload.bin"; - const formData = new FormData(); - formData.append("file_name", filename); - formData.append("file_type", "stream"); - formData.append("file", file, filename); + if (threadId && message.message_id) { + rememberThreadMessage(threadId, message.message_id); + } - const uploadResponse = await fetch("https://open.feishu.cn/open-apis/im/v1/files", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - body: formData, - }); + return { + status: "file_uploaded", + messageId: message.message_id ?? "", + channelId, + fileKey: uploadPayload.data.file_key, + }; +} - if (!uploadResponse.ok) { - throw new Error(`Lark file upload API ${uploadResponse.status} ${uploadResponse.statusText}`); - } +/** + * Fetch the messages of a Lark thread. Powers `ode messages get`. + * + * Lark doesn't expose a direct "thread replies" API like Slack; this helper + * reproduces the same filtering strategy used by the retired `handleLarkAction` + * — list recent channel messages and keep the ones whose `thread_id` / + * `root_id` / `parent_id` matches the requested thread, falling back to a + * per-message lookup using the local `threadMessageCache` hint. + */ +export async function getLarkThreadMessages(args: { + appId: string; + appSecret: string; + channelId?: string; + threadId: string; + limit?: number; +}): Promise<{ messages: Array<Record<string, unknown>> }> { + const appId = args.appId.trim(); + const appSecret = args.appSecret.trim(); + if (!appId || !appSecret) { + throw new Error("Lark app credentials missing"); + } + const threadId = requireString(args.threadId, "threadId"); + const channelId = args.channelId?.trim(); + const limit = Math.min(Math.max(args.limit ?? 20, 1), 50); + const token = await getTenantAccessToken(appId, appSecret); - const uploadPayload = await uploadResponse.json() as { - code?: number; - msg?: string; - data?: { - file_key?: string; - }; - }; - if ((uploadPayload.code ?? -1) !== 0 || !uploadPayload.data?.file_key) { - throw new Error(uploadPayload.msg || "Failed to upload file to Lark"); - } + let threadConversationId = ""; + try { + const root = await getMessageById(token, threadId); + threadConversationId = typeof root?.thread_id === "string" ? root.thread_id : ""; + } catch { + threadConversationId = ""; + } - const threadId = payload.threadId?.trim(); - if (payload.initialComment?.trim()) { - const comment = await postTextMessage({ - token, - channelId, - text: payload.initialComment.trim(), - threadId, - }); - if (threadId && comment.messageId) { - rememberThreadMessage(threadId, comment.messageId); + if (channelId) { + const data = await larkRequest<{ + items?: Array<Record<string, unknown>>; + }>( + "GET", + `/open-apis/im/v1/messages?container_id_type=chat&container_id=${encodeURIComponent(channelId)}&page_size=50`, + token + ); + const messages = (data.items ?? []) + .filter((item) => { + const messageId = typeof item.message_id === "string" ? item.message_id : ""; + const rootId = typeof item.root_id === "string" ? item.root_id : ""; + const parentId = typeof item.parent_id === "string" ? item.parent_id : ""; + const itemThreadId = typeof item.thread_id === "string" ? item.thread_id : ""; + if (threadConversationId) { + return itemThreadId === threadConversationId; } - } - - const message = await larkRequest<{ message_id?: string }>( - "POST", - threadId - ? `/open-apis/im/v1/messages/${encodeURIComponent(threadId)}/reply` - : "/open-apis/im/v1/messages?receive_id_type=chat_id", - token, - threadId - ? { - msg_type: "file", - content: JSON.stringify({ file_key: uploadPayload.data.file_key }), - reply_in_thread: true, - } - : { - receive_id: channelId, - msg_type: "file", - content: JSON.stringify({ file_key: uploadPayload.data.file_key }), - } - ); - - if (threadId && message.message_id) { - rememberThreadMessage(threadId, message.message_id); - } - - return { - status: "file_uploaded", - messageId: message.message_id ?? "", - channelId, - fileKey: uploadPayload.data.file_key, - }; + return messageId === threadId || rootId === threadId || parentId === threadId; + }) + .slice(-limit); + if (messages.length > 0) { + return { messages }; } - - default: - throw new Error(`Unknown Lark action: ${payload.action}`); } -} -export async function handleLarkActionPayload(payload: unknown): Promise<LarkApiResponse> { - if (!payload || typeof payload !== "object") { - return { ok: false, error: "Invalid payload" }; + const cachedIds = threadMessageCache.get(threadId) ?? []; + const uniqueIds = [threadId, ...cachedIds].filter((id, index, arr) => arr.indexOf(id) === index); + const messages: Array<Record<string, unknown>> = []; + for (const id of uniqueIds.slice(-limit)) { + try { + const item = await getMessageById(token, id); + if (item) messages.push(item); + } catch { + // ignore single message lookup failures + } } + return { messages }; +} - try { - const result = await handleLarkAction(payload as LarkActionRequest); - return { ok: true, result }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: message }; +/** + * Add a reaction to a Lark message. Powers `ode reaction add`. + */ +export async function addLarkReaction(args: { + appId: string; + appSecret: string; + messageId: string; + emoji: string; +}): Promise<{ status: "reaction_added"; messageId: string }> { + const appId = args.appId.trim(); + const appSecret = args.appSecret.trim(); + if (!appId || !appSecret) { + throw new Error("Lark app credentials missing"); } + const messageId = requireString(args.messageId, "messageId"); + const emoji = normalizeLarkReactionEmoji(requireString(args.emoji, "emoji")); + const token = await getTenantAccessToken(appId, appSecret); + await larkRequest<Record<string, unknown>>( + "POST", + `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`, + token, + { + reaction_type: { + emoji_type: emoji, + }, + } + ); + return { + status: "reaction_added", + messageId, + }; } diff --git a/packages/ims/lark/index.ts b/packages/ims/lark/index.ts index 20a99d82..5a53f3e9 100644 --- a/packages/ims/lark/index.ts +++ b/packages/ims/lark/index.ts @@ -1,4 +1,4 @@ -export { handleLarkActionPayload, type LarkActionRequest, type LarkApiResponse } from "./api"; +export { uploadLarkFile, getLarkThreadMessages, addLarkReaction } from "./api"; export { handleLarkEventPayload, startLarkRuntime, diff --git a/packages/ims/slack/api.ts b/packages/ims/slack/api.ts index f9e33065..2b7c0ef3 100644 --- a/packages/ims/slack/api.ts +++ b/packages/ims/slack/api.ts @@ -2,36 +2,24 @@ import { basename } from "path"; import { getApp, getSlackBotToken } from "./client"; import { hasSimpleOptions } from "@/core/runtime/helpers"; -export type SlackActionName = - | "get_thread_messages" - | "ask_user" - | "add_reaction" - | "get_user_info" - | "post_message" - | "upload_file"; - -export type SlackActionRequest = { - action: SlackActionName; - channelId: string; - threadId?: string; - messageId?: string; - text?: string; - emoji?: string; - question?: string; - options?: string[]; - limit?: number; - filePath?: string; - filename?: string; - title?: string; - initialComment?: string; - userId?: string; -}; - -export type SlackApiResponse = { - ok: boolean; - result?: unknown; - error?: string; -}; +// --------------------------------------------------------------------------- +// Slack IM helper module. +// +// Historically this file hosted a generic `/api/action` dispatcher +// (`handleSlackActionPayload`) that agents called via bash+curl. That +// mechanism has been retired in favour of dedicated `ode <verb>` CLIs +// (`ode send file`, `ode messages get`, `ode reaction add`, etc.), so this +// module now only exposes: +// +// - `postSlackQuestion` – used by the core runtime to render SDK-emitted +// question events in Slack. +// - `uploadSlackFile` – powering `ode send file` on Slack channels. +// - `getSlackThreadMessages` – powering `ode messages get`. +// - `addSlackReaction` – powering `ode reaction add`. +// +// The private helpers (`slackApiCall`, `slackFileUpload`, …) stay as +// implementation details for those exports. +// --------------------------------------------------------------------------- function requireString(value: unknown, label: string): string { if (!value || typeof value !== "string") { @@ -46,7 +34,7 @@ const REACTION_ALIASES: Record<string, string> = { ok: "ok_hand", }; -function normalizeEmojiName(emoji: string): string { +function normalizeSlackEmojiName(emoji: string): string { const trimmed = emoji.trim(); if (!trimmed) { throw new Error("emoji is required"); @@ -77,8 +65,7 @@ function normalizeOptionLabel(option: unknown): string { * the user can tap a choice. Otherwise — including when there are no options * at all — we fall back to a plain text message listing the choices inline. * - * Shared by `ask_user` (LLM action) and the runtime's `sendQuestion` path - * (SDK-emitted `question` events) so both render consistently. + * Used by the runtime's `sendQuestion` path (SDK-emitted `question` events). */ export async function postSlackQuestion(args: { channelId: string; @@ -135,14 +122,6 @@ export async function postSlackQuestion(args: { return result.ts ?? undefined; } -function normalizeSlackUserId(userId: string): string { - const trimmed = userId.trim(); - if (trimmed.startsWith("<@") && trimmed.endsWith(">")) { - return trimmed.slice(2, -1); - } - return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; -} - async function slackApiCall(method: string, body: Record<string, unknown>, token: string): Promise<unknown> { const formBody = new URLSearchParams(); for (const [key, value] of Object.entries(body)) { @@ -220,105 +199,82 @@ async function slackFileUpload( }, args.token); } -async function handleSlackAction(payload: SlackActionRequest): Promise<unknown> { - const channelId = requireString(payload.channelId, "channelId"); - const token = getSlackBotToken(channelId, typeof payload.threadId === "string" ? payload.threadId : undefined); +/** + * Upload a file to a Slack channel / thread using Slack's + * `files.getUploadURLExternal` + `files.completeUploadExternal` flow. + * Powers the `ode send file` CLI. + */ +export async function uploadSlackFile(args: { + channelId: string; + threadId?: string; + filePath: string; + filename?: string; + title?: string; + initialComment?: string; +}): Promise<{ status: "file_uploaded"; channelId: string; filename: string }> { + const channelId = requireString(args.channelId, "channelId"); + const filePath = requireString(args.filePath, "filePath"); + const token = getSlackBotToken(channelId, typeof args.threadId === "string" ? args.threadId : undefined); if (!token) { throw new Error("No Slack bot token available for channel"); } - const client = getApp().client; - - switch (payload.action) { - case "get_thread_messages": { - const threadId = requireString(payload.threadId, "threadId"); - const data = await client.conversations.replies({ - channel: channelId, - ts: threadId, - limit: payload.limit ?? 20, - token, - }); - return { messages: (data as any).messages ?? [] }; - } - - case "ask_user": { - const threadId = requireString(payload.threadId, "threadId"); - const question = requireString(payload.question, "question"); - const options = Array.isArray(payload.options) - ? payload.options.map(normalizeOptionLabel).filter((opt) => opt.trim().length > 0) - : []; - if (options.length < 2) { - throw new Error("options must have at least 2 items"); - } - - await postSlackQuestion({ - channelId, - threadId, - question, - options, - token, - }); - - return { status: "question_posted" }; - } - - case "add_reaction": { - const messageId = requireString(payload.messageId, "messageId"); - const emoji = requireString(payload.emoji, "emoji"); - const name = normalizeEmojiName(emoji); - await slackApiCall("reactions.add", { - channel: channelId, - timestamp: messageId, - name, - }, token); - return { status: "reaction_added" }; - } - - case "get_user_info": { - const userId = normalizeSlackUserId(requireString(payload.userId, "userId")); - const data = await client.users.info({ user: userId, token }); - return data; - } - - case "post_message": { - const text = requireString(payload.text, "text"); - const result = await client.chat.postMessage({ - channel: channelId, - thread_ts: payload.threadId, - text, - token, - }); - return { ts: result.ts, text }; - } - - case "upload_file": { - const filePath = requireString(payload.filePath, "filePath"); - const filename = payload.filename || basename(filePath); - await slackFileUpload({ - channelId, - threadId: payload.threadId, - filename, - title: payload.title, - initialComment: payload.initialComment, - token, - }, filePath); - return { status: "file_uploaded" }; - } - - default: - throw new Error(`Unknown action: ${payload.action}`); - } + const filename = args.filename || basename(filePath); + await slackFileUpload({ + channelId, + threadId: args.threadId, + filename, + title: args.title, + initialComment: args.initialComment, + token, + }, filePath); + return { status: "file_uploaded", channelId, filename }; } -export async function handleSlackActionPayload(payload: unknown): Promise<SlackApiResponse> { - if (!payload || typeof payload !== "object") { - return { ok: false, error: "Invalid payload" }; +/** + * Fetch the messages of a Slack thread. Powers `ode messages get`. + */ +export async function getSlackThreadMessages(args: { + channelId: string; + threadId: string; + limit?: number; +}): Promise<{ messages: unknown[] }> { + const channelId = requireString(args.channelId, "channelId"); + const threadId = requireString(args.threadId, "threadId"); + const token = getSlackBotToken(channelId, threadId); + if (!token) { + throw new Error("No Slack bot token available for channel"); } + const client = getApp().client; + const data = await client.conversations.replies({ + channel: channelId, + ts: threadId, + limit: args.limit ?? 20, + token, + }); + return { messages: (data as { messages?: unknown[] }).messages ?? [] }; +} - try { - const result = await handleSlackAction(payload as SlackActionRequest); - return { ok: true, result }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { ok: false, error: message }; +/** + * Add a reaction to a Slack message. Powers `ode reaction add`. + */ +export async function addSlackReaction(args: { + channelId: string; + messageId: string; + emoji: string; + threadId?: string; +}): Promise<{ status: "reaction_added" }> { + const channelId = requireString(args.channelId, "channelId"); + const messageId = requireString(args.messageId, "messageId"); + const emoji = requireString(args.emoji, "emoji"); + const name = normalizeSlackEmojiName(emoji); + const token = getSlackBotToken(channelId, args.threadId); + if (!token) { + throw new Error("No Slack bot token available for channel"); } + await slackApiCall("reactions.add", { + channel: channelId, + timestamp: messageId, + name, + }, token); + return { status: "reaction_added" }; } diff --git a/packages/ims/slack/client.ts b/packages/ims/slack/client.ts index a7acc3f4..8188b34e 100644 --- a/packages/ims/slack/client.ts +++ b/packages/ims/slack/client.ts @@ -18,7 +18,6 @@ import type { IMAdapter } from "@/core/types"; import { createAgentAdapter } from "@/agents/adapter"; import type { OpenCodeMessageContext } from "@/agents"; import { log } from "@/utils"; -import { getSlackActionApiUrl } from "./config"; import { fetchThreadHistoryByClient } from "./message-history"; import { registerSlackMessageRouter } from "./message-router"; import { syncSlackWorkspace } from "@/core/web/local-settings"; @@ -74,10 +73,6 @@ function getSlackProcessorRuntime(processorId?: string): ReturnType<typeof creat return slackProcessorManager.getRuntime(processorId); } -function getOdeSlackApiUrl(): string | undefined { - return getSlackActionApiUrl(); -} - async function buildSlackContext( channelId: string, threadId: string, @@ -92,7 +87,6 @@ async function buildSlackContext( threadId, userId, threadHistory: threadHistory ?? undefined, - odeSlackApiUrl: getOdeSlackApiUrl(), hasGitHubToken: Boolean(getGitHubInfoForUser(userId)?.token), channelSystemMessage: getChannelSystemMessage(channelId) ?? undefined, }, diff --git a/packages/ims/slack/config.ts b/packages/ims/slack/config.ts deleted file mode 100644 index 7fa1534b..00000000 --- a/packages/ims/slack/config.ts +++ /dev/null @@ -1 +0,0 @@ -export { getSlackActionApiUrl } from "@/config"; diff --git a/packages/ims/slack/index.ts b/packages/ims/slack/index.ts index 0b7914f4..0ea7491b 100644 --- a/packages/ims/slack/index.ts +++ b/packages/ims/slack/index.ts @@ -13,7 +13,7 @@ export { type MessageContext, } from "./client"; -export { handleSlackActionPayload, type SlackActionRequest, type SlackApiResponse } from "./api"; +export { uploadSlackFile, getSlackThreadMessages, addSlackReaction, postSlackQuestion } from "./api"; export { setupInteractiveHandlers } from "./commands"; diff --git a/packages/live-status-harness/scripts/capture-stream.ts b/packages/live-status-harness/scripts/capture-stream.ts index b1d9ed85..6ac7f335 100644 --- a/packages/live-status-harness/scripts/capture-stream.ts +++ b/packages/live-status-harness/scripts/capture-stream.ts @@ -194,7 +194,6 @@ async function main(): Promise<void> { channelId, threadId, userId, - odeSlackApiUrl: process.env.ODE_SLACK_API_URL, hasGitHubToken: Boolean(process.env.GH_TOKEN), }, };