Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
- name: Lint
run: pnpm lint

- name: Test plugins
run: pnpm test

- name: Validate plugin translations
run: pnpm build:registry

Expand Down
5 changes: 4 additions & 1 deletion plugins/feishu/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@
"config.domain": "Domain",
"label": "Feishu",
"description": "Chat with Cola in Feishu",
"channel.groupDisabled": "Group chat is not enabled. Please message the bot directly."
"channel.groupDisabled": "Group chat is not enabled. Please message the bot directly.",
"status.connecting": "Connecting…",
"status.reconnecting": "Connection lost; reconnecting…",
"status.failed": "Connection failed"
}
5 changes: 4 additions & 1 deletion plugins/feishu/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@
"config.domain": "Dominio",
"label": "Feishu",
"description": "Chatea con Cola en Feishu",
"channel.groupDisabled": "El chat de grupo no está habilitado. Envía un mensaje directo al bot."
"channel.groupDisabled": "El chat de grupo no está habilitado. Envía un mensaje directo al bot.",
"status.connecting": "Conectando…",
"status.reconnecting": "Conexión perdida; reconectando…",
"status.failed": "Error de conexión"
}
5 changes: 4 additions & 1 deletion plugins/feishu/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@
"config.domain": "ドメイン",
"label": "Feishu",
"description": "Feishu で Cola と会話",
"channel.groupDisabled": "グループチャットは無効です。ボットに直接メッセージを送信してください。"
"channel.groupDisabled": "グループチャットは無効です。ボットに直接メッセージを送信してください。",
"status.connecting": "接続中…",
"status.reconnecting": "接続が切れました。再接続中…",
"status.failed": "接続に失敗しました"
}
5 changes: 4 additions & 1 deletion plugins/feishu/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@
"config.domain": "도메인",
"label": "Feishu",
"description": "Feishu에서 Cola와 대화하세요",
"channel.groupDisabled": "그룹 채팅이 활성화되지 않았습니다. 봇에게 직접 메시지를 보내세요."
"channel.groupDisabled": "그룹 채팅이 활성화되지 않았습니다. 봇에게 직접 메시지를 보내세요.",
"status.connecting": "연결 중…",
"status.reconnecting": "연결이 끊어졌습니다. 다시 연결 중…",
"status.failed": "연결 실패"
}
5 changes: 4 additions & 1 deletion plugins/feishu/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@
"config.domain": "域名",
"label": "飞书",
"description": "在飞书中与 Cola 对话",
"channel.groupDisabled": "尚未启用群聊,请私信机器人。"
"channel.groupDisabled": "尚未启用群聊,请私信机器人。",
"status.connecting": "正在连接…",
"status.reconnecting": "连接已断开,正在重连…",
"status.failed": "连接失败"
}
5 changes: 4 additions & 1 deletion plugins/feishu/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@
"config.domain": "網域",
"label": "飛書",
"description": "在飛書中與 Cola 對話",
"channel.groupDisabled": "尚未啟用群聊,請私訊機器人。"
"channel.groupDisabled": "尚未啟用群聊,請私訊機器人。",
"status.connecting": "正在連線…",
"status.reconnecting": "連線已中斷,正在重新連線…",
"status.failed": "連線失敗"
}
4 changes: 2 additions & 2 deletions plugins/feishu/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cola-plugin-feishu",
"version": "0.2.0",
"version": "0.2.1",
"description": "Feishu/Lark channel plugin for Cola",
"license": "Apache-2.0",
"type": "module",
Expand All @@ -12,7 +12,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.61.1",
"@larksuiteoapi/node-sdk": "1.73.3",
"@marswave/cola-plugin-sdk": "0.0.5"
},
"devDependencies": {
Expand Down
33 changes: 16 additions & 17 deletions plugins/feishu/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
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 {
Expand Down Expand Up @@ -41,7 +40,15 @@ export function createLarkClient(accountId: string, config: FeishuAccountConfig)
return client;
}

export function createLarkWSClient(config: FeishuAccountConfig): lark.WSClient {
type WSLifecycleOptions = Pick<
ConstructorParameters<typeof lark.WSClient>[0],
"logger" | "onReady" | "onError" | "onReconnecting" | "onReconnected"
>;

export function createLarkWSClient(
config: FeishuAccountConfig,
lifecycle: WSLifecycleOptions,
): lark.WSClient {
const { appId, appSecret, domain } = config;
if (!appId || !appSecret) {
throw new Error("Feishu WSClient requires appId and appSecret");
Expand All @@ -52,6 +59,7 @@ export function createLarkWSClient(config: FeishuAccountConfig): lark.WSClient {
appSecret,
domain: resolveDomain(domain),
loggerLevel: lark.LoggerLevel.info,
...lifecycle,
});
}

Expand All @@ -64,22 +72,13 @@ 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<string | undefined> {
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 async function fetchBotOpenId(client: lark.Client): Promise<string | undefined> {
const res = (await client.request({
method: "GET",
url: "/open-apis/bot/v3/info",
})) as { bot?: { open_id?: string } };
return res?.bot?.open_id;
}

export function getLarkClient(accountId: string): lark.Client | undefined {
Expand Down
28 changes: 18 additions & 10 deletions plugins/feishu/src/commands/feishu.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { pluginMessage as m, joinPluginText as join } from "@marswave/cola-plugin-sdk";
import type { PluginText, PluginCommandDefinition } from "@marswave/cola-plugin-sdk";
import type { MonitorHandle } from "../gateway/monitor.js";
import type {
ChannelStatusResult,
PluginText,
PluginCommandDefinition,
} from "@marswave/cola-plugin-sdk";
import type { FeishuPluginConfig } from "../api/types.js";
import { parseAccountConfigs } from "../auth/accounts.js";
import { redactSecret } from "../util/redact.js";

export function createFeishuCommands(
getMonitors: () => Map<string, MonitorHandle>,
getAccountStatus: (accountId: string) => ChannelStatusResult,
): PluginCommandDefinition[] {
return [
{
Expand All @@ -24,20 +29,25 @@ export function createFeishuCommands(
],
async execute(ctx) {
const sub = ctx.args.trim() || "status";
const monitors = getMonitors();
const accounts = parseAccountConfigs(ctx.config as unknown as FeishuPluginConfig);

if (sub === "status") {
if (monitors.size === 0) {
if (accounts.size === 0) {
return { reply: m("status.noAccounts", "No accounts configured") };
}
const lines: PluginText[] = [
m("command.statusTitle", "**{{name}} Status**", { name: m("label", "Feishu") }),
"",
];
for (const [id] of monitors) {
for (const [id] of accounts) {
const status = getAccountStatus(id);
lines.push(
m("", "- **{{id}}**: {{status}}", { id, status: m("status.connected", "Connected") }),
m("", "- **{{id}}**: {{status}}", {
id,
status: status.message ?? m("status.disconnected", "Disconnected"),
}),
);
if (status.details) lines.push(status.details);
}
return { reply: join(lines) };
}
Expand All @@ -57,9 +67,7 @@ export function createFeishuCommands(
? redactSecret(acct.appId)
: m("state.missing", "Missing");
const domain = (acct.domain as string) ?? "feishu";
const active = monitors.has(id)
? m("status.connected", "Connected")
: m("status.disconnected", "Disconnected");
const active = getAccountStatus(id).message ?? m("status.disconnected", "Disconnected");
lines.push(
m(
"command.accountLine",
Expand Down
25 changes: 25 additions & 0 deletions plugins/feishu/src/gateway/connection-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Keep SDK diagnostics without serializing request headers, credentials, or stacks. */
export function describeConnectionError(error: unknown): string {
function parts(value: unknown, depth: number): string[] {
if (depth > 4) return [];
if (typeof value === "string") {
const text = value.trim();
return text && text !== "[ws]" ? [text] : [];
}
if (Array.isArray(value)) return value.flatMap((item) => parts(item, depth + 1));
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
const code =
typeof record.code === "string" || typeof record.code === "number"
? [`code: ${record.code}`]
: [];
const response = record.response as { data?: unknown } | undefined;
return [
...code,
...parts(record.message ?? record.msg, depth + 1),
...parts(response?.data, depth + 1),
...parts(record.cause, depth + 1),
];
}
return [...new Set(parts(error, 0))].join("; ").slice(0, 1000) || "Feishu connection failed";
}
18 changes: 15 additions & 3 deletions plugins/feishu/src/gateway/monitor.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import type { PluginRuntime } from "@marswave/cola-plugin-sdk";
import type * as lark from "@larksuiteoapi/node-sdk";
import type { PluginLogger, DeliverFn } from "@marswave/cola-plugin-sdk";
import type { ChannelStatusResult, PluginLogger, DeliverFn } from "@marswave/cola-plugin-sdk";
import type { FeishuAccountConfig } from "../api/types.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 { GroupContextTracker } from "./group-context.js";
import { describeConnectionError } from "./connection-error.js";

export type MonitorHandle = {
accountId: string;
client: lark.Client;
chatMap: ChatMap;
cleanup: () => void;
getStatus: () => ChannelStatusResult;
};

/**
Expand All @@ -30,6 +32,7 @@ export async function startMonitor(opts: {
groupEnabled: boolean;
}): Promise<MonitorHandle> {
const { accountId, config, deliver, logger, abortSignal, groupEnabled } = opts;
abortSignal.throwIfAborted();

// Create client and dispatcher
const client = createLarkClient(accountId, config);
Expand All @@ -39,7 +42,15 @@ export async function startMonitor(opts: {
const groupContext = new GroupContextTracker();

// Bot open_id is required to detect @bot mentions in group chats.
const botOpenId = await fetchBotOpenId(client, logger);
let botOpenId: string | undefined;
let initialError: string | undefined;
try {
botOpenId = await fetchBotOpenId(client);
} catch (err) {
initialError = describeConnectionError(err);
logger.warn(`feishu[${accountId}]: Failed to fetch bot open_id: ${initialError}`);
}
abortSignal.throwIfAborted();

const deps = {
client,
Expand All @@ -58,7 +69,7 @@ export async function startMonitor(opts: {
registerMessageHandler(dispatcher, deps);
registerReactionHandler(dispatcher, deps);

const handle = startWSGateway(accountId, config, dispatcher, abortSignal, logger);
const handle = startWSGateway(accountId, config, dispatcher, abortSignal, logger, initialError);

logger.info(`feishu[${accountId}]: monitor started (mode=websocket)`);

Expand All @@ -67,5 +78,6 @@ export async function startMonitor(opts: {
client,
chatMap,
cleanup: handle.cleanup,
getStatus: handle.getStatus,
};
}
Loading
Loading