From cf5c89bfddf6535c579197996479dd420beef1a7 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 20:01:25 +0800 Subject: [PATCH 01/26] build(feishu): bump @larksuiteoapi/node-sdk to ^1.61.1 for registerApp --- plugins/feishu/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/feishu/package.json b/plugins/feishu/package.json index a81c5f5..1396229 100644 --- a/plugins/feishu/package.json +++ b/plugins/feishu/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@larksuiteoapi/node-sdk": "^1.60.0", + "@larksuiteoapi/node-sdk": "^1.61.1", "@marswave/cola-plugin-sdk": "^0.0.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a427e1c..120b2fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: plugins/feishu: dependencies: '@larksuiteoapi/node-sdk': - specifier: ^1.60.0 - version: 1.60.0 + specifier: ^1.61.1 + version: 1.66.0 '@marswave/cola-plugin-sdk': specifier: ^0.0.1 version: 0.0.1 @@ -368,8 +368,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@larksuiteoapi/node-sdk@1.60.0': - resolution: {integrity: sha512-MS1eXx7K6HHIyIcCBkJLb21okoa8ZatUGQWZaCCUePm6a37RWFmT6ZKlKvHxAanSX26wNuNlwP0RhgscsE+T6g==} + '@larksuiteoapi/node-sdk@1.66.0': + resolution: {integrity: sha512-ueKbbdvmVGVie3KvKbvHZqvDC/gg3M0rRDeyQanQWK+i2bQgiiTpIfpqVWvxuTgprV31yqV7HPMjN6KegWSCfA==} '@marswave/cola-plugin-sdk@0.0.1': resolution: {integrity: sha512-TVfnamgC9nvSFjISxpRdbUjvP5q+BHlXizS4M3BCe/Ocl24pxSKdrSGqbUcsqR9S3FG8c5TYq1RjMb6T/Q3OQw==} @@ -1513,7 +1513,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@larksuiteoapi/node-sdk@1.60.0': + '@larksuiteoapi/node-sdk@1.66.0': dependencies: axios: 1.13.6 lodash.identity: 3.0.0 From fd56619b92a93936e5585510296455e2e62e40b3 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 20:01:25 +0800 Subject: [PATCH 02/26] feat(feishu): add isBotMentioned helper for group @bot gating --- plugins/feishu/src/util/mention.ts | 12 ++++++++++++ plugins/feishu/tests/mention.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 plugins/feishu/tests/mention.test.ts diff --git a/plugins/feishu/src/util/mention.ts b/plugins/feishu/src/util/mention.ts index 86e6c18..f8e0c90 100644 --- a/plugins/feishu/src/util/mention.ts +++ b/plugins/feishu/src/util/mention.ts @@ -21,3 +21,15 @@ export function stripBotMention( return text; } + +/** + * Whether the bot itself was @mentioned in the message. + * Used to gate group-chat delivery (the SDK access gate requires `mentionedBot`). + */ +export function isBotMentioned( + mentions: FeishuMention[] | undefined, + botOpenId: string | undefined, +): boolean { + if (!botOpenId || !mentions?.length) return false; + return mentions.some((mention) => mention.id.open_id === botOpenId); +} diff --git a/plugins/feishu/tests/mention.test.ts b/plugins/feishu/tests/mention.test.ts new file mode 100644 index 0000000..cff687c --- /dev/null +++ b/plugins/feishu/tests/mention.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { isBotMentioned } from "../src/util/mention.js"; +import type { FeishuMention } from "../src/api/types.js"; + +function mention(openId: string): FeishuMention { + return { key: "@_user_1", id: { open_id: openId }, name: "Bot" }; +} + +describe("isBotMentioned", () => { + it("returns true when a mention matches the bot open_id", () => { + expect(isBotMentioned([mention("ou_bot"), mention("ou_alice")], "ou_bot")).toBe(true); + }); + + it("returns false when no mention matches the bot open_id", () => { + expect(isBotMentioned([mention("ou_alice")], "ou_bot")).toBe(false); + }); + + it("returns false when there are no mentions", () => { + expect(isBotMentioned([], "ou_bot")).toBe(false); + expect(isBotMentioned(undefined, "ou_bot")).toBe(false); + }); + + it("returns false when botOpenId is undefined", () => { + expect(isBotMentioned([mention("ou_bot")], undefined)).toBe(false); + }); +}); From ea1554173234794c5bc0da30eb3da7de84e74522 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 20:02:09 +0800 Subject: [PATCH 03/26] feat(feishu): add fetchBotOpenId for group mention detection --- plugins/feishu/src/api/client.ts | 21 +++++++++++++++ plugins/feishu/tests/client.test.ts | 40 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 plugins/feishu/tests/client.test.ts diff --git a/plugins/feishu/src/api/client.ts b/plugins/feishu/src/api/client.ts index 0881b29..31a8f03 100644 --- a/plugins/feishu/src/api/client.ts +++ b/plugins/feishu/src/api/client.ts @@ -1,4 +1,5 @@ import * as lark from "@larksuiteoapi/node-sdk"; +import type { PluginLogger } from "@marswave/cola-plugin-sdk"; import type { FeishuDomain, FeishuAccountConfig } from "./types.js"; function resolveDomain(domain: FeishuDomain | undefined): lark.Domain | string { @@ -61,6 +62,26 @@ export function createEventDispatcher(config: FeishuAccountConfig): lark.EventDi }); } +/** + * Fetch the bot's own open_id (used to detect @bot mentions in group chats). + * Best-effort: returns undefined on failure so group gating degrades gracefully. + */ +export async function fetchBotOpenId( + client: lark.Client, + logger: PluginLogger, +): Promise { + try { + const res = (await client.request({ + method: "GET", + url: "/open-apis/bot/v3/info", + })) as { bot?: { open_id?: string } }; + return res?.bot?.open_id; + } catch (err) { + logger.warn("Failed to fetch Feishu bot open_id (group @mention detection disabled)", err); + return undefined; + } +} + export function getLarkClient(accountId: string): lark.Client | undefined { return clientCache.get(accountId)?.client; } diff --git a/plugins/feishu/tests/client.test.ts b/plugins/feishu/tests/client.test.ts new file mode 100644 index 0000000..c7b09ae --- /dev/null +++ b/plugins/feishu/tests/client.test.ts @@ -0,0 +1,40 @@ +import type * as lark from "@larksuiteoapi/node-sdk"; +import type { PluginLogger } from "@marswave/cola-plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { fetchBotOpenId } from "../src/api/client.js"; + +function makeLogger(): PluginLogger { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; +} + +describe("fetchBotOpenId", () => { + it("returns the bot open_id from /open-apis/bot/v3/info", async () => { + const request = vi.fn(async () => ({ bot: { open_id: "ou_bot" } })); + const client = { request } as unknown as lark.Client; + + const openId = await fetchBotOpenId(client, makeLogger()); + + expect(openId).toBe("ou_bot"); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ method: "GET", url: "/open-apis/bot/v3/info" }), + ); + }); + + it("returns undefined and warns when the request fails", async () => { + const request = vi.fn(async () => { + throw new Error("network"); + }); + const client = { request } as unknown as lark.Client; + const logger = makeLogger(); + + const openId = await fetchBotOpenId(client, logger); + + expect(openId).toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("returns undefined when the response has no bot open_id", async () => { + const client = { request: vi.fn(async () => ({})) } as unknown as lark.Client; + expect(await fetchBotOpenId(client, makeLogger())).toBeUndefined(); + }); +}); From d7332c3a86500ab79acfcae709a58d08c757fd0e Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 20:02:55 +0800 Subject: [PATCH 04/26] feat(feishu): add legacy authorizedOpenIds migration helper --- plugins/feishu/src/auth/legacy-allowlist.ts | 49 +++++++++++++++ plugins/feishu/tests/legacy-allowlist.test.ts | 62 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 plugins/feishu/src/auth/legacy-allowlist.ts create mode 100644 plugins/feishu/tests/legacy-allowlist.test.ts diff --git a/plugins/feishu/src/auth/legacy-allowlist.ts b/plugins/feishu/src/auth/legacy-allowlist.ts new file mode 100644 index 0000000..46a9e7c --- /dev/null +++ b/plugins/feishu/src/auth/legacy-allowlist.ts @@ -0,0 +1,49 @@ +import type { PluginLogger, PluginRuntime } from "@marswave/cola-plugin-sdk"; + +/** + * Parse a legacy `authorizedOpenIds` config value (comma/space separated string + * or array) into a set of open_ids. Retained only to migrate pre-SDK-gate config. + */ +export function parseAuthorizedOpenIds(value: unknown): Set { + if (Array.isArray(value)) { + return new Set( + value.filter((item): item is string => isNonEmptyString(item)).map((s) => s.trim()), + ); + } + + if (typeof value !== "string") return new Set(); + + return new Set( + value + .split(/[\s,]+/) + .map((item) => item.trim()) + .filter(Boolean), + ); +} + +/** + * One-time migration: bind every legacy `authorizedOpenIds` entry as an identity + * binding so existing authorized users keep access under the SDK access gate. + * Idempotent — re-binding an already-bound sender is a no-op on the host side. + */ +export async function migrateLegacyAllowlist( + accounts: Iterable<{ authorizedOpenIds?: unknown }>, + identity: PluginRuntime["identity"], + logger: PluginLogger, +): Promise { + const seen = new Set(); + for (const acct of accounts) { + for (const openId of parseAuthorizedOpenIds(acct.authorizedOpenIds)) { + if (seen.has(openId)) continue; + seen.add(openId); + await identity.bind(openId); + } + } + if (seen.size > 0) { + logger.info(`Migrated ${seen.size} legacy authorizedOpenId(s) to identity bindings`); + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/plugins/feishu/tests/legacy-allowlist.test.ts b/plugins/feishu/tests/legacy-allowlist.test.ts new file mode 100644 index 0000000..abcfd2c --- /dev/null +++ b/plugins/feishu/tests/legacy-allowlist.test.ts @@ -0,0 +1,62 @@ +import type { PluginLogger, PluginRuntime } from "@marswave/cola-plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { migrateLegacyAllowlist, parseAuthorizedOpenIds } from "../src/auth/legacy-allowlist.js"; + +function makeLogger(): PluginLogger { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; +} + +function makeIdentity(): PluginRuntime["identity"] { + return { + resolve: vi.fn(async () => null), + bind: vi.fn(async () => {}), + unbind: vi.fn(async () => {}), + }; +} + +describe("parseAuthorizedOpenIds", () => { + it("parses a comma/space separated string", () => { + expect([...parseAuthorizedOpenIds("ou_a, ou_b ou_c")]).toEqual(["ou_a", "ou_b", "ou_c"]); + }); + + it("parses an array of strings", () => { + expect([...parseAuthorizedOpenIds([" ou_a ", "ou_b"])]).toEqual(["ou_a", "ou_b"]); + }); + + it("returns an empty set for empty/invalid input", () => { + expect(parseAuthorizedOpenIds(undefined).size).toBe(0); + expect(parseAuthorizedOpenIds("").size).toBe(0); + expect(parseAuthorizedOpenIds(42).size).toBe(0); + }); +}); + +describe("migrateLegacyAllowlist", () => { + it("binds each unique legacy open_id once across accounts and logs a summary", async () => { + const identity = makeIdentity(); + const logger = makeLogger(); + + await migrateLegacyAllowlist( + [{ authorizedOpenIds: "ou_a, ou_b" }, { authorizedOpenIds: ["ou_b", "ou_c"] }], + identity, + logger, + ); + + expect((identity.bind as ReturnType).mock.calls.map((c) => c[0]).sort()).toEqual([ + "ou_a", + "ou_b", + "ou_c", + ]); + expect(identity.bind).toHaveBeenCalledTimes(3); + expect(logger.info).toHaveBeenCalled(); + }); + + it("is a no-op when no account has a legacy allowlist", async () => { + const identity = makeIdentity(); + const logger = makeLogger(); + + await migrateLegacyAllowlist([{}, { authorizedOpenIds: "" }], identity, logger); + + expect(identity.bind).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + }); +}); From 6c0f5089d3b92c7fc82360bc653c2a2a75b784c8 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 20:07:53 +0800 Subject: [PATCH 05/26] feat(feishu): migrate access control to SDK gate for DM and group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegate authorization to the host SDK access gate. The message handler no longer checks a bespoke authorizedOpenIds list or replies itself; it delivers with conversation (direct/group) and mentionedBot so the gate authorizes per sender (DM) or per group (@bot-gated). Group chats are now supported (one shared session per group); the "暂不支持群聊" path is removed. Adds a Chinese unauthorizedHint naming the cola channel allow[-group] command, fetches the bot open_id on monitor start for @mention detection, and one-time migrates any legacy authorizedOpenIds into identity bindings so existing users keep access. --- plugins/feishu/src/api/types.ts | 6 +- .../feishu/src/auth/authorized-open-ids.ts | 44 ----- plugins/feishu/src/auth/legacy-allowlist.ts | 3 +- plugins/feishu/src/commands/feishu.ts | 6 +- plugins/feishu/src/gateway/event-handler.ts | 121 ++++--------- plugins/feishu/src/gateway/monitor.ts | 43 ++--- plugins/feishu/src/index.ts | 35 ++-- .../feishu/tests/authorized-open-ids.test.ts | 17 -- plugins/feishu/tests/event-handler.test.ts | 170 +++++++----------- 9 files changed, 144 insertions(+), 301 deletions(-) delete mode 100644 plugins/feishu/src/auth/authorized-open-ids.ts delete mode 100644 plugins/feishu/tests/authorized-open-ids.test.ts diff --git a/plugins/feishu/src/api/types.ts b/plugins/feishu/src/api/types.ts index bada649..5baaa2b 100644 --- a/plugins/feishu/src/api/types.ts +++ b/plugins/feishu/src/api/types.ts @@ -6,7 +6,6 @@ export type FeishuAccountConfig = { appId: string; appSecret: string; domain?: FeishuDomain; - authorizedOpenIds?: string | string[]; encryptKey?: string; verificationToken?: string; enabled?: boolean; @@ -17,6 +16,11 @@ export type FeishuPluginConfig = { accounts?: Record; }; +/** Pre-SDK-gate account config that may still carry a legacy authorizedOpenIds allowlist. */ +export type LegacyFeishuAccountConfig = Partial & { + authorizedOpenIds?: string | string[]; +}; + export type ResolvedAccount = { id: string; config: FeishuAccountConfig; diff --git a/plugins/feishu/src/auth/authorized-open-ids.ts b/plugins/feishu/src/auth/authorized-open-ids.ts deleted file mode 100644 index acda54d..0000000 --- a/plugins/feishu/src/auth/authorized-open-ids.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { PluginLogger, PluginRuntime } from "@marswave/cola-plugin-sdk"; -import type { FeishuAccountConfig } from "../api/types.js"; - -export function parseAuthorizedOpenIds(value: unknown): Set { - if (Array.isArray(value)) { - return new Set(value.filter((item): item is string => isNonEmptyString(item)).map(trim)); - } - - if (typeof value !== "string") return new Set(); - - return new Set( - value - .split(/[\s,]+/) - .map((item) => item.trim()) - .filter(Boolean), - ); -} - -export function getAuthorizedOpenIds(config: FeishuAccountConfig): Set { - return parseAuthorizedOpenIds(config.authorizedOpenIds); -} - -export async function authorizeOpenId( - identity: PluginRuntime["identity"], - authorizedOpenIds: Set, - senderId: string, - logger: PluginLogger, -): Promise { - if (await identity.resolve(senderId)) return true; - if (!authorizedOpenIds.has(senderId)) return false; - - await identity.bind(senderId); - logger.info(`Bound Feishu sender ${senderId} from authorized open_id list`); - - return true; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function trim(value: string): string { - return value.trim(); -} diff --git a/plugins/feishu/src/auth/legacy-allowlist.ts b/plugins/feishu/src/auth/legacy-allowlist.ts index 46a9e7c..68aa641 100644 --- a/plugins/feishu/src/auth/legacy-allowlist.ts +++ b/plugins/feishu/src/auth/legacy-allowlist.ts @@ -1,4 +1,5 @@ import type { PluginLogger, PluginRuntime } from "@marswave/cola-plugin-sdk"; +import type { LegacyFeishuAccountConfig } from "../api/types.js"; /** * Parse a legacy `authorizedOpenIds` config value (comma/space separated string @@ -27,7 +28,7 @@ export function parseAuthorizedOpenIds(value: unknown): Set { * Idempotent — re-binding an already-bound sender is a no-op on the host side. */ export async function migrateLegacyAllowlist( - accounts: Iterable<{ authorizedOpenIds?: unknown }>, + accounts: Iterable, identity: PluginRuntime["identity"], logger: PluginLogger, ): Promise { diff --git a/plugins/feishu/src/commands/feishu.ts b/plugins/feishu/src/commands/feishu.ts index 4cdaf37..3f40e4f 100644 --- a/plugins/feishu/src/commands/feishu.ts +++ b/plugins/feishu/src/commands/feishu.ts @@ -1,7 +1,6 @@ import type { PluginCommandDefinition } from "@marswave/cola-plugin-sdk"; import type { MonitorHandle } from "../gateway/monitor.js"; import { redactSecret } from "../util/redact.js"; -import { parseAuthorizedOpenIds } from "../auth/authorized-open-ids.js"; export function createFeishuCommands( getMonitors: () => Map, @@ -37,10 +36,7 @@ export function createFeishuCommands( const appId = typeof acct.appId === "string" ? redactSecret(acct.appId) : "(missing)"; const domain = (acct.domain as string) ?? "feishu"; const active = monitors.has(id) ? "active" : "inactive"; - const authorized = parseAuthorizedOpenIds(acct.authorizedOpenIds).size; - lines.push( - `- **${id}**: appId=${appId}, domain=${domain}, authorizedOpenIds=${authorized}, ${active}`, - ); + lines.push(`- **${id}**: appId=${appId}, domain=${domain}, ${active}`); } return { reply: lines.join("\n") }; } diff --git a/plugins/feishu/src/gateway/event-handler.ts b/plugins/feishu/src/gateway/event-handler.ts index ec1f033..62fd44d 100644 --- a/plugins/feishu/src/gateway/event-handler.ts +++ b/plugins/feishu/src/gateway/event-handler.ts @@ -1,21 +1,18 @@ import type * as lark from "@larksuiteoapi/node-sdk"; -import type { PluginLogger, DeliverFn, PluginRuntime } from "@marswave/cola-plugin-sdk"; +import type { DeliverFn, PluginLogger } from "@marswave/cola-plugin-sdk"; import { parseMessage } from "./message-parser.js"; -import { stripBotMention } from "../util/mention.js"; +import { isBotMentioned, stripBotMention } from "../util/mention.js"; import { MessageDedup } from "./dedup.js"; import type { ChatMap } from "./chat-map.js"; -import { authorizeOpenId } from "../auth/authorized-open-ids.js"; -import { formatAsPost } from "../outbound/format.js"; export type EventHandlerDeps = { client: lark.Client; accountId: string; logger: PluginLogger; deliver: DeliverFn; - identity: PluginRuntime["identity"]; - authorizedOpenIds: Set; dedup: MessageDedup; chatMap: ChatMap; + /** Bot's own open_id, used to detect @bot mentions in group chats. */ botOpenId?: string; }; @@ -42,8 +39,18 @@ type ReactedMessageContext = { summary?: string; }; +type StripMention = { + key: string; + id: { open_id?: string; user_id?: string; union_id?: string }; + name: string; +}; + /** * Register the im.message.receive_v1 event handler on a dispatcher. + * + * Authorization is delegated to the host's SDK access gate: this handler only + * delivers, populating `conversation` (direct vs group) and `mentionedBot` so + * the gate can authorize per-sender (DM) or per-group (@bot-gated). */ export function registerMessageHandler( dispatcher: lark.EventDispatcher, @@ -60,25 +67,13 @@ export function registerMessageHandler( return {}; } - if (isGroupChat(message.chat_type)) { - logger.info(`Skipping Feishu group chat message ${message.message_id}`); - await sendUnsupportedGroupChatReply(client, message.chat_id, logger); - return {}; - } - const senderId = sender.sender_id?.open_id; if (!senderId) { logger.warn("Message missing sender open_id, skipping"); return {}; } - if (!(await authorizeOpenId(deps.identity, deps.authorizedOpenIds, senderId, logger))) { - logger.warn(`Skipping Feishu message from unauthorized sender ${senderId}`); - await sendAccessNotConfiguredReply(client, message.chat_id, senderId, logger); - return {}; - } - // Record chat mapping - chatMap.set(senderId, message.chat_id); + const isGroup = message.chat_type === "group"; // Parse message content const parsed = await parseMessage( @@ -115,22 +110,33 @@ export function registerMessageHandler( return {}; } - // Strip @bot mention - const mentionsForStrip = message.mentions?.map((m: Record) => ({ - key: m.key as string, - id: (m.id ?? {}) as { open_id?: string; user_id?: string; union_id?: string }, - name: (m.name ?? "") as string, - })); - let text = stripBotMention(parsed.text, mentionsForStrip, deps.botOpenId); + // Strip @bot mention from the text + const mentions: StripMention[] | undefined = message.mentions?.map( + (m: Record) => ({ + key: m.key as string, + id: (m.id ?? {}) as { open_id?: string; user_id?: string; union_id?: string }, + name: (m.name ?? "") as string, + }), + ); + const text = stripBotMention(parsed.text, mentions, deps.botOpenId); // Skip empty text after stripping mentions (unless attachments present) if (!text.trim() && parsed.attachments.length === 0) { return {}; } + // Record chat mapping for outbound routing + chatMap.set(senderId, message.chat_id); + await deliver({ - sessionId: ["chat", accountId, message.chat_id, "sender", senderId], + sessionId: isGroup + ? ["chat", accountId, message.chat_id] + : ["chat", accountId, message.chat_id, "sender", senderId], sender: { id: senderId }, + conversation: isGroup + ? { kind: "group", id: message.chat_id } + : { kind: "direct", id: senderId }, + mentionedBot: isGroup ? isBotMentioned(mentions, deps.botOpenId) : undefined, deliveryContext: { to: `chat:${message.chat_id}`, accountId, @@ -186,10 +192,6 @@ async function handleReaction( logger.warn("Reaction event missing emoji_type, skipping"); return; } - if (!(await authorizeOpenId(deps.identity, deps.authorizedOpenIds, senderId, logger))) { - logger.warn(`Skipping Feishu reaction from unauthorized sender ${senderId}`); - return; - } const dedupKey = data.event_id ?? @@ -207,11 +209,14 @@ async function handleReaction( const verb = action === "created" ? "added" : "removed"; const summary = context.summary ? `\nReacted message: ${context.summary}` : ""; + // Reactions are inherently per-user actions; deliver as a direct conversation + // so the gate authorizes by sender. (Group reaction semantics are not modeled.) await deliver({ sessionId: context.chatId ? ["chat", accountId, context.chatId, "sender", senderId] : ["reaction", accountId, messageId, "sender", senderId], sender: { id: senderId }, + conversation: { kind: "direct", id: senderId }, deliveryContext: { to: context.chatId ? `chat:${context.chatId}` : `user:${senderId}`, accountId, @@ -221,60 +226,6 @@ async function handleReaction( }); } -async function sendAccessNotConfiguredReply( - client: lark.Client, - chatId: string, - senderId: string, - logger: PluginLogger, -): Promise { - try { - await client.im.message.create({ - params: { receive_id_type: "chat_id" }, - data: { - receive_id: chatId, - content: formatAsPost(accessNotConfiguredMessage(senderId)), - msg_type: "post", - }, - }); - } catch (err) { - logger.warn(`Failed to send Feishu access notice for ${senderId}`, err); - } -} - -function accessNotConfiguredMessage(senderId: string): string { - return [ - "Cola Feishu: access not configured.", - "", - "Your Feishu open_id:", - "```", - senderId, - "```", - ].join("\n"); -} - -async function sendUnsupportedGroupChatReply( - client: lark.Client, - chatId: string, - logger: PluginLogger, -): Promise { - try { - await client.im.message.create({ - params: { receive_id_type: "chat_id" }, - data: { - receive_id: chatId, - content: formatAsPost("暂不支持群聊"), - msg_type: "post", - }, - }); - } catch (err) { - logger.warn(`Failed to send Feishu group chat unsupported notice for ${chatId}`, err); - } -} - -function isGroupChat(chatType: string | undefined): boolean { - return chatType === "group"; -} - async function resolveReactedMessageContext( client: lark.Client, messageId: string, diff --git a/plugins/feishu/src/gateway/monitor.ts b/plugins/feishu/src/gateway/monitor.ts index e80e037..c3032ca 100644 --- a/plugins/feishu/src/gateway/monitor.ts +++ b/plugins/feishu/src/gateway/monitor.ts @@ -1,12 +1,11 @@ import type * as lark from "@larksuiteoapi/node-sdk"; -import type { PluginLogger, DeliverFn, PluginRuntime } from "@marswave/cola-plugin-sdk"; +import type { PluginLogger, DeliverFn } from "@marswave/cola-plugin-sdk"; import type { FeishuAccountConfig } from "../api/types.js"; -import { createLarkClient, createEventDispatcher } from "../api/client.js"; +import { createLarkClient, createEventDispatcher, fetchBotOpenId } from "../api/client.js"; import { registerMessageHandler, registerReactionHandler } from "./event-handler.js"; import { startWSGateway } from "./ws-gateway.js"; import { MessageDedup } from "./dedup.js"; import { ChatMap } from "./chat-map.js"; -import { getAuthorizedOpenIds } from "../auth/authorized-open-ids.js"; export type MonitorHandle = { accountId: string; @@ -17,45 +16,31 @@ export type MonitorHandle = { /** * Start monitoring a single Feishu account — sets up client, event dispatcher, and transport. + * Authorization is handled by the host SDK access gate, not here. */ -export function startMonitor(opts: { +export async function startMonitor(opts: { accountId: string; config: FeishuAccountConfig; deliver: DeliverFn; - identity: PluginRuntime["identity"]; logger: PluginLogger; abortSignal: AbortSignal; -}): MonitorHandle { - const { accountId, config, deliver, identity, logger, abortSignal } = opts; +}): Promise { + const { accountId, config, deliver, logger, abortSignal } = opts; // Create client and dispatcher const client = createLarkClient(accountId, config); const dispatcher = createEventDispatcher(config); const dedup = new MessageDedup(); const chatMap = new ChatMap(accountId, logger); - const authorizedOpenIds = getAuthorizedOpenIds(config); - // Register event handler - registerMessageHandler(dispatcher, { - client, - accountId, - logger, - deliver, - identity, - authorizedOpenIds, - dedup, - chatMap, - }); - registerReactionHandler(dispatcher, { - client, - accountId, - logger, - deliver, - identity, - authorizedOpenIds, - dedup, - chatMap, - }); + // Bot open_id is required to detect @bot mentions in group chats. + const botOpenId = await fetchBotOpenId(client, logger); + + const deps = { client, accountId, logger, deliver, dedup, chatMap, botOpenId }; + + // Register event handlers + registerMessageHandler(dispatcher, deps); + registerReactionHandler(dispatcher, deps); const handle = startWSGateway(accountId, config, dispatcher, abortSignal, logger); diff --git a/plugins/feishu/src/index.ts b/plugins/feishu/src/index.ts index 605f728..575dfac 100644 --- a/plugins/feishu/src/index.ts +++ b/plugins/feishu/src/index.ts @@ -8,6 +8,7 @@ import type { } from "@marswave/cola-plugin-sdk"; import type { FeishuPluginConfig } from "./api/types.js"; import { setPluginDir, resolvePluginDir, parseAccountConfigs } from "./auth/accounts.js"; +import { migrateLegacyAllowlist } from "./auth/legacy-allowlist.js"; import { startMonitor, type MonitorHandle } from "./gateway/monitor.js"; import { sendText, sendMedia, sendReaction } from "./outbound/send.js"; import { createFeishuCommands } from "./commands/feishu.js"; @@ -99,18 +100,29 @@ export default defineChannel({ { label: "Lark", value: "lark" }, ], }, - { - key: "authorizedOpenIds", - path: ["accounts", "default", "authorizedOpenIds"], - label: "Authorized Open IDs", - type: "text", - placeholder: "ou_xxx,ou_yyy", - description: "Comma-separated Feishu/Lark sender open_id values allowed to bind to Cola.", - }, ], }, }, + 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"); + }, + commands: createFeishuCommands(() => activeMonitors), gateway: { @@ -128,13 +140,16 @@ export default defineChannel({ return; } + // One-time migration: move any legacy authorizedOpenIds into SDK identity + // bindings so previously-authorized users keep access under the access gate. + await migrateLegacyAllowlist(accounts.values(), ctx.runtime.identity, ctx.logger); + for (const [accountId, acctConfig] of accounts) { try { - const handle = startMonitor({ + const handle = await startMonitor({ accountId, config: acctConfig, deliver: ctx.deliver, - identity: ctx.runtime.identity, logger: ctx.logger, abortSignal: ctx.abortSignal, }); diff --git a/plugins/feishu/tests/authorized-open-ids.test.ts b/plugins/feishu/tests/authorized-open-ids.test.ts deleted file mode 100644 index 19c87bc..0000000 --- a/plugins/feishu/tests/authorized-open-ids.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseAuthorizedOpenIds } from "../src/auth/authorized-open-ids.js"; - -describe("Feishu authorized open_id parsing", () => { - it("normalizes comma and whitespace separated open_id values", () => { - expect([...parseAuthorizedOpenIds(" ou_1,ou_2\nou_3 ,, ")]).toEqual(["ou_1", "ou_2", "ou_3"]); - }); - - it("accepts string arrays and ignores blank values", () => { - expect([...parseAuthorizedOpenIds(["ou_1", " ", " ou_2 "])]).toEqual(["ou_1", "ou_2"]); - }); - - it("returns an empty set for unsupported values", () => { - expect(parseAuthorizedOpenIds(undefined).size).toBe(0); - expect(parseAuthorizedOpenIds(42).size).toBe(0); - }); -}); diff --git a/plugins/feishu/tests/event-handler.test.ts b/plugins/feishu/tests/event-handler.test.ts index 9afe1f0..d937f10 100644 --- a/plugins/feishu/tests/event-handler.test.ts +++ b/plugins/feishu/tests/event-handler.test.ts @@ -1,5 +1,5 @@ import type * as lark from "@larksuiteoapi/node-sdk"; -import type { DeliverFn, PluginLogger, PluginRuntime } from "@marswave/cola-plugin-sdk"; +import type { DeliverFn, PluginLogger } from "@marswave/cola-plugin-sdk"; import { describe, expect, it, vi } from "vitest"; import { registerMessageHandler } from "../src/gateway/event-handler.js"; import { MessageDedup } from "../src/gateway/dedup.js"; @@ -7,6 +7,8 @@ import type { ChatMap } from "../src/gateway/chat-map.js"; type Handler = (data: FeishuMessageData) => Promise>; +type Mention = { key: string; id: { open_id?: string }; name: string }; + type FeishuMessageData = { message: { message_id: string; @@ -14,25 +16,16 @@ type FeishuMessageData = { chat_type: string; message_type: string; content: string; - mentions?: unknown[]; + mentions?: Mention[]; }; sender: { - sender_id?: { - open_id?: string; - user_id?: string; - union_id?: string; - }; + sender_id?: { open_id?: string; user_id?: string; union_id?: string }; sender_type: string; }; }; function makeLogger(): PluginLogger { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; + return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; } function makeDispatcher() { @@ -45,7 +38,7 @@ function makeDispatcher() { return { dispatcher, handlers }; } -function message(openId: string): FeishuMessageData { +function directMessage(openId: string): FeishuMessageData { return { message: { message_id: "m1", @@ -54,35 +47,27 @@ function message(openId: string): FeishuMessageData { message_type: "text", content: JSON.stringify({ text: "hello" }), }, - sender: { - sender_id: { open_id: openId }, - sender_type: "user", - }, + sender: { sender_id: { open_id: openId }, sender_type: "user" }, }; } -function groupMessage(openId: string): FeishuMessageData { - const event = message(openId); +function groupMessage(openId: string, mentions?: Mention[]): FeishuMessageData { + const event = directMessage(openId); return { ...event, - message: { - ...event.message, - chat_id: "group-chat1", - chat_type: "group", - }, + message: { ...event.message, chat_id: "group-chat1", chat_type: "group", mentions }, }; } -function register(opts: { - authorizedOpenIds?: Set; - resolve?: (senderId: string) => Promise; -}) { +function botMention(botOpenId: string): Mention { + return { key: "@_user_1", id: { open_id: botOpenId }, name: "Cola" }; +} + +function register(opts: { botOpenId?: string } = {}) { const { dispatcher, handlers } = makeDispatcher(); - const bind = vi.fn(async () => {}); - const resolve = vi.fn(opts.resolve ?? (async () => null)); const logger = makeLogger(); const deliver = vi.fn(async () => {}) as unknown as DeliverFn; - const chatMap = { set: vi.fn() } as unknown as ChatMap; + const chatMap = { set: vi.fn(), get: vi.fn(), hasUser: vi.fn() } as unknown as ChatMap; const create = vi.fn(async () => {}); const client = { im: { message: { create } } } as unknown as lark.Client; @@ -91,119 +76,86 @@ function register(opts: { accountId: "default", logger, deliver, - identity: { resolve, bind, unbind: vi.fn() } as PluginRuntime["identity"], - authorizedOpenIds: opts.authorizedOpenIds ?? new Set(), dedup: new MessageDedup(), chatMap, + botOpenId: opts.botOpenId, }); - return { handler: handlers["im.message.receive_v1"], bind, resolve, deliver, chatMap, create }; + return { handler: handlers["im.message.receive_v1"], deliver, chatMap, create }; } -describe("Feishu message identity binding", () => { - it("replies with unsupported notice for group chats without delivery or binding", async () => { - const { handler, bind, resolve, deliver, chatMap, create } = register({ - authorizedOpenIds: new Set(["ou_allowed"]), - }); +describe("Feishu message delivery (SDK access gate)", () => { + it("delivers a direct message as a direct conversation without any in-plugin reply", async () => { + const { handler, deliver, chatMap, create } = register(); - await handler(groupMessage("ou_allowed")); + await handler(directMessage("ou_alice")); - expect(resolve).not.toHaveBeenCalled(); - expect(bind).not.toHaveBeenCalled(); - expect(chatMap.set).not.toHaveBeenCalled(); - expect(deliver).not.toHaveBeenCalled(); - expect(create).toHaveBeenCalledWith( + expect(create).not.toHaveBeenCalled(); + expect(chatMap.set).toHaveBeenCalledWith("ou_alice", "chat1"); + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith( expect.objectContaining({ - params: { receive_id_type: "chat_id" }, - data: expect.objectContaining({ - receive_id: "group-chat1", - msg_type: "post", - content: expect.stringContaining("暂不支持群聊"), + sessionId: ["chat", "default", "chat1", "sender", "ou_alice"], + sender: { id: "ou_alice" }, + conversation: { kind: "direct", id: "ou_alice" }, + mentionedBot: undefined, + deliveryContext: expect.objectContaining({ + to: "chat:chat1", + accountId: "default", + messageId: "m1", }), + message: "hello", }), ); }); - it("binds an authorized unbound sender before delivery", async () => { - const { handler, bind, resolve, deliver } = register({ - authorizedOpenIds: new Set(["ou_allowed"]), - }); + it("delivers a group message that @mentions the bot with mentionedBot=true", async () => { + const { handler, deliver } = register({ botOpenId: "ou_bot" }); - await handler(message("ou_allowed")); + await handler(groupMessage("ou_alice", [botMention("ou_bot")])); - expect(resolve).toHaveBeenCalledWith("ou_allowed"); - expect(bind).toHaveBeenCalledWith("ou_allowed"); expect(deliver).toHaveBeenCalledTimes(1); expect(deliver).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: ["chat", "default", "chat1", "sender", "ou_allowed"], - sender: { id: "ou_allowed" }, + sessionId: ["chat", "default", "group-chat1"], + sender: { id: "ou_alice" }, + conversation: { kind: "group", id: "group-chat1" }, + mentionedBot: true, + deliveryContext: expect.objectContaining({ to: "chat:group-chat1" }), message: "hello", }), ); }); - it("does not rebind an already-bound authorized sender", async () => { - const { handler, bind, deliver } = register({ - authorizedOpenIds: new Set(["ou_allowed"]), - resolve: async () => "user-1", - }); + it("delivers a group message without an @bot mention with mentionedBot=false", async () => { + const { handler, deliver } = register({ botOpenId: "ou_bot" }); - await handler(message("ou_allowed")); + await handler(groupMessage("ou_alice")); - expect(bind).not.toHaveBeenCalled(); - expect(deliver).toHaveBeenCalledTimes(1); - }); - - it("replies with access instructions for senders outside the authorized open_id list", async () => { - const { handler, bind, resolve, deliver, chatMap, create } = register({ - authorizedOpenIds: new Set(["ou_allowed"]), - }); - - await handler(message("ou_other")); - - expect(resolve).toHaveBeenCalledWith("ou_other"); - expect(bind).not.toHaveBeenCalled(); - expect(chatMap.set).not.toHaveBeenCalled(); - expect(deliver).not.toHaveBeenCalled(); - expect(create).toHaveBeenCalledWith( + expect(deliver).toHaveBeenCalledWith( expect.objectContaining({ - params: { receive_id_type: "chat_id" }, - data: expect.objectContaining({ - receive_id: "chat1", - msg_type: "post", - content: expect.stringContaining("ou_other"), - }), + conversation: { kind: "group", id: "group-chat1" }, + mentionedBot: false, }), ); }); - it("replies with access instructions when no authorized list is configured", async () => { - const { handler, bind, deliver, create } = register({}); + it("reports mentionedBot=false in groups when the bot open_id is unknown", async () => { + const { handler, deliver } = register(); - await handler(message("ou_unlisted")); + await handler(groupMessage("ou_alice", [botMention("ou_bot")])); - expect(bind).not.toHaveBeenCalled(); - expect(deliver).not.toHaveBeenCalled(); - expect(create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - content: expect.stringContaining("ou_unlisted"), - }), - }), - ); + expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ mentionedBot: false })); }); - it("delivers already-bound senders even when they are not in the authorized list", async () => { - const { handler, bind, deliver, create } = register({ - authorizedOpenIds: new Set(["ou_allowed"]), - resolve: async () => "user-1", - }); + it("skips delivery for an unknown sender", async () => { + const { handler, deliver } = register(); - await handler(message("ou_other")); + await handler({ + message: directMessage("ou_alice").message, + sender: { sender_type: "user" }, + }); - expect(bind).not.toHaveBeenCalled(); - expect(create).not.toHaveBeenCalled(); - expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).not.toHaveBeenCalled(); }); }); From 160fc1bbbc3781ba069fd2cb8d7e970e499a760b Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 20:09:19 +0800 Subject: [PATCH 06/26] feat(feishu): add one-click app creation login with config.patch write-back --- plugins/feishu/src/auth/login.ts | 52 +++++++++++++++++++ plugins/feishu/src/index.ts | 3 ++ plugins/feishu/tests/login.test.ts | 82 ++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 plugins/feishu/src/auth/login.ts create mode 100644 plugins/feishu/tests/login.test.ts diff --git a/plugins/feishu/src/auth/login.ts b/plugins/feishu/src/auth/login.ts new file mode 100644 index 0000000..0c67a2a --- /dev/null +++ b/plugins/feishu/src/auth/login.ts @@ -0,0 +1,52 @@ +import { registerApp } from "@larksuiteoapi/node-sdk"; +import type { AuthContext, ChannelAuthAdapter } from "@marswave/cola-plugin-sdk"; + +/** + * One-click Feishu app creation. `cola channel login feishu` triggers the RFC 8628 + * device flow via `registerApp`: the user scans a QR code, an app is created, and + * the returned credentials are written back into this plugin's config via + * `runtime.config.patch` (no manual appId/appSecret entry needed). The host reloads + * the gateway once login resolves, bringing the new account online. + */ +export function createFeishuAuth(): ChannelAuthAdapter { + return { + async login(ctx: AuthContext) { + ctx.onStatus?.("starting", "正在发起飞书一键创建应用…"); + + const result = await registerApp({ + onQRCodeReady(info) { + ctx.onQrCode?.(info.url, info.url); + ctx.onStatus?.("qr_ready", `请使用飞书扫码(${info.expireIn}s 内有效)`); + }, + onStatusChange(info) { + ctx.onStatus?.(info.status); + }, + }); + + // config.patch is a top-level shallow merge, so merge `accounts` ourselves + // to preserve any other accounts and existing fields on `default`. + const existing = (ctx.config.accounts as Record | undefined) ?? {}; + const existingDefault = (existing.default as Record | undefined) ?? {}; + const domain = result.user_info?.tenant_brand === "lark" ? "lark" : "feishu"; + + await ctx.runtime.config.patch({ + accounts: { + ...existing, + default: { + ...existingDefault, + appId: result.client_id, + appSecret: result.client_secret, + domain, + }, + }, + }); + + // Authorize the registering user so they can immediately DM the bot. + if (result.user_info?.open_id) { + await ctx.runtime.identity.bind(result.user_info.open_id); + } + + ctx.onStatus?.("success", "应用创建成功,凭据已写入"); + }, + }; +} diff --git a/plugins/feishu/src/index.ts b/plugins/feishu/src/index.ts index 575dfac..d3d6f57 100644 --- a/plugins/feishu/src/index.ts +++ b/plugins/feishu/src/index.ts @@ -9,6 +9,7 @@ import type { import type { FeishuPluginConfig } from "./api/types.js"; import { setPluginDir, resolvePluginDir, parseAccountConfigs } from "./auth/accounts.js"; import { migrateLegacyAllowlist } from "./auth/legacy-allowlist.js"; +import { createFeishuAuth } from "./auth/login.js"; import { startMonitor, type MonitorHandle } from "./gateway/monitor.js"; import { sendText, sendMedia, sendReaction } from "./outbound/send.js"; import { createFeishuCommands } from "./commands/feishu.js"; @@ -123,6 +124,8 @@ export default defineChannel({ ].join("\n"); }, + auth: createFeishuAuth(), + commands: createFeishuCommands(() => activeMonitors), gateway: { diff --git a/plugins/feishu/tests/login.test.ts b/plugins/feishu/tests/login.test.ts new file mode 100644 index 0000000..72e68f2 --- /dev/null +++ b/plugins/feishu/tests/login.test.ts @@ -0,0 +1,82 @@ +import type { AuthContext, PluginLogger, PluginRuntime } from "@marswave/cola-plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@larksuiteoapi/node-sdk", () => ({ registerApp: vi.fn() })); + +import { registerApp } from "@larksuiteoapi/node-sdk"; +import { createFeishuAuth } from "../src/auth/login.js"; + +const mockedRegisterApp = registerApp as unknown as ReturnType; + +function makeLogger(): PluginLogger { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; +} + +function makeCtx(overrides: { accounts?: Record } = {}) { + const onQrCode = vi.fn(); + const onStatus = vi.fn(); + const patch = vi.fn(async () => {}); + const bind = vi.fn(async () => {}); + const runtime = { + config: { get: () => ({}), patch }, + identity: { resolve: vi.fn(async () => null), bind, unbind: vi.fn(async () => {}) }, + } as unknown as PluginRuntime; + const ctx = { + config: { accounts: overrides.accounts }, + runtime, + logger: makeLogger(), + onQrCode, + onStatus, + } as AuthContext; + return { ctx, onQrCode, onStatus, patch, bind }; +} + +describe("createFeishuAuth().login (one-click app creation)", () => { + it("forwards QR/status, writes credentials back, and binds the registering user", async () => { + mockedRegisterApp.mockImplementation(async (opts: Parameters[0]) => { + opts.onQRCodeReady({ url: "https://feishu/qr", expireIn: 300 }); + opts.onStatusChange?.({ status: "polling" }); + return { + client_id: "cli_new", + client_secret: "secret_new", + user_info: { open_id: "ou_owner", tenant_brand: "lark" }, + }; + }); + + const { ctx, onQrCode, onStatus, patch, bind } = makeCtx({ + accounts: { other: { appId: "cli_other", appSecret: "s" } }, + }); + + await createFeishuAuth().login(ctx); + + expect(onQrCode).toHaveBeenCalledWith("https://feishu/qr", "https://feishu/qr"); + expect(onStatus).toHaveBeenCalledWith("polling"); + expect(onStatus).toHaveBeenCalledWith("success", expect.any(String)); + + expect(patch).toHaveBeenCalledTimes(1); + expect(patch).toHaveBeenCalledWith({ + accounts: { + other: { appId: "cli_other", appSecret: "s" }, + default: { appId: "cli_new", appSecret: "secret_new", domain: "lark" }, + }, + }); + + expect(bind).toHaveBeenCalledWith("ou_owner"); + }); + + it("defaults domain to feishu and skips bind when no user_info is returned", async () => { + mockedRegisterApp.mockImplementation(async (opts: Parameters[0]) => { + opts.onQRCodeReady({ url: "https://feishu/qr2", expireIn: 120 }); + return { client_id: "cli_a", client_secret: "secret_a" }; + }); + + const { ctx, patch, bind } = makeCtx(); + + await createFeishuAuth().login(ctx); + + expect(patch).toHaveBeenCalledWith({ + accounts: { default: { appId: "cli_a", appSecret: "secret_a", domain: "feishu" } }, + }); + expect(bind).not.toHaveBeenCalled(); + }); +}); From cd62b64bc7cff06a230ad3b4d85822b50b590985 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 20:11:52 +0800 Subject: [PATCH 07/26] docs(feishu): document SDK access gate and one-click login flow --- plugins/feishu/README.md | 123 ++++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 55 deletions(-) diff --git a/plugins/feishu/README.md b/plugins/feishu/README.md index 7d36ab0..14ae291 100644 --- a/plugins/feishu/README.md +++ b/plugins/feishu/README.md @@ -6,6 +6,8 @@ - 接收飞书/Lark 的文本、图片、文件和表情回应事件。 - 从 Cola 向飞书/Lark 发送文本、图片、文件、Markdown 和表情回应消息。 +- 支持私聊和群聊(群聊需 @机器人触发),访问授信由 `cola channel allow[-group]` 统一管理。 +- 支持 `cola channel login feishu` 扫码一键创建应用并自动回写凭据。 - 固定使用 WebSocket 长连接接收事件,不需要公网服务器。 - 支持通过 `accounts` 配置多个账号。 @@ -19,21 +21,23 @@ ## 配置流程 -### 方式一:一键创建飞书智能体应用(推荐) +### 方式一:扫码一键创建(推荐) -飞书开放平台提供了“一键创建飞书智能体应用”能力,适合把 Cola 接入飞书。创建成功后会 -直接返回 `App ID` 和 `App Secret`,并预置智能体常用的机器人能力和权限。 +插件内置了飞书开放平台的“一键创建飞书智能体应用”流程:扫码即可创建应用,凭据由插件 +通过 SDK 自动回写到配置,无需手动复制 `App ID` / `App Secret`。 -1. 打开 [飞书开放平台应用启动器](https://open.feishu.cn/page/launcher)。 -2. 按页面提示扫码或登录飞书。 -3. 选择创建新应用,或选择一个已有应用继续配置。 -4. 创建完成后复制 `App ID` 和 `App Secret`。 -5. 在 Cola 插件商店安装 Feishu 插件。 -6. 打开 Cola 里的 Feishu 插件设置。 -7. 填入 `appId` 和 `appSecret`。 -8. `domain` 选择 `feishu`;如果你使用国际版 Lark,选择 `lark`。 -9. 在 `authorizedOpenIds` 中填入允许使用 Cola 的飞书用户 `open_id`,多个值用逗号分隔。如果不知道获取方式,可以在保存 `appId` 和 `appSecret` 后见[获取用户 open_id](#获取用户-open_id)。 -10. 保存设置,并按 Cola 提示重启或重载 gateway。 +1. 在 Cola 插件商店安装 Feishu 插件。 +2. 运行: + +```text +cola channel login feishu +``` + +3. 用飞书扫描终端里的二维码并确认。 +4. 创建成功后,插件会自动把 `appId`、`appSecret` 和 `domain` 写入 `accounts.default` + 并重载 gateway,扫码用户也会被自动授信,可立即给机器人发消息。 + +之后再为其他用户或群授信,见[访问授信](#访问授信谁能使用-cola)。 ### 方式二:手动创建飞书自建应用 @@ -115,54 +119,63 @@ 2. 打开 Feishu 插件设置。 3. 填入 `appId` 和 `appSecret`。 4. `domain` 选择 `feishu` 或 `lark`。 -5. 在 `authorizedOpenIds` 中填入允许使用 Cola 的飞书用户 `open_id`,多个值用逗号分隔。 -6. 保存设置,并按 Cola 提示重启或重载 gateway。 +5. 保存设置,并按 Cola 提示重启或重载 gateway。 +6. 给需要使用 Cola 的用户或群授信,见[访问授信](#访问授信谁能使用-cola)。 Feishu 插件固定使用 WebSocket 长连接模式,不需要公网 HTTP 回调地址。 #### 7. 把机器人加入会话 -群聊中,打开群设置,进入 `群机器人`,添加你创建的机器人。很多飞书群聊配置下,用户需要 -@机器人 才会触发 Cola 回复。 +群聊中,打开群设置,进入 `群机器人`,添加你创建的机器人。群聊里必须 @机器人 才会触发 +Cola 回复,且该群需要先被授信(见下文)。 -单聊中,直接在飞书里搜索机器人名称并发起会话。如果你的 Cola 部署启用了私聊身份配对, -需要先把发送者的 `open_id` 加入 `authorizedOpenIds`,保存配置并重载 gateway 后,再让 -该用户给机器人发一条消息完成绑定。 +单聊中,直接在飞书里搜索机器人名称并发起会话。发送者需要先被授信,未授信用户发消息时, +插件会回复一段包含其 `open_id` 和授信命令的提示。 -## 获取用户 open_id +## 访问授信(谁能使用 Cola) -`authorizedOpenIds` 需要填写飞书/Lark 用户的 `open_id`,通常以 `ou_` 开头。 +谁能通过飞书驱动 Cola 由 Cola 的访问网关统一管理,使用 `cola channel` 命令授信,不再 +通过插件配置里的名单。授信对象有两类: -最简单的方式是先让需要使用 Cola 的用户给机器人发一条消息。插件收到 -未授权用户的消息时,会直接回复一段配对提示,里面包含发送者的 `open_id`,例如: +- **私聊**:按发送者 `open_id` 授信。 +- **群聊**:按群 `chat_id` 授信整群,且群里必须 @机器人 才会触发。 -````text -Cola Feishu: access not configured. - -Your Feishu open_id: -``` -ou_xxx +```text +cola channel allow feishu # 授信一个私聊用户 +cola channel revoke feishu # 取消用户授信 +cola channel allow-group feishu # 授信整个群 +cola channel revoke-group feishu # 取消群授信 +cola channel allowlist feishu # 查看已授信的用户和群 ``` -```` -复制其中的 `ou_xxx`,填入 `authorizedOpenIds`,保存设置并重载 gateway 后,让该用户再给 -机器人发一条消息即可自动完成绑定。 +### 怎么拿到 open_id / chat_id -如果需要从日志排查,也可以搜索新版本插件的提示: +最简单的方式是让对方先触发一次。未授信的用户私聊机器人、或在群里 @机器人时,插件会回复 +一段提示,里面已经带好待执行的命令,例如: -```text -[plugin:feishu] Skipping Feishu message from unauthorized sender ou_xxx +````text +你还没有被授信,无法使用 Cola。 +请管理员执行: +``` +cola channel allow feishu ou_xxx ``` +```` -如果你使用的是旧版本插件,也可能看到 host 层日志: +群聊里则是: -```text -[plugin:feishu] Ignoring message from unbound sender: ou_xxx +````text +这个群还没有被授信,无法使用 Cola。 +请管理员执行: +``` +cola channel allow-group feishu oc_xxx ``` +```` -如果你在飞书开放平台调试事件,也可以从 `im.message.receive_v1` 事件 payload 的 -`sender.sender_id.open_id` 字段获取。管理员或开发者也可以通过飞书开放平台的用户 ID -查询工具/API 获取用户的 `open_id`。 +把命令复制到终端执行即可。同一目标的提示有冷却时间,不会刷屏;未 @机器人的群消息会被 +直接忽略,不打扰群成员。 + +> 从旧版本升级:插件启动时会自动把遗留的 `authorizedOpenIds` 迁移成授信绑定,原有用户 +> 不需要重新授信。 ## 测试 @@ -186,12 +199,14 @@ ou_xxx ## 配置字段 -| 字段 | 必需 | 默认值 | 说明 | -| ------------------- | ---- | -------- | ------------------------------------------------------------------ | -| `appId` | 是 | | 飞书/Lark 机器人应用 ID,例如 `cli_xxx`。 | -| `appSecret` | 是 | | 机器人应用密钥。请作为 secret 保存。 | -| `domain` | 否 | `feishu` | 国内飞书用 `feishu`,国际版 Lark 用 `lark`。 | -| `authorizedOpenIds` | 否 | | 允许绑定到 Cola 的发送者 `open_id`,多个值用逗号、空格或换行分隔。 | +| 字段 | 必需 | 默认值 | 说明 | +| ----------- | ---- | -------- | ----------------------------------------- | +| `appId` | 是 | | 飞书/Lark 机器人应用 ID,例如 `cli_xxx`。 | +| `appSecret` | 是 | | 机器人应用密钥。请作为 secret 保存。 | +| `domain` | 否 | `feishu` | 国内飞书用 `feishu`,国际版 Lark 用 `lark`。 | + +谁能使用 Cola 不再通过配置字段控制,改用 `cola channel allow[-group]` 授信,见 +[访问授信](#访问授信谁能使用-cola)。 多账号高级配置可以直接使用 `accounts` 对象: @@ -201,8 +216,7 @@ ou_xxx "default": { "appId": "cli_xxx", "appSecret": "secret", - "domain": "feishu", - "authorizedOpenIds": "ou_xxx,ou_yyy" + "domain": "feishu" } } } @@ -227,11 +241,10 @@ ou_xxx - 应用已经发布,并且发送消息的用户在可用范围内。 - 机器人已经加入目标会话。 - 权限包含收消息和以机器人身份发消息的 scope。 -- 群聊里是否需要 @机器人。 -- 发送者的 `open_id` 是否已加入 `authorizedOpenIds`。 -- 保存配置并重载 gateway 后,是否让该 `open_id` 发过消息触发自动绑定。 -- 未授权用户是否收到了 `Cola Feishu: access not configured.` 提示;如果没有,请检查 - `im:message:send_as_bot` 权限。 +- 群聊里是否 @机器人;该群是否已用 `cola channel allow-group feishu ` 授信。 +- 私聊发送者是否已用 `cola channel allow feishu ` 授信。可用 + `cola channel allowlist feishu` 查看当前授信列表。 +- 未授信用户是否收到了带授信命令的提示;如果没有,请检查 `im:message:send_as_bot` 权限。 ### 图片或文件失败 From 9d5256bc988f049d12d63fe75a5f852619a65760 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 20:19:37 +0800 Subject: [PATCH 08/26] =?UTF-8?q?refactor(feishu):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20migrate=20disabled=20accounts,=20cover=20reactions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate legacy authorizedOpenIds from the raw config (before the no-accounts early return) so disabled / not-yet-credentialed accounts keep their allowlist. Document the group-reaction unauthorized-hint limitation in code. Add tests for the reaction delivery path and the strip-to-empty skip. --- plugins/feishu/src/gateway/event-handler.ts | 5 +- plugins/feishu/src/index.ts | 14 ++-- plugins/feishu/tests/event-handler.test.ts | 71 ++++++++++++++++++++- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/plugins/feishu/src/gateway/event-handler.ts b/plugins/feishu/src/gateway/event-handler.ts index 62fd44d..f7b4fd7 100644 --- a/plugins/feishu/src/gateway/event-handler.ts +++ b/plugins/feishu/src/gateway/event-handler.ts @@ -210,7 +210,10 @@ async function handleReaction( const summary = context.summary ? `\nReacted message: ${context.summary}` : ""; // Reactions are inherently per-user actions; deliver as a direct conversation - // so the gate authorizes by sender. (Group reaction semantics are not modeled.) + // so the gate authorizes by sender. Group reaction semantics are not modeled: + // a reaction from an unauthorized user on a group message is gated as a DM, so + // the host's user-level unauthorized hint may post into the group chat. Accepted + // limitation for this iteration (the per-target cooldown prevents spam). await deliver({ sessionId: context.chatId ? ["chat", accountId, context.chatId, "sender", senderId] diff --git a/plugins/feishu/src/index.ts b/plugins/feishu/src/index.ts index d3d6f57..4c6f71a 100644 --- a/plugins/feishu/src/index.ts +++ b/plugins/feishu/src/index.ts @@ -137,16 +137,22 @@ export default defineChannel({ const monitors = new Map(); ctx.state.monitors = monitors; + // One-time migration: move any legacy authorizedOpenIds into SDK identity + // bindings so previously-authorized users keep access under the access gate. + // Reads the raw config (not parseAccountConfigs) so disabled / not-yet-credentialed + // accounts still have their legacy allowlist migrated. + await migrateLegacyAllowlist( + Object.values(config.accounts ?? {}), + ctx.runtime.identity, + ctx.logger, + ); + const accounts = parseAccountConfigs(config); if (accounts.size === 0) { ctx.logger.warn("No Feishu accounts configured"); return; } - // One-time migration: move any legacy authorizedOpenIds into SDK identity - // bindings so previously-authorized users keep access under the access gate. - await migrateLegacyAllowlist(accounts.values(), ctx.runtime.identity, ctx.logger); - for (const [accountId, acctConfig] of accounts) { try { const handle = await startMonitor({ diff --git a/plugins/feishu/tests/event-handler.test.ts b/plugins/feishu/tests/event-handler.test.ts index d937f10..7783c35 100644 --- a/plugins/feishu/tests/event-handler.test.ts +++ b/plugins/feishu/tests/event-handler.test.ts @@ -1,7 +1,7 @@ import type * as lark from "@larksuiteoapi/node-sdk"; import type { DeliverFn, PluginLogger } from "@marswave/cola-plugin-sdk"; import { describe, expect, it, vi } from "vitest"; -import { registerMessageHandler } from "../src/gateway/event-handler.js"; +import { registerMessageHandler, registerReactionHandler } from "../src/gateway/event-handler.js"; import { MessageDedup } from "../src/gateway/dedup.js"; import type { ChatMap } from "../src/gateway/chat-map.js"; @@ -158,4 +158,73 @@ describe("Feishu message delivery (SDK access gate)", () => { expect(deliver).not.toHaveBeenCalled(); }); + + it("skips delivery when text is only the bot mention and there are no attachments", async () => { + const { handler, deliver } = register({ botOpenId: "ou_bot" }); + const event = groupMessage("ou_alice", [botMention("ou_bot")]); + event.message.content = JSON.stringify({ text: "@_user_1" }); + + await handler(event); + + expect(deliver).not.toHaveBeenCalled(); + }); +}); + +type ReactionData = { + message_id?: string; + user_id?: { open_id?: string }; + reaction_type?: { emoji_type?: string }; + event_id?: string; +}; + +describe("Feishu reaction delivery (SDK access gate)", () => { + it("delivers a reaction as a direct conversation routed to the reacted chat", async () => { + let handler!: (data: ReactionData) => Promise; + const dispatcher = { + register(events: Record Promise>>) { + handler = events["im.message.reaction.created_v1"]; + }, + } as unknown as lark.EventDispatcher; + + const deliver = vi.fn(async () => {}) as unknown as DeliverFn; + const chatMap = { set: vi.fn(), get: vi.fn(), hasUser: vi.fn() } as unknown as ChatMap; + const get = vi.fn(async () => ({ + data: { + items: [ + { chat_id: "chat1", msg_type: "text", body: { content: JSON.stringify({ text: "hi" }) } }, + ], + }, + })); + const client = { im: { message: { get } } } as unknown as lark.Client; + + registerReactionHandler(dispatcher, { + client, + accountId: "default", + logger: makeLogger(), + deliver, + dedup: new MessageDedup(), + chatMap, + }); + + await handler({ + message_id: "m9", + user_id: { open_id: "ou_alice" }, + reaction_type: { emoji_type: "THUMBSUP" }, + event_id: "e1", + }); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: ["chat", "default", "chat1", "sender", "ou_alice"], + sender: { id: "ou_alice" }, + conversation: { kind: "direct", id: "ou_alice" }, + deliveryContext: expect.objectContaining({ + to: "chat:chat1", + accountId: "default", + messageId: "m9", + }), + }), + ); + }); }); From 23dc8152c9dc58bee2e19002decbda35c3b306b2 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 21:15:30 +0800 Subject: [PATCH 09/26] build(feishu): depend on @marswave/cola-plugin-sdk 0.0.3-beta.0 --- plugins/feishu/package.json | 2 +- pnpm-lock.yaml | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/feishu/package.json b/plugins/feishu/package.json index 1396229..a024386 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.0.1" + "@marswave/cola-plugin-sdk": "0.0.3-beta.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 120b2fb..c8a98c2 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.1 - version: 0.0.1 + specifier: 0.0.3-beta.0 + version: 0.0.3-beta.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -374,6 +374,9 @@ packages: '@marswave/cola-plugin-sdk@0.0.1': resolution: {integrity: sha512-TVfnamgC9nvSFjISxpRdbUjvP5q+BHlXizS4M3BCe/Ocl24pxSKdrSGqbUcsqR9S3FG8c5TYq1RjMb6T/Q3OQw==} + '@marswave/cola-plugin-sdk@0.0.3-beta.0': + resolution: {integrity: sha512-X3QYIQ1WwkVs1O1UBUcJCTapAq2X+Doj1gf6zvtDJLJk23naUe+xjBtmBNJgnrYBcccYqYmwrAIgLRLmcQYCXQ==} + '@oxfmt/binding-android-arm-eabi@0.42.0': resolution: {integrity: sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1529,6 +1532,8 @@ snapshots: '@marswave/cola-plugin-sdk@0.0.1': {} + '@marswave/cola-plugin-sdk@0.0.3-beta.0': {} + '@oxfmt/binding-android-arm-eabi@0.42.0': optional: true From 48ed0a01ba5d1cdb9c2d0f2cc6cd492082851711 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 22:52:55 +0800 Subject: [PATCH 10/26] feat(feishu): add group-context builder and watermark tracker --- plugins/feishu/src/gateway/group-context.ts | 118 ++++++++++++++++++++ plugins/feishu/tests/group-context.test.ts | 90 +++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 plugins/feishu/src/gateway/group-context.ts create mode 100644 plugins/feishu/tests/group-context.test.ts diff --git a/plugins/feishu/src/gateway/group-context.ts b/plugins/feishu/src/gateway/group-context.ts new file mode 100644 index 0000000..e8cd4df --- /dev/null +++ b/plugins/feishu/src/gateway/group-context.ts @@ -0,0 +1,118 @@ +export const MAX_CONTEXT_MESSAGES = 50; + +const PER_LINE_MAX = 300; +const CONTEXT_HEADER = + "[Recent group messages since your last reply — context only, not all directed at you]"; +const CURRENT_HEADER = "[Current message — reply to this]"; + +export type GroupContextItem = { + message_id?: string; + msg_type?: string; + create_time?: string; + sender?: { id?: string; sender_type?: string }; + body?: { content?: string }; +}; + +/** Per-monitor in-memory watermark of the last group message we handled per chat. */ +export class GroupContextTracker { + private readonly watermarks = new Map(); + + get(chatId: string): number | undefined { + return this.watermarks.get(chatId); + } + + set(chatId: string, createTimeMs: number): void { + this.watermarks.set(chatId, createTimeMs); + } +} + +export function buildGroupContextBlock( + items: GroupContextItem[], + opts: { triggerMessageId: string; maxLines?: number }, +): string | undefined { + const maxLines = opts.maxLines ?? MAX_CONTEXT_MESSAGES; + const lines: string[] = []; + for (const item of items) { + if (item.message_id && item.message_id === opts.triggerMessageId) continue; + if (item.sender?.sender_type !== "user") continue; + const text = renderLineText(item.msg_type, item.body?.content); + if (!text) continue; + const sender = item.sender?.id ?? "unknown"; + lines.push(`[${sender}] ${truncate(text, PER_LINE_MAX)}`); + } + const kept = lines.slice(-maxLines); + if (kept.length === 0) return undefined; + return `${CONTEXT_HEADER}\n${kept.join("\n")}`; +} + +export function prependGroupContext(currentText: string, block: string | undefined): string { + if (!block) return currentText; + return `${block}\n\n${CURRENT_HEADER}\n${currentText}`; +} + +function renderLineText( + msgType: string | undefined, + content: string | undefined, +): string | undefined { + switch (msgType) { + case "text": + return extractText(content); + case "post": + return extractPostText(content); + case "image": + return "[图片]"; + case "media": + return "[视频]"; + case "audio": + return "[语音]"; + case "file": + return "[文件]"; + case "sticker": + return "[表情]"; + default: + return msgType ? `[${msgType}]` : undefined; + } +} + +function extractText(content: string | undefined): string | undefined { + if (!content) return undefined; + try { + const parsed = JSON.parse(content) as { text?: unknown }; + if (typeof parsed.text === "string") { + const t = normalizeWhitespace(parsed.text); + return t || undefined; + } + } catch { + /* fall through */ + } + return undefined; +} + +function extractPostText(content: string | undefined): string | undefined { + if (!content) return undefined; + try { + const parsed = JSON.parse(content) as { content?: unknown; title?: unknown }; + const segments: string[] = []; + if (typeof parsed.title === "string" && parsed.title.trim()) segments.push(parsed.title); + if (Array.isArray(parsed.content)) { + for (const node of parsed.content.flat()) { + if (node && typeof node === "object" && "text" in node && typeof node.text === "string") { + segments.push(node.text); + } + } + } + const text = normalizeWhitespace(segments.join(" ")); + return text || "[富文本]"; + } catch { + return "[富文本]"; + } +} + +function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 3)}...`; +} diff --git a/plugins/feishu/tests/group-context.test.ts b/plugins/feishu/tests/group-context.test.ts new file mode 100644 index 0000000..d9c0fb2 --- /dev/null +++ b/plugins/feishu/tests/group-context.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import { + GroupContextTracker, + buildGroupContextBlock, + prependGroupContext, + type GroupContextItem, +} from "../src/gateway/group-context.js"; + +const userMsg = ( + id: string, + text: string, + sender = "ou_user", +): GroupContextItem => ({ + message_id: id, + msg_type: "text", + sender: { id: sender, sender_type: "user" }, + body: { content: JSON.stringify({ text }) }, +}); + +describe("buildGroupContextBlock", () => { + it("renders user text lines under the context header", () => { + const block = buildGroupContextBlock( + [userMsg("m1", "几点开会", "ou_a"), userMsg("m2", "下午三点", "ou_b")], + { triggerMessageId: "trigger" }, + ); + expect(block).toBe( + "[Recent group messages since your last reply — context only, not all directed at you]\n" + + "[ou_a] 几点开会\n[ou_b] 下午三点", + ); + }); + + it("drops the trigger message and non-user senders", () => { + const block = buildGroupContextBlock( + [ + userMsg("trigger", "should be dropped"), + { message_id: "bot", msg_type: "text", sender: { id: "cli_x", sender_type: "app" }, body: { content: JSON.stringify({ text: "bot reply" }) } }, + userMsg("m3", "keep me", "ou_c"), + ], + { triggerMessageId: "trigger" }, + ); + expect(block).toBe( + "[Recent group messages since your last reply — context only, not all directed at you]\n[ou_c] keep me", + ); + }); + + it("renders placeholders for non-text message types", () => { + const block = buildGroupContextBlock( + [{ message_id: "i1", msg_type: "image", sender: { id: "ou_a", sender_type: "user" }, body: { content: "{}" } }], + { triggerMessageId: "trigger" }, + ); + expect(block).toContain("[ou_a] [图片]"); + }); + + it("keeps only the most recent maxLines messages", () => { + const items = Array.from({ length: 5 }, (_, i) => userMsg(`m${i}`, `line ${i}`)); + const block = buildGroupContextBlock(items, { triggerMessageId: "trigger", maxLines: 2 }); + expect(block).toContain("line 3"); + expect(block).toContain("line 4"); + expect(block).not.toContain("line 0"); + }); + + it("returns undefined when nothing remains", () => { + expect(buildGroupContextBlock([], { triggerMessageId: "trigger" })).toBeUndefined(); + expect( + buildGroupContextBlock([userMsg("trigger", "only the trigger")], { triggerMessageId: "trigger" }), + ).toBeUndefined(); + }); +}); + +describe("prependGroupContext", () => { + it("returns the current text unchanged when block is undefined", () => { + expect(prependGroupContext("hi", undefined)).toBe("hi"); + }); + + it("joins block, current header, and current text", () => { + expect(prependGroupContext("帮我确认时间", "BLOCK")).toBe( + "BLOCK\n\n[Current message — reply to this]\n帮我确认时间", + ); + }); +}); + +describe("GroupContextTracker", () => { + it("stores and returns per-chat watermarks", () => { + const t = new GroupContextTracker(); + expect(t.get("oc_1")).toBeUndefined(); + t.set("oc_1", 1000); + expect(t.get("oc_1")).toBe(1000); + expect(t.get("oc_2")).toBeUndefined(); + }); +}); From c5e054dfd6f4aa6f57052ca30550d2f00b9fe59e Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 22:59:26 +0800 Subject: [PATCH 11/26] fix(feishu): unwrap language-keyed post content in group context --- plugins/feishu/src/gateway/group-context.ts | 34 ++++++++++++++++----- plugins/feishu/tests/group-context.test.ts | 18 +++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/plugins/feishu/src/gateway/group-context.ts b/plugins/feishu/src/gateway/group-context.ts index e8cd4df..6d8b7a5 100644 --- a/plugins/feishu/src/gateway/group-context.ts +++ b/plugins/feishu/src/gateway/group-context.ts @@ -1,3 +1,5 @@ +import type { FeishuPostContent, FeishuPostElement } from "../api/types.js"; + export const MAX_CONTEXT_MESSAGES = 50; const PER_LINE_MAX = 300; @@ -91,15 +93,14 @@ function extractText(content: string | undefined): string | undefined { function extractPostText(content: string | undefined): string | undefined { if (!content) return undefined; try { - const parsed = JSON.parse(content) as { content?: unknown; title?: unknown }; + const parsed = JSON.parse(content) as Record; + // Post content is language-keyed; prefer zh_cn, then en_us, then first available. + const post = parsed.zh_cn ?? parsed.en_us ?? Object.values(parsed)[0]; + if (!post) return "[富文本]"; const segments: string[] = []; - if (typeof parsed.title === "string" && parsed.title.trim()) segments.push(parsed.title); - if (Array.isArray(parsed.content)) { - for (const node of parsed.content.flat()) { - if (node && typeof node === "object" && "text" in node && typeof node.text === "string") { - segments.push(node.text); - } - } + if (typeof post.title === "string" && post.title.trim()) segments.push(post.title); + for (const paragraph of post.content ?? []) { + segments.push(paragraph.map(postElementToText).join("")); } const text = normalizeWhitespace(segments.join(" ")); return text || "[富文本]"; @@ -108,6 +109,23 @@ function extractPostText(content: string | undefined): string | undefined { } } +function postElementToText(elem: FeishuPostElement): string { + switch (elem.tag) { + case "text": + return elem.text; + case "a": + return `[${elem.text}](${elem.href})`; + case "at": + return `@${elem.user_name ?? elem.user_id}`; + case "img": + return "[图片]"; + case "md": + return elem.text; + default: + return ""; + } +} + function normalizeWhitespace(text: string): string { return text.replace(/\s+/g, " ").trim(); } diff --git a/plugins/feishu/tests/group-context.test.ts b/plugins/feishu/tests/group-context.test.ts index d9c0fb2..f879dc1 100644 --- a/plugins/feishu/tests/group-context.test.ts +++ b/plugins/feishu/tests/group-context.test.ts @@ -51,6 +51,24 @@ describe("buildGroupContextBlock", () => { expect(block).toContain("[ou_a] [图片]"); }); + it("extracts language-keyed post (rich text) content", () => { + const post: GroupContextItem = { + message_id: "p1", + msg_type: "post", + sender: { id: "ou_a", sender_type: "user" }, + body: { + content: JSON.stringify({ + zh_cn: { + title: "标题", + content: [[{ tag: "text", text: "今天开会" }, { tag: "at", user_name: "张三" }]], + }, + }), + }, + }; + const block = buildGroupContextBlock([post], { triggerMessageId: "trigger" }); + expect(block).toContain("[ou_a] 标题 今天开会@张三"); + }); + it("keeps only the most recent maxLines messages", () => { const items = Array.from({ length: 5 }, (_, i) => userMsg(`m${i}`, `line ${i}`)); const block = buildGroupContextBlock(items, { triggerMessageId: "trigger", maxLines: 2 }); From 71fec51515ef9092b096026b4af11ff47d2635a4 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 23:02:08 +0800 Subject: [PATCH 12/26] feat(feishu): fetch group history window via List Messages API --- plugins/feishu/src/gateway/group-context.ts | 37 +++++++++++ plugins/feishu/tests/group-context.test.ts | 69 +++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/plugins/feishu/src/gateway/group-context.ts b/plugins/feishu/src/gateway/group-context.ts index 6d8b7a5..92eddeb 100644 --- a/plugins/feishu/src/gateway/group-context.ts +++ b/plugins/feishu/src/gateway/group-context.ts @@ -1,3 +1,5 @@ +import type * as lark from "@larksuiteoapi/node-sdk"; +import type { PluginLogger } from "@marswave/cola-plugin-sdk"; import type { FeishuPostContent, FeishuPostElement } from "../api/types.js"; export const MAX_CONTEXT_MESSAGES = 50; @@ -47,6 +49,41 @@ export function buildGroupContextBlock( return `${CONTEXT_HEADER}\n${kept.join("\n")}`; } +export async function fetchGroupContext(opts: { + client: lark.Client; + logger: PluginLogger; + chatId: string; + triggerMessageId: string; + triggerCreateTimeMs: number; + startTimeMs?: number; + maxMessages?: number; +}): Promise { + const { client, logger, chatId, triggerMessageId, triggerCreateTimeMs, startTimeMs } = opts; + const maxMessages = opts.maxMessages ?? MAX_CONTEXT_MESSAGES; + const hasStart = typeof startTimeMs === "number"; + try { + const params = { + container_id_type: "chat" as const, + container_id: chatId, + page_size: maxMessages, + end_time: String(Math.floor(triggerCreateTimeMs / 1000)), + sort_type: (hasStart ? "ByCreateTimeAsc" : "ByCreateTimeDesc") as + | "ByCreateTimeAsc" + | "ByCreateTimeDesc", + ...(startTimeMs !== undefined ? { start_time: String(Math.floor(startTimeMs / 1000)) } : {}), + }; + const res = await client.im.message.list({ params } as Parameters< + typeof client.im.message.list + >[0]); + let items = (res?.data?.items ?? []) as GroupContextItem[]; + if (!hasStart) items = [...items].reverse(); + return buildGroupContextBlock(items, { triggerMessageId, maxLines: maxMessages }); + } catch (err) { + logger.warn(`feishu: failed to fetch group context for chat ${chatId}`, err); + return undefined; + } +} + export function prependGroupContext(currentText: string, block: string | undefined): string { if (!block) return currentText; return `${block}\n\n${CURRENT_HEADER}\n${currentText}`; diff --git a/plugins/feishu/tests/group-context.test.ts b/plugins/feishu/tests/group-context.test.ts index f879dc1..dd1746d 100644 --- a/plugins/feishu/tests/group-context.test.ts +++ b/plugins/feishu/tests/group-context.test.ts @@ -3,6 +3,7 @@ import { GroupContextTracker, buildGroupContextBlock, prependGroupContext, + fetchGroupContext, type GroupContextItem, } from "../src/gateway/group-context.js"; @@ -106,3 +107,71 @@ describe("GroupContextTracker", () => { expect(t.get("oc_2")).toBeUndefined(); }); }); + +const silentLogger = { info() {}, warn() {}, error() {}, debug() {} } as never; + +function clientWith(listImpl: (arg: unknown) => unknown) { + return { im: { message: { list: listImpl } } } as never; +} + +describe("fetchGroupContext", () => { + it("uses an ascending window when a start watermark is provided", async () => { + let captured: { params?: Record } = {}; + const client = clientWith((arg) => { + captured = arg as { params?: Record }; + return { data: { items: [userMsg("m1", "hi", "ou_a")] } }; + }); + const block = await fetchGroupContext({ + client, + logger: silentLogger, + chatId: "oc_1", + triggerMessageId: "trigger", + triggerCreateTimeMs: 60_000, + startTimeMs: 30_000, + }); + expect(captured.params).toMatchObject({ + container_id_type: "chat", + container_id: "oc_1", + sort_type: "ByCreateTimeAsc", + start_time: "30", + end_time: "60", + }); + expect(block).toContain("[ou_a] hi"); + }); + + it("falls back to a descending window (reversed) on cold start", async () => { + let captured: { params?: Record } = {}; + const client = clientWith((arg) => { + captured = arg as { params?: Record }; + // SDK returns newest-first under ByCreateTimeDesc + return { data: { items: [userMsg("m2", "second", "ou_b"), userMsg("m1", "first", "ou_a")] } }; + }); + const block = await fetchGroupContext({ + client, + logger: silentLogger, + chatId: "oc_1", + triggerMessageId: "trigger", + triggerCreateTimeMs: 60_000, + }); + expect(captured.params).toMatchObject({ sort_type: "ByCreateTimeDesc", end_time: "60" }); + expect(captured.params).not.toHaveProperty("start_time"); + // reversed back to chronological: first then second + expect(block).toBe( + "[Recent group messages since your last reply — context only, not all directed at you]\n[ou_a] first\n[ou_b] second", + ); + }); + + it("returns undefined and does not throw when the API fails", async () => { + const client = clientWith(() => { + throw new Error("rate limited"); + }); + const block = await fetchGroupContext({ + client, + logger: silentLogger, + chatId: "oc_1", + triggerMessageId: "trigger", + triggerCreateTimeMs: 60_000, + }); + expect(block).toBeUndefined(); + }); +}); From 6bbceeef1ae7a86cba2e6d38950a1af92730b1e7 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 23:09:31 +0800 Subject: [PATCH 13/26] refactor(feishu): drop unnecessary message.list param cast --- plugins/feishu/src/gateway/group-context.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/feishu/src/gateway/group-context.ts b/plugins/feishu/src/gateway/group-context.ts index 92eddeb..048a189 100644 --- a/plugins/feishu/src/gateway/group-context.ts +++ b/plugins/feishu/src/gateway/group-context.ts @@ -72,9 +72,7 @@ export async function fetchGroupContext(opts: { | "ByCreateTimeDesc", ...(startTimeMs !== undefined ? { start_time: String(Math.floor(startTimeMs / 1000)) } : {}), }; - const res = await client.im.message.list({ params } as Parameters< - typeof client.im.message.list - >[0]); + const res = await client.im.message.list({ params }); let items = (res?.data?.items ?? []) as GroupContextItem[]; if (!hasStart) items = [...items].reverse(); return buildGroupContextBlock(items, { triggerMessageId, maxLines: maxMessages }); From bea72a303f44926de290e81b632aad8b1367c394 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 23:12:24 +0800 Subject: [PATCH 14/26] feat(feishu): prepend fetched group context on @-mention --- plugins/feishu/src/gateway/event-handler.ts | 28 ++++++++- plugins/feishu/src/gateway/monitor.ts | 4 +- plugins/feishu/tests/event-handler.test.ts | 63 ++++++++++++++++++++- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/plugins/feishu/src/gateway/event-handler.ts b/plugins/feishu/src/gateway/event-handler.ts index f7b4fd7..345ffc1 100644 --- a/plugins/feishu/src/gateway/event-handler.ts +++ b/plugins/feishu/src/gateway/event-handler.ts @@ -4,6 +4,7 @@ import { parseMessage } from "./message-parser.js"; import { isBotMentioned, stripBotMention } from "../util/mention.js"; import { MessageDedup } from "./dedup.js"; import type { ChatMap } from "./chat-map.js"; +import { fetchGroupContext, prependGroupContext, type GroupContextTracker } from "./group-context.js"; export type EventHandlerDeps = { client: lark.Client; @@ -12,6 +13,8 @@ export type EventHandlerDeps = { deliver: DeliverFn; dedup: MessageDedup; chatMap: ChatMap; + /** Per-monitor group-context watermark tracker. */ + groupContext: GroupContextTracker; /** Bot's own open_id, used to detect @bot mentions in group chats. */ botOpenId?: string; }; @@ -128,6 +131,27 @@ export function registerMessageHandler( // Record chat mapping for outbound routing chatMap.set(senderId, message.chat_id); + const mentioned = isGroup ? isBotMentioned(mentions, deps.botOpenId) : false; + + // For a group @, pull the recent chat history since we last replied and + // prepend it as context so the agent understands what is being discussed. + let messageText = text; + if (isGroup && mentioned) { + const createTimeMs = message.create_time ? Number(message.create_time) : undefined; + if (createTimeMs && Number.isFinite(createTimeMs)) { + const block = await fetchGroupContext({ + client, + logger, + chatId: message.chat_id, + triggerMessageId: message.message_id, + triggerCreateTimeMs: createTimeMs, + startTimeMs: deps.groupContext.get(message.chat_id), + }); + messageText = prependGroupContext(text, block); + deps.groupContext.set(message.chat_id, createTimeMs); + } + } + await deliver({ sessionId: isGroup ? ["chat", accountId, message.chat_id] @@ -136,13 +160,13 @@ export function registerMessageHandler( conversation: isGroup ? { kind: "group", id: message.chat_id } : { kind: "direct", id: senderId }, - mentionedBot: isGroup ? isBotMentioned(mentions, deps.botOpenId) : undefined, + mentionedBot: isGroup ? mentioned : undefined, deliveryContext: { to: `chat:${message.chat_id}`, accountId, messageId: message.message_id, }, - message: text, + message: messageText, attachments: parsed.attachments.length > 0 ? parsed.attachments : undefined, }); diff --git a/plugins/feishu/src/gateway/monitor.ts b/plugins/feishu/src/gateway/monitor.ts index c3032ca..93ec97a 100644 --- a/plugins/feishu/src/gateway/monitor.ts +++ b/plugins/feishu/src/gateway/monitor.ts @@ -6,6 +6,7 @@ import { registerMessageHandler, registerReactionHandler } from "./event-handler import { startWSGateway } from "./ws-gateway.js"; import { MessageDedup } from "./dedup.js"; import { ChatMap } from "./chat-map.js"; +import { GroupContextTracker } from "./group-context.js"; export type MonitorHandle = { accountId: string; @@ -32,11 +33,12 @@ export async function startMonitor(opts: { const dispatcher = createEventDispatcher(config); const dedup = new MessageDedup(); const chatMap = new ChatMap(accountId, logger); + const groupContext = new GroupContextTracker(); // Bot open_id is required to detect @bot mentions in group chats. const botOpenId = await fetchBotOpenId(client, logger); - const deps = { client, accountId, logger, deliver, dedup, chatMap, botOpenId }; + const deps = { client, accountId, logger, deliver, dedup, chatMap, groupContext, botOpenId }; // Register event handlers registerMessageHandler(dispatcher, deps); diff --git a/plugins/feishu/tests/event-handler.test.ts b/plugins/feishu/tests/event-handler.test.ts index 7783c35..577936b 100644 --- a/plugins/feishu/tests/event-handler.test.ts +++ b/plugins/feishu/tests/event-handler.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { registerMessageHandler, registerReactionHandler } from "../src/gateway/event-handler.js"; import { MessageDedup } from "../src/gateway/dedup.js"; import type { ChatMap } from "../src/gateway/chat-map.js"; +import { GroupContextTracker } from "../src/gateway/group-context.js"; type Handler = (data: FeishuMessageData) => Promise>; @@ -16,6 +17,7 @@ type FeishuMessageData = { chat_type: string; message_type: string; content: string; + create_time?: string; mentions?: Mention[]; }; sender: { @@ -63,13 +65,14 @@ function botMention(botOpenId: string): Mention { return { key: "@_user_1", id: { open_id: botOpenId }, name: "Cola" }; } -function register(opts: { botOpenId?: string } = {}) { +function register(opts: { botOpenId?: string; list?: ReturnType } = {}) { const { dispatcher, handlers } = makeDispatcher(); const logger = makeLogger(); const deliver = vi.fn(async () => {}) as unknown as DeliverFn; const chatMap = { set: vi.fn(), get: vi.fn(), hasUser: vi.fn() } as unknown as ChatMap; const create = vi.fn(async () => {}); - const client = { im: { message: { create } } } as unknown as lark.Client; + const list = opts.list ?? vi.fn(async () => ({ data: { items: [] } })); + const client = { im: { message: { create, list } } } as unknown as lark.Client; registerMessageHandler(dispatcher, { client, @@ -78,10 +81,11 @@ function register(opts: { botOpenId?: string } = {}) { deliver, dedup: new MessageDedup(), chatMap, + groupContext: new GroupContextTracker(), botOpenId: opts.botOpenId, }); - return { handler: handlers["im.message.receive_v1"], deliver, chatMap, create }; + return { handler: handlers["im.message.receive_v1"], deliver, chatMap, create, list }; } describe("Feishu message delivery (SDK access gate)", () => { @@ -168,6 +172,59 @@ describe("Feishu message delivery (SDK access gate)", () => { expect(deliver).not.toHaveBeenCalled(); }); + + it("prepends fetched group context for a group @mention", async () => { + const list = vi.fn(async () => ({ + data: { + items: [ + { + message_id: "ctx1", + msg_type: "text", + sender: { id: "ou_a", sender_type: "user" }, + body: { content: JSON.stringify({ text: "几点开会" }) }, + }, + ], + }, + })); + const { handler, deliver } = register({ botOpenId: "ou_bot", list }); + const event = groupMessage("ou_alice", [botMention("ou_bot")]); + event.message.message_id = "trigger"; + event.message.create_time = "60000"; + + await handler(event); + + expect(list).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledTimes(1); + const payload = (deliver as unknown as ReturnType).mock.calls[0][0]; + expect(payload.mentionedBot).toBe(true); + expect(payload.message).toContain("[Recent group messages since your last reply"); + expect(payload.message).toContain("[ou_a] 几点开会"); + expect(payload.message).toContain("[Current message — reply to this]"); + }); + + it("does not fetch group context for a direct message", async () => { + const { handler, deliver, list } = register({ botOpenId: "ou_bot" }); + + await handler(directMessage("ou_alice")); + + expect(list).not.toHaveBeenCalled(); + expect(deliver).toHaveBeenCalledTimes(1); + }); + + it("still delivers without a context block when the fetch fails", async () => { + const list = vi.fn().mockRejectedValue(new Error("boom")); + const { handler, deliver } = register({ botOpenId: "ou_bot", list }); + const event = groupMessage("ou_alice", [botMention("ou_bot")]); + event.message.message_id = "trigger"; + event.message.create_time = "60000"; + + await handler(event); + + expect(deliver).toHaveBeenCalledTimes(1); + const payload = (deliver as unknown as ReturnType).mock.calls[0][0]; + expect(payload.message).not.toContain("[Recent group messages"); + expect(payload.message).toBe("hello"); + }); }); type ReactionData = { From dd335e64dcfd6fc21cab48ccf7168ad99507b10c Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 23:18:32 +0800 Subject: [PATCH 15/26] docs(feishu): note GroupContextTracker is not concurrency-guarded --- plugins/feishu/src/gateway/group-context.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/feishu/src/gateway/group-context.ts b/plugins/feishu/src/gateway/group-context.ts index 048a189..63e3a92 100644 --- a/plugins/feishu/src/gateway/group-context.ts +++ b/plugins/feishu/src/gateway/group-context.ts @@ -17,7 +17,11 @@ export type GroupContextItem = { body?: { content?: string }; }; -/** Per-monitor in-memory watermark of the last group message we handled per chat. */ +/** + * Per-monitor in-memory watermark of the last group message we handled per chat. + * Not concurrency-guarded: simultaneous @-mentions in one chat may read the same + * watermark and fetch overlapping windows (over-inclusion only, never message loss). + */ export class GroupContextTracker { private readonly watermarks = new Map(); From b6cdc280bdc01c521fdfb4ddef94a2454ccbaf61 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 23:20:34 +0800 Subject: [PATCH 16/26] docs(feishu): document group-context fetch and required scope --- plugins/feishu/README.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/plugins/feishu/README.md b/plugins/feishu/README.md index 14ae291..dd9d26c 100644 --- a/plugins/feishu/README.md +++ b/plugins/feishu/README.md @@ -7,6 +7,7 @@ - 接收飞书/Lark 的文本、图片、文件和表情回应事件。 - 从 Cola 向飞书/Lark 发送文本、图片、文件、Markdown 和表情回应消息。 - 支持私聊和群聊(群聊需 @机器人触发),访问授信由 `cola channel allow[-group]` 统一管理。 +- 群聊里 @机器人时,自动补全「上次回复以来」的群聊上下文(见[群聊上下文](#群聊上下文))。 - 支持 `cola channel login feishu` 扫码一键创建应用并自动回写凭据。 - 固定使用 WebSocket 长连接接收事件,不需要公网服务器。 - 支持通过 `accounts` 配置多个账号。 @@ -177,6 +178,20 @@ cola channel allow-group feishu oc_xxx > 从旧版本升级:插件启动时会自动把遗留的 `authorizedOpenIds` 迁移成授信绑定,原有用户 > 不需要重新授信。 +## 群聊上下文 + +群聊里机器人默认只会收到「@它」的消息,看不到群里其他人的发言,被 @ 时容易缺少上下文。 +为此,当机器人在已授信的群里被 @ 时,插件会通过飞书「获取会话历史消息」接口拉取该群 +**自机器人上次回复以来**的最近消息,拼成一段「仅供参考」的上下文,连同当前这条消息一起 +交给 Cola,让回复能理解大家在讨论什么。 + +- **无需额外权限**:复用已申请的 `im:message` / `im:message:readonly`,前提是机器人已在该群里。 +- **作用范围**:仅群聊、仅被 @ 时触发;单聊不受影响。整群共享一段上下文,按群累计。 +- **失败不影响回复**:拉取历史失败时只记一条告警日志,机器人照常回复,只是少了上下文。 +- **当前限制**:上下文里的图片/文件等只显示占位符(如 `[图片]`),发言人以 `open_id` 标注; + 服务重启后首次被 @ 会改为拉取最近若干条。被动接收群里全部消息(需敏感权限 + `im:message.group_msg`)作为后续增强,暂未启用。 + ## 测试 1. 给机器人发单聊消息,或在已添加机器人的群里 @机器人。 @@ -199,10 +214,10 @@ cola channel allow-group feishu oc_xxx ## 配置字段 -| 字段 | 必需 | 默认值 | 说明 | -| ----------- | ---- | -------- | ----------------------------------------- | -| `appId` | 是 | | 飞书/Lark 机器人应用 ID,例如 `cli_xxx`。 | -| `appSecret` | 是 | | 机器人应用密钥。请作为 secret 保存。 | +| 字段 | 必需 | 默认值 | 说明 | +| ----------- | ---- | -------- | -------------------------------------------- | +| `appId` | 是 | | 飞书/Lark 机器人应用 ID,例如 `cli_xxx`。 | +| `appSecret` | 是 | | 机器人应用密钥。请作为 secret 保存。 | | `domain` | 否 | `feishu` | 国内飞书用 `feishu`,国际版 Lark 用 `lark`。 | 谁能使用 Cola 不再通过配置字段控制,改用 `cola channel allow[-group]` 授信,见 From 71efca94093261144ca2c48a9162d54d3a40f849 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 23:20:34 +0800 Subject: [PATCH 17/26] style(feishu): apply oxfmt to group-context wiring --- plugins/feishu/src/gateway/event-handler.ts | 6 +++- plugins/feishu/tests/group-context.test.ts | 33 +++++++++++++++------ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/plugins/feishu/src/gateway/event-handler.ts b/plugins/feishu/src/gateway/event-handler.ts index 345ffc1..3cd1173 100644 --- a/plugins/feishu/src/gateway/event-handler.ts +++ b/plugins/feishu/src/gateway/event-handler.ts @@ -4,7 +4,11 @@ import { parseMessage } from "./message-parser.js"; import { isBotMentioned, stripBotMention } from "../util/mention.js"; import { MessageDedup } from "./dedup.js"; import type { ChatMap } from "./chat-map.js"; -import { fetchGroupContext, prependGroupContext, type GroupContextTracker } from "./group-context.js"; +import { + fetchGroupContext, + prependGroupContext, + type GroupContextTracker, +} from "./group-context.js"; export type EventHandlerDeps = { client: lark.Client; diff --git a/plugins/feishu/tests/group-context.test.ts b/plugins/feishu/tests/group-context.test.ts index dd1746d..600240a 100644 --- a/plugins/feishu/tests/group-context.test.ts +++ b/plugins/feishu/tests/group-context.test.ts @@ -7,11 +7,7 @@ import { type GroupContextItem, } from "../src/gateway/group-context.js"; -const userMsg = ( - id: string, - text: string, - sender = "ou_user", -): GroupContextItem => ({ +const userMsg = (id: string, text: string, sender = "ou_user"): GroupContextItem => ({ message_id: id, msg_type: "text", sender: { id: sender, sender_type: "user" }, @@ -34,7 +30,12 @@ describe("buildGroupContextBlock", () => { const block = buildGroupContextBlock( [ userMsg("trigger", "should be dropped"), - { message_id: "bot", msg_type: "text", sender: { id: "cli_x", sender_type: "app" }, body: { content: JSON.stringify({ text: "bot reply" }) } }, + { + message_id: "bot", + msg_type: "text", + sender: { id: "cli_x", sender_type: "app" }, + body: { content: JSON.stringify({ text: "bot reply" }) }, + }, userMsg("m3", "keep me", "ou_c"), ], { triggerMessageId: "trigger" }, @@ -46,7 +47,14 @@ describe("buildGroupContextBlock", () => { it("renders placeholders for non-text message types", () => { const block = buildGroupContextBlock( - [{ message_id: "i1", msg_type: "image", sender: { id: "ou_a", sender_type: "user" }, body: { content: "{}" } }], + [ + { + message_id: "i1", + msg_type: "image", + sender: { id: "ou_a", sender_type: "user" }, + body: { content: "{}" }, + }, + ], { triggerMessageId: "trigger" }, ); expect(block).toContain("[ou_a] [图片]"); @@ -61,7 +69,12 @@ describe("buildGroupContextBlock", () => { content: JSON.stringify({ zh_cn: { title: "标题", - content: [[{ tag: "text", text: "今天开会" }, { tag: "at", user_name: "张三" }]], + content: [ + [ + { tag: "text", text: "今天开会" }, + { tag: "at", user_name: "张三" }, + ], + ], }, }), }, @@ -81,7 +94,9 @@ describe("buildGroupContextBlock", () => { it("returns undefined when nothing remains", () => { expect(buildGroupContextBlock([], { triggerMessageId: "trigger" })).toBeUndefined(); expect( - buildGroupContextBlock([userMsg("trigger", "only the trigger")], { triggerMessageId: "trigger" }), + buildGroupContextBlock([userMsg("trigger", "only the trigger")], { + triggerMessageId: "trigger", + }), ).toBeUndefined(); }); }); From 6dd299508037645dc547653bafa375c0faa035f2 Mon Sep 17 00:00:00 2001 From: Mack Date: Tue, 2 Jun 2026 23:25:21 +0800 Subject: [PATCH 18/26] test(feishu): cover group-context watermark round-trip across two @mentions --- plugins/feishu/tests/event-handler.test.ts | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/plugins/feishu/tests/event-handler.test.ts b/plugins/feishu/tests/event-handler.test.ts index 577936b..6ff0827 100644 --- a/plugins/feishu/tests/event-handler.test.ts +++ b/plugins/feishu/tests/event-handler.test.ts @@ -202,6 +202,35 @@ describe("Feishu message delivery (SDK access gate)", () => { expect(payload.message).toContain("[Current message — reply to this]"); }); + it("uses the prior trigger's time as the start watermark on the next @mention in the same chat", async () => { + const list = vi.fn(async () => ({ data: { items: [] } })); + const { handler } = register({ botOpenId: "ou_bot", list }); + + const first = groupMessage("ou_alice", [botMention("ou_bot")]); + first.message.message_id = "trigger1"; + first.message.create_time = "60000"; + await handler(first); + + const second = groupMessage("ou_alice", [botMention("ou_bot")]); + second.message.message_id = "trigger2"; + second.message.create_time = "120000"; + await handler(second); + + expect(list).toHaveBeenCalledTimes(2); + // First @ is a cold start: descending window, no start watermark. + expect(list.mock.calls[0][0].params).toMatchObject({ + sort_type: "ByCreateTimeDesc", + end_time: "60", + }); + expect(list.mock.calls[0][0].params).not.toHaveProperty("start_time"); + // Second @ resumes from the first trigger's second (watermark round-trip). + expect(list.mock.calls[1][0].params).toMatchObject({ + sort_type: "ByCreateTimeAsc", + start_time: "60", + end_time: "120", + }); + }); + it("does not fetch group context for a direct message", async () => { const { handler, deliver, list } = register({ botOpenId: "ou_bot" }); From 080c37c2f389ec375cb47ef87038ca735608c47e Mon Sep 17 00:00:00 2001 From: Mack Date: Thu, 4 Jun 2026 13:10:59 +0800 Subject: [PATCH 19/26] =?UTF-8?q?feat(feishu):=20gate=20group=20chat=20beh?= =?UTF-8?q?ind=20groupEnabled,=20reply=20'=E6=9A=82=E4=B8=8D=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=BE=A4=E8=81=8A'=20when=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/feishu/README.md | 17 ++--- plugins/feishu/src/api/types.ts | 2 + plugins/feishu/src/gateway/event-handler.ts | 42 ++++++++++--- plugins/feishu/src/gateway/monitor.ts | 15 ++++- plugins/feishu/src/index.ts | 12 ++++ plugins/feishu/tests/event-handler.test.ts | 69 ++++++++++++++++++--- 6 files changed, 132 insertions(+), 25 deletions(-) diff --git a/plugins/feishu/README.md b/plugins/feishu/README.md index dd9d26c..7baac98 100644 --- a/plugins/feishu/README.md +++ b/plugins/feishu/README.md @@ -6,8 +6,8 @@ - 接收飞书/Lark 的文本、图片、文件和表情回应事件。 - 从 Cola 向飞书/Lark 发送文本、图片、文件、Markdown 和表情回应消息。 -- 支持私聊和群聊(群聊需 @机器人触发),访问授信由 `cola channel allow[-group]` 统一管理。 -- 群聊里 @机器人时,自动补全「上次回复以来」的群聊上下文(见[群聊上下文](#群聊上下文))。 +- 默认仅支持私聊。群聊默认关闭,被 @机器人时回复「暂不支持群聊」;可通过配置 `groupEnabled` 开启。 +- 开启群聊后:需 @机器人触发、按群授信(`cola channel allow-group`),并自动补全「上次回复以来」的群聊上下文(见[群聊上下文](#群聊上下文))。 - 支持 `cola channel login feishu` 扫码一键创建应用并自动回写凭据。 - 固定使用 WebSocket 长连接接收事件,不需要公网服务器。 - 支持通过 `accounts` 配置多个账号。 @@ -180,6 +180,8 @@ cola channel allow-group feishu oc_xxx ## 群聊上下文 +> 仅在配置 `groupEnabled: true` 开启群聊后生效。群聊默认关闭,此时群里 @机器人只会收到「暂不支持群聊」提示。 + 群聊里机器人默认只会收到「@它」的消息,看不到群里其他人的发言,被 @ 时容易缺少上下文。 为此,当机器人在已授信的群里被 @ 时,插件会通过飞书「获取会话历史消息」接口拉取该群 **自机器人上次回复以来**的最近消息,拼成一段「仅供参考」的上下文,连同当前这条消息一起 @@ -214,11 +216,12 @@ cola channel allow-group feishu oc_xxx ## 配置字段 -| 字段 | 必需 | 默认值 | 说明 | -| ----------- | ---- | -------- | -------------------------------------------- | -| `appId` | 是 | | 飞书/Lark 机器人应用 ID,例如 `cli_xxx`。 | -| `appSecret` | 是 | | 机器人应用密钥。请作为 secret 保存。 | -| `domain` | 否 | `feishu` | 国内飞书用 `feishu`,国际版 Lark 用 `lark`。 | +| 字段 | 必需 | 默认值 | 说明 | +| -------------- | ---- | -------- | -------------------------------------------------------- | +| `appId` | 是 | | 飞书/Lark 机器人应用 ID,例如 `cli_xxx`。 | +| `appSecret` | 是 | | 机器人应用密钥。请作为 secret 保存。 | +| `domain` | 否 | `feishu` | 国内飞书用 `feishu`,国际版 Lark 用 `lark`。 | +| `groupEnabled` | 否 | `false` | 是否启用群聊。关闭时群里 @机器人只回复「暂不支持群聊」。 | 谁能使用 Cola 不再通过配置字段控制,改用 `cola channel allow[-group]` 授信,见 [访问授信](#访问授信谁能使用-cola)。 diff --git a/plugins/feishu/src/api/types.ts b/plugins/feishu/src/api/types.ts index 5baaa2b..f0cc4a1 100644 --- a/plugins/feishu/src/api/types.ts +++ b/plugins/feishu/src/api/types.ts @@ -14,6 +14,8 @@ export type FeishuAccountConfig = { export type FeishuPluginConfig = { pluginDir?: string; accounts?: Record; + /** Enable group chat. When false (default), @mentions in groups get a "not supported" reply. */ + groupEnabled?: boolean; }; /** Pre-SDK-gate account config that may still carry a legacy authorizedOpenIds allowlist. */ diff --git a/plugins/feishu/src/gateway/event-handler.ts b/plugins/feishu/src/gateway/event-handler.ts index 3cd1173..4058245 100644 --- a/plugins/feishu/src/gateway/event-handler.ts +++ b/plugins/feishu/src/gateway/event-handler.ts @@ -9,6 +9,10 @@ import { prependGroupContext, type GroupContextTracker, } from "./group-context.js"; +import { sendText } from "../outbound/send.js"; + +/** Reply sent to a group @mention while group chat is disabled. */ +const GROUP_DISABLED_NOTICE = "暂不支持群聊,请私聊我使用。"; export type EventHandlerDeps = { client: lark.Client; @@ -21,6 +25,8 @@ export type EventHandlerDeps = { groupContext: GroupContextTracker; /** Bot's own open_id, used to detect @bot mentions in group chats. */ botOpenId?: string; + /** When false, group @mentions get a "not supported" reply instead of reaching the agent. */ + groupEnabled: boolean; }; type ReactionAction = "created" | "deleted"; @@ -52,6 +58,15 @@ type StripMention = { name: string; }; +function mapMentions(raw: unknown): StripMention[] | undefined { + if (!Array.isArray(raw)) return undefined; + return raw.map((m: Record) => ({ + key: m.key as string, + id: (m.id ?? {}) as { open_id?: string; user_id?: string; union_id?: string }, + name: (m.name ?? "") as string, + })); +} + /** * Register the im.message.receive_v1 event handler on a dispatcher. * @@ -81,6 +96,26 @@ export function registerMessageHandler( } const isGroup = message.chat_type === "group"; + const mentions = mapMentions(message.mentions); + + // Group chat is opt-in. While disabled, reply "not supported" to a direct + // @mention and ignore all other group messages. + if (isGroup && !deps.groupEnabled) { + if (isBotMentioned(mentions, deps.botOpenId)) { + try { + await sendText( + client, + `chat:${message.chat_id}`, + GROUP_DISABLED_NOTICE, + chatMap, + logger, + ); + } catch (err) { + logger.warn(`Failed to send group-disabled notice to chat ${message.chat_id}`, err); + } + } + return {}; + } // Parse message content const parsed = await parseMessage( @@ -118,13 +153,6 @@ export function registerMessageHandler( } // Strip @bot mention from the text - const mentions: StripMention[] | undefined = message.mentions?.map( - (m: Record) => ({ - key: m.key as string, - id: (m.id ?? {}) as { open_id?: string; user_id?: string; union_id?: string }, - name: (m.name ?? "") as string, - }), - ); const text = stripBotMention(parsed.text, mentions, deps.botOpenId); // Skip empty text after stripping mentions (unless attachments present) diff --git a/plugins/feishu/src/gateway/monitor.ts b/plugins/feishu/src/gateway/monitor.ts index 93ec97a..8eda58c 100644 --- a/plugins/feishu/src/gateway/monitor.ts +++ b/plugins/feishu/src/gateway/monitor.ts @@ -25,8 +25,9 @@ export async function startMonitor(opts: { deliver: DeliverFn; logger: PluginLogger; abortSignal: AbortSignal; + groupEnabled: boolean; }): Promise { - const { accountId, config, deliver, logger, abortSignal } = opts; + const { accountId, config, deliver, logger, abortSignal, groupEnabled } = opts; // Create client and dispatcher const client = createLarkClient(accountId, config); @@ -38,7 +39,17 @@ export async function startMonitor(opts: { // Bot open_id is required to detect @bot mentions in group chats. const botOpenId = await fetchBotOpenId(client, logger); - const deps = { client, accountId, logger, deliver, dedup, chatMap, groupContext, botOpenId }; + const deps = { + client, + accountId, + logger, + deliver, + dedup, + chatMap, + groupContext, + botOpenId, + groupEnabled, + }; // Register event handlers registerMessageHandler(dispatcher, deps); diff --git a/plugins/feishu/src/index.ts b/plugins/feishu/src/index.ts index 4c6f71a..1066805 100644 --- a/plugins/feishu/src/index.ts +++ b/plugins/feishu/src/index.ts @@ -101,6 +101,15 @@ export default defineChannel({ { label: "Lark", value: "lark" }, ], }, + { + key: "groupEnabled", + path: ["groupEnabled"], + label: "启用群聊", + description: + "关闭时,群里 @机器人只会收到「暂不支持群聊」提示。开启后需对每个群单独授信。", + type: "boolean", + defaultValue: false, + }, ], }, }, @@ -153,6 +162,8 @@ export default defineChannel({ return; } + const groupEnabled = config.groupEnabled ?? false; + for (const [accountId, acctConfig] of accounts) { try { const handle = await startMonitor({ @@ -161,6 +172,7 @@ export default defineChannel({ deliver: ctx.deliver, logger: ctx.logger, abortSignal: ctx.abortSignal, + groupEnabled, }); monitors.set(accountId, handle); } catch (err) { diff --git a/plugins/feishu/tests/event-handler.test.ts b/plugins/feishu/tests/event-handler.test.ts index 6ff0827..378e8c0 100644 --- a/plugins/feishu/tests/event-handler.test.ts +++ b/plugins/feishu/tests/event-handler.test.ts @@ -65,7 +65,9 @@ function botMention(botOpenId: string): Mention { return { key: "@_user_1", id: { open_id: botOpenId }, name: "Cola" }; } -function register(opts: { botOpenId?: string; list?: ReturnType } = {}) { +function register( + opts: { botOpenId?: string; list?: ReturnType; groupEnabled?: boolean } = {}, +) { const { dispatcher, handlers } = makeDispatcher(); const logger = makeLogger(); const deliver = vi.fn(async () => {}) as unknown as DeliverFn; @@ -83,9 +85,10 @@ function register(opts: { botOpenId?: string; list?: ReturnType } chatMap, groupContext: new GroupContextTracker(), botOpenId: opts.botOpenId, + groupEnabled: opts.groupEnabled ?? false, }); - return { handler: handlers["im.message.receive_v1"], deliver, chatMap, create, list }; + return { handler: handlers["im.message.receive_v1"], deliver, chatMap, create, list, logger }; } describe("Feishu message delivery (SDK access gate)", () => { @@ -114,7 +117,7 @@ describe("Feishu message delivery (SDK access gate)", () => { }); it("delivers a group message that @mentions the bot with mentionedBot=true", async () => { - const { handler, deliver } = register({ botOpenId: "ou_bot" }); + const { handler, deliver } = register({ botOpenId: "ou_bot", groupEnabled: true }); await handler(groupMessage("ou_alice", [botMention("ou_bot")])); @@ -132,7 +135,7 @@ describe("Feishu message delivery (SDK access gate)", () => { }); it("delivers a group message without an @bot mention with mentionedBot=false", async () => { - const { handler, deliver } = register({ botOpenId: "ou_bot" }); + const { handler, deliver } = register({ botOpenId: "ou_bot", groupEnabled: true }); await handler(groupMessage("ou_alice")); @@ -145,7 +148,7 @@ describe("Feishu message delivery (SDK access gate)", () => { }); it("reports mentionedBot=false in groups when the bot open_id is unknown", async () => { - const { handler, deliver } = register(); + const { handler, deliver } = register({ groupEnabled: true }); await handler(groupMessage("ou_alice", [botMention("ou_bot")])); @@ -164,7 +167,7 @@ describe("Feishu message delivery (SDK access gate)", () => { }); it("skips delivery when text is only the bot mention and there are no attachments", async () => { - const { handler, deliver } = register({ botOpenId: "ou_bot" }); + const { handler, deliver } = register({ botOpenId: "ou_bot", groupEnabled: true }); const event = groupMessage("ou_alice", [botMention("ou_bot")]); event.message.content = JSON.stringify({ text: "@_user_1" }); @@ -186,7 +189,7 @@ describe("Feishu message delivery (SDK access gate)", () => { ], }, })); - const { handler, deliver } = register({ botOpenId: "ou_bot", list }); + const { handler, deliver } = register({ botOpenId: "ou_bot", list, groupEnabled: true }); const event = groupMessage("ou_alice", [botMention("ou_bot")]); event.message.message_id = "trigger"; event.message.create_time = "60000"; @@ -204,7 +207,7 @@ describe("Feishu message delivery (SDK access gate)", () => { it("uses the prior trigger's time as the start watermark on the next @mention in the same chat", async () => { const list = vi.fn(async () => ({ data: { items: [] } })); - const { handler } = register({ botOpenId: "ou_bot", list }); + const { handler } = register({ botOpenId: "ou_bot", list, groupEnabled: true }); const first = groupMessage("ou_alice", [botMention("ou_bot")]); first.message.message_id = "trigger1"; @@ -242,7 +245,7 @@ describe("Feishu message delivery (SDK access gate)", () => { it("still delivers without a context block when the fetch fails", async () => { const list = vi.fn().mockRejectedValue(new Error("boom")); - const { handler, deliver } = register({ botOpenId: "ou_bot", list }); + const { handler, deliver } = register({ botOpenId: "ou_bot", list, groupEnabled: true }); const event = groupMessage("ou_alice", [botMention("ou_bot")]); event.message.message_id = "trigger"; event.message.create_time = "60000"; @@ -256,6 +259,54 @@ 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 () => { + const { handler, deliver, create } = register({ botOpenId: "ou_bot", groupEnabled: false }); + + await handler(groupMessage("ou_alice", [botMention("ou_bot")])); + + expect(deliver).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalledTimes(1); + const arg = create.mock.calls[0][0] as { + params: { receive_id_type: string }; + data: { receive_id: string; content: string; msg_type: string }; + }; + expect(arg.params.receive_id_type).toBe("chat_id"); + expect(arg.data.receive_id).toBe("group-chat1"); + expect(arg.data.content).toContain("暂不支持群聊"); + }); + + it("ignores a group message without an @bot mention", async () => { + const { handler, deliver, create } = register({ botOpenId: "ou_bot", groupEnabled: false }); + + await handler(groupMessage("ou_alice")); + + expect(deliver).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it("does not reply when the bot open_id is unknown", async () => { + const { handler, deliver, create } = register({ groupEnabled: false }); + + await handler(groupMessage("ou_alice", [botMention("ou_bot")])); + + expect(deliver).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it("still delivers a direct message normally while group chat is disabled", async () => { + const { handler, deliver, create } = register({ botOpenId: "ou_bot", groupEnabled: false }); + + await handler(directMessage("ou_alice")); + + expect(create).not.toHaveBeenCalled(); + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith( + expect.objectContaining({ conversation: { kind: "direct", id: "ou_alice" } }), + ); + }); +}); + type ReactionData = { message_id?: string; user_id?: { open_id?: string }; From c4f4ad50a41cc83cd981573015a69e5be2368a37 Mon Sep 17 00:00:00 2001 From: Mack Date: Thu, 4 Jun 2026 13:24:10 +0800 Subject: [PATCH 20/26] =?UTF-8?q?feat(telegram):=20gate=20group=20chat=20b?= =?UTF-8?q?ehind=20groupEnabled,=20reply=20'=E6=9A=82=E4=B8=8D=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=BE=A4=E8=81=8A'=20when=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/telegram/README.md | 16 ++--- plugins/telegram/src/config.ts | 2 + plugins/telegram/src/gateway.ts | 54 ++++++++++++++++- plugins/telegram/src/index.ts | 8 +++ plugins/telegram/src/types.ts | 10 ++++ plugins/telegram/tests/gateway.test.ts | 82 ++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 8 deletions(-) diff --git a/plugins/telegram/README.md b/plugins/telegram/README.md index ef96c94..4429b45 100644 --- a/plugins/telegram/README.md +++ b/plugins/telegram/README.md @@ -9,6 +9,7 @@ endpoint. - Sends Cola replies back to the originating Telegram chat or forum topic. - Supports Telegram typing indicators. - Only accepts messages from explicitly configured Telegram chat IDs. +- Private chat only by default. Group chat is opt-in via `groupEnabled`; while off, an @mention or reply to the bot in a group gets a `暂不支持群聊` notice and other group messages are ignored. ## Requirements @@ -30,13 +31,14 @@ Telegram does not allow `getUpdates` long polling while a webhook is configured. ## Configuration -| Field | Required | Default | Description | -| ----------------------- | -------- | ------- | -------------------------------------------------------------------- | -| `botToken` | Yes | | Telegram bot token from BotFather. | -| `allowedChatIds` | Yes | | Comma-separated chat IDs accepted by the plugin. | -| `pollingTimeoutSeconds` | No | `25` | Long-poll timeout for `getUpdates`. | -| `dropPendingUpdates` | No | `false` | Whether to discard pending Telegram updates when the gateway starts. | -| `ignoreBotMessages` | No | `true` | Skip messages sent by Telegram bot accounts. | +| Field | Required | Default | Description | +| ----------------------- | -------- | ------- | ---------------------------------------------------------------------------------- | +| `botToken` | Yes | | Telegram bot token from BotFather. | +| `allowedChatIds` | Yes | | Comma-separated chat IDs accepted by the plugin. | +| `pollingTimeoutSeconds` | No | `25` | Long-poll timeout for `getUpdates`. | +| `dropPendingUpdates` | No | `false` | Whether to discard pending Telegram updates when the gateway starts. | +| `ignoreBotMessages` | No | `true` | Skip messages sent by Telegram bot accounts. | +| `groupEnabled` | No | `false` | Enable group chat. While off, group @mentions/replies get a `暂不支持群聊` notice. | ## Finding Chat IDs diff --git a/plugins/telegram/src/config.ts b/plugins/telegram/src/config.ts index 8ee4c3a..431212f 100644 --- a/plugins/telegram/src/config.ts +++ b/plugins/telegram/src/config.ts @@ -5,6 +5,7 @@ export type TelegramConfig = { allowedChatIds: Set; dropPendingUpdates: boolean; ignoreBotMessages: boolean; + groupEnabled: boolean; }; export function readTelegramConfig(raw: Readonly>): TelegramConfig { @@ -15,6 +16,7 @@ export function readTelegramConfig(raw: Readonly>): Tele allowedChatIds: parseChatIds(raw.allowedChatIds), dropPendingUpdates: readBoolean(raw.dropPendingUpdates, false), ignoreBotMessages: readBoolean(raw.ignoreBotMessages, true), + groupEnabled: readBoolean(raw.groupEnabled, false), }; } diff --git a/plugins/telegram/src/gateway.ts b/plugins/telegram/src/gateway.ts index 43b5b0c..64c00c4 100644 --- a/plugins/telegram/src/gateway.ts +++ b/plugins/telegram/src/gateway.ts @@ -3,7 +3,10 @@ import type { ChannelStatusResult, GatewayContext } from "@marswave/cola-plugin- import { TelegramApiClient } from "./api.js"; import { isTelegramConfigured, readTelegramConfig, type TelegramConfig } from "./config.js"; import { isFromBot, parseTelegramMessage } from "./message.js"; -import type { TelegramUpdate, TelegramUser } from "./types.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; @@ -125,6 +128,15 @@ export async function handleUpdate( const chatId = String(message.chat.id); if (config.ignoreBotMessages && isFromBot(message)) return; + // Group chat is opt-in. While disabled, reply "not supported" to a direct + // @mention / reply-to-bot and ignore all other non-private messages. + if (message.chat.type !== "private" && !config.groupEnabled) { + if (isBotAddressed(message, ctx.state.me)) { + await sendGroupDisabledReply(message, ctx, config); + } + return; + } + if (config.allowedChatIds.size > 0 && !config.allowedChatIds.has(chatId)) { ctx.logger.info(`Skipping Telegram message from unlisted chat ${chatId}`); await sendAccessNotConfiguredReply(message, ctx, config); @@ -175,6 +187,46 @@ async function sendAccessNotConfiguredReply( } } +async function sendGroupDisabledReply( + message: NonNullable, + ctx: GatewayContext, + config: TelegramConfig, +): Promise { + if (!config.botToken) return; + + const client = ctx.state.client ?? new TelegramApiClient({ botToken: config.botToken }); + try { + await client.sendMessage({ + chatId: String(message.chat.id), + messageThreadId: message.message_thread_id, + text: GROUP_DISABLED_NOTICE, + }); + } catch (err) { + ctx.logger.warn( + `Failed to send Telegram group-disabled notice for chat ${message.chat.id}`, + err, + ); + } +} + +/** True when the message @mentions the bot or replies to one of the bot's messages. */ +function isBotAddressed(message: TelegramMessage, me: TelegramUser | undefined): boolean { + if (!me) return false; + if (message.reply_to_message?.from?.id === me.id) return true; + + const username = me.username?.toLowerCase(); + const text = message.text ?? message.caption ?? ""; + const entities = message.entities ?? message.caption_entities ?? []; + for (const entity of entities) { + if (entity.type === "text_mention" && entity.user?.id === me.id) return true; + if (entity.type === "mention" && username) { + const mention = text.slice(entity.offset, entity.offset + entity.length).toLowerCase(); + if (mention === `@${username}`) return true; + } + } + return false; +} + function accessNotConfiguredMessage(message: NonNullable): string { const userId = message.from ? String(message.from.id) : undefined; const chatId = String(message.chat.id); diff --git a/plugins/telegram/src/index.ts b/plugins/telegram/src/index.ts index 92e26aa..e0f6839 100644 --- a/plugins/telegram/src/index.ts +++ b/plugins/telegram/src/index.ts @@ -60,6 +60,14 @@ export default defineChannel({ type: "boolean", defaultValue: true, }, + { + key: "groupEnabled", + label: "Enable group chat", + type: "boolean", + defaultValue: false, + description: + "When off, an @mention or reply to the bot in a group gets a 「暂不支持群聊」 notice and all other group messages are ignored.", + }, ], }, }, diff --git a/plugins/telegram/src/types.ts b/plugins/telegram/src/types.ts index 4e3b6d6..1abe6f2 100644 --- a/plugins/telegram/src/types.ts +++ b/plugins/telegram/src/types.ts @@ -30,6 +30,13 @@ export type TelegramChat = { last_name?: string; }; +export type TelegramMessageEntity = { + type: string; + offset: number; + length: number; + user?: TelegramUser; +}; + export type TelegramMessage = { message_id: number; message_thread_id?: number; @@ -39,6 +46,9 @@ export type TelegramMessage = { date: number; text?: string; caption?: string; + reply_to_message?: TelegramMessage; + entities?: TelegramMessageEntity[]; + caption_entities?: TelegramMessageEntity[]; photo?: unknown[]; document?: { file_name?: string; diff --git a/plugins/telegram/tests/gateway.test.ts b/plugins/telegram/tests/gateway.test.ts index 5a23ed0..e96d8a3 100644 --- a/plugins/telegram/tests/gateway.test.ts +++ b/plugins/telegram/tests/gateway.test.ts @@ -36,6 +36,34 @@ function privateUpdate(chatId: number, fromId: number, text = "hi"): TelegramUpd }; } +function groupUpdate( + chatId: number, + fromId: number, + opts: { mentionBot?: boolean; replyToBot?: boolean } = {}, +): TelegramUpdate { + const text = opts.mentionBot ? "@bot hi" : "hi"; + const message: NonNullable = { + message_id: 10, + chat: { id: chatId, type: "supergroup" }, + date: 1, + from: { id: fromId, is_bot: false, first_name: "Ada", username: "ada" }, + text, + }; + if (opts.mentionBot) { + message.entities = [{ type: "mention", offset: text.indexOf("@bot"), length: 4 }]; + } + if (opts.replyToBot) { + message.reply_to_message = { + message_id: 9, + chat: { id: chatId, type: "supergroup" }, + date: 1, + from: { id: 555, is_bot: true, first_name: "Bot", username: "bot" }, + text: "earlier", + }; + } + return { update_id: 2, message }; +} + describe("telegram gateway identity binding", () => { it("binds an unbound sender from an allowed chat before delivering", async () => { const { ctx, bind, resolve, deliver } = makeCtx(); @@ -83,3 +111,57 @@ describe("telegram gateway identity binding", () => { }); }); }); + +describe("telegram group chat disabled (groupEnabled=false)", () => { + const NOTICE = "暂不支持群聊,请私聊我使用。"; + + it("ignores a group message that does not address the bot", async () => { + const { ctx, deliver, resolve, sendMessage } = makeCtx(); + + await handleUpdate(groupUpdate(-100123, 5693819232), ctx, config); + + expect(deliver).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("replies '暂不支持群聊' to a group @mention and does not deliver", async () => { + const { ctx, deliver, sendMessage } = makeCtx(); + + await handleUpdate(groupUpdate(-100123, 5693819232, { mentionBot: true }), ctx, config); + + expect(deliver).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith({ + chatId: "-100123", + messageThreadId: undefined, + text: NOTICE, + }); + }); + + it("replies '暂不支持群聊' to a reply directed at the bot", async () => { + const { ctx, deliver, sendMessage } = makeCtx(); + + await handleUpdate(groupUpdate(-100123, 5693819232, { replyToBot: true }), ctx, config); + + expect(deliver).not.toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledWith({ + chatId: "-100123", + messageThreadId: undefined, + text: NOTICE, + }); + }); + + it("delivers group messages when groupEnabled is true", async () => { + const groupConfig = readTelegramConfig({ + botToken: "123456:secret", + allowedChatIds: "-100123", + groupEnabled: true, + }); + const { ctx, deliver, sendMessage } = makeCtx(); + + await handleUpdate(groupUpdate(-100123, 5693819232, { mentionBot: true }), ctx, groupConfig); + + expect(sendMessage).not.toHaveBeenCalled(); + expect(deliver).toHaveBeenCalledTimes(1); + }); +}); From 9396c72885394744131197c88e9a4b5eb7cca154 Mon Sep 17 00:00:00 2001 From: Mack Date: Thu, 4 Jun 2026 13:27:19 +0800 Subject: [PATCH 21/26] test(telegram): cover @mention of another user not triggering the group notice --- plugins/telegram/tests/gateway.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/plugins/telegram/tests/gateway.test.ts b/plugins/telegram/tests/gateway.test.ts index e96d8a3..6655ee3 100644 --- a/plugins/telegram/tests/gateway.test.ts +++ b/plugins/telegram/tests/gateway.test.ts @@ -151,6 +151,26 @@ describe("telegram group chat disabled (groupEnabled=false)", () => { }); }); + it("ignores a group @mention directed at another user", async () => { + const { ctx, deliver, sendMessage } = makeCtx(); + const update: TelegramUpdate = { + update_id: 3, + message: { + message_id: 11, + chat: { id: -100123, type: "supergroup" }, + date: 1, + from: { id: 5693819232, is_bot: false, first_name: "Ada", username: "ada" }, + text: "@ada hi", + entities: [{ type: "mention", offset: 0, length: 4 }], + }, + }; + + await handleUpdate(update, ctx, config); + + expect(deliver).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("delivers group messages when groupEnabled is true", async () => { const groupConfig = readTelegramConfig({ botToken: "123456:secret", From 1ec2fdefdba58cab8b12111ea9de8e1a587c7231 Mon Sep 17 00:00:00 2001 From: Mack Date: Thu, 4 Jun 2026 14:13:04 +0800 Subject: [PATCH 22/26] feat(feishu): implement auth.disconnect to clear credentials (enables logout button) --- plugins/feishu/src/auth/login.ts | 12 ++++++++++++ plugins/feishu/tests/login.test.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/plugins/feishu/src/auth/login.ts b/plugins/feishu/src/auth/login.ts index 0c67a2a..2d3e88b 100644 --- a/plugins/feishu/src/auth/login.ts +++ b/plugins/feishu/src/auth/login.ts @@ -48,5 +48,17 @@ export function createFeishuAuth(): ChannelAuthAdapter { ctx.onStatus?.("success", "应用创建成功,凭据已写入"); }, + + async disconnect(ctx: AuthContext) { + ctx.onStatus?.("disconnecting", "正在断开飞书连接…"); + + // 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 + // shallow merge, so replacing `accounts` with {} drops every account. + // Identity authorizations (cola channel allow/revoke) are left intact. + await ctx.runtime.config.patch({ accounts: {} }); + + ctx.onStatus?.("disconnected", "已断开,凭据已清空"); + }, }; } diff --git a/plugins/feishu/tests/login.test.ts b/plugins/feishu/tests/login.test.ts index 72e68f2..6a5f4ea 100644 --- a/plugins/feishu/tests/login.test.ts +++ b/plugins/feishu/tests/login.test.ts @@ -64,6 +64,18 @@ describe("createFeishuAuth().login (one-click app creation)", () => { expect(bind).toHaveBeenCalledWith("ou_owner"); }); + it("disconnect clears all accounts via config.patch and binds nobody", async () => { + const { ctx, patch, bind } = makeCtx({ + accounts: { default: { appId: "cli_old", appSecret: "s" } }, + }); + + await createFeishuAuth().disconnect!(ctx); + + expect(patch).toHaveBeenCalledTimes(1); + expect(patch).toHaveBeenCalledWith({ accounts: {} }); + expect(bind).not.toHaveBeenCalled(); + }); + it("defaults domain to feishu and skips bind when no user_info is returned", async () => { mockedRegisterApp.mockImplementation(async (opts: Parameters[0]) => { opts.onQRCodeReady({ url: "https://feishu/qr2", expireIn: 120 }); From f095ebf5b2081d5f48e4a43b18aba1a3d5f12d30 Mon Sep 17 00:00:00 2001 From: Mack Date: Thu, 4 Jun 2026 14:20:16 +0800 Subject: [PATCH 23/26] =?UTF-8?q?refactor(channel):=20shorten=20group-disa?= =?UTF-8?q?bled=20notice=20to=20"=E6=9A=82=E4=B8=8D=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=BE=A4=E8=81=8A"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/feishu/src/gateway/event-handler.ts | 2 +- plugins/telegram/src/gateway.ts | 2 +- plugins/telegram/tests/gateway.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/feishu/src/gateway/event-handler.ts b/plugins/feishu/src/gateway/event-handler.ts index 4058245..f686a99 100644 --- a/plugins/feishu/src/gateway/event-handler.ts +++ b/plugins/feishu/src/gateway/event-handler.ts @@ -12,7 +12,7 @@ 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 = "暂不支持群聊"; export type EventHandlerDeps = { client: lark.Client; diff --git a/plugins/telegram/src/gateway.ts b/plugins/telegram/src/gateway.ts index 64c00c4..5a5f47f 100644 --- a/plugins/telegram/src/gateway.ts +++ b/plugins/telegram/src/gateway.ts @@ -6,7 +6,7 @@ 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 = "暂不支持群聊,请私聊我使用。"; +const GROUP_DISABLED_NOTICE = "暂不支持群聊"; export type TelegramGatewayState = { abortController?: AbortController; diff --git a/plugins/telegram/tests/gateway.test.ts b/plugins/telegram/tests/gateway.test.ts index 6655ee3..9fb0da4 100644 --- a/plugins/telegram/tests/gateway.test.ts +++ b/plugins/telegram/tests/gateway.test.ts @@ -113,7 +113,7 @@ describe("telegram gateway identity binding", () => { }); 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(); From c8e26e7359461924f59de3ca12e42d4bd6e111a7 Mon Sep 17 00:00:00 2001 From: Mack Date: Thu, 4 Jun 2026 15:12:12 +0800 Subject: [PATCH 24/26] refactor(feishu): hide groupEnabled toggle from config UI Group chat stays disabled (gateway reads config.groupEnabled ?? false); the field remains in FeishuPluginConfig and can be set via channels.json. --- plugins/feishu/src/index.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/plugins/feishu/src/index.ts b/plugins/feishu/src/index.ts index 1066805..68afd11 100644 --- a/plugins/feishu/src/index.ts +++ b/plugins/feishu/src/index.ts @@ -101,15 +101,9 @@ export default defineChannel({ { label: "Lark", value: "lark" }, ], }, - { - key: "groupEnabled", - path: ["groupEnabled"], - label: "启用群聊", - description: - "关闭时,群里 @机器人只会收到「暂不支持群聊」提示。开启后需对每个群单独授信。", - type: "boolean", - defaultValue: false, - }, + // `groupEnabled` is intentionally not exposed in the config UI: group chat + // stays disabled (gateway reads `config.groupEnabled ?? false`). The field + // remains in FeishuPluginConfig and can be flipped via channels.json if needed. ], }, }, From d8be8034462e1dcdcf9c90e3d2537977492adf2d Mon Sep 17 00:00:00 2001 From: Mack Date: Thu, 4 Jun 2026 15:12:12 +0800 Subject: [PATCH 25/26] refactor(telegram): expose only bot token and allowed chat IDs in config UI Drops the polling timeout, drop-pending-updates, ignore-bot-messages and group-enabled fields from the UI; readTelegramConfig still applies their defaults, so behavior is unchanged and values can be set via channels.json. --- plugins/telegram/src/index.ts | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/plugins/telegram/src/index.ts b/plugins/telegram/src/index.ts index e0f6839..fe00b9b 100644 --- a/plugins/telegram/src/index.ts +++ b/plugins/telegram/src/index.ts @@ -42,32 +42,10 @@ export default defineChannel({ placeholder: "-1001234567890,123456789", description: "Comma-separated Telegram chat IDs accepted by the plugin.", }, - { - key: "pollingTimeoutSeconds", - label: "Polling timeout", - type: "number", - defaultValue: 25, - }, - { - key: "dropPendingUpdates", - label: "Drop pending updates on start", - type: "boolean", - defaultValue: false, - }, - { - key: "ignoreBotMessages", - label: "Ignore bot messages", - type: "boolean", - defaultValue: true, - }, - { - key: "groupEnabled", - label: "Enable group chat", - type: "boolean", - defaultValue: false, - description: - "When off, an @mention or reply to the bot in a group gets a 「暂不支持群聊」 notice and all other group messages are ignored.", - }, + // Only Bot token and Allowed chat IDs are exposed in the config UI. The + // remaining options (pollingTimeoutSeconds, dropPendingUpdates, + // ignoreBotMessages, groupEnabled) keep their defaults from + // readTelegramConfig and can be set via channels.json if needed. ], }, }, From 7b4b84cdc5162fce240cb1b5e810e4ecdb9c4cc0 Mon Sep 17 00:00:00 2001 From: Mack Date: Thu, 4 Jun 2026 21:03:38 +0800 Subject: [PATCH 26/26] chore(plugins): use dev plugin sdk --- plugins/feishu/package.json | 2 +- plugins/telegram/package.json | 2 +- pnpm-lock.yaml | 19 ++++------ scripts/build-registry.test.ts | 22 ++++++++++- scripts/build-registry.ts | 4 ++ smoke/upgrade-gates/README.md | 38 +++++++++++++++++++ smoke/upgrade-gates/compatible/index.js | 7 ++++ smoke/upgrade-gates/compatible/package.json | 23 +++++++++++ .../legacy-min-sdk-version/index.js | 7 ++++ .../legacy-min-sdk-version/package.json | 23 +++++++++++ smoke/upgrade-gates/min-cola-version/index.js | 7 ++++ .../min-cola-version/package.json | 23 +++++++++++ smoke/upgrade-gates/mock-registry.json | 21 ++++++++++ 13 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 smoke/upgrade-gates/README.md create mode 100644 smoke/upgrade-gates/compatible/index.js create mode 100644 smoke/upgrade-gates/compatible/package.json create mode 100644 smoke/upgrade-gates/legacy-min-sdk-version/index.js create mode 100644 smoke/upgrade-gates/legacy-min-sdk-version/package.json create mode 100644 smoke/upgrade-gates/min-cola-version/index.js create mode 100644 smoke/upgrade-gates/min-cola-version/package.json create mode 100644 smoke/upgrade-gates/mock-registry.json diff --git a/plugins/feishu/package.json b/plugins/feishu/package.json index a024386..306c059 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.0.3-beta.0" + "@marswave/cola-plugin-sdk": "0.0.4-dev.202606041251.c8b2bed3" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/plugins/telegram/package.json b/plugins/telegram/package.json index 8b161d2..73612e5 100644 --- a/plugins/telegram/package.json +++ b/plugins/telegram/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@marswave/cola-plugin-sdk": "^0.0.1" + "@marswave/cola-plugin-sdk": "0.0.4-dev.202606041251.c8b2bed3" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8a98c2..4003009 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-beta.0 - version: 0.0.3-beta.0 + specifier: 0.0.4-dev.202606041251.c8b2bed3 + version: 0.0.4-dev.202606041251.c8b2bed3 devDependencies: '@types/node': specifier: ^22.0.0 @@ -46,8 +46,8 @@ importers: plugins/telegram: dependencies: '@marswave/cola-plugin-sdk': - specifier: ^0.0.1 - version: 0.0.1 + specifier: 0.0.4-dev.202606041251.c8b2bed3 + version: 0.0.4-dev.202606041251.c8b2bed3 devDependencies: '@types/node': specifier: ^22.0.0 @@ -371,11 +371,8 @@ packages: '@larksuiteoapi/node-sdk@1.66.0': resolution: {integrity: sha512-ueKbbdvmVGVie3KvKbvHZqvDC/gg3M0rRDeyQanQWK+i2bQgiiTpIfpqVWvxuTgprV31yqV7HPMjN6KegWSCfA==} - '@marswave/cola-plugin-sdk@0.0.1': - resolution: {integrity: sha512-TVfnamgC9nvSFjISxpRdbUjvP5q+BHlXizS4M3BCe/Ocl24pxSKdrSGqbUcsqR9S3FG8c5TYq1RjMb6T/Q3OQw==} - - '@marswave/cola-plugin-sdk@0.0.3-beta.0': - resolution: {integrity: sha512-X3QYIQ1WwkVs1O1UBUcJCTapAq2X+Doj1gf6zvtDJLJk23naUe+xjBtmBNJgnrYBcccYqYmwrAIgLRLmcQYCXQ==} + '@marswave/cola-plugin-sdk@0.0.4-dev.202606041251.c8b2bed3': + resolution: {integrity: sha512-o00WrjR/+7PQL308H1w5JN5Rpz8Z12+1pmnnIf4ztNM7s81/HxOJovRCI+9W2y9hpntFmCzgH8bR2hW9JngULw==} '@oxfmt/binding-android-arm-eabi@0.42.0': resolution: {integrity: sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow==} @@ -1530,9 +1527,7 @@ snapshots: - debug - utf-8-validate - '@marswave/cola-plugin-sdk@0.0.1': {} - - '@marswave/cola-plugin-sdk@0.0.3-beta.0': {} + '@marswave/cola-plugin-sdk@0.0.4-dev.202606041251.c8b2bed3': {} '@oxfmt/binding-android-arm-eabi@0.42.0': optional: true diff --git a/scripts/build-registry.test.ts b/scripts/build-registry.test.ts index 08b4c2c..ec2fe37 100644 --- a/scripts/build-registry.test.ts +++ b/scripts/build-registry.test.ts @@ -6,7 +6,7 @@ const pkg = { version: "1.2.3", description: "pkg desc", cola: { - plugin: { id: "demo", entry: "./dist/index.js", minSdkVersion: "0.5.0" }, + plugin: { id: "demo", entry: "./dist/index.js", minColaVersion: "0.5.0" }, channel: { label: "Demo", description: "chan desc", @@ -40,6 +40,26 @@ describe("entryFromPackage", () => { ); }); + it("copies minColaVersion into the registry entry", () => { + const entry = entryFromPackage(pkg, base); + expect(entry?.minColaVersion).toBe("0.5.0"); + }); + + it("preserves legacy minSdkVersion for older plugin packages", () => { + const entry = entryFromPackage( + { + ...pkg, + cola: { + ...pkg.cola, + plugin: { id: "demo", entry: "./dist/index.js", minSdkVersion: "0.5.0" }, + }, + }, + base, + ); + + expect(entry?.minSdkVersion).toBe("0.5.0"); + }); + it("returns undefined when id/entry/version is missing", () => { expect(entryFromPackage({ version: "1.0.0" }, base)).toBeUndefined(); expect( diff --git a/scripts/build-registry.ts b/scripts/build-registry.ts index 00be862..65dae89 100644 --- a/scripts/build-registry.ts +++ b/scripts/build-registry.ts @@ -11,6 +11,8 @@ export type RegistryEntry = { label: string; description?: string; version: string; + minColaVersion?: string; + /** @deprecated Use minColaVersion. */ minSdkVersion?: string; aliases?: string[]; docsPath?: string; @@ -48,6 +50,7 @@ export function entryFromPackage(pkg: unknown, publicBase: string): RegistryEntr const label = normalizeString(channel?.label) ?? id; const description = normalizeString(channel?.description) ?? normalizeString(pkg.description); + const minColaVersion = normalizeString(plugin?.minColaVersion); const minSdkVersion = normalizeString(plugin?.minSdkVersion); const aliases = normalizeStringList(channel?.aliases); const docsPath = normalizeString(channel?.docsPath); @@ -59,6 +62,7 @@ export function entryFromPackage(pkg: unknown, publicBase: string): RegistryEntr label, ...(description ? { description } : {}), version, + ...(minColaVersion ? { minColaVersion } : {}), ...(minSdkVersion ? { minSdkVersion } : {}), ...(aliases ? { aliases } : {}), ...(docsPath ? { docsPath } : {}), diff --git a/smoke/upgrade-gates/README.md b/smoke/upgrade-gates/README.md new file mode 100644 index 0000000..f0b4629 --- /dev/null +++ b/smoke/upgrade-gates/README.md @@ -0,0 +1,38 @@ +# Plugin Upgrade Gate Smoke Fixtures + +These local-only fixtures are not part of `plugins/*`, so `pnpm build:registry` +will not publish them. + +From the matching Cola worktree, install one fixture at a time: + +```bash +pnpm cli:dev plugin install /Users/mack/code/marswave/cola-workspace/cola-plugins/smoke/upgrade-gates/compatible +pnpm cli:dev plugin install /Users/mack/code/marswave/cola-workspace/cola-plugins/smoke/upgrade-gates/min-cola-version +pnpm cli:dev plugin install /Users/mack/code/marswave/cola-workspace/cola-plugins/smoke/upgrade-gates/legacy-min-sdk-version +``` + +Expected results: + +- `compatible` installs and loads on current Cola. +- `min-cola-version` installs but is skipped by the loader with + `requires Cola >= 99.0.0`. +- `legacy-min-sdk-version` exercises the deprecated compatibility fallback and + is also skipped with `requires Cola >= 99.0.0`. + +To smoke the plugin store install gate, serve this directory and start Cola with +the mock registry: + +```bash +cd /Users/mack/code/marswave/cola-workspace/cola-plugins/smoke/upgrade-gates +python3 -m http.server 18765 +``` + +Then start the Cola dev server with: + +```bash +COLA_PLUGIN_REGISTRY_URL=http://127.0.0.1:18765/mock-registry.json pnpm dev +``` + +The store should list the mock entries from `mock-registry.json`. Installing +either future-version entry should fail before download with +`requires Cola >= 99.0.0`. diff --git a/smoke/upgrade-gates/compatible/index.js b/smoke/upgrade-gates/compatible/index.js new file mode 100644 index 0000000..9dabaa0 --- /dev/null +++ b/smoke/upgrade-gates/compatible/index.js @@ -0,0 +1,7 @@ +export default { + id: "smoke-compatible", + meta: { + label: "Smoke Compatible", + description: "Loads on current Cola versions for plugin compatibility smoke tests", + }, +}; diff --git a/smoke/upgrade-gates/compatible/package.json b/smoke/upgrade-gates/compatible/package.json new file mode 100644 index 0000000..8e64c64 --- /dev/null +++ b/smoke/upgrade-gates/compatible/package.json @@ -0,0 +1,23 @@ +{ + "name": "cola-smoke-compatible-plugin", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./index.js" + }, + "dependencies": { + "@marswave/cola-plugin-sdk": "0.0.4-dev.202606041251.c8b2bed3" + }, + "cola": { + "plugin": { + "id": "smoke-compatible", + "entry": "./index.js", + "minColaVersion": "0.0.1" + }, + "channel": { + "label": "Smoke Compatible", + "description": "Loads on current Cola versions for plugin compatibility smoke tests" + } + } +} diff --git a/smoke/upgrade-gates/legacy-min-sdk-version/index.js b/smoke/upgrade-gates/legacy-min-sdk-version/index.js new file mode 100644 index 0000000..bc967fe --- /dev/null +++ b/smoke/upgrade-gates/legacy-min-sdk-version/index.js @@ -0,0 +1,7 @@ +export default { + id: "smoke-legacy-min-sdk-version", + meta: { + label: "Smoke Legacy Requires New Cola", + description: "Uses legacy minSdkVersion for upgrade-gate fallback smoke tests", + }, +}; diff --git a/smoke/upgrade-gates/legacy-min-sdk-version/package.json b/smoke/upgrade-gates/legacy-min-sdk-version/package.json new file mode 100644 index 0000000..484898a --- /dev/null +++ b/smoke/upgrade-gates/legacy-min-sdk-version/package.json @@ -0,0 +1,23 @@ +{ + "name": "cola-smoke-legacy-min-sdk-version-plugin", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./index.js" + }, + "dependencies": { + "@marswave/cola-plugin-sdk": "0.0.4-dev.202606041251.c8b2bed3" + }, + "cola": { + "plugin": { + "id": "smoke-legacy-min-sdk-version", + "entry": "./index.js", + "minSdkVersion": "99.0.0" + }, + "channel": { + "label": "Smoke Legacy Requires New Cola", + "description": "Uses legacy minSdkVersion for upgrade-gate fallback smoke tests" + } + } +} diff --git a/smoke/upgrade-gates/min-cola-version/index.js b/smoke/upgrade-gates/min-cola-version/index.js new file mode 100644 index 0000000..89db07e --- /dev/null +++ b/smoke/upgrade-gates/min-cola-version/index.js @@ -0,0 +1,7 @@ +export default { + id: "smoke-min-cola-version", + meta: { + label: "Smoke Requires New Cola", + description: "Requires a future Cola app version for upgrade-gate smoke tests", + }, +}; diff --git a/smoke/upgrade-gates/min-cola-version/package.json b/smoke/upgrade-gates/min-cola-version/package.json new file mode 100644 index 0000000..e582256 --- /dev/null +++ b/smoke/upgrade-gates/min-cola-version/package.json @@ -0,0 +1,23 @@ +{ + "name": "cola-smoke-min-cola-version-plugin", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./index.js" + }, + "dependencies": { + "@marswave/cola-plugin-sdk": "0.0.4-dev.202606041251.c8b2bed3" + }, + "cola": { + "plugin": { + "id": "smoke-min-cola-version", + "entry": "./index.js", + "minColaVersion": "99.0.0" + }, + "channel": { + "label": "Smoke Requires New Cola", + "description": "Requires a future Cola app version for upgrade-gate smoke tests" + } + } +} diff --git a/smoke/upgrade-gates/mock-registry.json b/smoke/upgrade-gates/mock-registry.json new file mode 100644 index 0000000..4f9a136 --- /dev/null +++ b/smoke/upgrade-gates/mock-registry.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "plugins": [ + { + "id": "smoke-store-min-cola-version", + "label": "Smoke Store Requires New Cola", + "description": "Requires a future Cola app version for plugin store upgrade-gate smoke tests", + "version": "0.0.0", + "minColaVersion": "99.0.0", + "downloadUrl": "https://example.invalid/plugins/smoke-store-min-cola-version.tar.gz" + }, + { + "id": "smoke-store-legacy-min-sdk-version", + "label": "Smoke Store Legacy Requires New Cola", + "description": "Uses legacy minSdkVersion for plugin store upgrade-gate fallback smoke tests", + "version": "0.0.0", + "minSdkVersion": "99.0.0", + "downloadUrl": "https://example.invalid/plugins/smoke-store-legacy-min-sdk-version.tar.gz" + } + ] +}