From edc49cdb3441edb3ab93b223b794e5f5a29ffea0 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 16 Jun 2026 15:52:29 +0800 Subject: [PATCH 01/10] feat(slack): add Slack channel plugin --- plugins/slack/README.md | 146 +++++++++++++ plugins/slack/package.json | 39 ++++ plugins/slack/src/commands.ts | 52 +++++ plugins/slack/src/config.ts | 45 ++++ plugins/slack/src/format.ts | 161 +++++++++++++++ plugins/slack/src/gateway.ts | 293 ++++++++++++++++++++++++++ plugins/slack/src/index.ts | 118 +++++++++++ plugins/slack/src/media.ts | 97 +++++++++ plugins/slack/src/message.ts | 116 +++++++++++ plugins/slack/src/outbound.ts | 296 +++++++++++++++++++++++++++ plugins/slack/src/types.ts | 39 ++++ plugins/slack/tests/gateway.test.ts | 56 +++++ plugins/slack/tests/media.test.ts | 52 +++++ plugins/slack/tests/message.test.ts | 30 +++ plugins/slack/tests/outbound.test.ts | 57 ++++++ plugins/slack/tsconfig.json | 4 + plugins/slack/tsup.config.ts | 10 + pnpm-lock.yaml | 187 +++++++++++++++++ 18 files changed, 1798 insertions(+) create mode 100644 plugins/slack/README.md create mode 100644 plugins/slack/package.json create mode 100644 plugins/slack/src/commands.ts create mode 100644 plugins/slack/src/config.ts create mode 100644 plugins/slack/src/format.ts create mode 100644 plugins/slack/src/gateway.ts create mode 100644 plugins/slack/src/index.ts create mode 100644 plugins/slack/src/media.ts create mode 100644 plugins/slack/src/message.ts create mode 100644 plugins/slack/src/outbound.ts create mode 100644 plugins/slack/src/types.ts create mode 100644 plugins/slack/tests/gateway.test.ts create mode 100644 plugins/slack/tests/media.test.ts create mode 100644 plugins/slack/tests/message.test.ts create mode 100644 plugins/slack/tests/outbound.test.ts create mode 100644 plugins/slack/tsconfig.json create mode 100644 plugins/slack/tsup.config.ts diff --git a/plugins/slack/README.md b/plugins/slack/README.md new file mode 100644 index 0000000..514e882 --- /dev/null +++ b/plugins/slack/README.md @@ -0,0 +1,146 @@ +# Slack 频道插件 + +通过 Slack 官方 Socket Mode,把 Cola 接入 Slack 会话。无需公网回调地址,机器人用一条 +WebSocket 长连接接收事件。 + +## 功能 + +- 接收 Slack 的文本、图片、文件消息,以及私聊和频道里 @机器人的消息。 +- 从 Cola 向 Slack 发送文本、图片、文件、Markdown(自动转 Slack mrkdwn)和表情回应。 +- 支持流式「草稿预览」:先发占位消息,再随回复增量 `chat.update`(需宿主 SDK 支持,见 + [流式草稿](#流式草稿))。 +- 用 👀 reaction + `assistant.threads.setStatus` 模拟「正在输入」。 +- 按白名单授信:私聊按用户 ID、频道按频道 ID。 +- 固定使用 Socket Mode 长连接,不需要公网服务器。 + +## 准备工作 + +- 一个 Slack workspace,并且有创建应用的权限。 +- 一个 Slack App,开启 Socket Mode。 +- 该应用的 **Bot token**(`xoxb-` 开头)和 **App-level token**(`xapp-` 开头)。 + +## 配置流程 + +### 1. 创建 Slack App + +1. 打开 [Slack API: Your Apps](https://api.slack.com/apps),点击 `Create New App`。 +2. 选择 `From scratch`,填写名称(例如 `Cola`)并选择目标 workspace。 + +### 2. 开启 Socket Mode + +1. 进入 `Settings` → `Socket Mode`,打开开关。 +2. 系统会提示创建一个 **App-level token**,scope 选择 `connections:write`。 +3. 复制生成的 `xapp-...` token,这就是配置里的 `appToken`。 + +### 3. 配置 Bot 权限(OAuth Scopes) + +进入 `Features` → `OAuth & Permissions`,在 `Bot Token Scopes` 添加: + +| Scope | 用途 | +| ----------------- | --------------------------------------------- | +| `chat:write` | 以机器人身份发送和编辑消息。 | +| `files:read` | 下载用户上传的图片、文件。 | +| `files:write` | 向会话上传图片、文件。 | +| `reactions:write` | 添加/移除表情回应(也用于「正在输入」👀)。 | +| `reactions:read` | 读取表情回应上下文。 | +| `users:read` | 解析发送者的昵称、头像。 | +| `assistant:write` | 可选。在 thread 下显示原生「is typing…」状态。| + +### 4. 订阅事件(Event Subscriptions) + +进入 `Features` → `Event Subscriptions`,打开开关(Socket Mode 下无需填 Request URL), +在 `Subscribe to bot events` 添加: + +| 事件 | 用途 | +| ------------------ | -------------------------- | +| `message.im` | 接收私聊消息。 | +| `message.channels` | 接收公开频道里的消息。 | +| `message.groups` | 接收私有频道里的消息。 | +| `app_mention` | 接收 @机器人 的提及。 | + +### 5. 安装应用 + +进入 `Settings` → `Install App`,把应用安装到 workspace,复制 `Bot User OAuth Token` +(`xoxb-...`),这就是配置里的 `botToken`。 + +### 6. 配置 Cola 插件 + +1. 在 Cola 插件商店安装 Slack 插件。 +2. 打开 Slack 插件设置,填入: + - `botToken`:`xoxb-...` + - `appToken`:`xapp-...` + - `allowedIds`:逗号分隔的白名单(见下文)。 +3. 保存设置,并按 Cola 提示重启或重载 gateway。 + +### 7. 把机器人加入会话 + +- **私聊**:在 Slack 里搜索机器人名称直接发起会话。 +- **频道**:在目标频道里 `/invite @你的机器人`。频道里必须 @机器人 才会触发 Cola 回复。 + +## 访问授信(谁能使用 Cola) + +通过配置里的 `allowedIds` 控制,逗号分隔,可混合两类 ID: + +- **私聊**:填发送者的 Slack 用户 ID(`U` 开头)。 +- **频道**:填频道 ID(`C` 开头),整个频道授信,且频道里必须 @机器人 才触发。 + +### 怎么拿到用户 ID / 频道 ID + +最简单的方式是让对方先触发一次。未授信的用户私聊机器人、或在频道里 @机器人时,插件会 +回复一段提示,里面带好对应的用户 ID 和频道 ID,复制填进 `allowedIds` 即可。 + +也可以在 Slack 客户端里:点用户头像 → `Copy member ID`;或在频道详情底部查看 Channel ID。 + +## 流式草稿 + +如果宿主 Cola 的 SDK 支持 `sendDraft`,插件会在回复生成过程中先发一条占位消息,再随 +累积文本增量 `chat.update`,结束时替换为最终文本;若回复没有正文则删除占位消息。SDK 不 +支持时,插件回落为一次性 `sendText`,不影响使用。 + +## 测试 + +1. 给机器人发私聊消息,或在已加入机器人的频道里 @机器人。 +2. 在 Cola 中运行: + +```text +/slack status +/slack config +``` + +`status` 显示连接状态、机器人、team 和最近事件时间;`config` 显示脱敏后的 token 与白名单 +数量。如果显示未连接,请检查 `botToken` / `appToken` 是否正确,并确认保存后已重载 gateway。 + +## 配置字段 + +| 字段 | 必需 | 默认值 | 说明 | +| ------------------- | ---- | ------- | ---------------------------------------------------------- | +| `botToken` | 是 | | Bot User OAuth Token,`xoxb-` 开头。请作为 secret 保存。 | +| `appToken` | 是 | | App-level token,`xapp-` 开头,需 `connections:write`。 | +| `allowedIds` | 是 | | 逗号分隔的用户 ID(私聊)和频道 ID(频道)白名单。 | +| `ignoreBotMessages` | 否 | `true` | 是否忽略其他机器人/自己发的消息。 | +| `unfurlLinks` | 否 | `false` | 发送消息时是否展开链接和媒体预览。 | + +配置 UI 只暴露 `botToken`、`appToken`、`allowedIds`。`ignoreBotMessages` 与 `unfurlLinks` +保留默认值,需要时可在 `channels.json` 里设置。 + +## 常见问题 + +### 机器人收到消息但不回复 + +检查: + +- 应用已安装到 workspace,且发消息的用户/频道在 `allowedIds` 里。 +- 机器人已加入目标频道;频道里是否 @机器人。 +- Bot Token Scopes 是否包含 `chat:write`。 +- Event Subscriptions 是否订阅了对应的 `message.*` / `app_mention` 事件。 +- 未授信用户是否收到了带 ID 的提示;如果没有,多半是缺 `chat:write`。 + +### 图片或文件失败 + +- 下载失败、日志提示 `files:read`:给应用补 `files:read` 权限并重新安装。 +- 上传失败:补 `files:write` 权限。 + +### 「正在输入」不显示 + +`assistant.threads.setStatus` 需要 `assistant:write` 且应用启用了 Assistant 能力;缺失时 +插件只会用 👀 reaction 兜底,不影响回复。 diff --git a/plugins/slack/package.json b/plugins/slack/package.json new file mode 100644 index 0000000..e37c44f --- /dev/null +++ b/plugins/slack/package.json @@ -0,0 +1,39 @@ +{ + "name": "cola-plugin-slack", + "version": "0.1.0", + "description": "Slack channel plugin for Cola", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@marswave/cola-plugin-sdk": "0.0.3", + "@slack/socket-mode": "^2.0.4", + "@slack/web-api": "^7.9.3" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsup": "^8.5.1", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=22" + }, + "cola": { + "plugin": { + "id": "slack", + "entry": "./dist/index.js" + }, + "channel": { + "label": "Slack", + "description": "Slack messaging via Socket Mode", + "iconUrl": "https://a.slack-edge.com/80588/marketing/img/meta/slack_hash_256.png", + "docsPath": "https://github.com/marswaveai/cola-plugins/blob/main/plugins/slack/README.md" + } + } +} diff --git a/plugins/slack/src/commands.ts b/plugins/slack/src/commands.ts new file mode 100644 index 0000000..0ae1776 --- /dev/null +++ b/plugins/slack/src/commands.ts @@ -0,0 +1,52 @@ +import type { PluginCommandDefinition } from "@marswave/cola-plugin-sdk"; +import { readSlackConfig, redactToken } from "./config.js"; +import type { SlackGatewayState } from "./gateway.js"; + +export function createSlackCommands(getState: () => SlackGatewayState): PluginCommandDefinition[] { + return [ + { + name: "slack", + description: "Slack plugin status and configuration summary", + args: [{ name: "subcommand", description: '"status" or "config"', required: false }], + async execute(ctx) { + const subcommand = ctx.args.trim() || "status"; + const state = getState(); + + if (subcommand === "status") { + const bot = state.botName ? `@${state.botName}` : (state.botUserId ?? "-"); + const status = state.connected ? "connected" : "disconnected"; + const lastEvent = state.lastEventAt ? new Date(state.lastEventAt).toISOString() : "never"; + return { + reply: [ + "**Slack Status**", + "", + `- status: ${status}`, + `- bot: ${bot}`, + `- team: ${state.teamId ?? "-"}`, + `- last event: ${lastEvent}`, + state.lastError ? `- last error: ${state.lastError}` : undefined, + ] + .filter((line): line is string => Boolean(line)) + .join("\n"), + }; + } + + if (subcommand === "config") { + const config = readSlackConfig(ctx.config); + return { + reply: [ + "**Slack Config**", + "", + `- bot token: ${redactToken(config.botToken)}`, + `- app token: ${redactToken(config.appToken)}`, + `- allowed ids: ${config.allowedIds.size || "(missing)"}`, + `- ignore bot messages: ${config.ignoreBotMessages}`, + ].join("\n"), + }; + } + + return { reply: `Unknown subcommand: ${subcommand}. Use "status" or "config".` }; + }, + }, + ]; +} diff --git a/plugins/slack/src/config.ts b/plugins/slack/src/config.ts new file mode 100644 index 0000000..aee9225 --- /dev/null +++ b/plugins/slack/src/config.ts @@ -0,0 +1,45 @@ +export type SlackConfig = { + botToken: string; + appToken: string; + allowedIds: Set; + ignoreBotMessages: boolean; + unfurlLinks: boolean; +}; + +export function readSlackConfig(raw: Readonly>): SlackConfig { + return { + botToken: readString(raw.botToken), + appToken: readString(raw.appToken), + allowedIds: parseAllowedIds(raw.allowedIds), + ignoreBotMessages: readBoolean(raw.ignoreBotMessages, true), + unfurlLinks: readBoolean(raw.unfurlLinks, false), + }; +} + +export function isSlackConfigured(config: SlackConfig): boolean { + return config.botToken.length > 0 && config.appToken.length > 0 && config.allowedIds.size > 0; +} + +export function redactToken(token: string): string { + if (!token) return "(missing)"; + if (token.length <= 10) return "***"; + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} + +function readString(value: unknown, fallback = ""): string { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + +function readBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function parseAllowedIds(value: unknown): Set { + if (typeof value !== "string") return new Set(); + return new Set( + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ); +} diff --git a/plugins/slack/src/format.ts b/plugins/slack/src/format.ts new file mode 100644 index 0000000..c3a8be3 --- /dev/null +++ b/plugins/slack/src/format.ts @@ -0,0 +1,161 @@ +/** + * Convert standard Markdown to Slack mrkdwn. + * + * mrkdwn differences from Markdown: bold is `*text*`, italic `_text_`, + * strikethrough `~text~`, links ``, and there is no heading + * syntax (headings become bold lines). `&`, `<`, `>` are control characters + * and must be escaped in regular text. + */ +export function formatSlackMrkdwn(markdown: string): string { + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); + const output: string[] = []; + let codeFence: string[] | null = null; + + for (const line of lines) { + if (line.match(/^```/)) { + if (codeFence) { + output.push("```\n" + escapeMrkdwn(codeFence.join("\n")) + "\n```"); + codeFence = null; + } else { + codeFence = []; + } + continue; + } + + if (codeFence) { + codeFence.push(line); + continue; + } + + output.push(formatMarkdownLine(line)); + } + + if (codeFence) { + output.push("```\n" + escapeMrkdwn(codeFence.join("\n")) + "\n```"); + } + + return output.join("\n"); +} + +function formatMarkdownLine(line: string): string { + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) return `*${formatInlineMarkdown(heading[2])}*`; + + const task = line.match(/^(\s*)[-*]\s+\[([ xX])\]\s+(.+)$/); + if (task) { + const marker = task[2].toLowerCase() === "x" ? "☑" : "☐"; + return `${task[1]}${marker} ${formatInlineMarkdown(task[3])}`; + } + + const bullet = line.match(/^(\s*)[-*]\s+(.+)$/); + if (bullet) return `${bullet[1]}• ${formatInlineMarkdown(bullet[2])}`; + + const quote = line.match(/^>\s?(.*)$/); + if (quote) return `> ${formatInlineMarkdown(quote[1])}`; + + return formatInlineMarkdown(line); +} + +function formatInlineMarkdown(text: string): string { + let out = ""; + let index = 0; + + while (index < text.length) { + const code = consumeDelimited(text, index, "`", "`"); + if (code) { + out += `\`${escapeMrkdwn(code.content)}\``; + index = code.nextIndex; + continue; + } + + const link = consumeMarkdownLink(text, index); + if (link) { + out += `<${escapeMrkdwn(link.url)}|${escapeMrkdwn(link.label)}>`; + index = link.nextIndex; + continue; + } + + const boldItalic = consumeDelimited(text, index, "***", "***"); + if (boldItalic) { + out += `*_${formatInlineMarkdown(boldItalic.content)}_*`; + index = boldItalic.nextIndex; + continue; + } + + const bold = consumeDelimited(text, index, "**", "**"); + if (bold) { + out += `*${formatInlineMarkdown(bold.content)}*`; + index = bold.nextIndex; + continue; + } + + const strikethrough = consumeDelimited(text, index, "~~", "~~"); + if (strikethrough) { + out += `~${formatInlineMarkdown(strikethrough.content)}~`; + index = strikethrough.nextIndex; + continue; + } + + const italic = consumeDelimited(text, index, "*", "*"); + if (italic) { + out += `_${formatInlineMarkdown(italic.content)}_`; + index = italic.nextIndex; + continue; + } + + out += escapeMrkdwn(text[index]); + index += 1; + } + + return out; +} + +function consumeDelimited( + text: string, + index: number, + open: string, + close: string, +): { content: string; nextIndex: number } | null { + if (!text.startsWith(open, index)) return null; + const contentStart = index + open.length; + const contentEnd = text.indexOf(close, contentStart); + if (contentEnd === -1 || contentEnd === contentStart) return null; + return { + content: text.slice(contentStart, contentEnd), + nextIndex: contentEnd + close.length, + }; +} + +function consumeMarkdownLink( + text: string, + index: number, +): { label: string; url: string; nextIndex: number } | null { + if (text[index] !== "[") return null; + const labelEnd = text.indexOf("](", index + 1); + if (labelEnd === -1) return null; + const urlStart = labelEnd + 2; + const urlEnd = text.indexOf(")", urlStart); + if (urlEnd === -1) return null; + + const url = text.slice(urlStart, urlEnd).trim(); + if (!isSafeSlackLink(url)) return null; + + return { + label: text.slice(index + 1, labelEnd), + url, + nextIndex: urlEnd + 1, + }; +} + +function isSafeSlackLink(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function escapeMrkdwn(text: string): string { + return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} diff --git a/plugins/slack/src/gateway.ts b/plugins/slack/src/gateway.ts new file mode 100644 index 0000000..e1dd0bc --- /dev/null +++ b/plugins/slack/src/gateway.ts @@ -0,0 +1,293 @@ +import { SocketModeClient } from "@slack/socket-mode"; +import { WebClient } from "@slack/web-api"; +import type { ChannelSender, ChannelStatusResult, GatewayContext } from "@marswave/cola-plugin-sdk"; +import { isSlackConfigured, readSlackConfig, type SlackConfig } from "./config.js"; +import { downloadSlackFile } from "./media.js"; +import { + isBotMentioned, + isDirectMessage, + isFromBot, + parseSlackMessage, + shouldSkipSubtype, +} from "./message.js"; +import type { SlackMessageEvent, SlackUserProfile } from "./types.js"; + +export type SlackGatewayState = { + socket?: SocketModeClient; + web?: WebClient; + botUserId?: string; + botName?: string; + teamId?: string; + configured?: boolean; + connected?: boolean; + startedAt?: number; + lastEventAt?: number; + lastError?: string; + allowedIds?: string[]; +}; + +type SlackEventArgs = { + event: SlackMessageEvent; + ack: () => Promise; +}; + +/** Socket Mode redelivers events after reconnects; remember recent ones. */ +class EventDedup { + private seen = new Set(); + private order: string[] = []; + + isDuplicate(key: string): boolean { + if (this.seen.has(key)) return true; + this.seen.add(key); + this.order.push(key); + if (this.order.length > 1000) { + const oldest = this.order.shift(); + if (oldest) this.seen.delete(oldest); + } + return false; + } +} + +export async function startGateway(ctx: GatewayContext): Promise { + const config = readSlackConfig(ctx.config); + resetState(ctx.state); + ctx.state.configured = isSlackConfigured(config); + ctx.state.allowedIds = [...config.allowedIds]; + + if (!ctx.state.configured) { + ctx.logger.warn("Slack bot token, app token, and allowed IDs are required"); + return; + } + + try { + const web = new WebClient(config.botToken); + const auth = await web.auth.test(); + ctx.state.web = web; + ctx.state.botUserId = typeof auth.user_id === "string" ? auth.user_id : undefined; + ctx.state.botName = typeof auth.user === "string" ? auth.user : undefined; + ctx.state.teamId = typeof auth.team_id === "string" ? auth.team_id : undefined; + ctx.state.startedAt = Date.now(); + + const socket = new SocketModeClient({ appToken: config.appToken }); + ctx.state.socket = socket; + + const dedup = new EventDedup(); + const senderCache = new Map(); + + const handle = async ({ event, ack }: SlackEventArgs) => { + await ack(); + try { + await handleSlackEvent(event, ctx, config, dedup, senderCache); + } catch (err) { + ctx.logger.warn("Failed to handle Slack event", err); + } + }; + + // A channel @mention produces both a `message` and an `app_mention` event; + // dedup by channel:ts collapses them. + socket.on("message", handle); + socket.on("app_mention", handle); + + socket.on("connected", () => { + ctx.state.connected = true; + ctx.state.lastError = undefined; + }); + socket.on("disconnected", () => { + ctx.state.connected = false; + }); + socket.on("error", (error: unknown) => { + ctx.state.lastError = errorMessage(error); + ctx.logger.warn("Slack socket error", error); + }); + + ctx.abortSignal.addEventListener("abort", () => void socket.disconnect(), { once: true }); + + await socket.start(); + ctx.state.connected = true; + ctx.state.lastError = undefined; + ctx.logger.info( + `Slack gateway connected as @${ctx.state.botName ?? "?"} (${ctx.state.botUserId ?? "?"}) in team ${ctx.state.teamId ?? "?"}`, + ); + } catch (err) { + ctx.state.connected = false; + ctx.state.lastError = errorMessage(err); + ctx.logger.warn("Failed to start Slack gateway", err); + throw err; + } +} + +export async function stopGateway(ctx: GatewayContext): Promise { + const socket = ctx.state.socket; + ctx.state.socket = undefined; + ctx.state.web = undefined; + ctx.state.connected = false; + if (socket) { + try { + await socket.disconnect(); + } catch (err) { + ctx.logger.warn("Failed to disconnect Slack socket", err); + } + } +} + +export function getGatewayStatus(ctx: GatewayContext): ChannelStatusResult { + if (!ctx.state.configured) { + return { + connected: false, + configured: false, + message: "Bot token, app token, and allowed IDs are required", + }; + } + if (!ctx.state.connected) { + return { + connected: false, + configured: true, + message: ctx.state.lastError ? `Disconnected: ${ctx.state.lastError}` : "Disconnected", + }; + } + + const bot = ctx.state.botName ? `@${ctx.state.botName}` : ctx.state.botUserId; + const allowed = + ctx.state.allowedIds && ctx.state.allowedIds.length > 0 + ? `; allowed ids=${ctx.state.allowedIds.length}` + : ""; + return { + connected: true, + configured: true, + message: `Socket Mode${bot ? ` as ${bot}` : ""}${allowed}`, + }; +} + +async function handleSlackEvent( + event: SlackMessageEvent, + ctx: GatewayContext, + config: SlackConfig, + dedup: EventDedup, + senderCache: Map, +): Promise { + if (!event.channel || !event.ts) return; + if (dedup.isDuplicate(`${event.channel}:${event.ts}`)) return; + if (shouldSkipSubtype(event)) return; + if (config.ignoreBotMessages && isFromBot(event, ctx.state.botUserId)) return; + + const isDm = isDirectMessage(event); + const allowed = isDm + ? (event.user !== undefined && config.allowedIds.has(event.user)) || + config.allowedIds.has(event.channel) + : config.allowedIds.has(event.channel); + + if (!allowed) { + // Reply with the IDs needed for the allowlist: always in DMs, only on an + // explicit @mention in channels (anything else would spam the channel). + if (isDm || isBotMentioned(event, ctx.state.botUserId)) { + ctx.logger.info(`Skipping Slack message from unlisted ${isDm ? "user" : "channel"}`); + await sendAccessNotConfiguredReply(event, ctx); + } + return; + } + + const accountId = ctx.state.teamId ?? "default"; + const parsed = parseSlackMessage(event, accountId, ctx.state.botUserId); + if (!parsed) return; + + ctx.state.lastEventAt = Date.now(); + + const attachments: string[] = []; + for (const file of event.files ?? []) { + const filePath = await downloadSlackFile(file, config.botToken, ctx.logger); + if (filePath) attachments.push(filePath); + } + + // The configured allowlist is this channel's authorization gate, so bind the + // sender to the primary Cola user on first contact. Without a binding the host + // drops every message as an "unbound sender" and the bot never replies. + if (!(await ctx.runtime.identity.resolve(parsed.senderId))) { + await ctx.runtime.identity.bind(parsed.senderId); + ctx.logger.info(`Bound Slack sender ${parsed.senderId} from allowed ${event.channel}`); + } + + await ctx.deliver({ + sessionId: parsed.sessionId, + sender: await resolveSender(parsed.senderId, ctx, senderCache), + conversation: parsed.conversation, + mentionedBot: parsed.mentionedBot, + deliveryContext: { + to: parsed.deliveryTo, + accountId, + threadId: parsed.threadId, + messageId: parsed.messageId, + }, + message: parsed.text, + attachments: attachments.length > 0 ? attachments : undefined, + }); +} + +async function resolveSender( + userId: string, + ctx: GatewayContext, + cache: Map, +): Promise { + const cached = cache.get(userId); + if (cached) return cached; + + let sender: ChannelSender = { id: userId }; + try { + const result = await ctx.state.web?.users.info({ user: userId }); + const user = result?.user as SlackUserProfile | undefined; + if (user) { + sender = { + id: userId, + name: user.profile?.display_name || user.profile?.real_name || user.real_name || user.name, + handle: user.name ? `@${user.name}` : undefined, + avatarUrl: user.profile?.image_72, + }; + } + } catch { + // users:read scope may be missing; fall back to the bare ID. + } + cache.set(userId, sender); + return sender; +} + +async function sendAccessNotConfiguredReply( + event: SlackMessageEvent, + ctx: GatewayContext, +): Promise { + if (!ctx.state.web) return; + try { + await ctx.state.web.chat.postMessage({ + channel: event.channel, + text: accessNotConfiguredMessage(event), + ...(event.thread_ts ? { thread_ts: event.thread_ts } : {}), + }); + } catch (err) { + ctx.logger.warn(`Failed to send Slack access notice for ${event.channel}`, err); + } +} + +function accessNotConfiguredMessage(event: SlackMessageEvent): string { + const lines = ["Cola Slack: access not configured.", ""]; + if (event.user) { + lines.push("Your Slack user ID:", "```", event.user, "```", ""); + } + lines.push("This conversation's channel ID:", "```", event.channel, "```"); + return lines.join("\n"); +} + +function resetState(state: SlackGatewayState): void { + state.socket = undefined; + state.web = undefined; + state.botUserId = undefined; + state.botName = undefined; + state.teamId = undefined; + state.configured = false; + state.connected = false; + state.startedAt = undefined; + state.lastEventAt = undefined; + state.lastError = undefined; + state.allowedIds = undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/plugins/slack/src/index.ts b/plugins/slack/src/index.ts new file mode 100644 index 0000000..8c761d1 --- /dev/null +++ b/plugins/slack/src/index.ts @@ -0,0 +1,118 @@ +import { defineChannel } from "@marswave/cola-plugin-sdk"; +import type { + ChannelOutboundAdapter, + ChannelStatusResult, + OutboundContext, + ReactionContext, +} from "@marswave/cola-plugin-sdk"; +import { createSlackCommands } from "./commands.js"; +import { getGatewayStatus, startGateway, stopGateway, type SlackGatewayState } from "./gateway.js"; +import { + sendSlackDraft, + sendSlackMedia, + sendSlackReaction, + sendSlackText, + sendSlackTyping, + type SlackDraftContext, +} from "./outbound.js"; + +let activeState: SlackGatewayState = {}; + +// sendDraft/draftThrottleMs land in SDK 0.0.5 (streaming draft preview); the +// widened type lets this compile against 0.0.3 until the SDK bump. Hosts that +// predate the capability simply never call sendDraft. +const outbound: ChannelOutboundAdapter & { + sendDraft?(ctx: SlackDraftContext): Promise; + draftThrottleMs?: number; +} = { + textChunkLimit: 4000, + draftThrottleMs: 1000, + mediaCapabilities: { + maxBytesPerFile: 100 * 1024 * 1024, + supportedKinds: ["image", "file", "video", "audio"], + }, + async sendText(ctx: OutboundContext) { + await sendSlackText(ctx, activeState); + }, + async sendDraft(ctx: SlackDraftContext) { + await sendSlackDraft(ctx, activeState); + }, + async sendMedia(ctx: OutboundContext & { mediaType: string; filePath: string }) { + await sendSlackMedia(ctx, activeState); + }, + async sendReaction(ctx: ReactionContext) { + await sendSlackReaction(ctx, activeState); + }, + async sendTyping(ctx: OutboundContext & { active: boolean }) { + await sendSlackTyping(ctx, activeState); + }, +}; + +export default defineChannel({ + id: "slack", + meta: { + label: "Slack", + description: "Slack messaging via Socket Mode", + markdownCapable: true, + }, + capabilities: { + receive: { text: true, image: true, file: true }, + send: { text: true, markdown: true, image: true, file: true, reaction: true, typing: true }, + limits: { maxTextLength: 40000 }, + }, + config: { + schema: { + fields: [ + { + key: "botToken", + label: "Bot token", + type: "password", + required: true, + secret: true, + placeholder: "xoxb-...", + }, + { + key: "appToken", + label: "App-level token", + type: "password", + required: true, + secret: true, + placeholder: "xapp-...", + description: "App-level token with the connections:write scope (Socket Mode).", + }, + { + key: "allowedIds", + label: "Allowed IDs", + type: "text", + required: true, + placeholder: "U0123ABC,C0456DEF", + description: + "Comma-separated Slack user IDs (DMs) and channel IDs accepted by the plugin.", + }, + // Only the tokens and allowlist are exposed in the config UI. The + // remaining options (ignoreBotMessages, unfurlLinks) keep their + // defaults from readSlackConfig and can be set via channels.json. + ], + }, + }, + commands: createSlackCommands(() => activeState), + gateway: { + async start(ctx) { + activeState = ctx.state; + await startGateway(ctx); + }, + async stop(ctx) { + await stopGateway(ctx); + if (activeState === ctx.state) activeState = {}; + }, + async reload(ctx) { + await stopGateway(ctx); + activeState = ctx.state; + await startGateway(ctx); + }, + getStatus(ctx): ChannelStatusResult { + return getGatewayStatus(ctx); + }, + }, + outbound, +}); diff --git a/plugins/slack/src/media.ts b/plugins/slack/src/media.ts new file mode 100644 index 0000000..9423006 --- /dev/null +++ b/plugins/slack/src/media.ts @@ -0,0 +1,97 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; +import type { ReadableStream as NodeReadableStream } from "stream/web"; +import type { WebClient } from "@slack/web-api"; +import type { PluginLogger } from "@marswave/cola-plugin-sdk"; +import type { SlackFile } from "./types.js"; + +/** + * Download a Slack-hosted file to a temp path. Slack private URLs require the + * bot token as a Bearer header; an HTML response means auth/scope problems + * (Slack serves a login page instead of an error). + */ +export async function downloadSlackFile( + file: SlackFile, + botToken: string, + logger: PluginLogger, +): Promise { + const url = file.url_private_download ?? file.url_private; + if (!url) { + logger.warn(`Slack file ${file.id} has no private URL; missing files:read scope?`); + return undefined; + } + + let tmpPath: string | undefined; + try { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${botToken}` }, + }); + if (!response.ok) { + logger.warn(`Failed to download Slack file ${file.id}: HTTP ${response.status}`); + return undefined; + } + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("text/html")) { + logger.warn( + `Slack returned HTML for file ${file.id}; bot token likely lacks files:read scope`, + ); + return undefined; + } + + const tmpDir = path.join(os.tmpdir(), "cola-slack"); + fs.mkdirSync(tmpDir, { recursive: true }); + const safeName = sanitizeFileName(file.name ?? file.title ?? file.id); + tmpPath = path.join(tmpDir, `${Date.now()}-${safeName}`); + if (!response.body) { + logger.warn(`Slack file ${file.id} has no response body`); + return undefined; + } + await pipeline( + Readable.fromWeb(response.body as NodeReadableStream), + fs.createWriteStream(tmpPath), + ); + return tmpPath; + } catch (err) { + if (tmpPath) { + try { + fs.rmSync(tmpPath, { force: true }); + } catch { + // Best effort cleanup for partial downloads. + } + } + logger.warn( + `Failed to download Slack file ${file.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + return undefined; + } +} + +export async function uploadSlackFile( + client: WebClient, + opts: { + channelId: string; + filePath: string; + threadTs?: string; + comment?: string; + }, +): Promise { + const base = { + channel_id: opts.channelId, + file: fs.createReadStream(opts.filePath), + filename: path.basename(opts.filePath), + ...(opts.comment ? { initial_comment: opts.comment } : {}), + }; + if (opts.threadTs) { + await client.filesUploadV2({ ...base, thread_ts: opts.threadTs }); + } else { + await client.filesUploadV2(base); + } +} + +export function sanitizeFileName(name: string): string { + const base = path.basename(name).replace(/[^\w.()\- ]+/g, "_"); + return base || "file"; +} diff --git a/plugins/slack/src/message.ts b/plugins/slack/src/message.ts new file mode 100644 index 0000000..c1bcb22 --- /dev/null +++ b/plugins/slack/src/message.ts @@ -0,0 +1,116 @@ +import type { DeliverPayload, SessionId } from "@marswave/cola-plugin-sdk"; +import type { SlackMessageEvent } from "./types.js"; + +type InboundConversation = NonNullable; + +/** Message subtypes that still carry user content worth delivering. */ +const ALLOWED_SUBTYPES = new Set(["file_share", "thread_broadcast"]); + +export type ParsedSlackMessage = { + sessionId: SessionId; + senderId: string; + conversation: InboundConversation; + mentionedBot?: boolean; + deliveryTo: string; + threadId?: string; + messageId: string; + text: string; +}; + +export function isDirectMessage(event: SlackMessageEvent): boolean { + if (event.channel_type === "im") return true; + if (event.channel_type === "mpim" || event.channel_type === "channel") return false; + return event.channel.startsWith("D"); +} + +export function shouldSkipSubtype(event: SlackMessageEvent): boolean { + return event.subtype !== undefined && !ALLOWED_SUBTYPES.has(event.subtype); +} + +export function isFromBot(event: SlackMessageEvent, botUserId: string | undefined): boolean { + if (event.bot_id) return true; + return botUserId !== undefined && event.user === botUserId; +} + +export function isBotMentioned(event: SlackMessageEvent, botUserId: string | undefined): boolean { + if (!botUserId) return false; + return (event.text ?? "").includes(`<@${botUserId}>`); +} + +export function parseSlackMessage( + event: SlackMessageEvent, + accountId: string, + botUserId: string | undefined, +): ParsedSlackMessage | undefined { + const senderId = event.user; + if (!senderId) return undefined; + + const text = extractMessageText(event, botUserId); + if (!text.trim() && !event.files?.length) return undefined; + + const isDm = isDirectMessage(event); + const channelId = event.channel; + const conversation = resolveConversation(event, isDm, senderId); + + // Channel messages always reply in the message's thread (the top-level + // message starts one); DMs only thread when the user already did. + const threadId = isDm ? event.thread_ts : (event.thread_ts ?? event.ts); + const threadSuffix = !isDm && threadId ? ["thread", threadId] : []; + + return { + sessionId: isDm + ? ["chat", accountId, channelId, "sender", senderId] + : ["chat", accountId, channelId, ...threadSuffix], + senderId, + conversation, + mentionedBot: isDm ? undefined : isBotMentioned(event, botUserId), + deliveryTo: `channel:${channelId}`, + threadId, + messageId: event.ts, + text, + }; +} + +export function extractChannelId(deliveryTo: string): string { + return deliveryTo.startsWith("channel:") ? deliveryTo.slice("channel:".length) : deliveryTo; +} + +function resolveConversation( + event: SlackMessageEvent, + isDm: boolean, + senderId: string, +): InboundConversation { + if (event.channel_type === "im") return { kind: "direct", id: senderId }; + if (event.channel_type === "mpim" || event.channel_type === "group") { + return { kind: "group", id: event.channel }; + } + if (isDm) return { kind: "direct", id: senderId }; + return { kind: "channel", id: event.channel }; +} + +function extractMessageText(event: SlackMessageEvent, botUserId: string | undefined): string { + let text = event.text ?? ""; + if (botUserId) { + text = text.replaceAll(`<@${botUserId}>`, "").trim(); + } + text = decodeSlackEntities(text); + + if (!text && event.files?.length) { + return event.files + .map((file) => `[Slack file: ${file.name ?? file.title ?? file.id}]`) + .join("\n"); + } + return text; +} + +/** Convert Slack message entities (<@U…>, <#C…|name>, ) to plain text. */ +function decodeSlackEntities(text: string): string { + return text + .replace(/<@([A-Z0-9]+)>/g, "@$1") + .replace(/<#[A-Z0-9]+\|([^>]+)>/g, "#$1") + .replace(/<([^>|]+)\|([^>]+)>/g, "$2 ($1)") + .replace(/<([^>]+)>/g, "$1") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} diff --git a/plugins/slack/src/outbound.ts b/plugins/slack/src/outbound.ts new file mode 100644 index 0000000..cba452e --- /dev/null +++ b/plugins/slack/src/outbound.ts @@ -0,0 +1,296 @@ +import { WebClient } from "@slack/web-api"; +import type { OutboundContext, ReactionContext } from "@marswave/cola-plugin-sdk"; +import { readSlackConfig } from "./config.js"; +import { formatSlackMrkdwn } from "./format.js"; +import { uploadSlackFile } from "./media.js"; +import { extractChannelId } from "./message.js"; +import type { SlackGatewayState } from "./gateway.js"; + +/** Common unicode emoji → Slack reaction names. */ +const EMOJI_NAMES: Record = { + "👍": "+1", + "👎": "-1", + "❤️": "heart", + "✅": "white_check_mark", + "❌": "x", + "👀": "eyes", + "🎉": "tada", + "🚀": "rocket", + "😄": "smile", + "🙏": "pray", + "🔥": "fire", + "⭐": "star", + "🤔": "thinking_face", + "⏳": "hourglass_flowing_sand", +}; + +export async function sendSlackText(ctx: OutboundContext, state: SlackGatewayState): Promise { + const config = readSlackConfig(ctx.config); + const client = resolveClient(state, config.botToken); + + await client.chat.postMessage({ + channel: extractChannelId(ctx.deliveryContext.to), + text: formatSlackMrkdwn(ctx.text), + unfurl_links: config.unfurlLinks, + unfurl_media: config.unfurlLinks, + ...threadTs(ctx.deliveryContext.threadId), + }); +} + +/** + * Local mirror of the SDK 0.0.5 DraftContext (OutboundContext & { done }). + * Replace with the SDK export once @marswave/cola-plugin-sdk >= 0.0.5 lands. + */ +export type SlackDraftContext = OutboundContext & { done: boolean }; + +type SlackDraftMessage = { channel: string; ts: string; touchedAt: number }; + +const MAX_DRAFTS = 100; +const DRAFT_TTL_MS = 30 * 60 * 1000; + +/** promptId → placeholder message posted for the streaming draft. */ +const drafts = new Map(); +const pendingDrafts = new Map>(); + +/** + * Streaming draft preview: post a placeholder on the first update, then edit + * it in place as the host streams accumulated text. `done: true` replaces the + * draft with the final text; `done: true` with empty text deletes it + * (the reply produced no channel text). + */ +export async function sendSlackDraft( + ctx: SlackDraftContext, + state: SlackGatewayState, +): Promise { + const config = readSlackConfig(ctx.config); + const client = resolveClient(state, config.botToken); + const channel = extractChannelId(ctx.deliveryContext.to); + const draft = getDraft(ctx.promptId); + + if (ctx.done && !ctx.text) { + drafts.delete(ctx.promptId); + const pending = pendingDrafts.get(ctx.promptId); + const pendingDraft = pending ? await pending.catch(() => undefined) : undefined; + const draftToDelete = pendingDraft ?? draft; + if (draftToDelete) + await client.chat.delete({ channel: draftToDelete.channel, ts: draftToDelete.ts }); + return; + } + + const text = formatSlackMrkdwn(ctx.text); + + if (!draft) { + const pending = pendingDrafts.get(ctx.promptId); + if (pending) { + const pendingDraft = await pending; + if (pendingDraft) { + await updateDraft(client, ctx.promptId, pendingDraft, text, ctx.done); + return; + } + } + + // Finalize without a live draft (e.g. plugin reloaded mid-stream): throw + // so the host falls back to sendText instead of losing the reply. + if (ctx.done) throw new Error("no draft message to finalize"); + await createDraft(client, ctx.promptId, { + channel, + text, + unfurlLinks: config.unfurlLinks, + threadId: ctx.deliveryContext.threadId, + }); + return; + } + + await updateDraft(client, ctx.promptId, draft, text, ctx.done); +} + +export async function sendSlackMedia( + ctx: OutboundContext & { mediaType: string; filePath: string }, + state: SlackGatewayState, +): Promise { + const config = readSlackConfig(ctx.config); + const client = resolveClient(state, config.botToken); + + await uploadSlackFile(client, { + channelId: extractChannelId(ctx.deliveryContext.to), + filePath: ctx.filePath, + threadTs: threadTs(ctx.deliveryContext.threadId).thread_ts, + comment: ctx.text ? formatSlackMrkdwn(ctx.text) : undefined, + }); +} + +/** Reaction added to the triggering message while the bot is working. */ +const TYPING_REACTION = "eyes"; + +/** + * Slack has no typing API for bots. Mimic it the way openclaw/hermes do: + * `assistant.threads.setStatus` shows a native "is typing…" line under the + * thread (cleared by setting an empty status), and a 👀 reaction on the + * triggering message covers non-thread DMs. Both need optional scopes + * (assistant:write, reactions:write), so failures are swallowed. + */ +export async function sendSlackTyping( + ctx: OutboundContext & { active: boolean }, + state: SlackGatewayState, +): Promise { + const config = readSlackConfig(ctx.config); + const client = resolveClient(state, config.botToken); + const channel = extractChannelId(ctx.deliveryContext.to); + const thread = threadTs(ctx.deliveryContext.threadId).thread_ts; + const messageId = ctx.deliveryContext.messageId; + + if (thread) { + try { + await client.assistant.threads.setStatus({ + channel_id: channel, + thread_ts: thread, + status: ctx.active ? "is typing..." : "", + }); + } catch { + // Needs assistant:write and an assistant-enabled app; best effort only. + } + } + + if (messageId) { + const params = { channel, timestamp: messageId, name: TYPING_REACTION }; + try { + if (ctx.active) { + await client.reactions.add(params); + } else { + await client.reactions.remove(params); + } + } catch { + // already_reacted / no_reaction races and missing scopes are harmless. + } + } +} + +export async function sendSlackReaction( + ctx: ReactionContext, + state: SlackGatewayState, +): Promise { + const name = resolveEmojiName(ctx.emoji); + if (!name) { + ctx.logger.warn(`Unsupported Slack reaction emoji: ${ctx.emoji}`); + return; + } + + const config = readSlackConfig(ctx.config); + const client = resolveClient(state, config.botToken); + const params = { + channel: extractChannelId(ctx.deliveryContext.to), + timestamp: ctx.messageId, + name, + }; + + try { + if (ctx.action === "add") { + await client.reactions.add(params); + } else { + await client.reactions.remove(params); + } + } catch (err) { + // Duplicate add/remove races are harmless. + const code = (err as { data?: { error?: string } }).data?.error; + if (code === "already_reacted" || code === "no_reaction") return; + throw err; + } +} + +export function resolveEmojiName(emoji: string): string | undefined { + const trimmed = emoji.trim().replace(/^:|:$/g, ""); + if (/^[\w+-]+$/.test(trimmed)) return trimmed; + return EMOJI_NAMES[trimmed]; +} + +function resolveClient(state: SlackGatewayState, botToken: string): WebClient { + if (state.web) return state.web; + if (!botToken) throw new Error("Slack bot token is not configured"); + return new WebClient(botToken); +} + +async function createDraft( + client: WebClient, + promptId: string, + opts: { + channel: string; + text: string; + unfurlLinks: boolean; + threadId: string | number | undefined; + }, +): Promise { + const pending = (async () => { + const result = await client.chat.postMessage({ + channel: opts.channel, + text: opts.text, + unfurl_links: opts.unfurlLinks, + unfurl_media: opts.unfurlLinks, + ...threadTs(opts.threadId), + }); + if (typeof result.ts !== "string") return undefined; + + const draft = { + channel: String(result.channel ?? opts.channel), + ts: result.ts, + touchedAt: Date.now(), + }; + rememberDraft(promptId, draft); + return draft; + })(); + pendingDrafts.set(promptId, pending); + try { + return await pending; + } finally { + pendingDrafts.delete(promptId); + } +} + +async function updateDraft( + client: WebClient, + promptId: string, + draft: SlackDraftMessage, + text: string, + done: boolean, +): Promise { + await client.chat.update({ channel: draft.channel, ts: draft.ts, text }); + if (done) { + drafts.delete(promptId); + return; + } + rememberDraft(promptId, draft); +} + +function getDraft(promptId: string): SlackDraftMessage | undefined { + pruneDrafts(); + const draft = drafts.get(promptId); + if (!draft) return undefined; + if (Date.now() - draft.touchedAt > DRAFT_TTL_MS) { + drafts.delete(promptId); + return undefined; + } + return draft; +} + +function rememberDraft(promptId: string, draft: SlackDraftMessage): void { + draft.touchedAt = Date.now(); + drafts.delete(promptId); + drafts.set(promptId, draft); + pruneDrafts(); +} + +function pruneDrafts(): void { + const now = Date.now(); + for (const [promptId, draft] of drafts) { + if (now - draft.touchedAt > DRAFT_TTL_MS) drafts.delete(promptId); + } + while (drafts.size > MAX_DRAFTS) { + const oldest = drafts.keys().next().value; + if (!oldest) break; + drafts.delete(oldest); + } +} + +function threadTs(threadId: string | number | undefined): { thread_ts?: string } { + if (threadId === undefined || threadId === "") return {}; + return { thread_ts: String(threadId) }; +} diff --git a/plugins/slack/src/types.ts b/plugins/slack/src/types.ts new file mode 100644 index 0000000..5160bf9 --- /dev/null +++ b/plugins/slack/src/types.ts @@ -0,0 +1,39 @@ +/** + * Minimal shapes for the slices of the Slack API payloads this plugin reads. + * Socket Mode delivers loosely-typed event objects and `@slack/web-api` + * returns very wide response types, so we narrow to just the fields used here. + */ + +/** A file attached to a Slack message (events API + files.info). */ +export type SlackFile = { + id: string; + name?: string; + title?: string; + url_private?: string; + url_private_download?: string; +}; + +/** A `message`/`app_mention` event delivered over Socket Mode. */ +export type SlackMessageEvent = { + channel: string; + ts: string; + user?: string; + text?: string; + subtype?: string; + bot_id?: string; + thread_ts?: string; + /** "im" (DM), "mpim" (multi-person DM), "group" (private), "channel" (public). */ + channel_type?: string; + files?: SlackFile[]; +}; + +/** The slice of `users.info` we use to populate the channel sender. */ +export type SlackUserProfile = { + name?: string; + real_name?: string; + profile?: { + display_name?: string; + real_name?: string; + image_72?: string; + }; +}; diff --git a/plugins/slack/tests/gateway.test.ts b/plugins/slack/tests/gateway.test.ts new file mode 100644 index 0000000..7e67a36 --- /dev/null +++ b/plugins/slack/tests/gateway.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GatewayContext } from "@marswave/cola-plugin-sdk"; +import { getGatewayStatus, startGateway, type SlackGatewayState } from "../src/gateway.js"; + +const slackMocks = vi.hoisted(() => ({ + authTest: vi.fn(), + socketStart: vi.fn(), + socketDisconnect: vi.fn(), + socketOn: vi.fn(), +})); + +vi.mock("@slack/web-api", () => ({ + WebClient: vi.fn(() => ({ + auth: { test: slackMocks.authTest }, + })), +})); + +vi.mock("@slack/socket-mode", () => ({ + SocketModeClient: vi.fn(() => ({ + on: slackMocks.socketOn, + start: slackMocks.socketStart, + disconnect: slackMocks.socketDisconnect, + })), +})); + +describe("slack gateway startup", () => { + it("records auth failures in gateway status", async () => { + slackMocks.authTest.mockRejectedValueOnce(new Error("invalid_auth")); + + const ctx = makeGatewayContext(); + + await expect(startGateway(ctx)).rejects.toThrow("invalid_auth"); + + expect(ctx.state.lastError).toBe("invalid_auth"); + expect(getGatewayStatus(ctx)).toMatchObject({ + connected: false, + configured: true, + message: "Disconnected: invalid_auth", + }); + }); +}); + +function makeGatewayContext(): GatewayContext { + return { + config: { botToken: "xoxb-token", appToken: "xapp-token", allowedIds: "C123" }, + state: {}, + abortSignal: new AbortController().signal, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + runtime: { identity: { resolve: vi.fn(), bind: vi.fn(), unbind: vi.fn() } }, + deliver: vi.fn(), + } as unknown as GatewayContext; +} diff --git a/plugins/slack/tests/media.test.ts b/plugins/slack/tests/media.test.ts new file mode 100644 index 0000000..4807aea --- /dev/null +++ b/plugins/slack/tests/media.test.ts @@ -0,0 +1,52 @@ +import fs from "fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginLogger } from "@marswave/cola-plugin-sdk"; +import { downloadSlackFile } from "../src/media.js"; + +const logger: PluginLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("slack media downloads", () => { + it("streams private file downloads to disk", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("hello ")); + controller.enqueue(new TextEncoder().encode("world")); + controller.close(); + }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + headers: new Headers({ "content-type": "text/plain" }), + body, + arrayBuffer: async () => { + throw new Error("arrayBuffer should not be used"); + }, + })), + ); + + const filePath = await downloadSlackFile( + { + id: "F123", + name: "note.txt", + url_private_download: "https://slack.example/files/F123", + }, + "xoxb-token", + logger, + ); + + expect(filePath).toBeDefined(); + expect(fs.readFileSync(filePath!, "utf8")).toBe("hello world"); + fs.rmSync(filePath!, { force: true }); + }); +}); diff --git a/plugins/slack/tests/message.test.ts b/plugins/slack/tests/message.test.ts new file mode 100644 index 0000000..6258c5a --- /dev/null +++ b/plugins/slack/tests/message.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { parseSlackMessage } from "../src/message.js"; +import type { SlackMessageEvent } from "../src/types.js"; + +describe("slack message parsing", () => { + it("uses the same channel session for a root message and its thread replies", () => { + const root: SlackMessageEvent = { + channel: "C123", + channel_type: "channel", + ts: "1710000000.000100", + user: "U123", + text: "<@B123> hello", + }; + const reply: SlackMessageEvent = { + channel: "C123", + channel_type: "channel", + ts: "1710000001.000200", + thread_ts: root.ts, + user: "U123", + text: "follow-up", + }; + + const parsedRoot = parseSlackMessage(root, "T123", "B123"); + const parsedReply = parseSlackMessage(reply, "T123", "B123"); + + expect(parsedRoot?.threadId).toBe(root.ts); + expect(parsedReply?.threadId).toBe(root.ts); + expect(parsedRoot?.sessionId).toEqual(parsedReply?.sessionId); + }); +}); diff --git a/plugins/slack/tests/outbound.test.ts b/plugins/slack/tests/outbound.test.ts new file mode 100644 index 0000000..febb7c9 --- /dev/null +++ b/plugins/slack/tests/outbound.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginLogger } from "@marswave/cola-plugin-sdk"; +import type { SlackGatewayState } from "../src/gateway.js"; +import { sendSlackDraft, type SlackDraftContext } from "../src/outbound.js"; + +const logger: PluginLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; + +describe("slack draft streaming", () => { + it("coalesces concurrent first draft updates for the same prompt", async () => { + const postMessage = vi.fn(async () => ({ channel: "C123", ts: "1710000000.000100" })); + const update = vi.fn(async () => ({})); + const deleteMessage = vi.fn(async () => ({})); + const state = { + web: { + chat: { + postMessage, + update, + delete: deleteMessage, + }, + }, + } as unknown as SlackGatewayState; + + await Promise.all([ + sendSlackDraft(makeDraftContext("first"), state), + sendSlackDraft(makeDraftContext("second"), state), + ]); + + expect(postMessage).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith({ + channel: "C123", + ts: "1710000000.000100", + text: "second", + }); + + await sendSlackDraft(makeDraftContext("", true), state); + }); +}); + +function makeDraftContext(text: string, done = false): SlackDraftContext { + return { + deliveryContext: { + channel: "slack", + to: "channel:C123", + threadId: "1710000000.000100", + messageId: "1710000000.000000", + }, + text, + done, + promptId: "prompt-race", + config: { botToken: "xoxb-token", unfurlLinks: false }, + logger, + }; +} diff --git a/plugins/slack/tsconfig.json b/plugins/slack/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/plugins/slack/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/plugins/slack/tsup.config.ts b/plugins/slack/tsup.config.ts new file mode 100644 index 0000000..15f9788 --- /dev/null +++ b/plugins/slack/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + external: ["@slack/socket-mode", "@slack/web-api"], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d61d6a..5958569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,28 @@ importers: specifier: ^5.8.3 version: 5.9.3 + plugins/slack: + dependencies: + '@marswave/cola-plugin-sdk': + specifier: 0.0.3 + version: 0.0.3 + '@slack/socket-mode': + specifier: ^2.0.4 + version: 2.0.7 + '@slack/web-api': + specifier: ^7.9.3 + version: 7.17.0 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + tsup: + specifier: ^8.5.1 + version: 8.5.1(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + plugins/telegram: dependencies: '@marswave/cola-plugin-sdk': @@ -786,12 +808,34 @@ packages: cpu: [x64] os: [win32] + '@slack/logger@4.0.1': + resolution: {integrity: sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/socket-mode@2.0.7': + resolution: {integrity: sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/types@2.21.1': + resolution: {integrity: sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + + '@slack/web-api@7.17.0': + resolution: {integrity: sha512-jejr34a8B4L5AS713wOAx1LAqNkW16HVMDEa6sYBvFDc/llUBl8hXaiI4BwF+Al+Sug19Vn2O7iokTVIhVvZ1Q==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -826,6 +870,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -839,6 +887,9 @@ packages: axios@1.13.6: resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + axios@1.18.0: + resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -937,6 +988,12 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1001,6 +1058,17 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1086,6 +1154,22 @@ packages: vite-plus: optional: true + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1139,6 +1223,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -1154,6 +1242,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + rollup@4.60.1: resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1741,12 +1833,56 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@slack/logger@4.0.1': + dependencies: + '@types/node': 22.19.17 + + '@slack/socket-mode@2.0.7': + dependencies: + '@slack/logger': 4.0.1 + '@slack/web-api': 7.17.0 + '@types/node': 22.19.17 + '@types/ws': 8.18.1 + eventemitter3: 5.0.4 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + + '@slack/types@2.21.1': {} + + '@slack/web-api@7.17.0': + dependencies: + '@slack/logger': 4.0.1 + '@slack/types': 2.21.1 + '@types/node': 22.19.17 + '@types/retry': 0.12.0 + axios: 1.18.0 + eventemitter3: 5.0.4 + form-data: 4.0.5 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + - supports-color + '@types/estree@1.0.8': {} '@types/node@22.19.17': dependencies: undici-types: 6.21.0 + '@types/retry@0.12.0': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.17 + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -1789,6 +1925,12 @@ snapshots: acorn@8.16.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + any-promise@1.3.0: {} assertion-error@2.0.1: {} @@ -1803,6 +1945,16 @@ snapshots: transitivePeerDependencies: - debug + axios@1.18.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -1934,6 +2086,10 @@ snapshots: dependencies: '@types/estree': 1.0.8 + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} fdir@6.5.0(picomatch@4.0.4): @@ -1995,6 +2151,17 @@ snapshots: dependencies: function-bind: 1.1.2 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + is-electron@2.2.2: {} + + is-stream@2.0.1: {} + joycon@3.1.1: {} lilconfig@3.1.3: {} @@ -2092,6 +2259,22 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.67.0 '@oxlint/binding-win32-x64-msvc': 1.67.0 + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + pathe@1.1.2: {} pathe@2.0.3: {} @@ -2140,6 +2323,8 @@ snapshots: proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} + qs@6.15.1: dependencies: side-channel: 1.1.0 @@ -2150,6 +2335,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.13.1: {} + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 From 7a2434d9ce37734ecd91106e41dc444ee52ecaae Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 16 Jun 2026 16:42:24 +0800 Subject: [PATCH 02/10] docs(slack): add app manifest setup shortcut --- plugins/slack/README.md | 86 +++++++++++++++++++++------------ plugins/slack/app-manifest.yaml | 34 +++++++++++++ 2 files changed, 90 insertions(+), 30 deletions(-) create mode 100644 plugins/slack/app-manifest.yaml diff --git a/plugins/slack/README.md b/plugins/slack/README.md index 514e882..586b7e6 100644 --- a/plugins/slack/README.md +++ b/plugins/slack/README.md @@ -21,49 +21,73 @@ WebSocket 长连接接收事件。 ## 配置流程 -### 1. 创建 Slack App +### 推荐方式:用 App Manifest 快捷创建 + +打开 [Create Cola Slack App][create-cola-slack-app], +选择 workspace,确认 manifest 内容,然后创建 App。这个链接会预置 bot、OAuth scopes、 +Socket Mode 和事件订阅。 + +如果链接因 URL 过长不可用,也可以在 Slack 创建 App 时选择 `From an app manifest`,复制本目录的 +[`app-manifest.yaml`](./app-manifest.yaml) 内容粘贴进去。 + +Manifest 不能替你取回 token。创建 App 后仍需: + +1. 进入 `Settings` → `Basic Information` → `App-Level Tokens`,创建一个带 + `connections:write` scope 的 token,复制 `xapp-...`,作为 `appToken`。 +2. 进入 `Settings` → `Install App`,安装到 workspace,复制 `Bot User OAuth Token` + `xoxb-...`,作为 `botToken`。 +3. 打开 Cola Slack 插件设置,填入 `botToken`、`appToken`、`allowedIds`。 + +### 手动方式 + +#### 1. 创建 Slack App 1. 打开 [Slack API: Your Apps](https://api.slack.com/apps),点击 `Create New App`。 2. 选择 `From scratch`,填写名称(例如 `Cola`)并选择目标 workspace。 -### 2. 开启 Socket Mode +#### 2. 开启 Socket Mode 1. 进入 `Settings` → `Socket Mode`,打开开关。 2. 系统会提示创建一个 **App-level token**,scope 选择 `connections:write`。 3. 复制生成的 `xapp-...` token,这就是配置里的 `appToken`。 -### 3. 配置 Bot 权限(OAuth Scopes) +#### 3. 配置 Bot 权限(OAuth Scopes) 进入 `Features` → `OAuth & Permissions`,在 `Bot Token Scopes` 添加: -| Scope | 用途 | -| ----------------- | --------------------------------------------- | -| `chat:write` | 以机器人身份发送和编辑消息。 | -| `files:read` | 下载用户上传的图片、文件。 | -| `files:write` | 向会话上传图片、文件。 | -| `reactions:write` | 添加/移除表情回应(也用于「正在输入」👀)。 | -| `reactions:read` | 读取表情回应上下文。 | -| `users:read` | 解析发送者的昵称、头像。 | -| `assistant:write` | 可选。在 thread 下显示原生「is typing…」状态。| - -### 4. 订阅事件(Event Subscriptions) +| Scope | 用途 | +| ------------------- | ---------------------------------------------- | +| `app_mentions:read` | 接收频道里 @机器人 的消息。 | +| `channels:history` | 接收公开频道消息事件。 | +| `groups:history` | 接收私有频道消息事件。 | +| `im:history` | 接收私聊消息事件。 | +| `mpim:history` | 接收多人私聊消息事件。 | +| `chat:write` | 以机器人身份发送和编辑消息。 | +| `files:read` | 下载用户上传的图片、文件。 | +| `files:write` | 向会话上传图片、文件。 | +| `reactions:write` | 添加/移除表情回应(也用于「正在输入」👀)。 | +| `users:read` | 解析发送者的昵称、头像。 | +| `assistant:write` | 可选。在 thread 下显示原生「is typing…」状态。 | + +#### 4. 订阅事件(Event Subscriptions) 进入 `Features` → `Event Subscriptions`,打开开关(Socket Mode 下无需填 Request URL), 在 `Subscribe to bot events` 添加: -| 事件 | 用途 | -| ------------------ | -------------------------- | -| `message.im` | 接收私聊消息。 | -| `message.channels` | 接收公开频道里的消息。 | -| `message.groups` | 接收私有频道里的消息。 | -| `app_mention` | 接收 @机器人 的提及。 | +| 事件 | 用途 | +| ------------------ | ---------------------- | +| `message.im` | 接收私聊消息。 | +| `message.mpim` | 接收多人私聊消息。 | +| `message.channels` | 接收公开频道里的消息。 | +| `message.groups` | 接收私有频道里的消息。 | +| `app_mention` | 接收 @机器人 的提及。 | -### 5. 安装应用 +#### 5. 安装应用 进入 `Settings` → `Install App`,把应用安装到 workspace,复制 `Bot User OAuth Token` (`xoxb-...`),这就是配置里的 `botToken`。 -### 6. 配置 Cola 插件 +#### 6. 配置 Cola 插件 1. 在 Cola 插件商店安装 Slack 插件。 2. 打开 Slack 插件设置,填入: @@ -72,7 +96,7 @@ WebSocket 长连接接收事件。 - `allowedIds`:逗号分隔的白名单(见下文)。 3. 保存设置,并按 Cola 提示重启或重载 gateway。 -### 7. 把机器人加入会话 +#### 7. 把机器人加入会话 - **私聊**:在 Slack 里搜索机器人名称直接发起会话。 - **频道**:在目标频道里 `/invite @你的机器人`。频道里必须 @机器人 才会触发 Cola 回复。 @@ -112,13 +136,13 @@ WebSocket 长连接接收事件。 ## 配置字段 -| 字段 | 必需 | 默认值 | 说明 | -| ------------------- | ---- | ------- | ---------------------------------------------------------- | -| `botToken` | 是 | | Bot User OAuth Token,`xoxb-` 开头。请作为 secret 保存。 | -| `appToken` | 是 | | App-level token,`xapp-` 开头,需 `connections:write`。 | -| `allowedIds` | 是 | | 逗号分隔的用户 ID(私聊)和频道 ID(频道)白名单。 | -| `ignoreBotMessages` | 否 | `true` | 是否忽略其他机器人/自己发的消息。 | -| `unfurlLinks` | 否 | `false` | 发送消息时是否展开链接和媒体预览。 | +| 字段 | 必需 | 默认值 | 说明 | +| ------------------- | ---- | ------- | -------------------------------------------------------- | +| `botToken` | 是 | | Bot User OAuth Token,`xoxb-` 开头。请作为 secret 保存。 | +| `appToken` | 是 | | App-level token,`xapp-` 开头,需 `connections:write`。 | +| `allowedIds` | 是 | | 逗号分隔的用户 ID(私聊)和频道 ID(频道)白名单。 | +| `ignoreBotMessages` | 否 | `true` | 是否忽略其他机器人/自己发的消息。 | +| `unfurlLinks` | 否 | `false` | 发送消息时是否展开链接和媒体预览。 | 配置 UI 只暴露 `botToken`、`appToken`、`allowedIds`。`ignoreBotMessages` 与 `unfurlLinks` 保留默认值,需要时可在 `channels.json` 里设置。 @@ -144,3 +168,5 @@ WebSocket 长连接接收事件。 `assistant.threads.setStatus` 需要 `assistant:write` 且应用启用了 Assistant 能力;缺失时 插件只会用 👀 reaction 兜底,不影响回复。 + +[create-cola-slack-app]: https://api.slack.com/apps?new_app=1&manifest_yaml=_metadata%3A%0A%20%20major_version%3A%201%0A%20%20minor_version%3A%201%0Adisplay_information%3A%0A%20%20name%3A%20Cola%0A%20%20description%3A%20Cola%20Slack%20channel%20plugin%0Afeatures%3A%0A%20%20bot_user%3A%0A%20%20%20%20display_name%3A%20Cola%0A%20%20%20%20always_online%3A%20true%0Aoauth_config%3A%0A%20%20scopes%3A%0A%20%20%20%20bot%3A%0A%20%20%20%20%20%20-%20app_mentions%3Aread%0A%20%20%20%20%20%20-%20channels%3Ahistory%0A%20%20%20%20%20%20-%20chat%3Awrite%0A%20%20%20%20%20%20-%20files%3Aread%0A%20%20%20%20%20%20-%20files%3Awrite%0A%20%20%20%20%20%20-%20groups%3Ahistory%0A%20%20%20%20%20%20-%20im%3Ahistory%0A%20%20%20%20%20%20-%20mpim%3Ahistory%0A%20%20%20%20%20%20-%20reactions%3Awrite%0A%20%20%20%20%20%20-%20users%3Aread%0Asettings%3A%0A%20%20event_subscriptions%3A%0A%20%20%20%20bot_events%3A%0A%20%20%20%20%20%20-%20app_mention%0A%20%20%20%20%20%20-%20message.channels%0A%20%20%20%20%20%20-%20message.groups%0A%20%20%20%20%20%20-%20message.im%0A%20%20%20%20%20%20-%20message.mpim%0A%20%20org_deploy_enabled%3A%20false%0A%20%20socket_mode_enabled%3A%20true%0A%20%20token_rotation_enabled%3A%20false%0A diff --git a/plugins/slack/app-manifest.yaml b/plugins/slack/app-manifest.yaml new file mode 100644 index 0000000..42271dc --- /dev/null +++ b/plugins/slack/app-manifest.yaml @@ -0,0 +1,34 @@ +_metadata: + major_version: 1 + minor_version: 1 +display_information: + name: Cola + description: Cola Slack channel plugin +features: + bot_user: + display_name: Cola + always_online: true +oauth_config: + scopes: + bot: + - app_mentions:read + - channels:history + - chat:write + - files:read + - files:write + - groups:history + - im:history + - mpim:history + - reactions:write + - users:read +settings: + event_subscriptions: + bot_events: + - app_mention + - message.channels + - message.groups + - message.im + - message.mpim + org_deploy_enabled: false + socket_mode_enabled: true + token_rotation_enabled: false From 5ab44edb79c46f8bcd8139e098a40a5845894fac Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 16 Jun 2026 16:57:22 +0800 Subject: [PATCH 03/10] docs(slack): inline app manifest in readme --- plugins/slack/README.md | 41 +++++++++++++++++++++++++++++++-- plugins/slack/app-manifest.yaml | 34 --------------------------- 2 files changed, 39 insertions(+), 36 deletions(-) delete mode 100644 plugins/slack/app-manifest.yaml diff --git a/plugins/slack/README.md b/plugins/slack/README.md index 586b7e6..ce58805 100644 --- a/plugins/slack/README.md +++ b/plugins/slack/README.md @@ -27,8 +27,45 @@ WebSocket 长连接接收事件。 选择 workspace,确认 manifest 内容,然后创建 App。这个链接会预置 bot、OAuth scopes、 Socket Mode 和事件订阅。 -如果链接因 URL 过长不可用,也可以在 Slack 创建 App 时选择 `From an app manifest`,复制本目录的 -[`app-manifest.yaml`](./app-manifest.yaml) 内容粘贴进去。 +如果链接因 URL 过长不可用,也可以在 Slack 创建 App 时选择 `From an app manifest`,复制下面 +这段内容粘贴进去: + +```yaml +_metadata: + major_version: 1 + minor_version: 1 +display_information: + name: Cola + description: Cola Slack channel plugin +features: + bot_user: + display_name: Cola + always_online: true +oauth_config: + scopes: + bot: + - app_mentions:read + - channels:history + - chat:write + - files:read + - files:write + - groups:history + - im:history + - mpim:history + - reactions:write + - users:read +settings: + event_subscriptions: + bot_events: + - app_mention + - message.channels + - message.groups + - message.im + - message.mpim + org_deploy_enabled: false + socket_mode_enabled: true + token_rotation_enabled: false +``` Manifest 不能替你取回 token。创建 App 后仍需: diff --git a/plugins/slack/app-manifest.yaml b/plugins/slack/app-manifest.yaml deleted file mode 100644 index 42271dc..0000000 --- a/plugins/slack/app-manifest.yaml +++ /dev/null @@ -1,34 +0,0 @@ -_metadata: - major_version: 1 - minor_version: 1 -display_information: - name: Cola - description: Cola Slack channel plugin -features: - bot_user: - display_name: Cola - always_online: true -oauth_config: - scopes: - bot: - - app_mentions:read - - channels:history - - chat:write - - files:read - - files:write - - groups:history - - im:history - - mpim:history - - reactions:write - - users:read -settings: - event_subscriptions: - bot_events: - - app_mention - - message.channels - - message.groups - - message.im - - message.mpim - org_deploy_enabled: false - socket_mode_enabled: true - token_rotation_enabled: false From c21f8ab745e3c3557aaaf1cb778114d6be717bfb Mon Sep 17 00:00:00 2001 From: Mack Date: Mon, 7 Sep 2026 18:26:59 +0800 Subject: [PATCH 04/10] feat(channel): localize Feishu Slack and Telegram plugins --- .github/workflows/release.yml | 4 + .github/workflows/validate.yml | 3 + README.md | 94 +++++++++++++++++++++ README.zh-CN.md | 81 ++++++++++++++++++ plugins/feishu/locales/en.json | 27 ++++++ plugins/feishu/locales/es.json | 27 ++++++ plugins/feishu/locales/ja.json | 27 ++++++ plugins/feishu/locales/ko.json | 27 ++++++ plugins/feishu/locales/zh-CN.json | 27 ++++++ plugins/feishu/locales/zh-TW.json | 27 ++++++ plugins/feishu/package.json | 17 +++- plugins/feishu/src/auth/login.ts | 33 ++++++-- plugins/feishu/src/commands/feishu.ts | 63 +++++++++++--- plugins/feishu/src/gateway/event-handler.ts | 12 ++- plugins/feishu/src/gateway/monitor.ts | 3 + plugins/feishu/src/index.ts | 50 +++++------ plugins/feishu/tests/event-handler.test.ts | 7 +- plugins/feishu/tests/login.test.ts | 7 +- plugins/slack/locales/en.json | 35 ++++++++ plugins/slack/locales/es.json | 35 ++++++++ plugins/slack/locales/ja.json | 35 ++++++++ plugins/slack/locales/ko.json | 35 ++++++++ plugins/slack/locales/zh-CN.json | 35 ++++++++ plugins/slack/locales/zh-TW.json | 35 ++++++++ plugins/slack/package.json | 17 +++- plugins/slack/src/commands.ts | 91 +++++++++++++------- plugins/slack/src/gateway.ts | 31 +++---- plugins/slack/src/index.ts | 29 +++++-- plugins/slack/src/outbound.ts | 4 +- plugins/slack/tests/gateway.test.ts | 6 +- plugins/telegram/locales/en.json | 34 ++++++++ plugins/telegram/locales/es.json | 34 ++++++++ plugins/telegram/locales/ja.json | 34 ++++++++ plugins/telegram/locales/ko.json | 34 ++++++++ plugins/telegram/locales/zh-CN.json | 34 ++++++++ plugins/telegram/locales/zh-TW.json | 34 ++++++++ plugins/telegram/package.json | 17 +++- plugins/telegram/src/commands.ts | 91 +++++++++++++------- plugins/telegram/src/gateway.ts | 43 +++++----- plugins/telegram/src/index.ts | 23 ++++- plugins/telegram/src/outbound.ts | 3 +- plugins/telegram/tests/gateway.test.ts | 31 +++---- plugins/telegram/tests/i18n.test.ts | 51 +++++++++++ pnpm-lock.yaml | 17 ++-- scripts/build-registry.ts | 87 ++++++++++++++++--- scripts/plugin-translations.test.ts | 80 ++++++++++++++++++ scripts/stage-plugin-locales.ts | 24 ++++++ 47 files changed, 1388 insertions(+), 207 deletions(-) create mode 100644 plugins/feishu/locales/en.json create mode 100644 plugins/feishu/locales/es.json create mode 100644 plugins/feishu/locales/ja.json create mode 100644 plugins/feishu/locales/ko.json create mode 100644 plugins/feishu/locales/zh-CN.json create mode 100644 plugins/feishu/locales/zh-TW.json create mode 100644 plugins/slack/locales/en.json create mode 100644 plugins/slack/locales/es.json create mode 100644 plugins/slack/locales/ja.json create mode 100644 plugins/slack/locales/ko.json create mode 100644 plugins/slack/locales/zh-CN.json create mode 100644 plugins/slack/locales/zh-TW.json create mode 100644 plugins/telegram/locales/en.json create mode 100644 plugins/telegram/locales/es.json create mode 100644 plugins/telegram/locales/ja.json create mode 100644 plugins/telegram/locales/ko.json create mode 100644 plugins/telegram/locales/zh-CN.json create mode 100644 plugins/telegram/locales/zh-TW.json create mode 100644 plugins/telegram/tests/i18n.test.ts create mode 100644 scripts/plugin-translations.test.ts create mode 100644 scripts/stage-plugin-locales.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f575c8b..be7bacf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,9 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Validate plugin translations before publication + run: pnpm build:registry + - name: Validate OSS secrets env: OSS_ENDPOINT: ${{ secrets.ALIYUN_COLAOS_OSS_ENDPOINT }} @@ -118,6 +121,7 @@ jobs: cp "plugins/$plugin/README.md" "$staging/" fi cp -r "plugins/$plugin/dist" "$staging/dist" + pnpm exec tsx scripts/stage-plugin-locales.ts "plugins/$plugin" "$staging" (cd "$staging" && npm install --omit=dev --ignore-scripts) tar -czf "$tarball" -C "$staging" . diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a0ed89d..4878c5d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -29,6 +29,9 @@ jobs: - name: Lint run: pnpm lint + - name: Validate plugin translations + run: pnpm build:registry + - name: Detect changed plugins id: changed run: | diff --git a/README.md b/README.md index 72ca736..cbdfc2c 100644 --- a/README.md +++ b/README.md @@ -177,3 +177,97 @@ secrets, QR login results, account files, and message attachments as sensitive. ## License Licensed under the Apache License, Version 2.0. See `LICENSE`. + +## Plugin localization + +Plugin i18n is available in SDK 0.1.0. Register locale files in `package.json`: + +```json +{ + "cola": { + "plugin": { "id": "example", "entry": "./dist/index.js" }, + "channel": { + "label": "Example", + "description": "Example messaging channel", + "i18n": { + "en": "./locales/en.json", + "zh-CN": "./locales/zh-CN.json" + } + } + } +} +``` + +Each file is a flat JSON object mapping message keys to strings. The reserved +`label` and `description` keys supply the channel name and introduction, including +its store card before installation. Other keys belong to the plugin. Files must +be JSON files inside the package, referenced by relative paths without `..`. + +```json +{ + "label": "示例", + "description": "通过示例渠道与 Cola 对话", + "config.token": "机器人令牌", + "auth.timeout": "登录在 {{seconds}} 秒后超时,请重试。" +} +``` + +Keep the existing string `meta.label` and `meta.description` as defaults. Use +`pluginMessage(key, fallback, params?)` for configuration field labels, +descriptions, placeholders and option labels; gateway status messages; auth +status messages; command descriptions, argument descriptions and replies; and +`unauthorizedHint`. Plain strings continue to work. Keep IDs, command names, +configuration keys and option values stable. + +```ts +import { pluginMessage, PluginLocalizedError } from "@marswave/cola-plugin-sdk"; + +const field = { + key: "botToken", + type: "password" as const, + label: pluginMessage("config.token", "Bot token"), +}; + +throw new PluginLocalizedError( + pluginMessage("auth.timeout", "Login timed out after {{seconds}} seconds. Please retry.", { + seconds: 30, + }), + { cause: originalError }, +); +``` + +Messages serialize as `{ key, fallback, params? }`. Parameters accept strings, +numbers, booleans and nested messages. Use `joinPluginText(parts, separator?)` +to compose dynamic command replies without translating them early. Plugin keys +are isolated; they cannot overwrite Cola or another plugin's translations. + +The desktop resolves text in its current UI language and updates visible text +when the language changes. The server translates command replies and authorization +hints at delivery using Cola's language setting. For a plugin that sends text +directly through its platform client, use `await ctx.runtime.i18n!.text(message)` +at the send site. A host supporting i18n provides this optional runtime capability. + +Each field falls back from the exact UI locale to `en`, then to its original +fallback. Empty translations count as missing. Simplified and traditional Chinese +do not fall back to one another. Partial catalogs are supported. Cola currently +has `en`, `es`, `ja`, `ko`, `zh-CN` and `zh-TW` UI languages. Locale keys are +case-insensitive; use canonical tags in package metadata. + +The UI displays a localized error summary and expandable original details. +`ChannelStatusResult.details` can carry raw status diagnostics separately from `message`. +Unexpected errors get a generic localized summary; logs keep original errors. +Malformed or missing locale files produce runtime diagnostics and use fallback +text rather than preventing the plugin from loading. Publish validation rejects +missing files, invalid JSON, non-string values, escaping paths and mismatched +`{{parameter}}` names across translations. A catalog is limited to 1 MiB. + +`resolvePluginText`, `validatePluginCatalog` and `validatePluginTranslations` are +pure helpers. Node tooling can import `loadPluginTranslations` from +`@marswave/cola-plugin-sdk/i18n-files`; pass `{ strict: true }` for publish +validation, or `{ onWarning }` to retain valid locales on runtime failures. + +Publish SDK 0.1.0 before releasing plugins that depend on it, and set +`cola.plugin.minColaVersion` to the first released Cola version supporting i18n. +In `cola-plugins`, `pnpm build:registry` validates all declared catalogs and embeds +only `label`/`description` translations in the store index. Release packaging +copies every registered locale file; the installed host reads the full catalogs. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1c8ff6c..b371fa6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -157,3 +157,84 @@ plugins/{id}/{id}-{version}.tar.gz ## License 本仓库使用 Apache License, Version 2.0。详见 `LICENSE`。 + +## 插件多语言 + +SDK 0.1.0 新增插件 i18n。在 `package.json` 的 `cola.channel.i18n` 中注册语言文件: + +```json +{ + "cola": { + "plugin": { "id": "example", "entry": "./dist/index.js" }, + "channel": { + "label": "Example", + "description": "Example messaging channel", + "i18n": { + "en": "./locales/en.json", + "zh-CN": "./locales/zh-CN.json" + } + } + } +} +``` + +语言文件是扁平的 JSON 字符串字典。保留键 `label`、`description` 对应渠道名称和简介, +也用于尚未安装的商店卡片;其余 key 由插件定义。文件必须位于插件包内,使用不含 `..` +的相对路径。每个文件最多 1 MiB。 + +```json +{ + "label": "示例", + "description": "通过示例渠道与 Cola 对话", + "config.token": "机器人令牌", + "auth.timeout": "登录在 {{seconds}} 秒后超时,请重试。" +} +``` + +保留 `meta.label`、`meta.description` 原有字符串作为默认文案。 +配置字段的名称、说明、占位提示和选项名称,渠道状态、登录提示、命令说明及参数说明、 +命令回复和 `unauthorizedHint` 均接受 `pluginMessage(key, fallback, params?)`; +旧插件继续传普通字符串。插件 ID、命令名、配置 key 和选项 value 保持稳定。 + +```ts +import { pluginMessage, PluginLocalizedError } from "@marswave/cola-plugin-sdk"; + +const field = { + key: "botToken", + type: "password" as const, + label: pluginMessage("config.token", "Bot token"), +}; + +throw new PluginLocalizedError( + pluginMessage("auth.timeout", "Login timed out after {{seconds}} seconds. Please retry.", { + seconds: 30, + }), + { cause: originalError }, +); +``` + +文案通过 `{ key, fallback, params? }` 跨进程传递。参数支持字符串、数字、布尔值及嵌套文案。 +多行动态命令回复使用 `joinPluginText(parts, separator?)` 组合,保留文案直到展示或发送时 +再翻译。各插件的翻译资源独立,不能覆盖 Cola 或其他插件。 + +桌面按当前界面语言渲染,切换语言时已显示的文案同步更新。服务端在发送命令回复和授权 +提示时使用 Cola 设置的语言。插件直接调用平台 API 发送提示时,在发送处调用 +`await ctx.runtime.i18n!.text(message)`;支持此功能的宿主会提供该可选运行时能力。 + +每个字段依次回退:精确匹配当前语言 → `en` → 原有默认文案。空字符串视为缺失,允许 +只翻译部分字段或语言。简体和繁体不会互相回退。当前界面支持 `en`、`es`、`ja`、`ko`、 +`zh-CN`、`zh-TW`。语言代码匹配忽略大小写,注册时建议使用标准写法。 + +错误界面显示本地化说明,并提供可展开的原始详情。 +`ChannelStatusResult.details` 用于传递独立于 `message` 的原始状态诊断。无法识别的错误使用通用本地化说明, +日志保留原始错误。运行时语言文件缺失或损坏会记录诊断并回退,不会阻止插件加载。 +发布校验会拦截文件缺失、JSON 错误、非字符串值、越界路径以及各语言间不一致的 +`{{parameter}}` 占位参数。 + +`resolvePluginText`、`validatePluginCatalog`、`validatePluginTranslations` 是纯函数。 +Node 工具可从 `@marswave/cola-plugin-sdk/i18n-files` 导入 `loadPluginTranslations`: +发布时传 `{ strict: true }`,运行时传 `{ onWarning }` 以保留其他有效语言。 + +先发布 SDK 0.1.0,再发布依赖它的渠道。`cola.plugin.minColaVersion` 必须设置为首次支持 +此功能的 Cola 正式版本。`cola-plugins` 的 `pnpm build:registry` 校验所有已声明的语言 +文件,只把名称和简介翻译放进商店索引;打包时复制全部注册文件,安装后宿主读取完整字典。 diff --git a/plugins/feishu/locales/en.json b/plugins/feishu/locales/en.json new file mode 100644 index 0000000..2b25523 --- /dev/null +++ b/plugins/feishu/locales/en.json @@ -0,0 +1,27 @@ +{ + "status.connected": "Connected", + "status.disconnected": "Disconnected", + "status.noAccounts": "No accounts configured", + "status.accounts": "Connected accounts: {{count}}", + "state.missing": "Missing", + "command.description": "{{name}} status and configuration", + "command.args": "Subcommand: {{commands}}", + "command.statusTitle": "**{{name}} Status**", + "command.configTitle": "**{{name}} Configuration**", + "command.unknown": "Unknown subcommand: {{subcommand}}. Use {{commands}}.", + "command.accountLine": "- **{{id}}**: App ID={{appId}}, domain={{domain}}, {{status}}", + "auth.user": "Access is not authorized. Ask an administrator to run:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "This group is not authorized. Ask an administrator to run:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "auth.disconnected": "Disconnected from {{name}}.", + "auth.starting": "Creating a {{name}} app…", + "auth.scanExpiry": "Scan with {{name}} within {{seconds}} seconds.", + "auth.created": "App created and credentials saved.", + "auth.disconnecting": "Disconnecting {{name}}…", + "error.login": "Could not sign in to {{name}}. Please try again.", + "config.appId": "App ID", + "config.appSecret": "App Secret", + "config.domain": "Domain", + "label": "Feishu", + "description": "Chat with Cola in Feishu", + "channel.groupDisabled": "Group chat is not enabled. Please message the bot directly." +} diff --git a/plugins/feishu/locales/es.json b/plugins/feishu/locales/es.json new file mode 100644 index 0000000..79a7054 --- /dev/null +++ b/plugins/feishu/locales/es.json @@ -0,0 +1,27 @@ +{ + "status.connected": "Conectado", + "status.disconnected": "Desconectado", + "status.noAccounts": "No hay cuentas configuradas", + "status.accounts": "Cuentas conectadas: {{count}}", + "state.missing": "Falta", + "command.description": "Estado y configuración de {{name}}", + "command.args": "Subcomando: {{commands}}", + "command.statusTitle": "**Estado de {{name}}**", + "command.configTitle": "**Configuración de {{name}}**", + "command.unknown": "Subcomando desconocido: {{subcommand}}. Usa {{commands}}.", + "command.accountLine": "- **{{id}}**: ID de aplicación={{appId}}, dominio={{domain}}, {{status}}", + "auth.user": "Acceso no autorizado. Pide al administrador que ejecute:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "Este grupo no está autorizado. Pide al administrador que ejecute:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "auth.disconnected": "Desconectado de {{name}}.", + "auth.starting": "Creando una aplicación de {{name}}…", + "auth.scanExpiry": "Escanea con {{name}} en {{seconds}} segundos.", + "auth.created": "Aplicación creada y credenciales guardadas.", + "auth.disconnecting": "Desconectando {{name}}…", + "error.login": "No se pudo iniciar sesión en {{name}}. Inténtalo de nuevo.", + "config.appId": "ID de la aplicación", + "config.appSecret": "Secreto de la aplicación", + "config.domain": "Dominio", + "label": "Feishu", + "description": "Chatea con Cola en Feishu", + "channel.groupDisabled": "El chat de grupo no está habilitado. Envía un mensaje directo al bot." +} diff --git a/plugins/feishu/locales/ja.json b/plugins/feishu/locales/ja.json new file mode 100644 index 0000000..4d06cc6 --- /dev/null +++ b/plugins/feishu/locales/ja.json @@ -0,0 +1,27 @@ +{ + "status.connected": "接続済み", + "status.disconnected": "未接続", + "status.noAccounts": "アカウントが未設定です", + "status.accounts": "接続中のアカウント数:{{count}}", + "state.missing": "未設定", + "command.description": "{{name}} の状態と設定", + "command.args": "サブコマンド:{{commands}}", + "command.statusTitle": "**{{name}} の状態**", + "command.configTitle": "**{{name}} の設定**", + "command.unknown": "不明なサブコマンド:{{subcommand}}。{{commands}} を使用してください。", + "command.accountLine": "- **{{id}}**:アプリ ID={{appId}}、ドメイン={{domain}}、{{status}}", + "auth.user": "利用が許可されていません。管理者に次の実行を依頼してください:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "このグループは許可されていません。管理者に次の実行を依頼してください:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "auth.disconnected": "{{name}} から切断しました。", + "auth.starting": "{{name}} アプリを作成中…", + "auth.scanExpiry": "{{seconds}} 秒以内に {{name}} で読み取ってください。", + "auth.created": "アプリを作成し、認証情報を保存しました。", + "auth.disconnecting": "{{name}} を切断中…", + "error.login": "{{name}} にログインできませんでした。もう一度お試しください。", + "config.appId": "アプリ ID", + "config.appSecret": "アプリシークレット", + "config.domain": "ドメイン", + "label": "Feishu", + "description": "Feishu で Cola と会話", + "channel.groupDisabled": "グループチャットは無効です。ボットに直接メッセージを送信してください。" +} diff --git a/plugins/feishu/locales/ko.json b/plugins/feishu/locales/ko.json new file mode 100644 index 0000000..ab8ccd8 --- /dev/null +++ b/plugins/feishu/locales/ko.json @@ -0,0 +1,27 @@ +{ + "status.connected": "연결됨", + "status.disconnected": "연결 끊김", + "status.noAccounts": "설정된 계정이 없습니다", + "status.accounts": "연결된 계정: {{count}}", + "state.missing": "없음", + "command.description": "{{name}} 상태 및 설정", + "command.args": "하위 명령: {{commands}}", + "command.statusTitle": "**{{name}} 상태**", + "command.configTitle": "**{{name}} 설정**", + "command.unknown": "알 수 없는 하위 명령: {{subcommand}}. {{commands}}을(를) 사용하세요.", + "command.accountLine": "- **{{id}}**: 앱 ID={{appId}}, 도메인={{domain}}, {{status}}", + "auth.user": "접근 권한이 없습니다. 관리자에게 다음 명령 실행을 요청하세요:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "이 그룹은 권한이 없습니다. 관리자에게 다음 명령 실행을 요청하세요:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "auth.disconnected": "{{name}} 연결이 해제되었습니다.", + "auth.starting": "{{name}} 앱 생성 중…", + "auth.scanExpiry": "{{seconds}}초 이내에 {{name}}에서 스캔하세요.", + "auth.created": "앱이 생성되고 인증 정보가 저장되었습니다.", + "auth.disconnecting": "{{name}} 연결 해제 중…", + "error.login": "{{name}}에 로그인하지 못했습니다. 다시 시도하세요.", + "config.appId": "앱 ID", + "config.appSecret": "앱 시크릿", + "config.domain": "도메인", + "label": "Feishu", + "description": "Feishu에서 Cola와 대화하세요", + "channel.groupDisabled": "그룹 채팅이 활성화되지 않았습니다. 봇에게 직접 메시지를 보내세요." +} diff --git a/plugins/feishu/locales/zh-CN.json b/plugins/feishu/locales/zh-CN.json new file mode 100644 index 0000000..7aa1331 --- /dev/null +++ b/plugins/feishu/locales/zh-CN.json @@ -0,0 +1,27 @@ +{ + "status.connected": "已连接", + "status.disconnected": "未连接", + "status.noAccounts": "未配置账号", + "status.accounts": "已连接账号数:{{count}}", + "state.missing": "缺失", + "command.description": "{{name}} 状态和配置", + "command.args": "子命令:{{commands}}", + "command.statusTitle": "**{{name}} 状态**", + "command.configTitle": "**{{name}} 配置**", + "command.unknown": "未知子命令:{{subcommand}}。请使用 {{commands}}。", + "command.accountLine": "- **{{id}}**:应用 ID={{appId}},域名={{domain}},{{status}}", + "auth.user": "尚未授权使用 Cola。请管理员执行:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "此群尚未授权使用 Cola。请管理员执行:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "auth.disconnected": "已断开 {{name}}。", + "auth.starting": "正在创建 {{name}} 应用…", + "auth.scanExpiry": "请使用 {{name}} 扫码,{{seconds}} 秒内有效。", + "auth.created": "应用创建成功,凭据已保存。", + "auth.disconnecting": "正在断开 {{name}}…", + "error.login": "无法登录 {{name}},请重试。", + "config.appId": "应用 ID", + "config.appSecret": "应用密钥", + "config.domain": "域名", + "label": "飞书", + "description": "在飞书中与 Cola 对话", + "channel.groupDisabled": "尚未启用群聊,请私信机器人。" +} diff --git a/plugins/feishu/locales/zh-TW.json b/plugins/feishu/locales/zh-TW.json new file mode 100644 index 0000000..d2b6a5a --- /dev/null +++ b/plugins/feishu/locales/zh-TW.json @@ -0,0 +1,27 @@ +{ + "status.connected": "已連線", + "status.disconnected": "未連線", + "status.noAccounts": "未設定帳號", + "status.accounts": "已連線帳號數:{{count}}", + "state.missing": "缺少", + "command.description": "{{name}} 狀態與設定", + "command.args": "子命令:{{commands}}", + "command.statusTitle": "**{{name}} 狀態**", + "command.configTitle": "**{{name}} 設定**", + "command.unknown": "未知子命令:{{subcommand}}。請使用 {{commands}}。", + "command.accountLine": "- **{{id}}**:應用程式 ID={{appId}},網域={{domain}},{{status}}", + "auth.user": "尚未授權使用 Cola。請管理員執行:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "此群尚未授權使用 Cola。請管理員執行:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "auth.disconnected": "已中斷 {{name}} 連線。", + "auth.starting": "正在建立 {{name}} 應用程式…", + "auth.scanExpiry": "請使用 {{name}} 掃碼,{{seconds}} 秒內有效。", + "auth.created": "應用程式建立成功,憑證已儲存。", + "auth.disconnecting": "正在中斷 {{name}} 連線…", + "error.login": "無法登入 {{name}},請重試。", + "config.appId": "應用程式 ID", + "config.appSecret": "應用程式密鑰", + "config.domain": "網域", + "label": "飛書", + "description": "在飛書中與 Cola 對話", + "channel.groupDisabled": "尚未啟用群聊,請私訊機器人。" +} diff --git a/plugins/feishu/package.json b/plugins/feishu/package.json index 8fbe5f1..0be89f3 100644 --- a/plugins/feishu/package.json +++ b/plugins/feishu/package.json @@ -1,6 +1,6 @@ { "name": "cola-plugin-feishu", - "version": "0.1.3", + "version": "0.2.0", "description": "Feishu/Lark channel plugin for Cola", "license": "Apache-2.0", "type": "module", @@ -13,7 +13,7 @@ }, "dependencies": { "@larksuiteoapi/node-sdk": "^1.61.1", - "@marswave/cola-plugin-sdk": "0.0.3" + "@marswave/cola-plugin-sdk": "0.1.0" }, "devDependencies": { "@types/node": "^22.0.0", @@ -26,7 +26,8 @@ "cola": { "plugin": { "id": "feishu", - "entry": "./dist/index.js" + "entry": "./dist/index.js", + "minColaVersion": "1.99.0" }, "channel": { "label": "Feishu", @@ -35,7 +36,15 @@ "aliases": [ "lark" ], - "docsPath": "https://github.com/marswaveai/cola-plugins/blob/main/plugins/feishu/README.md" + "docsPath": "https://github.com/marswaveai/cola-plugins/blob/main/plugins/feishu/README.md", + "i18n": { + "en": "./locales/en.json", + "es": "./locales/es.json", + "ja": "./locales/ja.json", + "ko": "./locales/ko.json", + "zh-CN": "./locales/zh-CN.json", + "zh-TW": "./locales/zh-TW.json" + } } } } diff --git a/plugins/feishu/src/auth/login.ts b/plugins/feishu/src/auth/login.ts index 2d3e88b..7dcf673 100644 --- a/plugins/feishu/src/auth/login.ts +++ b/plugins/feishu/src/auth/login.ts @@ -1,3 +1,4 @@ +import { pluginMessage as m, PluginLocalizedError } from "@marswave/cola-plugin-sdk"; import { registerApp } from "@larksuiteoapi/node-sdk"; import type { AuthContext, ChannelAuthAdapter } from "@marswave/cola-plugin-sdk"; @@ -11,16 +12,32 @@ import type { AuthContext, ChannelAuthAdapter } from "@marswave/cola-plugin-sdk" export function createFeishuAuth(): ChannelAuthAdapter { return { async login(ctx: AuthContext) { - ctx.onStatus?.("starting", "正在发起飞书一键创建应用…"); + ctx.onStatus?.( + "starting", + m("auth.starting", "Creating a {{name}} app\u2026", { name: m("label", "Feishu") }), + ); const result = await registerApp({ onQRCodeReady(info) { ctx.onQrCode?.(info.url, info.url); - ctx.onStatus?.("qr_ready", `请使用飞书扫码(${info.expireIn}s 内有效)`); + ctx.onStatus?.( + "qr_ready", + m("auth.scanExpiry", "Scan with {{name}} within {{seconds}} seconds.", { + name: m("label", "Feishu"), + seconds: info.expireIn, + }), + ); }, onStatusChange(info) { ctx.onStatus?.(info.status); }, + }).catch((cause: unknown) => { + throw new PluginLocalizedError( + m("error.login", "Could not sign in to {{name}}. Please try again.", { + name: m("label", "Feishu"), + }), + { cause }, + ); }); // config.patch is a top-level shallow merge, so merge `accounts` ourselves @@ -46,11 +63,14 @@ export function createFeishuAuth(): ChannelAuthAdapter { await ctx.runtime.identity.bind(result.user_info.open_id); } - ctx.onStatus?.("success", "应用创建成功,凭据已写入"); + ctx.onStatus?.("success", m("auth.created", "App created and credentials saved.")); }, async disconnect(ctx: AuthContext) { - ctx.onStatus?.("disconnecting", "正在断开飞书连接…"); + ctx.onStatus?.( + "disconnecting", + m("auth.disconnecting", "Disconnecting {{name}}\u2026", { name: m("label", "Feishu") }), + ); // Clear stored credentials so the channel returns to an unconfigured state // and can be re-authorized via scan login. `config.patch` is a top-level @@ -58,7 +78,10 @@ export function createFeishuAuth(): ChannelAuthAdapter { // Identity authorizations (cola channel allow/revoke) are left intact. await ctx.runtime.config.patch({ accounts: {} }); - ctx.onStatus?.("disconnected", "已断开,凭据已清空"); + ctx.onStatus?.( + "disconnected", + m("auth.disconnected", "Disconnected from {{name}}.", { name: m("label", "Feishu") }), + ); }, }; } diff --git a/plugins/feishu/src/commands/feishu.ts b/plugins/feishu/src/commands/feishu.ts index 3f40e4f..94651d7 100644 --- a/plugins/feishu/src/commands/feishu.ts +++ b/plugins/feishu/src/commands/feishu.ts @@ -1,4 +1,5 @@ -import type { PluginCommandDefinition } from "@marswave/cola-plugin-sdk"; +import { pluginMessage as m, joinPluginText as join } from "@marswave/cola-plugin-sdk"; +import type { PluginText, PluginCommandDefinition } from "@marswave/cola-plugin-sdk"; import type { MonitorHandle } from "../gateway/monitor.js"; import { redactSecret } from "../util/redact.js"; @@ -9,39 +10,73 @@ export function createFeishuCommands( { name: "feishu", aliases: ["fs", "lark"], - description: "Feishu plugin status and account info", - args: [{ name: "subcommand", description: '"status" or "accounts"', required: false }], + description: m("command.description", "{{name}} status and configuration", { + name: m("label", "Feishu"), + }), + args: [ + { + name: "subcommand", + description: m("command.args", "Subcommand: {{commands}}", { + commands: "status | accounts", + }), + required: false, + }, + ], async execute(ctx) { const sub = ctx.args.trim() || "status"; const monitors = getMonitors(); if (sub === "status") { if (monitors.size === 0) { - return { reply: "No Feishu accounts active." }; + return { reply: m("status.noAccounts", "No accounts configured") }; } - const lines = ["**Feishu Status**", ""]; + const lines: PluginText[] = [ + m("command.statusTitle", "**{{name}} Status**", { name: m("label", "Feishu") }), + "", + ]; for (const [id] of monitors) { - lines.push(`- **${id}**: connected (client ready)`); + lines.push( + m("", "- **{{id}}**: {{status}}", { id, status: m("status.connected", "Connected") }), + ); } - return { reply: lines.join("\n") }; + return { reply: join(lines) }; } if (sub === "accounts") { const accounts = (ctx.config.accounts ?? {}) as Record>; if (Object.keys(accounts).length === 0) { - return { reply: "No Feishu accounts configured." }; + return { reply: m("status.noAccounts", "No accounts configured") }; } - const lines = ["**Feishu Accounts**", ""]; + const lines: PluginText[] = [ + m("command.configTitle", "**{{name}} Configuration**", { name: m("label", "Feishu") }), + "", + ]; for (const [id, acct] of Object.entries(accounts)) { - const appId = typeof acct.appId === "string" ? redactSecret(acct.appId) : "(missing)"; + const appId = + typeof acct.appId === "string" + ? redactSecret(acct.appId) + : m("state.missing", "Missing"); const domain = (acct.domain as string) ?? "feishu"; - const active = monitors.has(id) ? "active" : "inactive"; - lines.push(`- **${id}**: appId=${appId}, domain=${domain}, ${active}`); + const active = monitors.has(id) + ? m("status.connected", "Connected") + : m("status.disconnected", "Disconnected"); + lines.push( + m( + "command.accountLine", + "- **{{id}}**: App ID={{appId}}, domain={{domain}}, {{status}}", + { id, appId, domain, status: active }, + ), + ); } - return { reply: lines.join("\n") }; + return { reply: join(lines) }; } - return { reply: `Unknown subcommand: ${sub}. Use "status" or "accounts".` }; + return { + reply: m("command.unknown", "Unknown subcommand: {{subcommand}}. Use {{commands}}.", { + subcommand: sub, + commands: "status | accounts", + }), + }; }, }, ]; diff --git a/plugins/feishu/src/gateway/event-handler.ts b/plugins/feishu/src/gateway/event-handler.ts index f686a99..b91d5da 100644 --- a/plugins/feishu/src/gateway/event-handler.ts +++ b/plugins/feishu/src/gateway/event-handler.ts @@ -1,3 +1,5 @@ +import { pluginMessage, resolvePluginText } from "@marswave/cola-plugin-sdk"; +import type { PluginRuntime } from "@marswave/cola-plugin-sdk"; import type * as lark from "@larksuiteoapi/node-sdk"; import type { DeliverFn, PluginLogger } from "@marswave/cola-plugin-sdk"; import { parseMessage } from "./message-parser.js"; @@ -12,9 +14,13 @@ import { import { sendText } from "../outbound/send.js"; /** Reply sent to a group @mention while group chat is disabled. */ -const GROUP_DISABLED_NOTICE = "暂不支持群聊"; +const GROUP_DISABLED_NOTICE = pluginMessage( + "channel.groupDisabled", + "Group chat is not enabled. Please message the bot directly.", +); export type EventHandlerDeps = { + i18n?: PluginRuntime["i18n"]; client: lark.Client; accountId: string; logger: PluginLogger; @@ -106,7 +112,9 @@ export function registerMessageHandler( await sendText( client, `chat:${message.chat_id}`, - GROUP_DISABLED_NOTICE, + deps.i18n + ? await deps.i18n.text(GROUP_DISABLED_NOTICE) + : resolvePluginText(GROUP_DISABLED_NOTICE, undefined, "en"), chatMap, logger, ); diff --git a/plugins/feishu/src/gateway/monitor.ts b/plugins/feishu/src/gateway/monitor.ts index 8eda58c..d18484b 100644 --- a/plugins/feishu/src/gateway/monitor.ts +++ b/plugins/feishu/src/gateway/monitor.ts @@ -1,3 +1,4 @@ +import type { PluginRuntime } from "@marswave/cola-plugin-sdk"; import type * as lark from "@larksuiteoapi/node-sdk"; import type { PluginLogger, DeliverFn } from "@marswave/cola-plugin-sdk"; import type { FeishuAccountConfig } from "../api/types.js"; @@ -20,6 +21,7 @@ export type MonitorHandle = { * Authorization is handled by the host SDK access gate, not here. */ export async function startMonitor(opts: { + i18n?: PluginRuntime["i18n"]; accountId: string; config: FeishuAccountConfig; deliver: DeliverFn; @@ -41,6 +43,7 @@ export async function startMonitor(opts: { const deps = { client, + i18n: opts.i18n, accountId, logger, deliver, diff --git a/plugins/feishu/src/index.ts b/plugins/feishu/src/index.ts index c0f421c..e6634db 100644 --- a/plugins/feishu/src/index.ts +++ b/plugins/feishu/src/index.ts @@ -1,3 +1,4 @@ +import { pluginMessage as m } from "@marswave/cola-plugin-sdk"; import { defineChannel } from "@marswave/cola-plugin-sdk"; import type { GatewayContext, @@ -52,6 +53,19 @@ export default defineChannel({ markdownCapable: true, }, + unauthorizedHint(target) { + return target.kind === "group" + ? m( + "auth.group", + "This group is not authorized. Ask an administrator to run:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + { plugin: "feishu", id: target.id }, + ) + : m( + "auth.user", + "Access is not authorized. Ask an administrator to run:\n```\ncola channel allow {{plugin}} {{id}}\n```", + { plugin: "feishu", id: target.id }, + ); + }, capabilities: { receive: { text: true, @@ -77,7 +91,7 @@ export default defineChannel({ { key: "appId", path: ["accounts", "default", "appId"], - label: "App ID", + label: m("config.appId", "App ID"), type: "text", required: true, placeholder: "cli_xxx", @@ -85,7 +99,7 @@ export default defineChannel({ { key: "appSecret", path: ["accounts", "default", "appSecret"], - label: "App Secret", + label: m("config.appSecret", "App Secret"), type: "password", required: true, secret: true, @@ -93,11 +107,11 @@ export default defineChannel({ { key: "domain", path: ["accounts", "default", "domain"], - label: "Domain", + label: m("config.domain", "Domain"), type: "select", defaultValue: "feishu", options: [ - { label: "Feishu", value: "feishu" }, + { label: m("label", "Feishu"), value: "feishu" }, { label: "Lark", value: "lark" }, ], }, @@ -108,25 +122,6 @@ export default defineChannel({ }, }, - unauthorizedHint(target) { - if (target.kind === "group") { - return [ - "这个群还没有被授信,无法使用 Cola。", - "请管理员执行:", - "```", - `cola channel allow-group feishu ${target.id}`, - "```", - ].join("\n"); - } - return [ - "你还没有被授信,无法使用 Cola。", - "请管理员执行:", - "```", - `cola channel allow feishu ${target.id}`, - "```", - ].join("\n"); - }, - auth: createFeishuAuth(), commands: createFeishuCommands(() => activeMonitors), @@ -161,6 +156,7 @@ export default defineChannel({ for (const [accountId, acctConfig] of accounts) { try { const handle = await startMonitor({ + i18n: ctx.runtime.i18n, accountId, config: acctConfig, deliver: ctx.deliver, @@ -201,12 +197,16 @@ export default defineChannel({ getStatus(ctx: GatewayContext): ChannelStatusResult { const monitors = ctx.state.monitors; if (!monitors || monitors.size === 0) { - return { connected: false, configured: false, message: "No accounts configured" }; + return { + connected: false, + configured: false, + message: m("status.noAccounts", "No accounts configured"), + }; } return { connected: true, configured: true, - message: `${monitors.size} account(s) connected`, + message: m("status.accounts", "Connected accounts: {{count}}", { count: monitors.size }), }; }, }, diff --git a/plugins/feishu/tests/event-handler.test.ts b/plugins/feishu/tests/event-handler.test.ts index 378e8c0..f34d33a 100644 --- a/plugins/feishu/tests/event-handler.test.ts +++ b/plugins/feishu/tests/event-handler.test.ts @@ -1,3 +1,5 @@ +import { resolvePluginText } from "@marswave/cola-plugin-sdk"; +import zhCN from "../locales/zh-CN.json"; import type * as lark from "@larksuiteoapi/node-sdk"; import type { DeliverFn, PluginLogger } from "@marswave/cola-plugin-sdk"; import { describe, expect, it, vi } from "vitest"; @@ -79,6 +81,7 @@ function register( registerMessageHandler(dispatcher, { client, accountId: "default", + i18n: { text: async (text) => resolvePluginText(text, { "zh-CN": zhCN }, "zh-CN") }, logger, deliver, dedup: new MessageDedup(), @@ -260,7 +263,7 @@ describe("Feishu message delivery (SDK access gate)", () => { }); describe("Feishu group chat disabled (groupEnabled=false)", () => { - it("replies '暂不支持群聊' to a group @mention and does not deliver", async () => { + it("replies in the configured language to a group @mention and does not deliver", async () => { const { handler, deliver, create } = register({ botOpenId: "ou_bot", groupEnabled: false }); await handler(groupMessage("ou_alice", [botMention("ou_bot")])); @@ -273,7 +276,7 @@ describe("Feishu group chat disabled (groupEnabled=false)", () => { }; expect(arg.params.receive_id_type).toBe("chat_id"); expect(arg.data.receive_id).toBe("group-chat1"); - expect(arg.data.content).toContain("暂不支持群聊"); + expect(arg.data.content).toContain("尚未启用群聊,请私信机器人。"); }); it("ignores a group message without an @bot mention", async () => { diff --git a/plugins/feishu/tests/login.test.ts b/plugins/feishu/tests/login.test.ts index 6a5f4ea..e402a44 100644 --- a/plugins/feishu/tests/login.test.ts +++ b/plugins/feishu/tests/login.test.ts @@ -1,3 +1,5 @@ +import { resolvePluginText } from "@marswave/cola-plugin-sdk"; +import zhCN from "../locales/zh-CN.json"; import type { AuthContext, PluginLogger, PluginRuntime } from "@marswave/cola-plugin-sdk"; import { describe, expect, it, vi } from "vitest"; @@ -51,7 +53,10 @@ describe("createFeishuAuth().login (one-click app creation)", () => { expect(onQrCode).toHaveBeenCalledWith("https://feishu/qr", "https://feishu/qr"); expect(onStatus).toHaveBeenCalledWith("polling"); - expect(onStatus).toHaveBeenCalledWith("success", expect.any(String)); + const success = onStatus.mock.calls.find(([status]) => status === "success"); + expect(resolvePluginText(success?.[1], { "zh-CN": zhCN }, "zh-CN")).toBe( + "应用创建成功,凭据已保存。", + ); expect(patch).toHaveBeenCalledTimes(1); expect(patch).toHaveBeenCalledWith({ diff --git a/plugins/slack/locales/en.json b/plugins/slack/locales/en.json new file mode 100644 index 0000000..a0c7128 --- /dev/null +++ b/plugins/slack/locales/en.json @@ -0,0 +1,35 @@ +{ + "status.connected": "Connected", + "status.disconnected": "Disconnected", + "status.gateway": "{{mode}} · {{bot}} · Allowed: {{count}}", + "state.never": "Never", + "state.missing": "Missing", + "state.yes": "Yes", + "state.no": "No", + "command.description": "{{name}} status and configuration", + "command.args": "Subcommand: {{commands}}", + "command.statusTitle": "**{{name}} Status**", + "command.configTitle": "**{{name}} Configuration**", + "command.statusLine": "- Status: {{status}}", + "command.botLine": "- Bot: {{bot}}", + "command.teamLine": "- Team: {{team}}", + "command.eventLine": "- Last activity: {{time}}", + "command.errorLine": "- Error details: {{error}}", + "command.tokenLine": "- Bot token: {{token}}", + "command.appTokenLine": "- App token: {{token}}", + "command.allowedLine": "- Allowed IDs: {{count}}", + "command.ignoreLine": "- Ignore bot messages: {{value}}", + "command.unknown": "Unknown subcommand: {{subcommand}}. Use {{commands}}.", + "auth.user": "Access is not authorized. Ask an administrator to run:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "This group is not authorized. Ask an administrator to run:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "Bot token is not configured.", + "config.botToken": "Bot token", + "config.appToken": "App-level token", + "config.appTokenHelp": "App-level token with the connections:write scope (Socket Mode).", + "config.allowedIds": "Allowed IDs", + "config.slackHelp": "Comma-separated Slack user IDs (DMs) and channel IDs accepted by the plugin.", + "config.requiredSlack": "Bot token, app token, and allowed IDs are required.", + "label": "Slack", + "description": "Chat with Cola in Slack", + "slack.access": "Slack access is not configured.\nUser ID: {{user}}\nChat ID: {{chat}}" +} diff --git a/plugins/slack/locales/es.json b/plugins/slack/locales/es.json new file mode 100644 index 0000000..f140e2b --- /dev/null +++ b/plugins/slack/locales/es.json @@ -0,0 +1,35 @@ +{ + "status.connected": "Conectado", + "status.disconnected": "Desconectado", + "status.gateway": "{{mode}} · {{bot}} · Permitidos: {{count}}", + "state.never": "Nunca", + "state.missing": "Falta", + "state.yes": "Sí", + "state.no": "No", + "command.description": "Estado y configuración de {{name}}", + "command.args": "Subcomando: {{commands}}", + "command.statusTitle": "**Estado de {{name}}**", + "command.configTitle": "**Configuración de {{name}}**", + "command.statusLine": "- Estado: {{status}}", + "command.botLine": "- Bot: {{bot}}", + "command.teamLine": "- Equipo: {{team}}", + "command.eventLine": "- Última actividad: {{time}}", + "command.errorLine": "- Detalles del error: {{error}}", + "command.tokenLine": "- Token del bot: {{token}}", + "command.appTokenLine": "- Token de la aplicación: {{token}}", + "command.allowedLine": "- ID permitidos: {{count}}", + "command.ignoreLine": "- Ignorar mensajes de bots: {{value}}", + "command.unknown": "Subcomando desconocido: {{subcommand}}. Usa {{commands}}.", + "auth.user": "Acceso no autorizado. Pide al administrador que ejecute:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "Este grupo no está autorizado. Pide al administrador que ejecute:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "El token del bot no está configurado.", + "config.botToken": "Token del bot", + "config.appToken": "Token de la aplicación", + "config.appTokenHelp": "Token de aplicación con el permiso connections:write (Socket Mode).", + "config.allowedIds": "ID permitidos", + "config.slackHelp": "ID de usuarios (mensajes directos) y canales de Slack permitidos, separados por comas.", + "config.requiredSlack": "Se requieren los tokens del bot y de la aplicación, y los ID permitidos.", + "label": "Slack", + "description": "Chatea con Cola en Slack", + "slack.access": "El acceso a Slack no está configurado.\nID de usuario: {{user}}\nID de chat: {{chat}}" +} diff --git a/plugins/slack/locales/ja.json b/plugins/slack/locales/ja.json new file mode 100644 index 0000000..9f07655 --- /dev/null +++ b/plugins/slack/locales/ja.json @@ -0,0 +1,35 @@ +{ + "status.connected": "接続済み", + "status.disconnected": "未接続", + "status.gateway": "{{mode}} · {{bot}} · 許可数:{{count}}", + "state.never": "なし", + "state.missing": "未設定", + "state.yes": "はい", + "state.no": "いいえ", + "command.description": "{{name}} の状態と設定", + "command.args": "サブコマンド:{{commands}}", + "command.statusTitle": "**{{name}} の状態**", + "command.configTitle": "**{{name}} の設定**", + "command.statusLine": "- 状態:{{status}}", + "command.botLine": "- ボット:{{bot}}", + "command.teamLine": "- チーム:{{team}}", + "command.eventLine": "- 最終アクティビティ:{{time}}", + "command.errorLine": "- エラーの詳細:{{error}}", + "command.tokenLine": "- ボットトークン:{{token}}", + "command.appTokenLine": "- アプリトークン:{{token}}", + "command.allowedLine": "- 許可 ID 数:{{count}}", + "command.ignoreLine": "- ボットのメッセージを無視:{{value}}", + "command.unknown": "不明なサブコマンド:{{subcommand}}。{{commands}} を使用してください。", + "auth.user": "利用が許可されていません。管理者に次の実行を依頼してください:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "このグループは許可されていません。管理者に次の実行を依頼してください:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "ボットトークンが未設定です。", + "config.botToken": "ボットトークン", + "config.appToken": "アプリレベルトークン", + "config.appTokenHelp": "connections:write 権限を持つアプリレベルトークン(Socket Mode)。", + "config.allowedIds": "許可する ID", + "config.slackHelp": "許可する Slack ユーザー ID(DM)とチャンネル ID をカンマで区切って入力してください。", + "config.requiredSlack": "ボットトークン、アプリトークン、許可する ID が必要です。", + "label": "Slack", + "description": "Slack で Cola と会話", + "slack.access": "Slack の利用が未設定です。\nユーザー ID:{{user}}\nチャット ID:{{chat}}" +} diff --git a/plugins/slack/locales/ko.json b/plugins/slack/locales/ko.json new file mode 100644 index 0000000..3cd9802 --- /dev/null +++ b/plugins/slack/locales/ko.json @@ -0,0 +1,35 @@ +{ + "status.connected": "연결됨", + "status.disconnected": "연결 끊김", + "status.gateway": "{{mode}} · {{bot}} · 허용: {{count}}", + "state.never": "없음", + "state.missing": "없음", + "state.yes": "예", + "state.no": "아니요", + "command.description": "{{name}} 상태 및 설정", + "command.args": "하위 명령: {{commands}}", + "command.statusTitle": "**{{name}} 상태**", + "command.configTitle": "**{{name}} 설정**", + "command.statusLine": "- 상태: {{status}}", + "command.botLine": "- 봇: {{bot}}", + "command.teamLine": "- 팀: {{team}}", + "command.eventLine": "- 마지막 활동: {{time}}", + "command.errorLine": "- 오류 세부 정보: {{error}}", + "command.tokenLine": "- 봇 토큰: {{token}}", + "command.appTokenLine": "- 앱 토큰: {{token}}", + "command.allowedLine": "- 허용된 ID: {{count}}", + "command.ignoreLine": "- 봇 메시지 무시: {{value}}", + "command.unknown": "알 수 없는 하위 명령: {{subcommand}}. {{commands}}을(를) 사용하세요.", + "auth.user": "접근 권한이 없습니다. 관리자에게 다음 명령 실행을 요청하세요:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "이 그룹은 권한이 없습니다. 관리자에게 다음 명령 실행을 요청하세요:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "봇 토큰이 설정되지 않았습니다.", + "config.botToken": "봇 토큰", + "config.appToken": "앱 수준 토큰", + "config.appTokenHelp": "connections:write 권한이 있는 앱 수준 토큰 (Socket Mode).", + "config.allowedIds": "허용된 ID", + "config.slackHelp": "허용할 Slack 사용자 ID (DM)와 채널 ID를 쉼표로 구분하여 입력하세요.", + "config.requiredSlack": "봇 토큰, 앱 토큰 및 허용된 ID가 필요합니다.", + "label": "Slack", + "description": "Slack에서 Cola와 대화하세요", + "slack.access": "Slack 접근 권한이 설정되지 않았습니다.\n사용자 ID: {{user}}\n채팅 ID: {{chat}}" +} diff --git a/plugins/slack/locales/zh-CN.json b/plugins/slack/locales/zh-CN.json new file mode 100644 index 0000000..43c3f39 --- /dev/null +++ b/plugins/slack/locales/zh-CN.json @@ -0,0 +1,35 @@ +{ + "status.connected": "已连接", + "status.disconnected": "未连接", + "status.gateway": "{{mode}} · {{bot}} · 已允许:{{count}}", + "state.never": "从未", + "state.missing": "缺失", + "state.yes": "是", + "state.no": "否", + "command.description": "{{name}} 状态和配置", + "command.args": "子命令:{{commands}}", + "command.statusTitle": "**{{name}} 状态**", + "command.configTitle": "**{{name}} 配置**", + "command.statusLine": "- 状态:{{status}}", + "command.botLine": "- 机器人:{{bot}}", + "command.teamLine": "- 团队:{{team}}", + "command.eventLine": "- 最近活动:{{time}}", + "command.errorLine": "- 错误详情:{{error}}", + "command.tokenLine": "- 机器人令牌:{{token}}", + "command.appTokenLine": "- 应用令牌:{{token}}", + "command.allowedLine": "- 已允许的 ID:{{count}}", + "command.ignoreLine": "- 忽略机器人消息:{{value}}", + "command.unknown": "未知子命令:{{subcommand}}。请使用 {{commands}}。", + "auth.user": "尚未授权使用 Cola。请管理员执行:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "此群尚未授权使用 Cola。请管理员执行:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "尚未配置机器人令牌。", + "config.botToken": "机器人令牌", + "config.appToken": "应用级令牌", + "config.appTokenHelp": "具有 connections:write 权限的应用级令牌(Socket Mode)。", + "config.allowedIds": "允许的 ID", + "config.slackHelp": "插件接受的 Slack 用户 ID(私信)和频道 ID,以英文逗号分隔。", + "config.requiredSlack": "请填写机器人令牌、应用令牌和允许的 ID。", + "label": "Slack", + "description": "在Slack中与 Cola 对话", + "slack.access": "尚未配置 Slack 访问权限。\n用户 ID:{{user}}\n聊天 ID:{{chat}}" +} diff --git a/plugins/slack/locales/zh-TW.json b/plugins/slack/locales/zh-TW.json new file mode 100644 index 0000000..5a3e364 --- /dev/null +++ b/plugins/slack/locales/zh-TW.json @@ -0,0 +1,35 @@ +{ + "status.connected": "已連線", + "status.disconnected": "未連線", + "status.gateway": "{{mode}} · {{bot}} · 已允許:{{count}}", + "state.never": "從未", + "state.missing": "缺少", + "state.yes": "是", + "state.no": "否", + "command.description": "{{name}} 狀態與設定", + "command.args": "子命令:{{commands}}", + "command.statusTitle": "**{{name}} 狀態**", + "command.configTitle": "**{{name}} 設定**", + "command.statusLine": "- 狀態:{{status}}", + "command.botLine": "- 機器人:{{bot}}", + "command.teamLine": "- 團隊:{{team}}", + "command.eventLine": "- 最近活動:{{time}}", + "command.errorLine": "- 錯誤詳情:{{error}}", + "command.tokenLine": "- 機器人權杖:{{token}}", + "command.appTokenLine": "- 應用程式權杖:{{token}}", + "command.allowedLine": "- 已允許的 ID:{{count}}", + "command.ignoreLine": "- 忽略機器人訊息:{{value}}", + "command.unknown": "未知子命令:{{subcommand}}。請使用 {{commands}}。", + "auth.user": "尚未授權使用 Cola。請管理員執行:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "此群尚未授權使用 Cola。請管理員執行:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "尚未設定機器人權杖。", + "config.botToken": "機器人權杖", + "config.appToken": "應用程式層級權杖", + "config.appTokenHelp": "具有 connections:write 權限的應用程式層級權杖(Socket Mode)。", + "config.allowedIds": "允許的 ID", + "config.slackHelp": "外掛程式接受的 Slack 使用者 ID(私訊)與頻道 ID,以半形逗號分隔。", + "config.requiredSlack": "請填寫機器人權杖、應用程式權杖與允許的 ID。", + "label": "Slack", + "description": "在Slack中與 Cola 對話", + "slack.access": "尚未設定 Slack 存取權限。\n使用者 ID:{{user}}\n聊天 ID:{{chat}}" +} diff --git a/plugins/slack/package.json b/plugins/slack/package.json index e37c44f..c3b7307 100644 --- a/plugins/slack/package.json +++ b/plugins/slack/package.json @@ -1,6 +1,6 @@ { "name": "cola-plugin-slack", - "version": "0.1.0", + "version": "0.2.0", "description": "Slack channel plugin for Cola", "license": "Apache-2.0", "type": "module", @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@marswave/cola-plugin-sdk": "0.0.3", + "@marswave/cola-plugin-sdk": "0.1.0", "@slack/socket-mode": "^2.0.4", "@slack/web-api": "^7.9.3" }, @@ -27,13 +27,22 @@ "cola": { "plugin": { "id": "slack", - "entry": "./dist/index.js" + "entry": "./dist/index.js", + "minColaVersion": "1.99.0" }, "channel": { "label": "Slack", "description": "Slack messaging via Socket Mode", "iconUrl": "https://a.slack-edge.com/80588/marketing/img/meta/slack_hash_256.png", - "docsPath": "https://github.com/marswaveai/cola-plugins/blob/main/plugins/slack/README.md" + "docsPath": "https://github.com/marswaveai/cola-plugins/blob/main/plugins/slack/README.md", + "i18n": { + "en": "./locales/en.json", + "es": "./locales/es.json", + "ja": "./locales/ja.json", + "ko": "./locales/ko.json", + "zh-CN": "./locales/zh-CN.json", + "zh-TW": "./locales/zh-TW.json" + } } } } diff --git a/plugins/slack/src/commands.ts b/plugins/slack/src/commands.ts index 0ae1776..9a074de 100644 --- a/plugins/slack/src/commands.ts +++ b/plugins/slack/src/commands.ts @@ -1,51 +1,80 @@ -import type { PluginCommandDefinition } from "@marswave/cola-plugin-sdk"; +import { pluginMessage as m, joinPluginText as join } from "@marswave/cola-plugin-sdk"; +import type { PluginCommandDefinition, PluginText } from "@marswave/cola-plugin-sdk"; import { readSlackConfig, redactToken } from "./config.js"; import type { SlackGatewayState } from "./gateway.js"; - export function createSlackCommands(getState: () => SlackGatewayState): PluginCommandDefinition[] { return [ { name: "slack", - description: "Slack plugin status and configuration summary", - args: [{ name: "subcommand", description: '"status" or "config"', required: false }], + + description: m("command.description", "{{name}} status and configuration", { + name: m("label", "Slack"), + }), + args: [ + { + name: "subcommand", + description: m("command.args", "Subcommand: {{commands}}", { + commands: "status | config", + }), + required: false, + }, + ], async execute(ctx) { const subcommand = ctx.args.trim() || "status"; const state = getState(); - if (subcommand === "status") { - const bot = state.botName ? `@${state.botName}` : (state.botUserId ?? "-"); - const status = state.connected ? "connected" : "disconnected"; - const lastEvent = state.lastEventAt ? new Date(state.lastEventAt).toISOString() : "never"; - return { - reply: [ - "**Slack Status**", - "", - `- status: ${status}`, - `- bot: ${bot}`, - `- team: ${state.teamId ?? "-"}`, - `- last event: ${lastEvent}`, - state.lastError ? `- last error: ${state.lastError}` : undefined, - ] - .filter((line): line is string => Boolean(line)) - .join("\n"), - }; + const bot = state.botName ? `@${state.botName}` : (state.botUserId ?? "—"); + const status = state.connected + ? m("status.connected", "Connected") + : m("status.disconnected", "Disconnected"); + const time = state.lastEventAt; + const lines: PluginText[] = [ + m("command.statusTitle", "**{{name}} Status**", { name: m("label", "Slack") }), + "", + m("command.statusLine", "- Status: {{status}}", { status }), + m("command.botLine", "- Bot: {{bot}}", { bot }), + m("command.eventLine", "- Last activity: {{time}}", { + time: time ? new Date(time).toISOString() : m("state.never", "Never"), + }), + ]; + lines.push(m("command.teamLine", "- Team: {{team}}", { team: state.teamId ?? "—" })); + if (state.lastError) + lines.push( + m("command.errorLine", "- Error details: {{error}}", { error: state.lastError }), + ); + return { reply: join(lines) }; } - if (subcommand === "config") { const config = readSlackConfig(ctx.config); return { - reply: [ - "**Slack Config**", + reply: join([ + m("command.configTitle", "**{{name}} Configuration**", { name: m("label", "Slack") }), "", - `- bot token: ${redactToken(config.botToken)}`, - `- app token: ${redactToken(config.appToken)}`, - `- allowed ids: ${config.allowedIds.size || "(missing)"}`, - `- ignore bot messages: ${config.ignoreBotMessages}`, - ].join("\n"), + m("command.tokenLine", "- Bot token: {{token}}", { + token: config.botToken + ? redactToken(config.botToken) + : m("state.missing", "Missing"), + }), + m("command.allowedLine", "- Allowed IDs: {{count}}", { + count: config.allowedIds.size || m("state.missing", "Missing"), + }), + m("command.appTokenLine", "- App token: {{token}}", { + token: config.appToken + ? redactToken(config.appToken) + : m("state.missing", "Missing"), + }), + m("command.ignoreLine", "- Ignore bot messages: {{value}}", { + value: config.ignoreBotMessages ? m("state.yes", "Yes") : m("state.no", "No"), + }), + ]), }; } - - return { reply: `Unknown subcommand: ${subcommand}. Use "status" or "config".` }; + return { + reply: m("command.unknown", "Unknown subcommand: {{subcommand}}. Use {{commands}}.", { + subcommand, + commands: "status | config", + }), + }; }, }, ]; diff --git a/plugins/slack/src/gateway.ts b/plugins/slack/src/gateway.ts index e1dd0bc..dc87e36 100644 --- a/plugins/slack/src/gateway.ts +++ b/plugins/slack/src/gateway.ts @@ -1,3 +1,4 @@ +import { pluginMessage as m } from "@marswave/cola-plugin-sdk"; import { SocketModeClient } from "@slack/socket-mode"; import { WebClient } from "@slack/web-api"; import type { ChannelSender, ChannelStatusResult, GatewayContext } from "@marswave/cola-plugin-sdk"; @@ -135,26 +136,27 @@ export function getGatewayStatus(ctx: GatewayContext): Channe return { connected: false, configured: false, - message: "Bot token, app token, and allowed IDs are required", + message: m("config.requiredSlack", "Bot token, app token, and allowed IDs are required."), }; } if (!ctx.state.connected) { return { connected: false, configured: true, - message: ctx.state.lastError ? `Disconnected: ${ctx.state.lastError}` : "Disconnected", + message: m("status.disconnected", "Disconnected"), + details: ctx.state.lastError, }; } const bot = ctx.state.botName ? `@${ctx.state.botName}` : ctx.state.botUserId; - const allowed = - ctx.state.allowedIds && ctx.state.allowedIds.length > 0 - ? `; allowed ids=${ctx.state.allowedIds.length}` - : ""; return { connected: true, configured: true, - message: `Socket Mode${bot ? ` as ${bot}` : ""}${allowed}`, + message: m("status.gateway", "{{mode}} \u00b7 {{bot}} \u00b7 Allowed: {{count}}", { + mode: "Socket Mode", + bot: bot ?? "—", + count: ctx.state.allowedIds?.length ?? 0, + }), }; } @@ -257,7 +259,7 @@ async function sendAccessNotConfiguredReply( try { await ctx.state.web.chat.postMessage({ channel: event.channel, - text: accessNotConfiguredMessage(event), + text: await ctx.runtime.i18n!.text(accessNotConfiguredMessage(event)), ...(event.thread_ts ? { thread_ts: event.thread_ts } : {}), }); } catch (err) { @@ -265,13 +267,12 @@ async function sendAccessNotConfiguredReply( } } -function accessNotConfiguredMessage(event: SlackMessageEvent): string { - const lines = ["Cola Slack: access not configured.", ""]; - if (event.user) { - lines.push("Your Slack user ID:", "```", event.user, "```", ""); - } - lines.push("This conversation's channel ID:", "```", event.channel, "```"); - return lines.join("\n"); +function accessNotConfiguredMessage(event: SlackMessageEvent) { + return m( + "slack.access", + "Slack access is not configured.\nUser ID: {{user}}\nChat ID: {{chat}}", + { user: event.user ?? "—", chat: event.channel }, + ); } function resetState(state: SlackGatewayState): void { diff --git a/plugins/slack/src/index.ts b/plugins/slack/src/index.ts index 8c761d1..f67f7bd 100644 --- a/plugins/slack/src/index.ts +++ b/plugins/slack/src/index.ts @@ -1,3 +1,4 @@ +import { pluginMessage as m } from "@marswave/cola-plugin-sdk"; import { defineChannel } from "@marswave/cola-plugin-sdk"; import type { ChannelOutboundAdapter, @@ -55,6 +56,19 @@ export default defineChannel({ description: "Slack messaging via Socket Mode", markdownCapable: true, }, + unauthorizedHint(target) { + return target.kind === "group" + ? m( + "auth.group", + "This group is not authorized. Ask an administrator to run:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + { plugin: "slack", id: target.id }, + ) + : m( + "auth.user", + "Access is not authorized. Ask an administrator to run:\n```\ncola channel allow {{plugin}} {{id}}\n```", + { plugin: "slack", id: target.id }, + ); + }, capabilities: { receive: { text: true, image: true, file: true }, send: { text: true, markdown: true, image: true, file: true, reaction: true, typing: true }, @@ -65,7 +79,7 @@ export default defineChannel({ fields: [ { key: "botToken", - label: "Bot token", + label: m("config.botToken", "Bot token"), type: "password", required: true, secret: true, @@ -73,21 +87,26 @@ export default defineChannel({ }, { key: "appToken", - label: "App-level token", + label: m("config.appToken", "App-level token"), type: "password", required: true, secret: true, placeholder: "xapp-...", - description: "App-level token with the connections:write scope (Socket Mode).", + description: m( + "config.appTokenHelp", + "App-level token with the connections:write scope (Socket Mode).", + ), }, { key: "allowedIds", - label: "Allowed IDs", + label: m("config.allowedIds", "Allowed IDs"), type: "text", required: true, placeholder: "U0123ABC,C0456DEF", - description: + description: m( + "config.slackHelp", "Comma-separated Slack user IDs (DMs) and channel IDs accepted by the plugin.", + ), }, // Only the tokens and allowlist are exposed in the config UI. The // remaining options (ignoreBotMessages, unfurlLinks) keep their diff --git a/plugins/slack/src/outbound.ts b/plugins/slack/src/outbound.ts index cba452e..8b8aabb 100644 --- a/plugins/slack/src/outbound.ts +++ b/plugins/slack/src/outbound.ts @@ -1,3 +1,4 @@ +import { pluginMessage as m, PluginLocalizedError } from "@marswave/cola-plugin-sdk"; import { WebClient } from "@slack/web-api"; import type { OutboundContext, ReactionContext } from "@marswave/cola-plugin-sdk"; import { readSlackConfig } from "./config.js"; @@ -205,7 +206,8 @@ export function resolveEmojiName(emoji: string): string | undefined { function resolveClient(state: SlackGatewayState, botToken: string): WebClient { if (state.web) return state.web; - if (!botToken) throw new Error("Slack bot token is not configured"); + if (!botToken) + throw new PluginLocalizedError(m("error.botToken", "Bot token is not configured.")); return new WebClient(botToken); } diff --git a/plugins/slack/tests/gateway.test.ts b/plugins/slack/tests/gateway.test.ts index 7e67a36..b1fa252 100644 --- a/plugins/slack/tests/gateway.test.ts +++ b/plugins/slack/tests/gateway.test.ts @@ -1,3 +1,5 @@ +import { resolvePluginText } from "@marswave/cola-plugin-sdk"; +import zhCN from "../locales/zh-CN.json"; import { describe, expect, it, vi } from "vitest"; import type { GatewayContext } from "@marswave/cola-plugin-sdk"; import { getGatewayStatus, startGateway, type SlackGatewayState } from "../src/gateway.js"; @@ -32,10 +34,12 @@ describe("slack gateway startup", () => { await expect(startGateway(ctx)).rejects.toThrow("invalid_auth"); expect(ctx.state.lastError).toBe("invalid_auth"); + expect(resolvePluginText(getGatewayStatus(ctx).message, { "zh-CN": zhCN }, "zh-CN")).toBe( + "未连接", + ); expect(getGatewayStatus(ctx)).toMatchObject({ connected: false, configured: true, - message: "Disconnected: invalid_auth", }); }); }); diff --git a/plugins/telegram/locales/en.json b/plugins/telegram/locales/en.json new file mode 100644 index 0000000..06ccd8c --- /dev/null +++ b/plugins/telegram/locales/en.json @@ -0,0 +1,34 @@ +{ + "status.connected": "Connected", + "status.disconnected": "Disconnected", + "status.gateway": "{{mode}} · {{bot}} · Allowed: {{count}}", + "status.polling": "Polling", + "state.never": "Never", + "state.missing": "Missing", + "state.yes": "Yes", + "state.no": "No", + "command.description": "{{name}} status and configuration", + "command.args": "Subcommand: {{commands}}", + "command.statusTitle": "**{{name}} Status**", + "command.configTitle": "**{{name}} Configuration**", + "command.statusLine": "- Status: {{status}}", + "command.botLine": "- Bot: {{bot}}", + "command.eventLine": "- Last activity: {{time}}", + "command.errorLine": "- Error details: {{error}}", + "command.tokenLine": "- Bot token: {{token}}", + "command.allowedLine": "- Allowed IDs: {{count}}", + "command.timeoutLine": "- Polling timeout: {{seconds}} s", + "command.dropLine": "- Drop pending updates: {{value}}", + "command.unknown": "Unknown subcommand: {{subcommand}}. Use {{commands}}.", + "auth.user": "Access is not authorized. Ask an administrator to run:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "This group is not authorized. Ask an administrator to run:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "Bot token is not configured.", + "config.botToken": "Bot token", + "config.allowedChats": "Allowed chat IDs", + "config.telegramHelp": "Comma-separated Telegram chat IDs accepted by the plugin.", + "config.requiredTelegram": "Bot token and allowed chat IDs are required.", + "telegram.access": "Telegram access is not configured.\nUser ID: {{user}}\nChat ID: {{chat}}", + "label": "Telegram", + "description": "Chat with Cola in Telegram", + "channel.groupDisabled": "Group chat is not enabled. Please message the bot directly." +} diff --git a/plugins/telegram/locales/es.json b/plugins/telegram/locales/es.json new file mode 100644 index 0000000..edfa8ce --- /dev/null +++ b/plugins/telegram/locales/es.json @@ -0,0 +1,34 @@ +{ + "status.connected": "Conectado", + "status.disconnected": "Desconectado", + "status.gateway": "{{mode}} · {{bot}} · Permitidos: {{count}}", + "status.polling": "Sondeo", + "state.never": "Nunca", + "state.missing": "Falta", + "state.yes": "Sí", + "state.no": "No", + "command.description": "Estado y configuración de {{name}}", + "command.args": "Subcomando: {{commands}}", + "command.statusTitle": "**Estado de {{name}}**", + "command.configTitle": "**Configuración de {{name}}**", + "command.statusLine": "- Estado: {{status}}", + "command.botLine": "- Bot: {{bot}}", + "command.eventLine": "- Última actividad: {{time}}", + "command.errorLine": "- Detalles del error: {{error}}", + "command.tokenLine": "- Token del bot: {{token}}", + "command.allowedLine": "- ID permitidos: {{count}}", + "command.timeoutLine": "- Tiempo de espera del sondeo: {{seconds}} s", + "command.dropLine": "- Descartar actualizaciones pendientes: {{value}}", + "command.unknown": "Subcomando desconocido: {{subcommand}}. Usa {{commands}}.", + "auth.user": "Acceso no autorizado. Pide al administrador que ejecute:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "Este grupo no está autorizado. Pide al administrador que ejecute:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "El token del bot no está configurado.", + "config.botToken": "Token del bot", + "config.allowedChats": "ID de chats permitidos", + "config.telegramHelp": "ID de chats de Telegram permitidos, separados por comas.", + "config.requiredTelegram": "Se requieren el token del bot y los ID de chats permitidos.", + "telegram.access": "El acceso a Telegram no está configurado.\nID de usuario: {{user}}\nID de chat: {{chat}}", + "label": "Telegram", + "description": "Chatea con Cola en Telegram", + "channel.groupDisabled": "El chat de grupo no está habilitado. Envía un mensaje directo al bot." +} diff --git a/plugins/telegram/locales/ja.json b/plugins/telegram/locales/ja.json new file mode 100644 index 0000000..d0d6e6a --- /dev/null +++ b/plugins/telegram/locales/ja.json @@ -0,0 +1,34 @@ +{ + "status.connected": "接続済み", + "status.disconnected": "未接続", + "status.gateway": "{{mode}} · {{bot}} · 許可数:{{count}}", + "status.polling": "ポーリング", + "state.never": "なし", + "state.missing": "未設定", + "state.yes": "はい", + "state.no": "いいえ", + "command.description": "{{name}} の状態と設定", + "command.args": "サブコマンド:{{commands}}", + "command.statusTitle": "**{{name}} の状態**", + "command.configTitle": "**{{name}} の設定**", + "command.statusLine": "- 状態:{{status}}", + "command.botLine": "- ボット:{{bot}}", + "command.eventLine": "- 最終アクティビティ:{{time}}", + "command.errorLine": "- エラーの詳細:{{error}}", + "command.tokenLine": "- ボットトークン:{{token}}", + "command.allowedLine": "- 許可 ID 数:{{count}}", + "command.timeoutLine": "- ポーリングのタイムアウト:{{seconds}} 秒", + "command.dropLine": "- 保留中の更新を破棄:{{value}}", + "command.unknown": "不明なサブコマンド:{{subcommand}}。{{commands}} を使用してください。", + "auth.user": "利用が許可されていません。管理者に次の実行を依頼してください:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "このグループは許可されていません。管理者に次の実行を依頼してください:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "ボットトークンが未設定です。", + "config.botToken": "ボットトークン", + "config.allowedChats": "許可するチャット ID", + "config.telegramHelp": "許可する Telegram チャット ID をカンマで区切って入力してください。", + "config.requiredTelegram": "ボットトークンと許可するチャット ID が必要です。", + "telegram.access": "Telegram の利用が未設定です。\nユーザー ID:{{user}}\nチャット ID:{{chat}}", + "label": "Telegram", + "description": "Telegram で Cola と会話", + "channel.groupDisabled": "グループチャットは無効です。ボットに直接メッセージを送信してください。" +} diff --git a/plugins/telegram/locales/ko.json b/plugins/telegram/locales/ko.json new file mode 100644 index 0000000..ff7bbfa --- /dev/null +++ b/plugins/telegram/locales/ko.json @@ -0,0 +1,34 @@ +{ + "status.connected": "연결됨", + "status.disconnected": "연결 끊김", + "status.gateway": "{{mode}} · {{bot}} · 허용: {{count}}", + "status.polling": "폴링", + "state.never": "없음", + "state.missing": "없음", + "state.yes": "예", + "state.no": "아니요", + "command.description": "{{name}} 상태 및 설정", + "command.args": "하위 명령: {{commands}}", + "command.statusTitle": "**{{name}} 상태**", + "command.configTitle": "**{{name}} 설정**", + "command.statusLine": "- 상태: {{status}}", + "command.botLine": "- 봇: {{bot}}", + "command.eventLine": "- 마지막 활동: {{time}}", + "command.errorLine": "- 오류 세부 정보: {{error}}", + "command.tokenLine": "- 봇 토큰: {{token}}", + "command.allowedLine": "- 허용된 ID: {{count}}", + "command.timeoutLine": "- 폴링 제한 시간: {{seconds}}초", + "command.dropLine": "- 대기 중인 업데이트 삭제: {{value}}", + "command.unknown": "알 수 없는 하위 명령: {{subcommand}}. {{commands}}을(를) 사용하세요.", + "auth.user": "접근 권한이 없습니다. 관리자에게 다음 명령 실행을 요청하세요:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "이 그룹은 권한이 없습니다. 관리자에게 다음 명령 실행을 요청하세요:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "봇 토큰이 설정되지 않았습니다.", + "config.botToken": "봇 토큰", + "config.allowedChats": "허용된 채팅 ID", + "config.telegramHelp": "허용할 Telegram 채팅 ID를 쉼표로 구분하여 입력하세요.", + "config.requiredTelegram": "봇 토큰과 허용된 채팅 ID가 필요합니다.", + "telegram.access": "Telegram 접근 권한이 설정되지 않았습니다.\n사용자 ID: {{user}}\n채팅 ID: {{chat}}", + "label": "Telegram", + "description": "Telegram에서 Cola와 대화하세요", + "channel.groupDisabled": "그룹 채팅이 활성화되지 않았습니다. 봇에게 직접 메시지를 보내세요." +} diff --git a/plugins/telegram/locales/zh-CN.json b/plugins/telegram/locales/zh-CN.json new file mode 100644 index 0000000..b0535e4 --- /dev/null +++ b/plugins/telegram/locales/zh-CN.json @@ -0,0 +1,34 @@ +{ + "status.connected": "已连接", + "status.disconnected": "未连接", + "status.gateway": "{{mode}} · {{bot}} · 已允许:{{count}}", + "status.polling": "轮询中", + "state.never": "从未", + "state.missing": "缺失", + "state.yes": "是", + "state.no": "否", + "command.description": "{{name}} 状态和配置", + "command.args": "子命令:{{commands}}", + "command.statusTitle": "**{{name}} 状态**", + "command.configTitle": "**{{name}} 配置**", + "command.statusLine": "- 状态:{{status}}", + "command.botLine": "- 机器人:{{bot}}", + "command.eventLine": "- 最近活动:{{time}}", + "command.errorLine": "- 错误详情:{{error}}", + "command.tokenLine": "- 机器人令牌:{{token}}", + "command.allowedLine": "- 已允许的 ID:{{count}}", + "command.timeoutLine": "- 轮询超时:{{seconds}} 秒", + "command.dropLine": "- 丢弃待处理更新:{{value}}", + "command.unknown": "未知子命令:{{subcommand}}。请使用 {{commands}}。", + "auth.user": "尚未授权使用 Cola。请管理员执行:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "此群尚未授权使用 Cola。请管理员执行:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "尚未配置机器人令牌。", + "config.botToken": "机器人令牌", + "config.allowedChats": "允许的聊天 ID", + "config.telegramHelp": "插件接受的 Telegram 聊天 ID,以英文逗号分隔。", + "config.requiredTelegram": "请填写机器人令牌和允许的聊天 ID。", + "telegram.access": "尚未配置 Telegram 访问权限。\n用户 ID:{{user}}\n聊天 ID:{{chat}}", + "label": "Telegram", + "description": "在Telegram中与 Cola 对话", + "channel.groupDisabled": "尚未启用群聊,请私信机器人。" +} diff --git a/plugins/telegram/locales/zh-TW.json b/plugins/telegram/locales/zh-TW.json new file mode 100644 index 0000000..1c09271 --- /dev/null +++ b/plugins/telegram/locales/zh-TW.json @@ -0,0 +1,34 @@ +{ + "status.connected": "已連線", + "status.disconnected": "未連線", + "status.gateway": "{{mode}} · {{bot}} · 已允許:{{count}}", + "status.polling": "輪詢中", + "state.never": "從未", + "state.missing": "缺少", + "state.yes": "是", + "state.no": "否", + "command.description": "{{name}} 狀態與設定", + "command.args": "子命令:{{commands}}", + "command.statusTitle": "**{{name}} 狀態**", + "command.configTitle": "**{{name}} 設定**", + "command.statusLine": "- 狀態:{{status}}", + "command.botLine": "- 機器人:{{bot}}", + "command.eventLine": "- 最近活動:{{time}}", + "command.errorLine": "- 錯誤詳情:{{error}}", + "command.tokenLine": "- 機器人權杖:{{token}}", + "command.allowedLine": "- 已允許的 ID:{{count}}", + "command.timeoutLine": "- 輪詢逾時:{{seconds}} 秒", + "command.dropLine": "- 捨棄待處理更新:{{value}}", + "command.unknown": "未知子命令:{{subcommand}}。請使用 {{commands}}。", + "auth.user": "尚未授權使用 Cola。請管理員執行:\n```\ncola channel allow {{plugin}} {{id}}\n```", + "auth.group": "此群尚未授權使用 Cola。請管理員執行:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + "error.botToken": "尚未設定機器人權杖。", + "config.botToken": "機器人權杖", + "config.allowedChats": "允許的聊天 ID", + "config.telegramHelp": "外掛程式接受的 Telegram 聊天 ID,以半形逗號分隔。", + "config.requiredTelegram": "請填寫機器人權杖與允許的聊天 ID。", + "telegram.access": "尚未設定 Telegram 存取權限。\n使用者 ID:{{user}}\n聊天 ID:{{chat}}", + "label": "Telegram", + "description": "在Telegram中與 Cola 對話", + "channel.groupDisabled": "尚未啟用群聊,請私訊機器人。" +} diff --git a/plugins/telegram/package.json b/plugins/telegram/package.json index fee2a04..b6b2cdd 100644 --- a/plugins/telegram/package.json +++ b/plugins/telegram/package.json @@ -1,6 +1,6 @@ { "name": "cola-plugin-telegram", - "version": "0.1.3", + "version": "0.2.0", "description": "Telegram Bot channel plugin for Cola", "license": "Apache-2.0", "type": "module", @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@marswave/cola-plugin-sdk": "0.0.3" + "@marswave/cola-plugin-sdk": "0.1.0" }, "devDependencies": { "@types/node": "^22.0.0", @@ -25,7 +25,8 @@ "cola": { "plugin": { "id": "telegram", - "entry": "./dist/index.js" + "entry": "./dist/index.js", + "minColaVersion": "1.99.0" }, "channel": { "label": "Telegram", @@ -34,7 +35,15 @@ "aliases": [ "tg" ], - "docsPath": "https://github.com/marswaveai/cola-plugins/blob/main/plugins/telegram/README.md" + "docsPath": "https://github.com/marswaveai/cola-plugins/blob/main/plugins/telegram/README.md", + "i18n": { + "en": "./locales/en.json", + "es": "./locales/es.json", + "ja": "./locales/ja.json", + "ko": "./locales/ko.json", + "zh-CN": "./locales/zh-CN.json", + "zh-TW": "./locales/zh-TW.json" + } } } } diff --git a/plugins/telegram/src/commands.ts b/plugins/telegram/src/commands.ts index caeb3de..ab331a6 100644 --- a/plugins/telegram/src/commands.ts +++ b/plugins/telegram/src/commands.ts @@ -1,7 +1,7 @@ -import type { PluginCommandDefinition } from "@marswave/cola-plugin-sdk"; +import { pluginMessage as m, joinPluginText as join } from "@marswave/cola-plugin-sdk"; +import type { PluginCommandDefinition, PluginText } from "@marswave/cola-plugin-sdk"; import { readTelegramConfig, redactToken } from "./config.js"; import type { TelegramGatewayState } from "./gateway.js"; - export function createTelegramCommands( getState: () => TelegramGatewayState, ): PluginCommandDefinition[] { @@ -9,47 +9,74 @@ export function createTelegramCommands( { name: "telegram", aliases: ["tg"], - description: "Telegram plugin status and configuration summary", - args: [{ name: "subcommand", description: '"status" or "config"', required: false }], + description: m("command.description", "{{name}} status and configuration", { + name: m("label", "Telegram"), + }), + args: [ + { + name: "subcommand", + description: m("command.args", "Subcommand: {{commands}}", { + commands: "status | config", + }), + required: false, + }, + ], async execute(ctx) { const subcommand = ctx.args.trim() || "status"; const state = getState(); - if (subcommand === "status") { - const bot = state.me?.username ? `@${state.me.username}` : (state.me?.first_name ?? "-"); - const status = state.connected ? "connected" : "disconnected"; - const lastUpdate = state.lastUpdateAt - ? new Date(state.lastUpdateAt).toISOString() - : "never"; - return { - reply: [ - "**Telegram Status**", - "", - `- status: ${status}`, - `- bot: ${bot}`, - `- last update: ${lastUpdate}`, - state.lastError ? `- last error: ${state.lastError}` : undefined, - ] - .filter((line): line is string => Boolean(line)) - .join("\n"), - }; - } + const bot = state.me?.username ? `@${state.me.username}` : (state.me?.first_name ?? "—"); + const status = state.connected + ? m("status.connected", "Connected") + : m("status.disconnected", "Disconnected"); + const time = state.lastUpdateAt; + const lines: PluginText[] = [ + m("command.statusTitle", "**{{name}} Status**", { name: m("label", "Telegram") }), + "", + m("command.statusLine", "- Status: {{status}}", { status }), + m("command.botLine", "- Bot: {{bot}}", { bot }), + m("command.eventLine", "- Last activity: {{time}}", { + time: time ? new Date(time).toISOString() : m("state.never", "Never"), + }), + ]; + if (state.lastError) + lines.push( + m("command.errorLine", "- Error details: {{error}}", { error: state.lastError }), + ); + return { reply: join(lines) }; + } if (subcommand === "config") { const config = readTelegramConfig(ctx.config); return { - reply: [ - "**Telegram Config**", + reply: join([ + m("command.configTitle", "**{{name}} Configuration**", { + name: m("label", "Telegram"), + }), "", - `- bot token: ${redactToken(config.botToken)}`, - `- polling timeout: ${config.pollingTimeoutSeconds}s`, - `- allowed chats: ${config.allowedChatIds.size || "(missing)"}`, - `- drop pending updates: ${config.dropPendingUpdates}`, - ].join("\n"), + m("command.tokenLine", "- Bot token: {{token}}", { + token: config.botToken + ? redactToken(config.botToken) + : m("state.missing", "Missing"), + }), + m("command.allowedLine", "- Allowed IDs: {{count}}", { + count: config.allowedChatIds.size || m("state.missing", "Missing"), + }), + m("command.timeoutLine", "- Polling timeout: {{seconds}} s", { + seconds: config.pollingTimeoutSeconds, + }), + m("command.dropLine", "- Drop pending updates: {{value}}", { + value: config.dropPendingUpdates ? m("state.yes", "Yes") : m("state.no", "No"), + }), + ]), }; } - - return { reply: `Unknown subcommand: ${subcommand}. Use "status" or "config".` }; + return { + reply: m("command.unknown", "Unknown subcommand: {{subcommand}}. Use {{commands}}.", { + subcommand, + commands: "status | config", + }), + }; }, }, ]; diff --git a/plugins/telegram/src/gateway.ts b/plugins/telegram/src/gateway.ts index 5a5f47f..8b114b6 100644 --- a/plugins/telegram/src/gateway.ts +++ b/plugins/telegram/src/gateway.ts @@ -1,3 +1,4 @@ +import { pluginMessage as m } from "@marswave/cola-plugin-sdk"; import { createPollLoop } from "@marswave/cola-plugin-sdk"; import type { ChannelStatusResult, GatewayContext } from "@marswave/cola-plugin-sdk"; import { TelegramApiClient } from "./api.js"; @@ -5,9 +6,6 @@ import { isTelegramConfigured, readTelegramConfig, type TelegramConfig } from ". import { isFromBot, parseTelegramMessage } from "./message.js"; import type { TelegramMessage, TelegramUpdate, TelegramUser } from "./types.js"; -/** Reply sent to a group @mention/reply while group chat is disabled. */ -const GROUP_DISABLED_NOTICE = "暂不支持群聊"; - export type TelegramGatewayState = { abortController?: AbortController; client?: TelegramApiClient; @@ -93,26 +91,27 @@ export function getGatewayStatus(ctx: GatewayContext): Cha return { connected: false, configured: false, - message: "Bot token and allowed chat IDs are required", + message: m("config.requiredTelegram", "Bot token and allowed chat IDs are required."), }; } if (!ctx.state.connected) { return { connected: false, configured: true, - message: ctx.state.lastError ? `Disconnected: ${ctx.state.lastError}` : "Disconnected", + message: m("status.disconnected", "Disconnected"), + details: ctx.state.lastError, }; } const bot = ctx.state.me?.username ? `@${ctx.state.me.username}` : ctx.state.me?.first_name; - const allowed = - ctx.state.allowedChatIds && ctx.state.allowedChatIds.length > 0 - ? `; allowed chats=${ctx.state.allowedChatIds.length}` - : ""; return { connected: true, configured: true, - message: `Polling${bot ? ` as ${bot}` : ""}${allowed}`, + message: m("status.gateway", "{{mode}} \u00b7 {{bot}} \u00b7 Allowed: {{count}}", { + mode: m("status.polling", "Polling"), + bot: bot ?? "—", + count: ctx.state.allowedChatIds?.length ?? 0, + }), }; } @@ -180,7 +179,7 @@ async function sendAccessNotConfiguredReply( await client.sendMessage({ chatId: String(message.chat.id), messageThreadId: message.message_thread_id, - text: accessNotConfiguredMessage(message), + text: await ctx.runtime.i18n!.text(accessNotConfiguredMessage(message)), }); } catch (err) { ctx.logger.warn(`Failed to send Telegram access notice for chat ${message.chat.id}`, err); @@ -199,7 +198,9 @@ async function sendGroupDisabledReply( await client.sendMessage({ chatId: String(message.chat.id), messageThreadId: message.message_thread_id, - text: GROUP_DISABLED_NOTICE, + text: await ctx.runtime.i18n!.text( + m("channel.groupDisabled", "Group chat is not enabled. Please message the bot directly."), + ), }); } catch (err) { ctx.logger.warn( @@ -227,18 +228,12 @@ function isBotAddressed(message: TelegramMessage, me: TelegramUser | undefined): return false; } -function accessNotConfiguredMessage(message: NonNullable): string { - const userId = message.from ? String(message.from.id) : undefined; - const chatId = String(message.chat.id); - const lines = ["Cola Telegram: access not configured.", ""]; - - if (userId) { - lines.push("Your Telegram user id:", "```", userId, "```", ""); - } - - lines.push("Your Telegram chat id:", "```", chatId, "```"); - - return lines.join("\n"); +function accessNotConfiguredMessage(message: NonNullable) { + return m( + "telegram.access", + "Telegram access is not configured.\nUser ID: {{user}}\nChat ID: {{chat}}", + { user: message.from ? String(message.from.id) : "—", chat: String(message.chat.id) }, + ); } function resetState(state: TelegramGatewayState): void { diff --git a/plugins/telegram/src/index.ts b/plugins/telegram/src/index.ts index fe00b9b..4aa85d7 100644 --- a/plugins/telegram/src/index.ts +++ b/plugins/telegram/src/index.ts @@ -1,3 +1,4 @@ +import { pluginMessage as m } from "@marswave/cola-plugin-sdk"; import { defineChannel } from "@marswave/cola-plugin-sdk"; import type { ChannelStatusResult, OutboundContext } from "@marswave/cola-plugin-sdk"; import { createTelegramCommands } from "./commands.js"; @@ -18,6 +19,19 @@ export default defineChannel({ description: "Telegram messaging via Bot API long polling", markdownCapable: true, }, + unauthorizedHint(target) { + return target.kind === "group" + ? m( + "auth.group", + "This group is not authorized. Ask an administrator to run:\n```\ncola channel allow-group {{plugin}} {{id}}\n```", + { plugin: "telegram", id: target.id }, + ) + : m( + "auth.user", + "Access is not authorized. Ask an administrator to run:\n```\ncola channel allow {{plugin}} {{id}}\n```", + { plugin: "telegram", id: target.id }, + ); + }, capabilities: { receive: { text: true }, send: { text: true, markdown: true, typing: true }, @@ -28,7 +42,7 @@ export default defineChannel({ fields: [ { key: "botToken", - label: "Bot token", + label: m("config.botToken", "Bot token"), type: "password", required: true, secret: true, @@ -36,11 +50,14 @@ export default defineChannel({ }, { key: "allowedChatIds", - label: "Allowed chat IDs", + label: m("config.allowedChats", "Allowed chat IDs"), type: "text", required: true, placeholder: "-1001234567890,123456789", - description: "Comma-separated Telegram chat IDs accepted by the plugin.", + description: m( + "config.telegramHelp", + "Comma-separated Telegram chat IDs accepted by the plugin.", + ), }, // Only Bot token and Allowed chat IDs are exposed in the config UI. The // remaining options (pollingTimeoutSeconds, dropPendingUpdates, diff --git a/plugins/telegram/src/outbound.ts b/plugins/telegram/src/outbound.ts index 769f1d2..53e942e 100644 --- a/plugins/telegram/src/outbound.ts +++ b/plugins/telegram/src/outbound.ts @@ -1,3 +1,4 @@ +import { pluginMessage as m, PluginLocalizedError } from "@marswave/cola-plugin-sdk"; import type { OutboundContext } from "@marswave/cola-plugin-sdk"; import { TelegramApiClient } from "./api.js"; import { readTelegramConfig } from "./config.js"; @@ -11,7 +12,7 @@ export async function sendTelegramText( ): Promise { const config = readTelegramConfig(ctx.config); if (!config.botToken) { - throw new Error("Telegram bot token is not configured"); + throw new PluginLocalizedError(m("error.botToken", "Bot token is not configured.")); } const client = state.client ?? new TelegramApiClient({ botToken: config.botToken }); diff --git a/plugins/telegram/tests/gateway.test.ts b/plugins/telegram/tests/gateway.test.ts index 9fb0da4..9f727c9 100644 --- a/plugins/telegram/tests/gateway.test.ts +++ b/plugins/telegram/tests/gateway.test.ts @@ -1,3 +1,6 @@ +import { resolvePluginText } from "@marswave/cola-plugin-sdk"; +import en from "../locales/en.json"; +import zhCN from "../locales/zh-CN.json"; import { describe, expect, it, vi } from "vitest"; import type { GatewayContext } from "@marswave/cola-plugin-sdk"; import { readTelegramConfig } from "../src/config.js"; @@ -16,7 +19,13 @@ function makeCtx(resolveImpl: (id: string) => Promise = async () me: { id: 555, is_bot: true, first_name: "Bot", username: "bot" }, client: { sendMessage }, }, - runtime: { identity: { resolve, bind, unbind: vi.fn() } }, + runtime: { + i18n: { + text: async (text: Parameters[0]) => + resolvePluginText(text, { en, "zh-CN": zhCN }, "zh-CN"), + }, + identity: { resolve, bind, unbind: vi.fn() }, + }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, deliver, } as unknown as GatewayContext; @@ -95,25 +104,13 @@ describe("telegram gateway identity binding", () => { expect(sendMessage).toHaveBeenCalledWith({ chatId: "999", messageThreadId: undefined, - text: [ - "Cola Telegram: access not configured.", - "", - "Your Telegram user id:", - "```", - "999", - "```", - "", - "Your Telegram chat id:", - "```", - "999", - "```", - ].join("\n"), + text: "尚未配置 Telegram 访问权限。\n用户 ID:999\n聊天 ID:999", }); }); }); describe("telegram group chat disabled (groupEnabled=false)", () => { - const NOTICE = "暂不支持群聊"; + const NOTICE = "尚未启用群聊,请私信机器人。"; it("ignores a group message that does not address the bot", async () => { const { ctx, deliver, resolve, sendMessage } = makeCtx(); @@ -125,7 +122,7 @@ describe("telegram group chat disabled (groupEnabled=false)", () => { expect(sendMessage).not.toHaveBeenCalled(); }); - it("replies '暂不支持群聊' to a group @mention and does not deliver", async () => { + it("replies in the configured language to a group @mention and does not deliver", async () => { const { ctx, deliver, sendMessage } = makeCtx(); await handleUpdate(groupUpdate(-100123, 5693819232, { mentionBot: true }), ctx, config); @@ -138,7 +135,7 @@ describe("telegram group chat disabled (groupEnabled=false)", () => { }); }); - it("replies '暂不支持群聊' to a reply directed at the bot", async () => { + it("replies in the configured language to a reply directed at the bot", async () => { const { ctx, deliver, sendMessage } = makeCtx(); await handleUpdate(groupUpdate(-100123, 5693819232, { replyToBot: true }), ctx, config); diff --git a/plugins/telegram/tests/i18n.test.ts b/plugins/telegram/tests/i18n.test.ts new file mode 100644 index 0000000..0bd7f47 --- /dev/null +++ b/plugins/telegram/tests/i18n.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolvePluginText } from "@marswave/cola-plugin-sdk"; +import { createTelegramCommands } from "../src/commands.js"; +import en from "../locales/en.json"; +import es from "../locales/es.json"; +import ja from "../locales/ja.json"; +import ko from "../locales/ko.json"; +import zhCN from "../locales/zh-CN.json"; +import zhTW from "../locales/zh-TW.json"; + +const resources = { en, es, ja, ko, "zh-CN": zhCN, "zh-TW": zhTW }; +const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; +const command = createTelegramCommands(() => ({ + connected: true, + me: { id: 123, first_name: "Bot", is_bot: true, username: "example_bot" }, +}))[0]!; + +describe("translated plugin command replies", () => { + it.each([ + ["en", "Connected"], + ["es", "Conectado"], + ["ja", "接続済み"], + ["ko", "연결됨"], + ["zh-CN", "已连接"], + ["zh-TW", "已連線"], + ])("renders the status and preserves bot identity in %s", async (language, connected) => { + const result = await command.execute({ args: "status", config: {}, logger }); + const text = resolvePluginText(JSON.parse(JSON.stringify(result.reply)), resources, language); + expect(text).toContain(connected); + expect(text).toContain("@example_bot"); + expect(text).not.toContain("{{"); + expect(text).not.toContain("[object Object]"); + }); + + it("localizes configuration and subcommand errors without exposing secrets", async () => { + const result = await command.execute({ + args: "config", + config: { botToken: "123456:super-secret-value", allowedChatIds: "1,2" }, + logger, + }); + const text = resolvePluginText(result.reply, resources, "zh-CN"); + expect(text).toContain("机器人令牌"); + expect(text).not.toContain("super-secret-value"); + const missing = await command.execute({ args: "config", config: {}, logger }); + expect(resolvePluginText(missing.reply, resources, "zh-CN")).toContain("机器人令牌:缺失"); + const unknown = await command.execute({ args: "bogus", config: {}, logger }); + expect(resolvePluginText(unknown.reply, resources, "zh-CN")).toBe( + "未知子命令:bogus。请使用 status | config。", + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5958569..7df1511 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^1.61.1 version: 1.66.0 '@marswave/cola-plugin-sdk': - specifier: 0.0.3 - version: 0.0.3 + specifier: 0.1.0 + version: 0.1.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -46,8 +46,8 @@ importers: plugins/slack: dependencies: '@marswave/cola-plugin-sdk': - specifier: 0.0.3 - version: 0.0.3 + specifier: 0.1.0 + version: 0.1.0 '@slack/socket-mode': specifier: ^2.0.4 version: 2.0.7 @@ -68,8 +68,8 @@ importers: plugins/telegram: dependencies: '@marswave/cola-plugin-sdk': - specifier: 0.0.3 - version: 0.0.3 + specifier: 0.1.0 + version: 0.1.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -396,6 +396,9 @@ packages: '@marswave/cola-plugin-sdk@0.0.3': resolution: {integrity: sha512-oO1rCwUXZqKlJdE80VclGmsMKW/yU6BbgKcHAnWR0RzksEder56QWSoH7ckAAmWnw4BY2ATFlRWw5BRLMVYIgA==} + '@marswave/cola-plugin-sdk@0.1.0': + resolution: {integrity: sha512-OJagQaduZIfp1cYqNUJL+HAStjpasFCiiDvak+MPxflp6FHZ+XI1L5a/E3h2qLFn4X2nsH59hmAHtOKYXvVKyA==} + '@oxfmt/binding-android-arm-eabi@0.42.0': resolution: {integrity: sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1621,6 +1624,8 @@ snapshots: '@marswave/cola-plugin-sdk@0.0.3': {} + '@marswave/cola-plugin-sdk@0.1.0': {} + '@oxfmt/binding-android-arm-eabi@0.42.0': optional: true diff --git a/scripts/build-registry.ts b/scripts/build-registry.ts index 65dae89..e9599e1 100644 --- a/scripts/build-registry.ts +++ b/scripts/build-registry.ts @@ -1,4 +1,4 @@ -import fs from "fs"; +import fs from "node:fs/promises"; import path from "path"; import { fileURLToPath, pathToFileURL } from "url"; @@ -8,6 +8,7 @@ const DEFAULT_PUBLIC_BASE = "https://files.colaos.ai"; export type RegistryEntry = { id: string; + i18n?: Record>; label: string; description?: string; version: string; @@ -71,16 +72,75 @@ export function entryFromPackage(pkg: unknown, publicBase: string): RegistryEntr }; } -export function buildRegistry(pluginsDir: string, publicBase: string) { +export async function readTranslations(directory: string, files: unknown) { + const resources: Record> = Object.create(null); + if (files === undefined) return resources; + if (!isRecord(files)) throw new Error("cola.channel.i18n must map locales to JSON files"); + const root = await fs.realpath(directory); + const parameters = new Map(); + for (const [locale, file] of Object.entries(files)) { + if (!/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i.test(locale) || typeof file !== "string") + throw new Error(`Invalid translation registration: ${locale}`); + if (path.isAbsolute(file) || file.split(/[\\/]/).includes("..")) + throw new Error(`Translation path must be relative to the plugin: ${file}`); + const target = await fs.realpath(path.resolve(root, file)); + const relative = path.relative(root, target); + if ( + !relative || + relative.startsWith("..") || + path.isAbsolute(relative) || + !target.endsWith(".json") + ) + throw new Error(`Translation file must be inside the plugin: ${file}`); + if ((await fs.stat(target)).size > 1024 * 1024) + throw new Error(`Translation file exceeds 1 MiB: ${file}`); + const catalog: unknown = JSON.parse(await fs.readFile(target, "utf8")); + if (!isRecord(catalog)) throw new Error(`Invalid translation catalog: ${locale}`); + const valid: Record = Object.create(null); + for (const [key, value] of Object.entries(catalog)) { + if (typeof value !== "string") throw new Error(`Invalid translation: ${locale}:${key}`); + valid[key] = value; + if (!value.trim()) continue; + const names = [ + ...new Set([...value.matchAll(/\{\{\s*([\w.-]+)\s*\}\}/g)].map((match) => match[1])), + ] + .sort() + .join(","); + if (parameters.has(key) && parameters.get(key) !== names) + throw new Error(`Translation parameters differ for ${locale}:${key}`); + parameters.set(key, names); + } + resources[locale] = valid; + } + return resources; +} + +export async function buildRegistry(pluginsDir: string, publicBase: string) { const entries: RegistryEntry[] = []; - for (const name of fs.readdirSync(pluginsDir)) { + for (const name of await fs.readdir(pluginsDir)) { const dir = path.join(pluginsDir, name); - if (!fs.statSync(dir).isDirectory()) continue; + if (!(await fs.stat(dir)).isDirectory()) continue; const pkgPath = path.join(dir, "package.json"); - if (!fs.existsSync(pkgPath)) continue; - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + const raw = await fs.readFile(pkgPath, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined; + throw error; + }); + if (raw === undefined) continue; + const pkg = JSON.parse(raw); const entry = entryFromPackage(pkg, publicBase); - if (entry) entries.push(entry); + if (!entry) continue; + const resources = await readTranslations(dir, pkg.cola?.channel?.i18n); + if (Object.keys(resources).length) { + entry.i18n = Object.fromEntries( + Object.entries(resources).map(([locale, catalog]) => [ + locale, + Object.fromEntries( + Object.entries(catalog).filter(([key]) => key === "label" || key === "description"), + ), + ]), + ); + } + entries.push(entry); } return { version: 1, plugins: entries }; } @@ -88,12 +148,19 @@ export function buildRegistry(pluginsDir: string, publicBase: string) { const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; -if (isMain) { +async function main() { const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const pluginsDir = path.resolve(moduleDir, "..", "plugins"); const publicBase = process.env.OSS_PUBLIC_BASE || DEFAULT_PUBLIC_BASE; - const registry = buildRegistry(pluginsDir, publicBase); + const registry = await buildRegistry(pluginsDir, publicBase); const outPath = path.resolve(moduleDir, "..", "registry.json"); - fs.writeFileSync(outPath, JSON.stringify(registry, null, 2) + "\n"); + await fs.writeFile(outPath, JSON.stringify(registry, null, 2) + "\n"); console.log(`Wrote registry.json with ${registry.plugins.length} plugin(s)`); } + +if (isMain) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/plugin-translations.test.ts b/scripts/plugin-translations.test.ts new file mode 100644 index 0000000..ff3d256 --- /dev/null +++ b/scripts/plugin-translations.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { buildRegistry, readTranslations } from "./build-registry.js"; +import { stagePluginLocales } from "./stage-plugin-locales.js"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const temporary: string[] = []; +afterEach(async () => { + await Promise.all(temporary.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "cola-registry-i18n-")); + temporary.push(root); + const plugin = path.join(root, "plugins/example"); + await mkdir(path.join(plugin, "translations"), { recursive: true }); + const pkg = { + version: "1.0.0", + cola: { + plugin: { id: "example", entry: "./dist/index.js" }, + channel: { + label: "Example", + i18n: { en: "./translations/en.json", "zh-CN": "./translations/zh-CN.json" }, + }, + }, + }; + await writeFile(path.join(plugin, "package.json"), JSON.stringify(pkg)); + await writeFile( + path.join(plugin, "translations/en.json"), + JSON.stringify({ label: "Example", "auth.wait": "Wait {{seconds}} seconds" }), + ); + await writeFile( + path.join(plugin, "translations/zh-CN.json"), + JSON.stringify({ label: "示例", description: "中文简介", "auth.wait": "等待 {{seconds}} 秒" }), + ); + return { root, plugin, pkg }; +} + +describe("plugin translation publication", () => { + it("publishes store text without runtime catalogs and packages every registered file", async () => { + const { root, plugin, pkg } = await fixture(); + const registry = await buildRegistry(path.join(root, "plugins"), "https://files.example.com"); + expect(registry.plugins[0]?.i18n).toEqual({ + en: { label: "Example" }, + "zh-CN": { label: "示例", description: "中文简介" }, + }); + const staging = path.join(root, "staging"); + await stagePluginLocales(plugin, staging); + const installed = await readTranslations(staging, pkg.cola.channel.i18n); + expect(installed["zh-CN"]?.["auth.wait"]).toBe("等待 {{seconds}} 秒"); + expect(await readFile(path.join(staging, "translations/en.json"), "utf8")).toContain("Wait"); + }); + + it("rejects missing files and inconsistent interpolation parameters before publication", async () => { + const { root, plugin } = await fixture(); + await writeFile( + path.join(plugin, "translations/zh-CN.json"), + JSON.stringify({ "auth.wait": "等待 {{minutes}} 分钟" }), + ); + await expect( + buildRegistry(path.join(root, "plugins"), "https://files.example.com"), + ).rejects.toThrow("parameters differ"); + await expect(readTranslations(plugin, { ja: "./missing.json" })).rejects.toThrow(); + await expect(readTranslations(plugin, { en: "../outside.json" })).rejects.toThrow("relative"); + }); + + it("runs the release staging command through tsx and fails on invalid catalogs", async () => { + const { root, plugin, pkg } = await fixture(); + const staging = path.join(root, "release"); + const command = path.resolve("node_modules/.bin/tsx"); + const args = ["scripts/stage-plugin-locales.ts", plugin, staging]; + await promisify(execFile)(command, args); + expect((await readTranslations(staging, pkg.cola.channel.i18n))["zh-CN"]?.label).toBe("示例"); + await writeFile(path.join(plugin, "translations/zh-CN.json"), "invalid"); + await expect(promisify(execFile)(command, args)).rejects.toThrow("Command failed"); + }); +}); diff --git a/scripts/stage-plugin-locales.ts b/scripts/stage-plugin-locales.ts new file mode 100644 index 0000000..d671294 --- /dev/null +++ b/scripts/stage-plugin-locales.ts @@ -0,0 +1,24 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { readTranslations } from "./build-registry.js"; + +export async function stagePluginLocales(directory: string, staging: string) { + const pkg = JSON.parse(await fs.readFile(path.join(directory, "package.json"), "utf8")); + const files = pkg.cola?.channel?.i18n; + await readTranslations(directory, files); + for (const file of Object.values(files ?? {}) as string[]) { + const target = path.resolve(staging, file); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.copyFile(path.resolve(directory, file), target); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [directory, staging] = process.argv.slice(2); + if (!directory || !staging) throw new Error("Usage: stage-plugin-locales "); + stagePluginLocales(directory, staging).catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} From 93c2d4aefb44d1029a08840976202c05024fdf31 Mon Sep 17 00:00:00 2001 From: Mack Date: Mon, 7 Sep 2026 19:16:12 +0800 Subject: [PATCH 05/10] fix(channel): reject source and catalog placeholder drift --- README.md | 6 ++ scripts/build-registry.ts | 4 ++ scripts/plugin-message-validation.ts | 99 ++++++++++++++++++++++++++++ scripts/plugin-translations.test.ts | 32 +++++++++ scripts/stage-plugin-locales.ts | 4 +- 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 scripts/plugin-message-validation.ts diff --git a/README.md b/README.md index cbdfc2c..0a6083b 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,12 @@ pure helpers. Node tooling can import `loadPluginTranslations` from `@marswave/cola-plugin-sdk/i18n-files`; pass `{ strict: true }` for publish validation, or `{ onWarning }` to retain valid locales on runtime failures. +The bundled-plugin check and official plugin publication scripts also compare +source `pluginMessage` calls with every catalog. For messages with a nonempty key, +keep the key and fallback as string literals; put dynamic values in `params`. +The check supports SDK import aliases and namespace imports without executing +plugin code. Missing or empty translations remain valid fallbacks. + Publish SDK 0.1.0 before releasing plugins that depend on it, and set `cola.plugin.minColaVersion` to the first released Cola version supporting i18n. In `cola-plugins`, `pnpm build:registry` validates all declared catalogs and embeds diff --git a/scripts/build-registry.ts b/scripts/build-registry.ts index e9599e1..a629710 100644 --- a/scripts/build-registry.ts +++ b/scripts/build-registry.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "path"; import { fileURLToPath, pathToFileURL } from "url"; +import { validatePluginMessageSources } from "./plugin-message-validation.js"; // 下载与 manifest 的公开根 URL(colaos OSS bucket 的 CDN 域名)。 // 可用 OSS_PUBLIC_BASE 环境变量覆盖(本地/staging)。 @@ -130,6 +131,9 @@ export async function buildRegistry(pluginsDir: string, publicBase: string) { const entry = entryFromPackage(pkg, publicBase); if (!entry) continue; const resources = await readTranslations(dir, pkg.cola?.channel?.i18n); + if (pkg.cola?.channel?.i18n) { + await validatePluginMessageSources(path.join(dir, "src"), resources); + } if (Object.keys(resources).length) { entry.i18n = Object.fromEntries( Object.entries(resources).map(([locale, catalog]) => [ diff --git a/scripts/plugin-message-validation.ts b/scripts/plugin-message-validation.ts new file mode 100644 index 0000000..ed325a3 --- /dev/null +++ b/scripts/plugin-message-validation.ts @@ -0,0 +1,99 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import * as ts from "typescript"; + +function parameters(text: string) { + return [...new Set([...text.matchAll(/\{\{\s*([\w.-]+)\s*\}\}/g)].map((match) => match[1]))] + .sort() + .join(","); +} + +// Inspect source without importing plugins or running their initialization code. +export async function validatePluginMessageSources( + directory: string, + resources: Record>, +) { + async function visitDirectory(dir: string) { + await Promise.all( + (await readdir(dir, { withFileTypes: true })).map(async (entry) => { + const filename = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!["node_modules", "tests", "__tests__"].includes(entry.name)) + await visitDirectory(filename); + return; + } + if ( + !entry.isFile() || + !/\.[cm]?[jt]sx?$/.test(entry.name) || + /\.(?:d|test|spec)\.[cm]?[jt]sx?$/.test(entry.name) + ) + return; + const source = ts.createSourceFile( + filename, + await readFile(filename, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const factories = new Set(); + const namespaces = new Set(); + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) + continue; + const module = statement.moduleSpecifier.text; + if ( + module !== "@marswave/cola-plugin-sdk" && + !module.startsWith("@marswave/cola-plugin-sdk/source") + ) + continue; + const bindings = statement.importClause?.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + for (const binding of bindings.elements) { + if ((binding.propertyName ?? binding.name).text === "pluginMessage") + factories.add(binding.name.text); + } + } else if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text); + } + function visit(node: ts.Node) { + if (ts.isCallExpression(node)) { + const callee = node.expression; + const isFactory = ts.isIdentifier(callee) + ? factories.has(callee.text) + : ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + namespaces.has(callee.expression.text) && + callee.name.text === "pluginMessage"; + if (isFactory) { + const [key, fallback] = node.arguments; + const location = `${filename}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`; + if (!key || !ts.isStringLiteralLike(key)) { + throw new Error( + `${location}: pluginMessage key must be a string literal for translation validation`, + ); + } + if (key.text) { + if (!fallback || !ts.isStringLiteralLike(fallback)) { + throw new Error( + `${location}: pluginMessage key and fallback must be string literals for translation validation`, + ); + } + for (const [locale, catalog] of Object.entries(resources)) { + const translated = Object.hasOwn(catalog, key.text) + ? catalog[key.text] + : undefined; + if (translated?.trim() && parameters(translated) !== parameters(fallback.text)) { + throw new Error( + `${location}: Translation parameters differ from code fallback for ${locale}:${key.text}`, + ); + } + } + } + } + } + ts.forEachChild(node, visit); + } + visit(source); + }), + ); + } + await visitDirectory(directory); +} diff --git a/scripts/plugin-translations.test.ts b/scripts/plugin-translations.test.ts index ff3d256..75fa5f4 100644 --- a/scripts/plugin-translations.test.ts +++ b/scripts/plugin-translations.test.ts @@ -17,6 +17,14 @@ async function fixture() { temporary.push(root); const plugin = path.join(root, "plugins/example"); await mkdir(path.join(plugin, "translations"), { recursive: true }); + await mkdir(path.join(plugin, "src")); + await writeFile( + path.join(plugin, "src/index.ts"), + ` + import { pluginMessage as m } from '@marswave/cola-plugin-sdk'; + m('auth.wait', 'Wait {{seconds}} seconds'); + `, + ); const pkg = { version: "1.0.0", cola: { @@ -77,4 +85,28 @@ describe("plugin translation publication", () => { await writeFile(path.join(plugin, "translations/zh-CN.json"), "invalid"); await expect(promisify(execFile)(command, args)).rejects.toThrow("Command failed"); }); + + it("rejects catalogs that agree with each other but no longer match the source fallback", async () => { + const { root, plugin } = await fixture(); + await writeFile( + path.join(plugin, "src/index.ts"), + ` + import { pluginMessage as localized } from '@marswave/cola-plugin-sdk'; + localized('auth.wait', 'Wait {{minutes}} minutes'); + `, + ); + await expect( + buildRegistry(path.join(root, "plugins"), "https://files.example.com"), + ).rejects.toThrow("parameters differ from code fallback for en:auth.wait"); + await expect(stagePluginLocales(plugin, path.join(root, "staging"))).rejects.toThrow( + "parameters differ from code fallback", + ); + await expect( + promisify(execFile)(path.resolve("node_modules/.bin/tsx"), [ + "scripts/stage-plugin-locales.ts", + plugin, + path.join(root, "release"), + ]), + ).rejects.toThrow("parameters differ from code fallback"); + }); }); diff --git a/scripts/stage-plugin-locales.ts b/scripts/stage-plugin-locales.ts index d671294..8587cc2 100644 --- a/scripts/stage-plugin-locales.ts +++ b/scripts/stage-plugin-locales.ts @@ -2,11 +2,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { readTranslations } from "./build-registry.js"; +import { validatePluginMessageSources } from "./plugin-message-validation.js"; export async function stagePluginLocales(directory: string, staging: string) { const pkg = JSON.parse(await fs.readFile(path.join(directory, "package.json"), "utf8")); const files = pkg.cola?.channel?.i18n; - await readTranslations(directory, files); + const resources = await readTranslations(directory, files); + if (files) await validatePluginMessageSources(path.join(directory, "src"), resources); for (const file of Object.values(files ?? {}) as string[]) { const target = path.resolve(staging, file); await fs.mkdir(path.dirname(target), { recursive: true }); From 9e669283a5340891fd026892914057a312713b0b Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 8 Sep 2026 13:49:42 +0800 Subject: [PATCH 06/10] fix(slack): bound downloads and support allowlist setup --- plugins/slack/README.md | 20 ++-- plugins/slack/locales/en.json | 4 +- plugins/slack/locales/es.json | 4 +- plugins/slack/locales/ja.json | 4 +- plugins/slack/locales/ko.json | 4 +- plugins/slack/locales/zh-CN.json | 4 +- plugins/slack/locales/zh-TW.json | 4 +- plugins/slack/src/config.ts | 2 +- plugins/slack/src/gateway.ts | 10 +- plugins/slack/src/index.ts | 3 +- plugins/slack/src/media.ts | 57 +++++++-- plugins/slack/src/types.ts | 1 + plugins/slack/tests/gateway.test.ts | 82 ++++++++++--- plugins/slack/tests/media.test.ts | 178 ++++++++++++++++++++++------ 14 files changed, 292 insertions(+), 85 deletions(-) diff --git a/plugins/slack/README.md b/plugins/slack/README.md index ce58805..3a37498 100644 --- a/plugins/slack/README.md +++ b/plugins/slack/README.md @@ -149,9 +149,15 @@ Manifest 不能替你取回 token。创建 App 后仍需: 最简单的方式是让对方先触发一次。未授信的用户私聊机器人、或在频道里 @机器人时,插件会 回复一段提示,里面带好对应的用户 ID 和频道 ID,复制填进 `allowedIds` 即可。 +首次配置可以先留空 `allowedIds` 并保存两个令牌;插件会连接 Slack,但只回复 ID 配置提示, +不会下载附件或将消息交给 Cola。加入白名单后才会处理消息。 也可以在 Slack 客户端里:点用户头像 → `Copy member ID`;或在频道详情底部查看 Channel ID。 +## 附件下载 + +入站附件每个最多 100 MiB,单次下载最多 60 秒。超限、超时或网关停止时会取消下载并删除未完成的文件。 + ## 流式草稿 如果宿主 Cola 的 SDK 支持 `sendDraft`,插件会在回复生成过程中先发一条占位消息,再随 @@ -173,13 +179,13 @@ Manifest 不能替你取回 token。创建 App 后仍需: ## 配置字段 -| 字段 | 必需 | 默认值 | 说明 | -| ------------------- | ---- | ------- | -------------------------------------------------------- | -| `botToken` | 是 | | Bot User OAuth Token,`xoxb-` 开头。请作为 secret 保存。 | -| `appToken` | 是 | | App-level token,`xapp-` 开头,需 `connections:write`。 | -| `allowedIds` | 是 | | 逗号分隔的用户 ID(私聊)和频道 ID(频道)白名单。 | -| `ignoreBotMessages` | 否 | `true` | 是否忽略其他机器人/自己发的消息。 | -| `unfurlLinks` | 否 | `false` | 发送消息时是否展开链接和媒体预览。 | +| 字段 | 必需 | 默认值 | 说明 | +| ------------------- | ---- | ------- | -------------------------------------------------------------------------- | +| `botToken` | 是 | | Bot User OAuth Token,`xoxb-` 开头。请作为 secret 保存。 | +| `appToken` | 是 | | App-level token,`xapp-` 开头,需 `connections:write`。 | +| `allowedIds` | 否 | | 逗号分隔的用户 ID(私聊)和频道 ID(频道)白名单;留空仅提供 ID 配置提示。 | +| `ignoreBotMessages` | 否 | `true` | 是否忽略其他机器人/自己发的消息。 | +| `unfurlLinks` | 否 | `false` | 发送消息时是否展开链接和媒体预览。 | 配置 UI 只暴露 `botToken`、`appToken`、`allowedIds`。`ignoreBotMessages` 与 `unfurlLinks` 保留默认值,需要时可在 `channels.json` 里设置。 diff --git a/plugins/slack/locales/en.json b/plugins/slack/locales/en.json index a0c7128..fa7095f 100644 --- a/plugins/slack/locales/en.json +++ b/plugins/slack/locales/en.json @@ -27,8 +27,8 @@ "config.appToken": "App-level token", "config.appTokenHelp": "App-level token with the connections:write scope (Socket Mode).", "config.allowedIds": "Allowed IDs", - "config.slackHelp": "Comma-separated Slack user IDs (DMs) and channel IDs accepted by the plugin.", - "config.requiredSlack": "Bot token, app token, and allowed IDs are required.", + "config.slackHelp": "Comma-separated Slack user IDs (DMs) and channel IDs. Leave empty to connect and receive ID setup hints without delivering messages to Cola.", + "config.requiredSlack": "Bot token and app token are required.", "label": "Slack", "description": "Chat with Cola in Slack", "slack.access": "Slack access is not configured.\nUser ID: {{user}}\nChat ID: {{chat}}" diff --git a/plugins/slack/locales/es.json b/plugins/slack/locales/es.json index f140e2b..526028e 100644 --- a/plugins/slack/locales/es.json +++ b/plugins/slack/locales/es.json @@ -27,8 +27,8 @@ "config.appToken": "Token de la aplicación", "config.appTokenHelp": "Token de aplicación con el permiso connections:write (Socket Mode).", "config.allowedIds": "ID permitidos", - "config.slackHelp": "ID de usuarios (mensajes directos) y canales de Slack permitidos, separados por comas.", - "config.requiredSlack": "Se requieren los tokens del bot y de la aplicación, y los ID permitidos.", + "config.slackHelp": "IDs de usuarios de Slack (mensajes directos) y canales, separados por comas. Déjalo vacío para conectarte y recibir instrucciones para obtener los IDs sin enviar mensajes a Cola.", + "config.requiredSlack": "Se requieren el token del bot y el token de la aplicación.", "label": "Slack", "description": "Chatea con Cola en Slack", "slack.access": "El acceso a Slack no está configurado.\nID de usuario: {{user}}\nID de chat: {{chat}}" diff --git a/plugins/slack/locales/ja.json b/plugins/slack/locales/ja.json index 9f07655..8dd0698 100644 --- a/plugins/slack/locales/ja.json +++ b/plugins/slack/locales/ja.json @@ -27,8 +27,8 @@ "config.appToken": "アプリレベルトークン", "config.appTokenHelp": "connections:write 権限を持つアプリレベルトークン(Socket Mode)。", "config.allowedIds": "許可する ID", - "config.slackHelp": "許可する Slack ユーザー ID(DM)とチャンネル ID をカンマで区切って入力してください。", - "config.requiredSlack": "ボットトークン、アプリトークン、許可する ID が必要です。", + "config.slackHelp": "Slack ユーザー ID(DM)とチャンネル ID をカンマ区切りで指定します。空欄でも接続して ID の設定案内を受け取れますが、メッセージは Cola に送信されません。", + "config.requiredSlack": "Bot トークンとアプリトークンが必要です。", "label": "Slack", "description": "Slack で Cola と会話", "slack.access": "Slack の利用が未設定です。\nユーザー ID:{{user}}\nチャット ID:{{chat}}" diff --git a/plugins/slack/locales/ko.json b/plugins/slack/locales/ko.json index 3cd9802..e195525 100644 --- a/plugins/slack/locales/ko.json +++ b/plugins/slack/locales/ko.json @@ -27,8 +27,8 @@ "config.appToken": "앱 수준 토큰", "config.appTokenHelp": "connections:write 권한이 있는 앱 수준 토큰 (Socket Mode).", "config.allowedIds": "허용된 ID", - "config.slackHelp": "허용할 Slack 사용자 ID (DM)와 채널 ID를 쉼표로 구분하여 입력하세요.", - "config.requiredSlack": "봇 토큰, 앱 토큰 및 허용된 ID가 필요합니다.", + "config.slackHelp": "Slack 사용자 ID(DM)와 채널 ID를 쉼표로 구분합니다. 비워 두면 연결하여 ID 설정 안내를 받을 수 있지만 메시지는 Cola로 전달되지 않습니다.", + "config.requiredSlack": "봇 토큰과 앱 토큰이 필요합니다.", "label": "Slack", "description": "Slack에서 Cola와 대화하세요", "slack.access": "Slack 접근 권한이 설정되지 않았습니다.\n사용자 ID: {{user}}\n채팅 ID: {{chat}}" diff --git a/plugins/slack/locales/zh-CN.json b/plugins/slack/locales/zh-CN.json index 43c3f39..3084ee0 100644 --- a/plugins/slack/locales/zh-CN.json +++ b/plugins/slack/locales/zh-CN.json @@ -27,8 +27,8 @@ "config.appToken": "应用级令牌", "config.appTokenHelp": "具有 connections:write 权限的应用级令牌(Socket Mode)。", "config.allowedIds": "允许的 ID", - "config.slackHelp": "插件接受的 Slack 用户 ID(私信)和频道 ID,以英文逗号分隔。", - "config.requiredSlack": "请填写机器人令牌、应用令牌和允许的 ID。", + "config.slackHelp": "用逗号分隔 Slack 用户 ID(私聊)和频道 ID。留空可先连接并获取 ID 配置提示,消息不会传递给 Cola。", + "config.requiredSlack": "需要填写机器人令牌和应用令牌。", "label": "Slack", "description": "在Slack中与 Cola 对话", "slack.access": "尚未配置 Slack 访问权限。\n用户 ID:{{user}}\n聊天 ID:{{chat}}" diff --git a/plugins/slack/locales/zh-TW.json b/plugins/slack/locales/zh-TW.json index 5a3e364..3ced514 100644 --- a/plugins/slack/locales/zh-TW.json +++ b/plugins/slack/locales/zh-TW.json @@ -27,8 +27,8 @@ "config.appToken": "應用程式層級權杖", "config.appTokenHelp": "具有 connections:write 權限的應用程式層級權杖(Socket Mode)。", "config.allowedIds": "允許的 ID", - "config.slackHelp": "外掛程式接受的 Slack 使用者 ID(私訊)與頻道 ID,以半形逗號分隔。", - "config.requiredSlack": "請填寫機器人權杖、應用程式權杖與允許的 ID。", + "config.slackHelp": "以逗號分隔 Slack 使用者 ID(私訊)和頻道 ID。留空可先連線並取得 ID 設定提示,訊息不會傳遞給 Cola。", + "config.requiredSlack": "需要填寫機器人權杖和應用程式權杖。", "label": "Slack", "description": "在Slack中與 Cola 對話", "slack.access": "尚未設定 Slack 存取權限。\n使用者 ID:{{user}}\n聊天 ID:{{chat}}" diff --git a/plugins/slack/src/config.ts b/plugins/slack/src/config.ts index aee9225..1d374db 100644 --- a/plugins/slack/src/config.ts +++ b/plugins/slack/src/config.ts @@ -17,7 +17,7 @@ export function readSlackConfig(raw: Readonly>): SlackCo } export function isSlackConfigured(config: SlackConfig): boolean { - return config.botToken.length > 0 && config.appToken.length > 0 && config.allowedIds.size > 0; + return config.botToken.length > 0 && config.appToken.length > 0; } export function redactToken(token: string): string { diff --git a/plugins/slack/src/gateway.ts b/plugins/slack/src/gateway.ts index dc87e36..2d2b4b8 100644 --- a/plugins/slack/src/gateway.ts +++ b/plugins/slack/src/gateway.ts @@ -56,7 +56,7 @@ export async function startGateway(ctx: GatewayContext): Prom ctx.state.allowedIds = [...config.allowedIds]; if (!ctx.state.configured) { - ctx.logger.warn("Slack bot token, app token, and allowed IDs are required"); + ctx.logger.warn("Slack bot token and app token are required"); return; } @@ -136,7 +136,7 @@ export function getGatewayStatus(ctx: GatewayContext): Channe return { connected: false, configured: false, - message: m("config.requiredSlack", "Bot token, app token, and allowed IDs are required."), + message: m("config.requiredSlack", "Bot token and app token are required."), }; } if (!ctx.state.connected) { @@ -196,9 +196,13 @@ async function handleSlackEvent( const attachments: string[] = []; for (const file of event.files ?? []) { - const filePath = await downloadSlackFile(file, config.botToken, ctx.logger); + if (ctx.abortSignal.aborted) return; + const filePath = await downloadSlackFile(file, config.botToken, ctx.logger, { + signal: ctx.abortSignal, + }); if (filePath) attachments.push(filePath); } + if (ctx.abortSignal.aborted) return; // The configured allowlist is this channel's authorization gate, so bind the // sender to the primary Cola user on first contact. Without a binding the host diff --git a/plugins/slack/src/index.ts b/plugins/slack/src/index.ts index f67f7bd..1aa0bdb 100644 --- a/plugins/slack/src/index.ts +++ b/plugins/slack/src/index.ts @@ -101,11 +101,10 @@ export default defineChannel({ key: "allowedIds", label: m("config.allowedIds", "Allowed IDs"), type: "text", - required: true, placeholder: "U0123ABC,C0456DEF", description: m( "config.slackHelp", - "Comma-separated Slack user IDs (DMs) and channel IDs accepted by the plugin.", + "Comma-separated Slack user IDs (DMs) and channel IDs. Leave empty to connect and receive ID setup hints without delivering messages to Cola.", ), }, // Only the tokens and allowlist are exposed in the config UI. The diff --git a/plugins/slack/src/media.ts b/plugins/slack/src/media.ts index 9423006..21b3dc0 100644 --- a/plugins/slack/src/media.ts +++ b/plugins/slack/src/media.ts @@ -1,13 +1,21 @@ -import fs from "fs"; +import { randomUUID } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; import os from "os"; import path from "path"; -import { Readable } from "stream"; +import { Readable, Transform } from "stream"; import { pipeline } from "stream/promises"; import type { ReadableStream as NodeReadableStream } from "stream/web"; import type { WebClient } from "@slack/web-api"; import type { PluginLogger } from "@marswave/cola-plugin-sdk"; import type { SlackFile } from "./types.js"; +type SlackDownloadOptions = { + maxBytes?: number; + timeoutMs?: number; + signal?: AbortSignal; +}; + /** * Download a Slack-hosted file to a temp path. Slack private URLs require the * bot token as a Bearer header; an HTML response means auth/scope problems @@ -17,6 +25,7 @@ export async function downloadSlackFile( file: SlackFile, botToken: string, logger: PluginLogger, + options: SlackDownloadOptions = {}, ): Promise { const url = file.url_private_download ?? file.url_private; if (!url) { @@ -24,10 +33,19 @@ export async function downloadSlackFile( return undefined; } + const maxBytes = options.maxBytes ?? 100 * 1024 * 1024; + const timeout = AbortSignal.timeout(options.timeoutMs ?? 60_000); + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + let response: Response | undefined; let tmpPath: string | undefined; try { - const response = await fetch(url, { + signal.throwIfAborted(); + if (file.size !== undefined && file.size > maxBytes) { + throw new Error(`File exceeds the ${maxBytes} byte download limit`); + } + response = await fetch(url, { headers: { Authorization: `Bearer ${botToken}` }, + signal, }); if (!response.ok) { logger.warn(`Failed to download Slack file ${file.id}: HTTP ${response.status}`); @@ -41,23 +59,38 @@ export async function downloadSlackFile( return undefined; } - const tmpDir = path.join(os.tmpdir(), "cola-slack"); - fs.mkdirSync(tmpDir, { recursive: true }); - const safeName = sanitizeFileName(file.name ?? file.title ?? file.id); - tmpPath = path.join(tmpDir, `${Date.now()}-${safeName}`); + if (Number(response.headers.get("content-length")) > maxBytes) { + throw new Error(`File exceeds the ${maxBytes} byte download limit`); + } if (!response.body) { logger.warn(`Slack file ${file.id} has no response body`); return undefined; } + const tmpDir = path.join(os.tmpdir(), "cola-slack"); + await mkdir(tmpDir, { recursive: true }); + const safeName = sanitizeFileName(file.name ?? file.title ?? file.id); + tmpPath = path.join(tmpDir, `${randomUUID()}-${safeName}`); + let downloadedBytes = 0; await pipeline( Readable.fromWeb(response.body as NodeReadableStream), - fs.createWriteStream(tmpPath), + new Transform({ + transform(chunk: Buffer, _encoding, callback) { + downloadedBytes += chunk.length; + if (downloadedBytes > maxBytes) { + callback(new Error(`File exceeds the ${maxBytes} byte download limit`)); + } else { + callback(null, chunk); + } + }, + }), + createWriteStream(tmpPath), + { signal }, ); return tmpPath; } catch (err) { if (tmpPath) { try { - fs.rmSync(tmpPath, { force: true }); + await rm(tmpPath, { force: true }); } catch { // Best effort cleanup for partial downloads. } @@ -66,6 +99,10 @@ export async function downloadSlackFile( `Failed to download Slack file ${file.id}: ${err instanceof Error ? err.message : String(err)}`, ); return undefined; + } finally { + if (response?.body && !response.body.locked) { + await response.body.cancel().catch(() => {}); + } } } @@ -80,7 +117,7 @@ export async function uploadSlackFile( ): Promise { const base = { channel_id: opts.channelId, - file: fs.createReadStream(opts.filePath), + file: createReadStream(opts.filePath), filename: path.basename(opts.filePath), ...(opts.comment ? { initial_comment: opts.comment } : {}), }; diff --git a/plugins/slack/src/types.ts b/plugins/slack/src/types.ts index 5160bf9..88b2b1d 100644 --- a/plugins/slack/src/types.ts +++ b/plugins/slack/src/types.ts @@ -9,6 +9,7 @@ export type SlackFile = { id: string; name?: string; title?: string; + size?: number; url_private?: string; url_private_download?: string; }; diff --git a/plugins/slack/tests/gateway.test.ts b/plugins/slack/tests/gateway.test.ts index b1fa252..958cb83 100644 --- a/plugins/slack/tests/gateway.test.ts +++ b/plugins/slack/tests/gateway.test.ts @@ -1,19 +1,25 @@ import { resolvePluginText } from "@marswave/cola-plugin-sdk"; import zhCN from "../locales/zh-CN.json"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayContext } from "@marswave/cola-plugin-sdk"; import { getGatewayStatus, startGateway, type SlackGatewayState } from "../src/gateway.js"; +import slack from "../src/index.js"; +import type { SlackMessageEvent } from "../src/types.js"; const slackMocks = vi.hoisted(() => ({ authTest: vi.fn(), socketStart: vi.fn(), socketDisconnect: vi.fn(), socketOn: vi.fn(), + postMessage: vi.fn(), + userInfo: vi.fn(), })); vi.mock("@slack/web-api", () => ({ WebClient: vi.fn(() => ({ auth: { test: slackMocks.authTest }, + chat: { postMessage: slackMocks.postMessage }, + users: { info: slackMocks.userInfo }, })), })); @@ -25,36 +31,82 @@ vi.mock("@slack/socket-mode", () => ({ })), })); +beforeEach(() => { + for (const mock of Object.values(slackMocks)) mock.mockReset(); + slackMocks.authTest.mockResolvedValue({ user_id: "UBOT", user: "cola", team_id: "T123" }); +}); + +async function receive(event: SlackMessageEvent, type = "message") { + const listener = slackMocks.socketOn.mock.calls.find(([name]) => name === type)?.[1]; + expect(listener).toBeDefined(); + const ack = vi.fn(async () => {}); + await listener({ event, ack }); + expect(ack).toHaveBeenCalledOnce(); +} + describe("slack gateway startup", () => { it("records auth failures in gateway status", async () => { slackMocks.authTest.mockRejectedValueOnce(new Error("invalid_auth")); - const ctx = makeGatewayContext(); - await expect(startGateway(ctx)).rejects.toThrow("invalid_auth"); - expect(ctx.state.lastError).toBe("invalid_auth"); expect(resolvePluginText(getGatewayStatus(ctx).message, { "zh-CN": zhCN }, "zh-CN")).toBe( "未连接", ); - expect(getGatewayStatus(ctx)).toMatchObject({ - connected: false, - configured: true, - }); + expect(getGatewayStatus(ctx)).toMatchObject({ connected: false, configured: true }); + }); + + it("connects with an empty allowlist and only replies with setup IDs", async () => { + const ctx = makeGatewayContext(""); + expect( + slack.config?.schema?.fields.find((field) => field.key === "allowedIds")?.required, + ).not.toBe(true); + await startGateway(ctx); + expect(slackMocks.socketStart).toHaveBeenCalledOnce(); + expect(getGatewayStatus(ctx)).toMatchObject({ connected: true, configured: true }); + + await receive({ channel: "D123", channel_type: "im", ts: "1", user: "U123", text: "hello" }); + await receive({ channel: "C123", ts: "2", user: "U456", text: "<@UBOT> hello" }, "app_mention"); + await receive({ channel: "C123", ts: "3", user: "U456", text: "ordinary channel message" }); + expect(slackMocks.postMessage.mock.calls.map(([message]) => message)).toEqual([ + { channel: "D123", text: "Slack access is not configured.\nUser ID: U123\nChat ID: D123" }, + { channel: "C123", text: "Slack access is not configured.\nUser ID: U456\nChat ID: C123" }, + ]); + expect(ctx.deliver).not.toHaveBeenCalled(); + expect(ctx.runtime.identity.bind).not.toHaveBeenCalled(); + }); + + it("delivers messages only after their sender is allowed", async () => { + const ctx = makeGatewayContext("U123"); + await startGateway(ctx); + await receive({ channel: "D123", channel_type: "im", ts: "1", user: "U123", text: "hello" }); + await receive({ channel: "D456", channel_type: "im", ts: "2", user: "U456", text: "blocked" }); + expect(ctx.deliver).toHaveBeenCalledOnce(); + expect(ctx.deliver).toHaveBeenCalledWith(expect.objectContaining({ message: "hello" })); + expect(ctx.runtime.identity.bind).toHaveBeenCalledOnce(); + expect(ctx.runtime.identity.bind).toHaveBeenCalledWith("U123"); + }); + + it.each(["botToken", "appToken"])("does not connect without %s", async (missing) => { + const ctx = makeGatewayContext(""); + ctx.config = { ...ctx.config, [missing]: "" }; + await startGateway(ctx); + expect(slackMocks.authTest).not.toHaveBeenCalled(); + expect(slackMocks.socketStart).not.toHaveBeenCalled(); + expect(getGatewayStatus(ctx)).toMatchObject({ connected: false, configured: false }); }); }); -function makeGatewayContext(): GatewayContext { +function makeGatewayContext(allowedIds = "C123"): GatewayContext { return { - config: { botToken: "xoxb-token", appToken: "xapp-token", allowedIds: "C123" }, + config: { botToken: "xoxb-token", appToken: "xapp-token", allowedIds }, state: {}, abortSignal: new AbortController().signal, - logger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + runtime: { + identity: { resolve: vi.fn(), bind: vi.fn(), unbind: vi.fn() }, + i18n: { text: async (message) => resolvePluginText(message, {}, "en") }, }, - runtime: { identity: { resolve: vi.fn(), bind: vi.fn(), unbind: vi.fn() } }, deliver: vi.fn(), } as unknown as GatewayContext; } diff --git a/plugins/slack/tests/media.test.ts b/plugins/slack/tests/media.test.ts index 4807aea..e7c49b3 100644 --- a/plugins/slack/tests/media.test.ts +++ b/plugins/slack/tests/media.test.ts @@ -1,52 +1,160 @@ -import fs from "fs"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginLogger } from "@marswave/cola-plugin-sdk"; import { downloadSlackFile } from "../src/media.js"; -const logger: PluginLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), +const logger: PluginLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; +const file = { + id: "F123", + name: "note.txt", + url_private_download: "https://slack.example/files/F123", }; +let temporary: string; -afterEach(() => { +beforeEach(async () => { + temporary = await mkdtemp(path.join(os.tmpdir(), "cola-slack-download-test-")); + vi.spyOn(os, "tmpdir").mockReturnValue(temporary); +}); + +afterEach(async () => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); + await rm(temporary, { recursive: true, force: true }); }); +async function downloadedFiles() { + return readdir(path.join(temporary, "cola-slack")).catch((error) => { + if (error.code === "ENOENT") return []; + throw error; + }); +} + describe("slack media downloads", () => { - it("streams private file downloads to disk", async () => { - const body = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("hello ")); - controller.enqueue(new TextEncoder().encode("world")); - controller.close(); - }, - }); + it("streams a file exactly at the limit to disk", async () => { + const fetch = vi.fn(async () => new Response("hello world")); + vi.stubGlobal("fetch", fetch); + const result = await downloadSlackFile(file, "xoxb-token", logger, { maxBytes: 11 }); + expect(result).toBeDefined(); + expect(await readFile(result!, "utf8")).toBe("hello world"); + expect(fetch.mock.calls[0]).toEqual([ + file.url_private_download, + expect.objectContaining({ + headers: { Authorization: "Bearer xoxb-token" }, + signal: expect.any(AbortSignal), + }), + ]); + }); + + it("rejects metadata exceeding the default 100 MiB limit before fetching", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + expect( + await downloadSlackFile({ ...file, size: 100 * 1024 * 1024 + 1 }, "token", logger), + ).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + expect(await downloadedFiles()).toEqual([]); + }); + + it("cancels an oversized Content-Length response before writing a file", async () => { + const cancel = vi.fn(); vi.stubGlobal( "fetch", - vi.fn(async () => ({ - ok: true, - status: 200, - headers: new Headers({ "content-type": "text/plain" }), - body, - arrayBuffer: async () => { - throw new Error("arrayBuffer should not be used"); - }, - })), + vi.fn( + async () => + new Response(new ReadableStream({ cancel }), { + headers: { "content-length": "9" }, + }), + ), ); + expect(await downloadSlackFile(file, "token", logger, { maxBytes: 8 })).toBeUndefined(); + expect(cancel).toHaveBeenCalledOnce(); + expect(await downloadedFiles()).toEqual([]); + }); - const filePath = await downloadSlackFile( - { - id: "F123", - name: "note.txt", - url_private_download: "https://slack.example/files/F123", - }, - "xoxb-token", - logger, + it.each([undefined, "1"])( + "bounds streaming bytes with Content-Length %s and removes partial files", + async (length) => { + const cancel = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(4)); + }, + cancel, + }), + { headers: length ? { "content-length": length } : {} }, + ), + ), + ); + expect(await downloadSlackFile(file, "token", logger, { maxBytes: 8 })).toBeUndefined(); + expect(cancel).toHaveBeenCalledOnce(); + expect(await downloadedFiles()).toEqual([]); + }, + ); + + it("times out a fetch that never returns headers", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + (_url, { signal }: RequestInit) => + new Promise((_resolve, reject) => { + signal!.addEventListener("abort", () => reject(signal!.reason), { once: true }); + }), + ), + ); + expect(await downloadSlackFile(file, "token", logger, { timeoutMs: 20 })).toBeUndefined(); + expect(await downloadedFiles()).toEqual([]); + }); + + it("times out a stalled body and removes its partial file", async () => { + const cancel = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4)); + }, + cancel, + }), + ), + ), ); + expect(await downloadSlackFile(file, "token", logger, { timeoutMs: 20 })).toBeUndefined(); + expect(cancel).toHaveBeenCalledOnce(); + expect(await downloadedFiles()).toEqual([]); + }); - expect(filePath).toBeDefined(); - expect(fs.readFileSync(filePath!, "utf8")).toBe("hello world"); - fs.rmSync(filePath!, { force: true }); + it("cancels an in-flight download when its gateway stops", async () => { + const gateway = new AbortController(); + const cancel = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array(4)); + gateway.abort(); + }, + cancel, + }), + ), + ), + ); + expect( + await downloadSlackFile(file, "token", logger, { signal: gateway.signal }), + ).toBeUndefined(); + expect(cancel).toHaveBeenCalledOnce(); + expect(await downloadedFiles()).toEqual([]); }); }); From fa8c4c65d5f94aa057b0f633f03e338724fcd1de Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 8 Sep 2026 14:26:22 +0800 Subject: [PATCH 07/10] chore(channel): target plugin SDK 0.0.5 --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- plugins/feishu/package.json | 2 +- plugins/slack/package.json | 2 +- plugins/slack/src/index.ts | 5 ++--- plugins/slack/src/outbound.ts | 3 +-- plugins/telegram/package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- 8 files changed, 19 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0a6083b..420bc47 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ Licensed under the Apache License, Version 2.0. See `LICENSE`. ## Plugin localization -Plugin i18n is available in SDK 0.1.0. Register locale files in `package.json`: +Plugin i18n is available in SDK 0.0.5. Register locale files in `package.json`: ```json { @@ -272,7 +272,7 @@ keep the key and fallback as string literals; put dynamic values in `params`. The check supports SDK import aliases and namespace imports without executing plugin code. Missing or empty translations remain valid fallbacks. -Publish SDK 0.1.0 before releasing plugins that depend on it, and set +Publish SDK 0.0.5 before releasing plugins that depend on it, and set `cola.plugin.minColaVersion` to the first released Cola version supporting i18n. In `cola-plugins`, `pnpm build:registry` validates all declared catalogs and embeds only `label`/`description` translations in the store index. Release packaging diff --git a/README.zh-CN.md b/README.zh-CN.md index b371fa6..8019fb2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -160,7 +160,7 @@ plugins/{id}/{id}-{version}.tar.gz ## 插件多语言 -SDK 0.1.0 新增插件 i18n。在 `package.json` 的 `cola.channel.i18n` 中注册语言文件: +SDK 0.0.5 新增插件 i18n。在 `package.json` 的 `cola.channel.i18n` 中注册语言文件: ```json { @@ -235,6 +235,6 @@ throw new PluginLocalizedError( Node 工具可从 `@marswave/cola-plugin-sdk/i18n-files` 导入 `loadPluginTranslations`: 发布时传 `{ strict: true }`,运行时传 `{ onWarning }` 以保留其他有效语言。 -先发布 SDK 0.1.0,再发布依赖它的渠道。`cola.plugin.minColaVersion` 必须设置为首次支持 +先发布 SDK 0.0.5,再发布依赖它的渠道。`cola.plugin.minColaVersion` 必须设置为首次支持 此功能的 Cola 正式版本。`cola-plugins` 的 `pnpm build:registry` 校验所有已声明的语言 文件,只把名称和简介翻译放进商店索引;打包时复制全部注册文件,安装后宿主读取完整字典。 diff --git a/plugins/feishu/package.json b/plugins/feishu/package.json index 0be89f3..9ae5e6a 100644 --- a/plugins/feishu/package.json +++ b/plugins/feishu/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@larksuiteoapi/node-sdk": "^1.61.1", - "@marswave/cola-plugin-sdk": "0.1.0" + "@marswave/cola-plugin-sdk": "0.0.5" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/plugins/slack/package.json b/plugins/slack/package.json index c3b7307..14ad5f8 100644 --- a/plugins/slack/package.json +++ b/plugins/slack/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@marswave/cola-plugin-sdk": "0.1.0", + "@marswave/cola-plugin-sdk": "0.0.5", "@slack/socket-mode": "^2.0.4", "@slack/web-api": "^7.9.3" }, diff --git a/plugins/slack/src/index.ts b/plugins/slack/src/index.ts index 1aa0bdb..87a8e97 100644 --- a/plugins/slack/src/index.ts +++ b/plugins/slack/src/index.ts @@ -19,9 +19,8 @@ import { let activeState: SlackGatewayState = {}; -// sendDraft/draftThrottleMs land in SDK 0.0.5 (streaming draft preview); the -// widened type lets this compile against 0.0.3 until the SDK bump. Hosts that -// predate the capability simply never call sendDraft. +// Keep draft preview fields local until the SDK exposes them. +// Hosts without draft preview support simply never call sendDraft. const outbound: ChannelOutboundAdapter & { sendDraft?(ctx: SlackDraftContext): Promise; draftThrottleMs?: number; diff --git a/plugins/slack/src/outbound.ts b/plugins/slack/src/outbound.ts index 8b8aabb..a9f5b5c 100644 --- a/plugins/slack/src/outbound.ts +++ b/plugins/slack/src/outbound.ts @@ -39,8 +39,7 @@ export async function sendSlackText(ctx: OutboundContext, state: SlackGatewaySta } /** - * Local mirror of the SDK 0.0.5 DraftContext (OutboundContext & { done }). - * Replace with the SDK export once @marswave/cola-plugin-sdk >= 0.0.5 lands. + * Keep the draft context local until the SDK exports it. */ export type SlackDraftContext = OutboundContext & { done: boolean }; diff --git a/plugins/telegram/package.json b/plugins/telegram/package.json index b6b2cdd..95afebb 100644 --- a/plugins/telegram/package.json +++ b/plugins/telegram/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@marswave/cola-plugin-sdk": "0.1.0" + "@marswave/cola-plugin-sdk": "0.0.5" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7df1511..3df9102 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^1.61.1 version: 1.66.0 '@marswave/cola-plugin-sdk': - specifier: 0.1.0 - version: 0.1.0 + specifier: 0.0.5 + version: 0.0.5 devDependencies: '@types/node': specifier: ^22.0.0 @@ -46,8 +46,8 @@ importers: plugins/slack: dependencies: '@marswave/cola-plugin-sdk': - specifier: 0.1.0 - version: 0.1.0 + specifier: 0.0.5 + version: 0.0.5 '@slack/socket-mode': specifier: ^2.0.4 version: 2.0.7 @@ -68,8 +68,8 @@ importers: plugins/telegram: dependencies: '@marswave/cola-plugin-sdk': - specifier: 0.1.0 - version: 0.1.0 + specifier: 0.0.5 + version: 0.0.5 devDependencies: '@types/node': specifier: ^22.0.0 @@ -396,8 +396,8 @@ packages: '@marswave/cola-plugin-sdk@0.0.3': resolution: {integrity: sha512-oO1rCwUXZqKlJdE80VclGmsMKW/yU6BbgKcHAnWR0RzksEder56QWSoH7ckAAmWnw4BY2ATFlRWw5BRLMVYIgA==} - '@marswave/cola-plugin-sdk@0.1.0': - resolution: {integrity: sha512-OJagQaduZIfp1cYqNUJL+HAStjpasFCiiDvak+MPxflp6FHZ+XI1L5a/E3h2qLFn4X2nsH59hmAHtOKYXvVKyA==} + '@marswave/cola-plugin-sdk@0.0.5': + resolution: {tarball: https://registry.npmjs.org/@marswave/cola-plugin-sdk/-/cola-plugin-sdk-0.0.5.tgz} '@oxfmt/binding-android-arm-eabi@0.42.0': resolution: {integrity: sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow==} @@ -1624,7 +1624,7 @@ snapshots: '@marswave/cola-plugin-sdk@0.0.3': {} - '@marswave/cola-plugin-sdk@0.1.0': {} + '@marswave/cola-plugin-sdk@0.0.5': {} '@oxfmt/binding-android-arm-eabi@0.42.0': optional: true From a7ec9bedc1aab06f4dbb1dafaa4f84fab472372d Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 8 Sep 2026 15:54:28 +0800 Subject: [PATCH 08/10] fix(channel): handle Slack cancellation and attachment edge cases --- plugins/slack/src/gateway.ts | 94 ++++++++++++++------- plugins/slack/src/media.ts | 21 +++-- plugins/slack/src/message.ts | 4 +- plugins/slack/src/types.ts | 2 + plugins/slack/tests/gateway.test.ts | 121 +++++++++++++++++++++++++-- plugins/slack/tests/media.test.ts | 79 +++++++++++++++++ plugins/slack/tests/message.test.ts | 12 +++ scripts/plugin-message-validation.ts | 42 +++++++--- scripts/plugin-translations.test.ts | 28 +++++++ 9 files changed, 351 insertions(+), 52 deletions(-) diff --git a/plugins/slack/src/gateway.ts b/plugins/slack/src/gateway.ts index 2d2b4b8..0f87b11 100644 --- a/plugins/slack/src/gateway.ts +++ b/plugins/slack/src/gateway.ts @@ -1,6 +1,7 @@ import { pluginMessage as m } from "@marswave/cola-plugin-sdk"; import { SocketModeClient } from "@slack/socket-mode"; import { WebClient } from "@slack/web-api"; +import { rm } from "node:fs/promises"; import type { ChannelSender, ChannelStatusResult, GatewayContext } from "@marswave/cola-plugin-sdk"; import { isSlackConfigured, readSlackConfig, type SlackConfig } from "./config.js"; import { downloadSlackFile } from "./media.js"; @@ -55,6 +56,7 @@ export async function startGateway(ctx: GatewayContext): Prom ctx.state.configured = isSlackConfigured(config); ctx.state.allowedIds = [...config.allowedIds]; + if (ctx.abortSignal.aborted) return; if (!ctx.state.configured) { ctx.logger.warn("Slack bot token and app token are required"); return; @@ -63,6 +65,7 @@ export async function startGateway(ctx: GatewayContext): Prom try { const web = new WebClient(config.botToken); const auth = await web.auth.test(); + if (ctx.abortSignal.aborted) return; ctx.state.web = web; ctx.state.botUserId = typeof auth.user_id === "string" ? auth.user_id : undefined; ctx.state.botName = typeof auth.user === "string" ? auth.user : undefined; @@ -77,6 +80,7 @@ export async function startGateway(ctx: GatewayContext): Prom const handle = async ({ event, ack }: SlackEventArgs) => { await ack(); + if (ctx.abortSignal.aborted) return; try { await handleSlackEvent(event, ctx, config, dedup, senderCache); } catch (err) { @@ -90,6 +94,7 @@ export async function startGateway(ctx: GatewayContext): Prom socket.on("app_mention", handle); socket.on("connected", () => { + if (ctx.abortSignal.aborted) return; ctx.state.connected = true; ctx.state.lastError = undefined; }); @@ -101,9 +106,22 @@ export async function startGateway(ctx: GatewayContext): Prom ctx.logger.warn("Slack socket error", error); }); - ctx.abortSignal.addEventListener("abort", () => void socket.disconnect(), { once: true }); + ctx.abortSignal.addEventListener( + "abort", + () => { + ctx.state.connected = false; + void socket + .disconnect() + .catch((error) => ctx.logger.warn("Failed to disconnect Slack socket", error)); + }, + { once: true }, + ); await socket.start(); + if (ctx.abortSignal.aborted) { + await socket.disconnect(); + return; + } ctx.state.connected = true; ctx.state.lastError = undefined; ctx.logger.info( @@ -111,6 +129,7 @@ export async function startGateway(ctx: GatewayContext): Prom ); } catch (err) { ctx.state.connected = false; + if (ctx.abortSignal.aborted) return; ctx.state.lastError = errorMessage(err); ctx.logger.warn("Failed to start Slack gateway", err); throw err; @@ -195,37 +214,54 @@ async function handleSlackEvent( ctx.state.lastEventAt = Date.now(); const attachments: string[] = []; - for (const file of event.files ?? []) { + let delivered = false; + try { + for (const file of event.files ?? []) { + if (ctx.abortSignal.aborted) return; + const filePath = await downloadSlackFile(file, config.botToken, ctx.logger, { + signal: ctx.abortSignal, + }); + if (filePath) attachments.push(filePath); + } if (ctx.abortSignal.aborted) return; - const filePath = await downloadSlackFile(file, config.botToken, ctx.logger, { - signal: ctx.abortSignal, - }); - if (filePath) attachments.push(filePath); - } - if (ctx.abortSignal.aborted) return; - // The configured allowlist is this channel's authorization gate, so bind the - // sender to the primary Cola user on first contact. Without a binding the host - // drops every message as an "unbound sender" and the bot never replies. - if (!(await ctx.runtime.identity.resolve(parsed.senderId))) { - await ctx.runtime.identity.bind(parsed.senderId); - ctx.logger.info(`Bound Slack sender ${parsed.senderId} from allowed ${event.channel}`); - } + // The configured allowlist is this channel's authorization gate, so bind the + // sender to the primary Cola user on first contact. Without a binding the host + // drops every message as an "unbound sender" and the bot never replies. + if (!(await ctx.runtime.identity.resolve(parsed.senderId))) { + await ctx.runtime.identity.bind(parsed.senderId); + ctx.logger.info(`Bound Slack sender ${parsed.senderId} from allowed ${event.channel}`); + } - await ctx.deliver({ - sessionId: parsed.sessionId, - sender: await resolveSender(parsed.senderId, ctx, senderCache), - conversation: parsed.conversation, - mentionedBot: parsed.mentionedBot, - deliveryContext: { - to: parsed.deliveryTo, - accountId, - threadId: parsed.threadId, - messageId: parsed.messageId, - }, - message: parsed.text, - attachments: attachments.length > 0 ? attachments : undefined, - }); + const sender = await resolveSender(parsed.senderId, ctx, senderCache); + if (ctx.abortSignal.aborted) return; + await ctx.deliver({ + sessionId: parsed.sessionId, + sender, + conversation: parsed.conversation, + mentionedBot: parsed.mentionedBot, + deliveryContext: { + to: parsed.deliveryTo, + accountId, + threadId: parsed.threadId, + messageId: parsed.messageId, + }, + message: parsed.text, + attachments: attachments.length > 0 ? attachments : undefined, + }); + delivered = true; + } finally { + // The host only receives ownership of completed downloads after delivery. + if (!delivered) { + await Promise.all( + attachments.map((filePath) => + rm(filePath, { force: true }).catch((error) => { + ctx.logger.warn("Failed to remove an undelivered Slack attachment", error); + }), + ), + ); + } + } } async function resolveSender( diff --git a/plugins/slack/src/media.ts b/plugins/slack/src/media.ts index 21b3dc0..4f0347b 100644 --- a/plugins/slack/src/media.ts +++ b/plugins/slack/src/media.ts @@ -18,8 +18,7 @@ type SlackDownloadOptions = { /** * Download a Slack-hosted file to a temp path. Slack private URLs require the - * bot token as a Bearer header; an HTML response means auth/scope problems - * (Slack serves a login page instead of an error). + * bot token as a Bearer header. Unexpected HTML can be a login page. */ export async function downloadSlackFile( file: SlackFile, @@ -51,8 +50,15 @@ export async function downloadSlackFile( logger.warn(`Failed to download Slack file ${file.id}: HTTP ${response.status}`); return undefined; } - const contentType = response.headers.get("content-type") ?? ""; - if (contentType.includes("text/html")) { + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + const attachment = /^attachment(?:;|$)/i.test( + response.headers.get("content-disposition")?.trim() ?? "", + ); + const declaredHtml = + file.mimetype?.split(";")[0].trim().toLowerCase() === "text/html" || file.filetype === "html"; + const loginPage = + response.url && /\/(?:signin|sign_in|login)(?:\/|$)/i.test(new URL(response.url).pathname); + if (contentType.includes("text/html") && (loginPage || (!attachment && !declaredHtml))) { logger.warn( `Slack returned HTML for file ${file.id}; bot token likely lacks files:read scope`, ); @@ -130,5 +136,10 @@ export async function uploadSlackFile( export function sanitizeFileName(name: string): string { const base = path.basename(name).replace(/[^\w.()\- ]+/g, "_"); - return base || "file"; + // Sanitization is ASCII-only; reserve 37 bytes for the UUID and separator. + const maxBytes = 255 - 37; + if (base.length <= maxBytes) return base || "file"; + const extension = path.extname(base); + const suffix = extension.slice(0, 32); + return base.slice(0, Math.min(base.length - extension.length, maxBytes - suffix.length)) + suffix; } diff --git a/plugins/slack/src/message.ts b/plugins/slack/src/message.ts index c1bcb22..0ae2f47 100644 --- a/plugins/slack/src/message.ts +++ b/plugins/slack/src/message.ts @@ -55,11 +55,11 @@ export function parseSlackMessage( // Channel messages always reply in the message's thread (the top-level // message starts one); DMs only thread when the user already did. const threadId = isDm ? event.thread_ts : (event.thread_ts ?? event.ts); - const threadSuffix = !isDm && threadId ? ["thread", threadId] : []; + const threadSuffix = threadId ? ["thread", threadId] : []; return { sessionId: isDm - ? ["chat", accountId, channelId, "sender", senderId] + ? ["chat", accountId, channelId, "sender", senderId, ...threadSuffix] : ["chat", accountId, channelId, ...threadSuffix], senderId, conversation, diff --git a/plugins/slack/src/types.ts b/plugins/slack/src/types.ts index 88b2b1d..8951240 100644 --- a/plugins/slack/src/types.ts +++ b/plugins/slack/src/types.ts @@ -10,6 +10,8 @@ export type SlackFile = { name?: string; title?: string; size?: number; + mimetype?: string; + filetype?: string; url_private?: string; url_private_download?: string; }; diff --git a/plugins/slack/tests/gateway.test.ts b/plugins/slack/tests/gateway.test.ts index 958cb83..e84d36e 100644 --- a/plugins/slack/tests/gateway.test.ts +++ b/plugins/slack/tests/gateway.test.ts @@ -1,8 +1,16 @@ +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { resolvePluginText } from "@marswave/cola-plugin-sdk"; import zhCN from "../locales/zh-CN.json"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayContext } from "@marswave/cola-plugin-sdk"; -import { getGatewayStatus, startGateway, type SlackGatewayState } from "../src/gateway.js"; +import { + getGatewayStatus, + startGateway, + stopGateway, + type SlackGatewayState, +} from "../src/gateway.js"; import slack from "../src/index.js"; import type { SlackMessageEvent } from "../src/types.js"; @@ -31,11 +39,21 @@ vi.mock("@slack/socket-mode", () => ({ })), })); -beforeEach(() => { +let temporary: string; +beforeEach(async () => { + temporary = await mkdtemp(path.join(os.tmpdir(), "cola-slack-gateway-test-")); + vi.spyOn(os, "tmpdir").mockReturnValue(temporary); for (const mock of Object.values(slackMocks)) mock.mockReset(); + slackMocks.socketDisconnect.mockResolvedValue(undefined); slackMocks.authTest.mockResolvedValue({ user_id: "UBOT", user: "cola", team_id: "T123" }); }); +afterEach(async () => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + await rm(temporary, { recursive: true, force: true }); +}); + async function receive(event: SlackMessageEvent, type = "message") { const listener = slackMocks.socketOn.mock.calls.find(([name]) => name === type)?.[1]; expect(listener).toBeDefined(); @@ -95,13 +113,106 @@ describe("slack gateway startup", () => { expect(slackMocks.socketStart).not.toHaveBeenCalled(); expect(getGatewayStatus(ctx)).toMatchObject({ connected: false, configured: false }); }); + it("does not authenticate or connect when already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const ctx = makeGatewayContext("U123", controller.signal); + await startGateway(ctx); + expect(slackMocks.authTest).not.toHaveBeenCalled(); + expect(slackMocks.socketStart).not.toHaveBeenCalled(); + expect(ctx.state.connected).toBe(false); + }); + + it("does not resume startup after shutdown while authentication is pending", async () => { + let finishAuth!: (value: unknown) => void; + slackMocks.authTest.mockReturnValueOnce( + new Promise((resolve) => { + finishAuth = resolve; + }), + ); + const controller = new AbortController(); + const ctx = makeGatewayContext("U123", controller.signal); + const startup = startGateway(ctx); + controller.abort(); + await stopGateway(ctx); + finishAuth({ user_id: "UBOT", team_id: "T123" }); + await startup; + expect(slackMocks.socketStart).not.toHaveBeenCalled(); + expect(ctx.state.socket).toBeUndefined(); + expect(ctx.state.connected).toBe(false); + }); + + it("disconnects and ignores late connected events when startup is cancelled", async () => { + const controller = new AbortController(); + const ctx = makeGatewayContext("U123", controller.signal); + slackMocks.socketStart.mockImplementationOnce(async () => { + controller.abort(); + slackMocks.socketOn.mock.calls.find(([name]) => name === "connected")![1](); + }); + await startGateway(ctx); + expect(slackMocks.socketDisconnect).toHaveBeenCalled(); + expect(ctx.state.connected).toBe(false); + }); + + it.each(["abort-second", "identity-error", "deliver-error", "abort-before-delivery", "success"])( + "handles attachment ownership on %s", + async (outcome) => { + const controller = new AbortController(); + const ctx = makeGatewayContext("U123", controller.signal); + await startGateway(ctx); + let downloads = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + if (++downloads === 2) { + controller.abort(); + throw new DOMException("Stopped", "AbortError"); + } + return new Response("downloaded attachment"); + }), + ); + if (outcome === "identity-error") + vi.mocked(ctx.runtime.identity.resolve).mockRejectedValueOnce(new Error("identity failed")); + if (outcome === "deliver-error") + vi.mocked(ctx.deliver).mockRejectedValueOnce(new Error("delivery failed")); + if (outcome === "abort-before-delivery") + slackMocks.userInfo.mockImplementationOnce(async () => { + controller.abort(); + return {}; + }); + const files = [{ id: "F1", name: "first.txt", url_private: "https://slack.example/first" }]; + if (outcome === "abort-second") + files.push({ id: "F2", name: "second.txt", url_private: "https://slack.example/second" }); + await receive({ + channel: "D123", + channel_type: "im", + ts: "1", + user: "U123", + text: "files", + files, + }); + const remaining = await readdir(path.join(temporary, "cola-slack")); + if (outcome === "success") { + expect(ctx.deliver).toHaveBeenCalledOnce(); + expect(remaining).toHaveLength(1); + const delivered = vi.mocked(ctx.deliver).mock.calls[0][0]; + expect(await readFile(delivered.attachments![0], "utf8")).toBe("downloaded attachment"); + } else { + expect(remaining).toEqual([]); + if (outcome !== "deliver-error") expect(ctx.deliver).not.toHaveBeenCalled(); + } + }, + ); }); -function makeGatewayContext(allowedIds = "C123"): GatewayContext { +function makeGatewayContext( + allowedIds = "C123", + signal = new AbortController().signal, +): GatewayContext { return { config: { botToken: "xoxb-token", appToken: "xapp-token", allowedIds }, state: {}, - abortSignal: new AbortController().signal, + abortSignal: signal, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, runtime: { identity: { resolve: vi.fn(), bind: vi.fn(), unbind: vi.fn() }, diff --git a/plugins/slack/tests/media.test.ts b/plugins/slack/tests/media.test.ts index e7c49b3..99a1074 100644 --- a/plugins/slack/tests/media.test.ts +++ b/plugins/slack/tests/media.test.ts @@ -157,4 +157,83 @@ describe("slack media downloads", () => { expect(cancel).toHaveBeenCalledOnce(); expect(await downloadedFiles()).toEqual([]); }); + it.each([219, 255, 400])( + "downloads a file with a %i-byte source name and keeps its extension", + async (length) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("file contents")), + ); + const result = await downloadSlackFile( + { ...file, name: "a".repeat(length - 4) + ".txt" }, + "token", + logger, + ); + expect(result).toBeDefined(); + expect(Buffer.byteLength(path.basename(result!))).toBeLessThanOrEqual(255); + expect(path.extname(result!)).toBe(".txt"); + expect(await readFile(result!, "utf8")).toBe("file contents"); + }, + ); + + it.each(["disposition", "mimetype", "filetype"])( + "accepts HTML identified by %s", + async (evidence) => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("report", { + headers: { + "content-type": "text/html; charset=utf-8", + ...(evidence === "disposition" + ? { "content-disposition": 'attachment; filename="report.html"' } + : {}), + }, + }), + ), + ); + const result = await downloadSlackFile( + { + ...file, + name: "report.html", + ...(evidence === "mimetype" ? { mimetype: "text/html" } : {}), + ...(evidence === "filetype" ? { filetype: "html" } : {}), + }, + "token", + logger, + ); + expect(await readFile(result!, "utf8")).toBe("report"); + }, + ); + + it.each(["https://slack.com/signin", "https://example.slack.com/login"])( + "rejects a login redirect to %s even for a declared HTML file", + async (url) => { + const response = new Response("Sign in", { + headers: { "content-type": "text/html" }, + }); + Object.defineProperty(response, "url", { value: url }); + vi.stubGlobal( + "fetch", + vi.fn(async () => response), + ); + expect( + await downloadSlackFile({ ...file, mimetype: "text/html" }, "token", logger), + ).toBeUndefined(); + expect(await downloadedFiles()).toEqual([]); + }, + ); + + it("rejects unexpected HTML without file metadata or an attachment disposition", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("Sign in", { headers: { "content-type": "text/html" } }), + ), + ); + expect(await downloadSlackFile(file, "token", logger)).toBeUndefined(); + expect(await downloadedFiles()).toEqual([]); + }); }); diff --git a/plugins/slack/tests/message.test.ts b/plugins/slack/tests/message.test.ts index 6258c5a..1cd1cef 100644 --- a/plugins/slack/tests/message.test.ts +++ b/plugins/slack/tests/message.test.ts @@ -27,4 +27,16 @@ describe("slack message parsing", () => { expect(parsedReply?.threadId).toBe(root.ts); expect(parsedRoot?.sessionId).toEqual(parsedReply?.sessionId); }); + it("keeps separate DM threads isolated while preserving unthreaded DM sessions", () => { + const event = { channel: "D123", channel_type: "im", ts: "11", user: "U123", text: "hello" }; + const plain = parseSlackMessage(event, "T123", "B123")!; + const first = parseSlackMessage({ ...event, thread_ts: "10" }, "T123", "B123")!; + const followup = parseSlackMessage({ ...event, ts: "12", thread_ts: "10" }, "T123", "B123")!; + const second = parseSlackMessage({ ...event, ts: "21", thread_ts: "20" }, "T123", "B123")!; + expect(plain.sessionId).toEqual(["chat", "T123", "D123", "sender", "U123"]); + expect(first.sessionId).toEqual(followup.sessionId); + expect(first.sessionId).not.toEqual(second.sessionId); + expect(first.sessionId).not.toEqual(plain.sessionId); + expect([first.threadId, second.threadId]).toEqual(["10", "20"]); + }); }); diff --git a/scripts/plugin-message-validation.ts b/scripts/plugin-message-validation.ts index ed325a3..1a017d6 100644 --- a/scripts/plugin-message-validation.ts +++ b/scripts/plugin-message-validation.ts @@ -34,8 +34,21 @@ export async function validatePluginMessageSources( ts.ScriptTarget.Latest, true, ); - const factories = new Set(); - const namespaces = new Set(); + const options: ts.CompilerOptions = { + noResolve: true, + noLib: true, + types: [], + allowJs: true, + }; + const host = ts.createCompilerHost(options); + host.getSourceFile = (name) => + path.resolve(name) === path.resolve(filename) ? source : undefined; + host.fileExists = (name) => path.resolve(name) === path.resolve(filename); + host.readFile = (name) => + path.resolve(name) === path.resolve(filename) ? source.text : undefined; + const checker = ts.createProgram([filename], options, host).getTypeChecker(); + const factories = new Set(); + const namespaces = new Set(); for (const statement of source.statements) { if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; @@ -48,20 +61,27 @@ export async function validatePluginMessageSources( const bindings = statement.importClause?.namedBindings; if (bindings && ts.isNamedImports(bindings)) { for (const binding of bindings.elements) { - if ((binding.propertyName ?? binding.name).text === "pluginMessage") - factories.add(binding.name.text); + if ((binding.propertyName ?? binding.name).text === "pluginMessage") { + const symbol = checker.getSymbolAtLocation(binding.name); + if (symbol) factories.add(symbol); + } } - } else if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text); + } else if (bindings && ts.isNamespaceImport(bindings)) { + const symbol = checker.getSymbolAtLocation(bindings.name); + if (symbol) namespaces.add(symbol); + } } function visit(node: ts.Node) { if (ts.isCallExpression(node)) { const callee = node.expression; - const isFactory = ts.isIdentifier(callee) - ? factories.has(callee.text) - : ts.isPropertyAccessExpression(callee) && - ts.isIdentifier(callee.expression) && - namespaces.has(callee.expression.text) && - callee.name.text === "pluginMessage"; + const target = ts.isIdentifier(callee) + ? callee + : ts.isPropertyAccessExpression(callee) && callee.name.text === "pluginMessage" + ? callee.expression + : undefined; + const symbol = target && checker.getSymbolAtLocation(target); + const isFactory = + symbol && (ts.isIdentifier(callee) ? factories.has(symbol) : namespaces.has(symbol)); if (isFactory) { const [key, fallback] = node.arguments; const location = `${filename}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`; diff --git a/scripts/plugin-translations.test.ts b/scripts/plugin-translations.test.ts index 75fa5f4..ea1efb8 100644 --- a/scripts/plugin-translations.test.ts +++ b/scripts/plugin-translations.test.ts @@ -109,4 +109,32 @@ describe("plugin translation publication", () => { ]), ).rejects.toThrow("parameters differ from code fallback"); }); + it("ignores shadowed SDK aliases but still validates calls to the imported bindings", async () => { + const { root, plugin } = await fixture(); + await writeFile( + path.join(plugin, "src/index.ts"), + ` + import { pluginMessage as m } from '@marswave/cola-plugin-sdk'; + import * as sdk from '@marswave/cola-plugin-sdk'; + function parameter(m: any, value: string) { return m(value); } + { const m = (value: string) => value; m(dynamicValue); } + try {} catch (m) { m(dynamicValue); } + function namespace(sdk: any) { return sdk.pluginMessage(dynamicKey); } + function hoisted(value: string) { m(value); function m(value: string) { return value; } } + m('auth.wait', 'Wait {{seconds}} seconds'); + sdk.pluginMessage('auth.wait', 'Wait {{seconds}} seconds'); + `, + ); + await expect( + buildRegistry(path.join(root, "plugins"), "https://files.example.com"), + ).resolves.toMatchObject({ version: 1 }); + await expect(stagePluginLocales(plugin, path.join(root, "staging"))).resolves.toBeUndefined(); + await writeFile( + path.join(plugin, "translations/en.json"), + JSON.stringify({ "auth.wait": "Wait {{minutes}} minutes" }), + ); + await expect( + buildRegistry(path.join(root, "plugins"), "https://files.example.com"), + ).rejects.toThrow("parameters differ"); + }); }); From 655e329a477fd9b1a543898e51ec02851a5880ea Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 8 Sep 2026 19:03:10 +0800 Subject: [PATCH 09/10] fix(slack): bound message attachments and guard inbound events --- plugins/slack/README.md | 4 + plugins/slack/src/gateway.ts | 29 ++++---- plugins/slack/src/media.ts | 71 +++++++++++++++++- plugins/slack/tests/gateway.test.ts | 109 ++++++++++++++++++++++++++++ plugins/slack/tests/media.test.ts | 100 ++++++++++++++++++++++++- 5 files changed, 294 insertions(+), 19 deletions(-) diff --git a/plugins/slack/README.md b/plugins/slack/README.md index 3a37498..3ea4695 100644 --- a/plugins/slack/README.md +++ b/plugins/slack/README.md @@ -158,6 +158,10 @@ Manifest 不能替你取回 token。创建 App 后仍需: 入站附件每个最多 100 MiB,单次下载最多 60 秒。超限、超时或网关停止时会取消下载并删除未完成的文件。 +每条消息最多包含 10 个附件,累计下载最多 100 MiB,整批下载最多 60 秒;失败下载已接收的字节也计入总量。 +超过整批限制时停止处理该消息,清理已下载的附件,不再投递给 Cola。频道及群聊消息(包括线程回复)必须 +@机器人后才会下载附件或处理发送者身份。 + ## 流式草稿 如果宿主 Cola 的 SDK 支持 `sendDraft`,插件会在回复生成过程中先发一条占位消息,再随 diff --git a/plugins/slack/src/gateway.ts b/plugins/slack/src/gateway.ts index 0f87b11..7268b24 100644 --- a/plugins/slack/src/gateway.ts +++ b/plugins/slack/src/gateway.ts @@ -4,7 +4,7 @@ import { WebClient } from "@slack/web-api"; import { rm } from "node:fs/promises"; import type { ChannelSender, ChannelStatusResult, GatewayContext } from "@marswave/cola-plugin-sdk"; import { isSlackConfigured, readSlackConfig, type SlackConfig } from "./config.js"; -import { downloadSlackFile } from "./media.js"; +import { downloadSlackFiles } from "./media.js"; import { isBotMentioned, isDirectMessage, @@ -79,9 +79,9 @@ export async function startGateway(ctx: GatewayContext): Prom const senderCache = new Map(); const handle = async ({ event, ack }: SlackEventArgs) => { - await ack(); - if (ctx.abortSignal.aborted) return; try { + await ack(); + if (ctx.abortSignal.aborted) return; await handleSlackEvent(event, ctx, config, dedup, senderCache); } catch (err) { ctx.logger.warn("Failed to handle Slack event", err); @@ -192,18 +192,19 @@ async function handleSlackEvent( if (config.ignoreBotMessages && isFromBot(event, ctx.state.botUserId)) return; const isDm = isDirectMessage(event); + // The host requires a mention for every group/channel message, including + // thread replies. Apply the same gate before downloads and identity writes. + if (!isDm && !isBotMentioned(event, ctx.state.botUserId)) return; const allowed = isDm ? (event.user !== undefined && config.allowedIds.has(event.user)) || config.allowedIds.has(event.channel) : config.allowedIds.has(event.channel); if (!allowed) { - // Reply with the IDs needed for the allowlist: always in DMs, only on an - // explicit @mention in channels (anything else would spam the channel). - if (isDm || isBotMentioned(event, ctx.state.botUserId)) { - ctx.logger.info(`Skipping Slack message from unlisted ${isDm ? "user" : "channel"}`); - await sendAccessNotConfiguredReply(event, ctx); - } + // Only DMs and channel mentions reach this point, so setup notices cannot + // spam the channel in response to ordinary posts. + ctx.logger.info(`Skipping Slack message from unlisted ${isDm ? "user" : "channel"}`); + await sendAccessNotConfiguredReply(event, ctx); return; } @@ -216,13 +217,11 @@ async function handleSlackEvent( const attachments: string[] = []; let delivered = false; try { - for (const file of event.files ?? []) { - if (ctx.abortSignal.aborted) return; - const filePath = await downloadSlackFile(file, config.botToken, ctx.logger, { + attachments.push( + ...(await downloadSlackFiles(event.files ?? [], config.botToken, ctx.logger, { signal: ctx.abortSignal, - }); - if (filePath) attachments.push(filePath); - } + })), + ); if (ctx.abortSignal.aborted) return; // The configured allowlist is this channel's authorization gate, so bind the diff --git a/plugins/slack/src/media.ts b/plugins/slack/src/media.ts index 4f0347b..5fb66f3 100644 --- a/plugins/slack/src/media.ts +++ b/plugins/slack/src/media.ts @@ -14,8 +14,69 @@ type SlackDownloadOptions = { maxBytes?: number; timeoutMs?: number; signal?: AbortSignal; + onBytes?: (bytes: number) => void; }; +type SlackAttachmentOptions = { + maxBytes?: number; + maxFiles?: number; + timeoutMs?: number; + signal?: AbortSignal; +}; + +/** Download one message's attachments, retaining ownership until the batch succeeds. */ +export async function downloadSlackFiles( + files: SlackFile[], + botToken: string, + logger: PluginLogger, + options: SlackAttachmentOptions = {}, +): Promise { + const maxFiles = options.maxFiles ?? 10; + if (files.length > maxFiles) { + throw new Error(`Message exceeds the ${maxFiles} attachment limit`); + } + if (files.length === 0) return []; + + const maxBytes = options.maxBytes ?? 100 * 1024 * 1024; + const budget = new AbortController(); + const signal = AbortSignal.any([ + budget.signal, + AbortSignal.timeout(options.timeoutMs ?? 60_000), + ...(options.signal ? [options.signal] : []), + ]); + const paths: string[] = []; + let downloadedBytes = 0; + try { + for (const file of files) { + signal.throwIfAborted(); + const filePath = await downloadSlackFile(file, botToken, logger, { + signal, + onBytes(bytes) { + // Failed downloads also consume the message's transfer budget. + downloadedBytes += bytes; + if (downloadedBytes > maxBytes) { + const error = new Error(`Message exceeds the ${maxBytes} byte attachment limit`); + budget.abort(error); + throw error; + } + }, + }); + if (filePath) paths.push(filePath); + signal.throwIfAborted(); + } + return paths; + } catch (error) { + await Promise.all( + paths.map((filePath) => + rm(filePath, { force: true }).catch((cleanupError) => { + logger.warn("Failed to remove an undelivered Slack attachment", cleanupError); + }), + ), + ); + throw error; + } +} + /** * Download a Slack-hosted file to a temp path. Slack private URLs require the * bot token as a Bearer header. Unexpected HTML can be a login page. @@ -82,10 +143,14 @@ export async function downloadSlackFile( new Transform({ transform(chunk: Buffer, _encoding, callback) { downloadedBytes += chunk.length; - if (downloadedBytes > maxBytes) { - callback(new Error(`File exceeds the ${maxBytes} byte download limit`)); - } else { + try { + options.onBytes?.(chunk.length); + if (downloadedBytes > maxBytes) { + throw new Error(`File exceeds the ${maxBytes} byte download limit`); + } callback(null, chunk); + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error))); } }, }), diff --git a/plugins/slack/tests/gateway.test.ts b/plugins/slack/tests/gateway.test.ts index e84d36e..dc99f9d 100644 --- a/plugins/slack/tests/gateway.test.ts +++ b/plugins/slack/tests/gateway.test.ts @@ -105,6 +105,115 @@ describe("slack gateway startup", () => { expect(ctx.runtime.identity.bind).toHaveBeenCalledWith("U123"); }); + it("logs acknowledgement failures without rejecting the event listener", async () => { + const ctx = makeGatewayContext("U123"); + await startGateway(ctx); + const listener = slackMocks.socketOn.mock.calls.find(([name]) => name === "message")![1]; + const error = new Error("Socket closed during acknowledgement"); + await expect( + listener({ event: {}, ack: vi.fn().mockRejectedValue(error) }), + ).resolves.toBeUndefined(); + expect(ctx.logger.warn).toHaveBeenCalledWith("Failed to handle Slack event", error); + expect(ctx.deliver).not.toHaveBeenCalled(); + }); + + it.each(["channel", "group", "mpim"])( + "ignores unmentioned %s posts before downloading or binding their sender", + async (channelType) => { + const ctx = makeGatewayContext("C123"); + await startGateway(ctx); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("unrelated attachment")), + ); + await receive({ + channel: "C123", + channel_type: channelType, + ts: "1", + user: "U123", + text: "ordinary post", + files: [{ id: "F1", name: "note.txt", url_private: "https://slack.example/file" }], + }); + expect(fetch).not.toHaveBeenCalled(); + expect(ctx.runtime.identity.resolve).not.toHaveBeenCalled(); + expect(ctx.runtime.identity.bind).not.toHaveBeenCalled(); + expect(slackMocks.userInfo).not.toHaveBeenCalled(); + expect(ctx.deliver).not.toHaveBeenCalled(); + expect(ctx.state.lastEventAt).toBeUndefined(); + }, + ); + + it("delivers channel mentions once across message and app_mention events", async () => { + const ctx = makeGatewayContext("C123"); + await startGateway(ctx); + const event = { channel: "C123", ts: "1", user: "U123", text: "<@UBOT> hello" }; + await receive(event); + await receive(event, "app_mention"); + expect(ctx.deliver).toHaveBeenCalledOnce(); + expect(ctx.deliver).toHaveBeenCalledWith( + expect.objectContaining({ mentionedBot: true, message: "hello" }), + ); + }); + + it("rejects more than ten attachments before fetching or binding", async () => { + const ctx = makeGatewayContext("U123"); + await startGateway(ctx); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("file")), + ); + await receive({ + channel: "D123", + channel_type: "im", + ts: "1", + user: "U123", + text: "files", + files: Array.from({ length: 11 }, (_, index) => ({ + id: `F${index}`, + url_private: `https://slack.example/${index}`, + })), + }); + expect(fetch).not.toHaveBeenCalled(); + expect(ctx.runtime.identity.bind).not.toHaveBeenCalled(); + expect(ctx.deliver).not.toHaveBeenCalled(); + }); + + it("cleans up the whole message when two valid files exceed 100 MiB together", async () => { + const ctx = makeGatewayContext("U123"); + await startGateway(ctx); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + let chunks = 60; + const chunk = new Uint8Array(1024 * 1024); + return new Response( + new ReadableStream({ + pull(controller) { + if (chunks-- > 0) controller.enqueue(chunk); + else controller.close(); + }, + }), + ); + }), + ); + await receive({ + channel: "D123", + channel_type: "im", + ts: "1", + user: "U123", + text: "files", + files: [ + { id: "F1", url_private: "https://slack.example/one" }, + { id: "F2", url_private: "https://slack.example/two" }, + { id: "F3", url_private: "https://slack.example/three" }, + ], + }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(await readdir(path.join(temporary, "cola-slack"))).toEqual([]); + expect(ctx.runtime.identity.bind).not.toHaveBeenCalled(); + expect(ctx.deliver).not.toHaveBeenCalled(); + }); + it.each(["botToken", "appToken"])("does not connect without %s", async (missing) => { const ctx = makeGatewayContext(""); ctx.config = { ...ctx.config, [missing]: "" }; diff --git a/plugins/slack/tests/media.test.ts b/plugins/slack/tests/media.test.ts index 99a1074..f5a923f 100644 --- a/plugins/slack/tests/media.test.ts +++ b/plugins/slack/tests/media.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginLogger } from "@marswave/cola-plugin-sdk"; -import { downloadSlackFile } from "../src/media.js"; +import { downloadSlackFile, downloadSlackFiles } from "../src/media.js"; const logger: PluginLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; const file = { @@ -31,6 +31,104 @@ async function downloadedFiles() { }); } +describe("slack message attachment budgets", () => { + const files = [file, { ...file, id: "F456", name: "second.txt" }]; + + it("accepts multiple files exactly at the total byte limit", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("1234")), + ); + const paths = await downloadSlackFiles(files, "token", logger, { maxBytes: 8 }); + expect(paths).toHaveLength(2); + expect(await Promise.all(paths.map((name) => readFile(name, "utf8")))).toEqual([ + "1234", + "1234", + ]); + }); + + it.each([undefined, "1"])( + "counts streamed bytes across files with Content-Length %s and removes the whole batch", + async (length) => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("123456", { + headers: length ? { "content-length": length } : {}, + }), + ), + ); + await expect( + downloadSlackFiles( + [...files, { ...file, id: "F789" }].map((attachment) => ({ ...attachment, size: 1 })), + "token", + logger, + { maxBytes: 10 }, + ), + ).rejects.toThrow("Message exceeds the 10 byte attachment limit"); + expect(fetch).toHaveBeenCalledTimes(2); + expect(await downloadedFiles()).toEqual([]); + }, + ); + + it("includes streamed bytes from failed downloads in the total budget", async () => { + let requests = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + if (++requests !== 1) return new Response("1234"); + let sent = false; + return new Response( + new ReadableStream({ + async pull(controller) { + if (!sent) { + sent = true; + controller.enqueue(new TextEncoder().encode("1234")); + } else { + // Let the first chunk reach the file transform before the body fails. + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.error(new Error("Connection interrupted")); + } + }, + }), + ); + }), + ); + await expect(downloadSlackFiles(files, "token", logger, { maxBytes: 6 })).rejects.toThrow( + "Message exceeds the 6 byte attachment limit", + ); + expect(fetch).toHaveBeenCalledTimes(2); + expect(await downloadedFiles()).toEqual([]); + }); + + it("uses the batch deadline for a later stalled file and removes earlier files", async () => { + const deadline = new AbortController(); + vi.spyOn(AbortSignal, "timeout").mockReturnValueOnce(deadline.signal); + let requests = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + if (++requests === 1) return new Response("first file"); + expect(await downloadedFiles()).toHaveLength(1); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial second file")); + deadline.abort(new DOMException("Message download timed out", "TimeoutError")); + }, + }), + ); + }), + ); + await expect(downloadSlackFiles(files, "token", logger)).rejects.toThrow( + "Message download timed out", + ); + expect(fetch).toHaveBeenCalledTimes(2); + expect(await downloadedFiles()).toEqual([]); + }); +}); + describe("slack media downloads", () => { it("streams a file exactly at the limit to disk", async () => { const fetch = vi.fn(async () => new Response("hello world")); From 8cb4a1d14ab2e9979f48a3081cbba0d033a31c8b Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 8 Sep 2026 19:27:54 +0800 Subject: [PATCH 10/10] fix(slack): clean up abort listeners and preserve link parentheses --- plugins/slack/src/format.ts | 23 +++++++++++++++++++--- plugins/slack/src/gateway.ts | 30 +++++++++++++++++++---------- plugins/slack/tests/format.test.ts | 30 +++++++++++++++++++++++++++++ plugins/slack/tests/gateway.test.ts | 30 +++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 plugins/slack/tests/format.test.ts diff --git a/plugins/slack/src/format.ts b/plugins/slack/src/format.ts index c3a8be3..1e39f00 100644 --- a/plugins/slack/src/format.ts +++ b/plugins/slack/src/format.ts @@ -134,10 +134,27 @@ function consumeMarkdownLink( const labelEnd = text.indexOf("](", index + 1); if (labelEnd === -1) return null; const urlStart = labelEnd + 2; - const urlEnd = text.indexOf(")", urlStart); - if (urlEnd === -1) return null; + let urlEnd = urlStart; + let depth = 0; + let destination = ""; + for (; urlEnd < text.length; urlEnd++) { + const character = text[urlEnd]; + // Escaped parentheses are URL characters, not Markdown delimiters. An + // escaped backslash must be consumed too so it cannot escape the next ')'. + if (character === "\\" && /[\\()]/.test(text[urlEnd + 1] ?? "")) { + destination += text[++urlEnd]; + continue; + } + if (character === "(") depth++; + else if (character === ")") { + if (depth === 0) break; + depth--; + } + destination += character; + } + if (urlEnd === text.length) return null; - const url = text.slice(urlStart, urlEnd).trim(); + const url = destination.trim(); if (!isSafeSlackLink(url)) return null; return { diff --git a/plugins/slack/src/gateway.ts b/plugins/slack/src/gateway.ts index 7268b24..0fdd6bf 100644 --- a/plugins/slack/src/gateway.ts +++ b/plugins/slack/src/gateway.ts @@ -16,6 +16,7 @@ import type { SlackMessageEvent, SlackUserProfile } from "./types.js"; export type SlackGatewayState = { socket?: SocketModeClient; + removeAbortListener?: () => void; web?: WebClient; botUserId?: string; botName?: string; @@ -62,6 +63,7 @@ export async function startGateway(ctx: GatewayContext): Prom return; } + let removeAbortListener: (() => void) | undefined; try { const web = new WebClient(config.botToken); const auth = await web.auth.test(); @@ -106,16 +108,21 @@ export async function startGateway(ctx: GatewayContext): Prom ctx.logger.warn("Slack socket error", error); }); - ctx.abortSignal.addEventListener( - "abort", - () => { - ctx.state.connected = false; - void socket - .disconnect() - .catch((error) => ctx.logger.warn("Failed to disconnect Slack socket", error)); - }, - { once: true }, - ); + const onAbort = () => { + removeAbortListener?.(); + ctx.state.connected = false; + void socket + .disconnect() + .catch((error) => ctx.logger.warn("Failed to disconnect Slack socket", error)); + }; + removeAbortListener = () => { + ctx.abortSignal.removeEventListener("abort", onAbort); + if (ctx.state.removeAbortListener === removeAbortListener) { + ctx.state.removeAbortListener = undefined; + } + }; + ctx.state.removeAbortListener = removeAbortListener; + ctx.abortSignal.addEventListener("abort", onAbort, { once: true }); await socket.start(); if (ctx.abortSignal.aborted) { @@ -128,6 +135,7 @@ export async function startGateway(ctx: GatewayContext): Prom `Slack gateway connected as @${ctx.state.botName ?? "?"} (${ctx.state.botUserId ?? "?"}) in team ${ctx.state.teamId ?? "?"}`, ); } catch (err) { + removeAbortListener?.(); ctx.state.connected = false; if (ctx.abortSignal.aborted) return; ctx.state.lastError = errorMessage(err); @@ -137,6 +145,7 @@ export async function startGateway(ctx: GatewayContext): Prom } export async function stopGateway(ctx: GatewayContext): Promise { + ctx.state.removeAbortListener?.(); const socket = ctx.state.socket; ctx.state.socket = undefined; ctx.state.web = undefined; @@ -315,6 +324,7 @@ function accessNotConfiguredMessage(event: SlackMessageEvent) { } function resetState(state: SlackGatewayState): void { + state.removeAbortListener?.(); state.socket = undefined; state.web = undefined; state.botUserId = undefined; diff --git a/plugins/slack/tests/format.test.ts b/plugins/slack/tests/format.test.ts new file mode 100644 index 0000000..d45afb4 --- /dev/null +++ b/plugins/slack/tests/format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { formatSlackMrkdwn } from "../src/format.js"; + +describe("slack Markdown links", () => { + it.each([ + [ + "[Group](https://en.wikipedia.org/wiki/Group_(mathematics))", + "", + ], + ["[Nested](https://example.com/a(b(c)d)e)", ""], + [String.raw`[Escaped](https://example.com/a\(b\)c)`, ""], + [String.raw`[Close](https://example.com/a\)b)`, ""], + [String.raw`[Open](https://example.com/a\(b)`, ""], + [ + "See ([One](https://example.com/a(b))) and [Two](https://example.com/two).", + "See () and .", + ], + ["[Query](https://example.com/?q=(a)&b=2)", ""], + ])("preserves the full destination in %s", (input, expected) => { + expect(formatSlackMrkdwn(input)).toBe(expected); + }); + + it.each([ + "[Unbalanced](https://example.com/a(b)", + "[Unsafe](javascript:alert(1))", + "`[Code](https://example.com/a(b))`", + ])("keeps malformed, unsafe, and code-span links as text: %s", (input) => { + expect(formatSlackMrkdwn(input)).toBe(input); + }); +}); diff --git a/plugins/slack/tests/gateway.test.ts b/plugins/slack/tests/gateway.test.ts index dc99f9d..e3a3a9f 100644 --- a/plugins/slack/tests/gateway.test.ts +++ b/plugins/slack/tests/gateway.test.ts @@ -1,4 +1,5 @@ import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { getEventListeners } from "node:events"; import os from "node:os"; import path from "node:path"; import { resolvePluginText } from "@marswave/cola-plugin-sdk"; @@ -63,6 +64,35 @@ async function receive(event: SlackMessageEvent, type = "message") { } describe("slack gateway startup", () => { + it("removes abort listeners on stop and only disconnects the current socket at shutdown", async () => { + const controller = new AbortController(); + const ctx = makeGatewayContext("U123", controller.signal); + for (let i = 0; i < 12; i++) { + await startGateway(ctx); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(1); + await stopGateway(ctx); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + } + await startGateway(ctx); + slackMocks.socketDisconnect.mockClear(); + controller.abort(); + await Promise.resolve(); + expect(slackMocks.socketDisconnect).toHaveBeenCalledOnce(); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + }); + + it("removes the abort listener when connection startup fails", async () => { + const controller = new AbortController(); + const ctx = makeGatewayContext("U123", controller.signal); + slackMocks.socketStart.mockRejectedValueOnce(new Error("Connection failed")); + await expect(startGateway(ctx)).rejects.toThrow("Connection failed"); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + await startGateway(ctx); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(1); + await stopGateway(ctx); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + }); + it("records auth failures in gateway status", async () => { slackMocks.authTest.mockRejectedValueOnce(new Error("invalid_auth")); const ctx = makeGatewayContext();