@max1874/feishu 0.2.27 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max1874/feishu",
3
- "version": "0.2.27",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Feishu/Lark channel plugin",
6
6
  "license": "MIT",
package/src/accounts.ts CHANGED
@@ -21,16 +21,34 @@ export function resolveFeishuCredentials(cfg?: FeishuConfig): {
21
21
  };
22
22
  }
23
23
 
24
+ /**
25
+ * Merge per-account overrides with the base (top-level) feishu config.
26
+ * Account-specific fields (credentials, domain, connectionMode, etc.) override the base.
27
+ */
28
+ export function mergeFeishuAccountConfig(
29
+ baseCfg: FeishuConfig,
30
+ accountId: string,
31
+ ): FeishuConfig {
32
+ if (accountId === DEFAULT_ACCOUNT_ID) return baseCfg;
33
+ const overrides = baseCfg.accounts?.[accountId];
34
+ if (!overrides) return baseCfg;
35
+ // Strip `accounts` from merged result to prevent downstream confusion
36
+ const { accounts: _, ...base } = baseCfg;
37
+ return { ...base, ...overrides };
38
+ }
39
+
24
40
  export function resolveFeishuAccount(params: {
25
41
  cfg: ClawdbotConfig;
26
42
  accountId?: string | null;
27
43
  }): ResolvedFeishuAccount {
28
44
  const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
29
- const enabled = feishuCfg?.enabled !== false;
30
- const creds = resolveFeishuCredentials(feishuCfg);
45
+ const accountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID;
46
+ const merged = feishuCfg ? mergeFeishuAccountConfig(feishuCfg, accountId) : undefined;
47
+ const enabled = merged?.enabled !== false;
48
+ const creds = resolveFeishuCredentials(merged);
31
49
 
32
50
  return {
33
- accountId: params.accountId?.trim() || DEFAULT_ACCOUNT_ID,
51
+ accountId,
34
52
  enabled,
35
53
  configured: Boolean(creds),
36
54
  appId: creds?.appId,
@@ -38,12 +56,17 @@ export function resolveFeishuAccount(params: {
38
56
  };
39
57
  }
40
58
 
41
- export function listFeishuAccountIds(_cfg: ClawdbotConfig): string[] {
42
- return [DEFAULT_ACCOUNT_ID];
59
+ export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
60
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
61
+ const accounts = feishuCfg?.accounts;
62
+ if (!accounts) return [DEFAULT_ACCOUNT_ID];
63
+ const ids = Object.keys(accounts);
64
+ return ids.length === 0 ? [DEFAULT_ACCOUNT_ID] : ids;
43
65
  }
44
66
 
45
- export function resolveDefaultFeishuAccountId(_cfg: ClawdbotConfig): string {
46
- return DEFAULT_ACCOUNT_ID;
67
+ export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
68
+ const ids = listFeishuAccountIds(cfg);
69
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
47
70
  }
48
71
 
49
72
  export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] {
package/src/bot.ts CHANGED
@@ -490,8 +490,9 @@ export async function handleFeishuMessage(params: {
490
490
  botOpenId?: string;
491
491
  runtime?: RuntimeEnv;
492
492
  chatHistories?: Map<string, HistoryEntry[]>;
493
+ accountId?: string;
493
494
  }): Promise<void> {
494
- const { cfg, event, botOpenId, runtime, chatHistories } = params;
495
+ const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
495
496
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
496
497
  const log = runtime?.log ?? console.log;
497
498
  const error = runtime?.error ?? console.error;
@@ -613,9 +614,18 @@ export async function handleFeishuMessage(params: {
613
614
  const feishuFrom = `feishu:${ctx.senderOpenId}`;
614
615
  const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
615
616
 
617
+ // Default DM scope to per-channel-peer so each user gets their own session.
618
+ // The framework default ("main") would merge all DMs into one shared session.
619
+ const dmScope = feishuCfg?.dmScope ?? "per-channel-peer";
620
+ const routingCfg = cfg.session?.dmScope ? cfg : {
621
+ ...cfg,
622
+ session: { ...cfg.session, dmScope },
623
+ };
624
+
616
625
  const route = core.channel.routing.resolveAgentRoute({
617
- cfg,
626
+ cfg: routingCfg,
618
627
  channel: "feishu",
628
+ accountId,
619
629
  peer: {
620
630
  kind: isGroup ? "group" : "dm",
621
631
  id: isGroup ? ctx.chatId : ctx.senderOpenId,
package/src/channel.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
2
2
  import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
3
3
  import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
4
- import { resolveFeishuAccount, resolveFeishuCredentials } from "./accounts.js";
4
+ import { resolveFeishuAccount, resolveFeishuCredentials, listFeishuAccountIds, mergeFeishuAccountConfig } from "./accounts.js";
5
5
  import { feishuOutbound } from "./outbound.js";
6
6
  import { probeFeishu } from "./probe.js";
7
7
  import { resolveFeishuGroupToolPolicy } from "./policy.js";
@@ -87,24 +87,78 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
87
87
  chunkMode: { type: "string", enum: ["length", "newline"] },
88
88
  mediaMaxMb: { type: "number", minimum: 0 },
89
89
  renderMode: { type: "string", enum: ["auto", "raw", "card"] },
90
+ accounts: {
91
+ type: "object",
92
+ additionalProperties: {
93
+ type: "object",
94
+ properties: {
95
+ enabled: { type: "boolean" },
96
+ name: { type: "string" },
97
+ appId: { type: "string" },
98
+ appSecret: { type: "string" },
99
+ encryptKey: { type: "string" },
100
+ verificationToken: { type: "string" },
101
+ domain: { type: "string", enum: ["feishu", "lark"] },
102
+ connectionMode: { type: "string", enum: ["websocket", "webhook"] },
103
+ webhookPath: { type: "string" },
104
+ webhookPort: { type: "integer", minimum: 1 },
105
+ },
106
+ },
107
+ },
90
108
  },
91
109
  },
92
110
  },
93
111
  config: {
94
- listAccountIds: () => [DEFAULT_ACCOUNT_ID],
95
- resolveAccount: (cfg) => resolveFeishuAccount({ cfg }),
96
- defaultAccountId: () => DEFAULT_ACCOUNT_ID,
97
- setAccountEnabled: ({ cfg, enabled }) => ({
98
- ...cfg,
99
- channels: {
100
- ...cfg.channels,
101
- feishu: {
102
- ...cfg.channels?.feishu,
103
- enabled,
112
+ listAccountIds: (cfg) => listFeishuAccountIds(cfg),
113
+ resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
114
+ defaultAccountId: (cfg) => listFeishuAccountIds(cfg)[0] ?? DEFAULT_ACCOUNT_ID,
115
+ setAccountEnabled: ({ cfg, accountId, enabled }) => {
116
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
117
+ // Per-account enable/disable when using named accounts
118
+ if (accountId && accountId !== DEFAULT_ACCOUNT_ID && feishuCfg?.accounts?.[accountId]) {
119
+ return {
120
+ ...cfg,
121
+ channels: {
122
+ ...cfg.channels,
123
+ feishu: {
124
+ ...feishuCfg,
125
+ accounts: {
126
+ ...feishuCfg.accounts,
127
+ [accountId]: { ...feishuCfg.accounts[accountId], enabled },
128
+ },
129
+ },
130
+ },
131
+ };
132
+ }
133
+ return {
134
+ ...cfg,
135
+ channels: {
136
+ ...cfg.channels,
137
+ feishu: {
138
+ ...cfg.channels?.feishu,
139
+ enabled,
140
+ },
104
141
  },
105
- },
106
- }),
107
- deleteAccount: ({ cfg }) => {
142
+ };
143
+ },
144
+ deleteAccount: ({ cfg, accountId }) => {
145
+ const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
146
+ // Delete a named account
147
+ if (accountId && accountId !== DEFAULT_ACCOUNT_ID && feishuCfg?.accounts) {
148
+ const nextAccounts = { ...feishuCfg.accounts };
149
+ delete nextAccounts[accountId];
150
+ return {
151
+ ...cfg,
152
+ channels: {
153
+ ...cfg.channels,
154
+ feishu: {
155
+ ...feishuCfg,
156
+ accounts: Object.keys(nextAccounts).length > 0 ? nextAccounts : undefined,
157
+ },
158
+ },
159
+ };
160
+ }
161
+ // Delete the whole channel (default account)
108
162
  const next = { ...cfg } as ClawdbotConfig;
109
163
  const nextChannels = { ...cfg.channels };
110
164
  delete (nextChannels as Record<string, unknown>).feishu;
@@ -115,8 +169,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
115
169
  }
116
170
  return next;
117
171
  },
118
- isConfigured: (_account, cfg) =>
119
- Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)),
172
+ isConfigured: (account) => account.configured,
120
173
  describeAccount: (account) => ({
121
174
  accountId: account.accountId,
122
175
  enabled: account.enabled,
@@ -142,7 +195,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
142
195
  },
143
196
  },
144
197
  setup: {
145
- resolveAccountId: () => DEFAULT_ACCOUNT_ID,
198
+ resolveAccountId: (cfg) => listFeishuAccountIds(cfg)[0] ?? DEFAULT_ACCOUNT_ID,
146
199
  applyAccountConfig: ({ cfg }) => ({
147
200
  ...cfg,
148
201
  channels: {
@@ -193,8 +246,11 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
193
246
  probe: snapshot.probe,
194
247
  lastProbeAt: snapshot.lastProbeAt ?? null,
195
248
  }),
196
- probeAccount: async ({ cfg }) =>
197
- await probeFeishu(cfg.channels?.feishu as FeishuConfig | undefined),
249
+ probeAccount: async ({ cfg, accountId }) => {
250
+ const baseCfg = cfg.channels?.feishu as FeishuConfig | undefined;
251
+ const merged = baseCfg ? mergeFeishuAccountConfig(baseCfg, accountId ?? DEFAULT_ACCOUNT_ID) : baseCfg;
252
+ return probeFeishu(merged);
253
+ },
198
254
  buildAccountSnapshot: ({ account, runtime, probe }) => ({
199
255
  accountId: account.accountId,
200
256
  enabled: account.enabled,
@@ -210,12 +266,23 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
210
266
  gateway: {
211
267
  startAccount: async (ctx) => {
212
268
  const { monitorFeishuProvider } = await import("./monitor.js");
213
- const feishuCfg = ctx.cfg.channels?.feishu as FeishuConfig | undefined;
214
- const port = feishuCfg?.webhookPort ?? null;
269
+ const baseCfg = ctx.cfg.channels?.feishu as FeishuConfig | undefined;
270
+ const merged = baseCfg ? mergeFeishuAccountConfig(baseCfg, ctx.accountId) : baseCfg;
271
+ const port = merged?.webhookPort ?? null;
272
+
273
+ // Inject merged account config so all downstream reads of cfg.channels.feishu get the right values
274
+ const accountCfg: ClawdbotConfig = {
275
+ ...ctx.cfg,
276
+ channels: {
277
+ ...ctx.cfg.channels,
278
+ feishu: merged,
279
+ },
280
+ };
281
+
215
282
  ctx.setStatus({ accountId: ctx.accountId, port });
216
- ctx.log?.info(`starting feishu provider (mode: ${feishuCfg?.connectionMode ?? "websocket"})`);
283
+ ctx.log?.info(`starting feishu provider (account: ${ctx.accountId}, mode: ${merged?.connectionMode ?? "websocket"})`);
217
284
  return monitorFeishuProvider({
218
- config: ctx.cfg,
285
+ config: accountCfg,
219
286
  runtime: ctx.runtime,
220
287
  abortSignal: ctx.abortSignal,
221
288
  accountId: ctx.accountId,
package/src/client.ts CHANGED
@@ -2,8 +2,8 @@ import * as Lark from "@larksuiteoapi/node-sdk";
2
2
  import type { FeishuConfig, FeishuDomain } from "./types.js";
3
3
  import { resolveFeishuCredentials } from "./accounts.js";
4
4
 
5
- let cachedClient: Lark.Client | null = null;
6
- let cachedConfig: { appId: string; appSecret: string; domain: FeishuDomain } | null = null;
5
+ type CachedEntry = { client: Lark.Client; appId: string; appSecret: string; domain: FeishuDomain };
6
+ const clientCache = new Map<string, CachedEntry>();
7
7
 
8
8
  function resolveDomain(domain: FeishuDomain) {
9
9
  return domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu;
@@ -15,14 +15,10 @@ export function createFeishuClient(cfg: FeishuConfig): Lark.Client {
15
15
  throw new Error("Feishu credentials not configured (appId, appSecret required)");
16
16
  }
17
17
 
18
- if (
19
- cachedClient &&
20
- cachedConfig &&
21
- cachedConfig.appId === creds.appId &&
22
- cachedConfig.appSecret === creds.appSecret &&
23
- cachedConfig.domain === creds.domain
24
- ) {
25
- return cachedClient;
18
+ const cacheKey = `${creds.appId}:${creds.domain}`;
19
+ const cached = clientCache.get(cacheKey);
20
+ if (cached && cached.appSecret === creds.appSecret) {
21
+ return cached.client;
26
22
  }
27
23
 
28
24
  const client = new Lark.Client({
@@ -32,8 +28,7 @@ export function createFeishuClient(cfg: FeishuConfig): Lark.Client {
32
28
  domain: resolveDomain(creds.domain),
33
29
  });
34
30
 
35
- cachedClient = client;
36
- cachedConfig = { appId: creds.appId, appSecret: creds.appSecret, domain: creds.domain };
31
+ clientCache.set(cacheKey, { client, appId: creds.appId, appSecret: creds.appSecret, domain: creds.domain });
37
32
 
38
33
  return client;
39
34
  }
@@ -60,7 +55,10 @@ export function createEventDispatcher(cfg: FeishuConfig): Lark.EventDispatcher {
60
55
  });
61
56
  }
62
57
 
63
- export function clearClientCache() {
64
- cachedClient = null;
65
- cachedConfig = null;
58
+ export function clearClientCache(accountKey?: string) {
59
+ if (accountKey) {
60
+ clientCache.delete(accountKey);
61
+ } else {
62
+ clientCache.clear();
63
+ }
66
64
  }
@@ -33,6 +33,10 @@ const MarkdownConfigSchema = z
33
33
  // Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
34
34
  const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
35
35
 
36
+ // DM session scope: controls how session keys are computed for private chats.
37
+ // Framework default is "main" (all DMs share one session), which is rarely desired.
38
+ const DmScopeSchema = z.enum(["main", "per-peer", "per-channel-peer", "per-account-channel-peer"]).optional();
39
+
36
40
  // Reply-to (threading) mode: controls whether bot replies are sent as threaded replies.
37
41
  // - "all" (default): always reply in thread (current behavior)
38
42
  // - "off": reply in main chat; only thread if the triggering message was already in a thread
@@ -75,6 +79,21 @@ export const FeishuGroupSchema = z
75
79
  })
76
80
  .strict();
77
81
 
82
+ export const FeishuAccountOverrideSchema = z
83
+ .object({
84
+ enabled: z.boolean().optional(),
85
+ name: z.string().optional(),
86
+ appId: z.string().optional(),
87
+ appSecret: z.string().optional(),
88
+ encryptKey: z.string().optional(),
89
+ verificationToken: z.string().optional(),
90
+ domain: FeishuDomainSchema.optional(),
91
+ connectionMode: FeishuConnectionModeSchema.optional(),
92
+ webhookPath: z.string().optional(),
93
+ webhookPort: z.number().int().positive().optional(),
94
+ })
95
+ .strict();
96
+
78
97
  export const FeishuConfigSchema = z
79
98
  .object({
80
99
  enabled: z.boolean().optional(),
@@ -106,6 +125,8 @@ export const FeishuConfigSchema = z
106
125
  renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
107
126
  replyToMode: ReplyToModeSchema, // "all" = always thread (default), "off" = main chat unless already in thread
108
127
  replyToModeByChatType: ReplyToModeByChatTypeSchema, // per-chat-type overrides for replyToMode
128
+ dmScope: DmScopeSchema, // override session.dmScope for this channel (default: per-channel-peer)
129
+ accounts: z.record(z.string(), FeishuAccountOverrideSchema.optional()).optional(),
109
130
  })
110
131
  .strict()
111
132
  .superRefine((value, ctx) => {
@@ -111,6 +111,63 @@ export class FeishuRenderer {
111
111
  return `block_${this.blockId}`;
112
112
  }
113
113
 
114
+ /**
115
+ * marked follows CommonMark emphasis delimiter rules. One sharp edge:
116
+ * sequences like `总花费**$122.64**` (no whitespace before `**` and the
117
+ * inner text starts with punctuation like `$` or `(`) are tokenized as
118
+ * plain text, so `**` would be rendered literally.
119
+ *
120
+ * For Feishu doc rendering we treat `**...**` inside text tokens as bold
121
+ * as a permissive fallback, which makes LLM-generated Markdown more robust.
122
+ */
123
+ private renderTextTokenWithStrongFallback(text: string): TextElement | TextElement[] {
124
+ if (!text.includes("**")) return { text_run: { content: text } };
125
+
126
+ const elements: TextElement[] = [];
127
+ let cursor = 0;
128
+
129
+ while (cursor < text.length) {
130
+ const openIdx = text.indexOf("**", cursor);
131
+ if (openIdx === -1) {
132
+ const tail = text.slice(cursor);
133
+ if (tail) elements.push({ text_run: { content: tail } });
134
+ break;
135
+ }
136
+
137
+ // Plain text before `**`
138
+ if (openIdx > cursor) {
139
+ const plain = text.slice(cursor, openIdx);
140
+ if (plain) elements.push({ text_run: { content: plain } });
141
+ }
142
+
143
+ const contentStart = openIdx + 2;
144
+ const closeIdx = text.indexOf("**", contentStart);
145
+ if (closeIdx === -1) {
146
+ // No matching close delimiter; render the rest literally.
147
+ const rest = text.slice(openIdx);
148
+ if (rest) elements.push({ text_run: { content: rest } });
149
+ break;
150
+ }
151
+
152
+ const boldContent = text.slice(contentStart, closeIdx);
153
+ if (boldContent) {
154
+ elements.push({
155
+ text_run: {
156
+ content: boldContent,
157
+ text_element_style: { bold: true },
158
+ },
159
+ });
160
+ } else {
161
+ // Degenerate `****` case; keep delimiters literal.
162
+ elements.push({ text_run: { content: "****" } });
163
+ }
164
+
165
+ cursor = closeIdx + 2;
166
+ }
167
+
168
+ return elements.length > 0 ? elements : { text_run: { content: text } };
169
+ }
170
+
114
171
  /**
115
172
  * Render markdown tokens to Feishu blocks.
116
173
  */
@@ -244,7 +301,9 @@ export class FeishuRenderer {
244
301
  if (textToken.tokens) {
245
302
  elements.push(...this.renderInlineTokens(textToken.tokens));
246
303
  } else {
247
- elements.push({ text_run: { content: textToken.raw } });
304
+ const fallback = this.renderTextTokenWithStrongFallback(textToken.raw);
305
+ if (Array.isArray(fallback)) elements.push(...fallback);
306
+ else elements.push(fallback);
248
307
  }
249
308
  } else if (child.type === "paragraph") {
250
309
  const paraToken = child as Tokens.Paragraph;
@@ -325,7 +384,7 @@ export class FeishuRenderer {
325
384
  private renderInline(token: Token): TextElement | TextElement[] | null {
326
385
  switch (token.type) {
327
386
  case "text":
328
- return { text_run: { content: (token as Tokens.Text).text } };
387
+ return this.renderTextTokenWithStrongFallback((token as Tokens.Text).text);
329
388
 
330
389
  case "strong": {
331
390
  const strongToken = token as Tokens.Strong;
package/src/monitor.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import http from "node:http";
2
2
  import * as Lark from "@larksuiteoapi/node-sdk";
3
3
  import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
4
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
4
5
  import type { FeishuConfig } from "./types.js";
5
6
  import { createFeishuWSClient, createEventDispatcher } from "./client.js";
6
7
  import { resolveFeishuCredentials } from "./accounts.js";
@@ -14,9 +15,9 @@ export type MonitorFeishuOpts = {
14
15
  accountId?: string;
15
16
  };
16
17
 
17
- let currentWsClient: Lark.WSClient | null = null;
18
- let currentHttpServer: http.Server | null = null;
19
- let botOpenId: string | undefined;
18
+ const wsClients = new Map<string, Lark.WSClient>();
19
+ const httpServers = new Map<string, http.Server>();
20
+ const botOpenIds = new Map<string, string>();
20
21
 
21
22
  async function fetchBotOpenId(cfg: FeishuConfig): Promise<string | undefined> {
22
23
  try {
@@ -33,6 +34,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
33
34
  throw new Error("Config is required for Feishu monitor");
34
35
  }
35
36
 
37
+ const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID;
36
38
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
37
39
  const creds = resolveFeishuCredentials(feishuCfg);
38
40
  if (!creds) {
@@ -43,17 +45,22 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
43
45
  const error = opts.runtime?.error ?? console.error;
44
46
 
45
47
  if (feishuCfg) {
46
- botOpenId = await fetchBotOpenId(feishuCfg);
47
- log(`feishu: bot open_id resolved: ${botOpenId ?? "unknown"}`);
48
+ const openId = await fetchBotOpenId(feishuCfg);
49
+ if (openId) {
50
+ botOpenIds.set(accountId, openId);
51
+ } else {
52
+ botOpenIds.delete(accountId);
53
+ }
54
+ log(`feishu[${accountId}]: bot open_id resolved: ${openId ?? "unknown"}`);
48
55
  }
49
56
 
50
57
  const connectionMode = feishuCfg?.connectionMode ?? "websocket";
51
58
 
52
59
  if (connectionMode === "websocket") {
53
- return monitorWebSocket({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal });
60
+ return monitorWebSocket({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal, accountId });
54
61
  }
55
62
 
56
- return monitorWebhook({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal });
63
+ return monitorWebhook({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal, accountId });
57
64
  }
58
65
 
59
66
  async function monitorWebSocket(params: {
@@ -61,17 +68,19 @@ async function monitorWebSocket(params: {
61
68
  feishuCfg: FeishuConfig;
62
69
  runtime?: RuntimeEnv;
63
70
  abortSignal?: AbortSignal;
71
+ accountId: string;
64
72
  }): Promise<void> {
65
- const { cfg, feishuCfg, runtime, abortSignal } = params;
73
+ const { cfg, feishuCfg, runtime, abortSignal, accountId } = params;
66
74
  const log = runtime?.log ?? console.log;
67
75
  const error = runtime?.error ?? console.error;
68
76
 
69
- log("feishu: starting WebSocket connection...");
77
+ log(`feishu[${accountId}]: starting WebSocket connection...`);
70
78
 
71
79
  const wsClient = createFeishuWSClient(feishuCfg);
72
- currentWsClient = wsClient;
80
+ wsClients.set(accountId, wsClient);
73
81
 
74
82
  const chatHistories = new Map<string, HistoryEntry[]>();
83
+ const botOpenId = botOpenIds.get(accountId);
75
84
 
76
85
  const eventDispatcher = createEventDispatcher(feishuCfg);
77
86
 
@@ -85,9 +94,10 @@ async function monitorWebSocket(params: {
85
94
  botOpenId,
86
95
  runtime,
87
96
  chatHistories,
97
+ accountId,
88
98
  });
89
99
  } catch (err) {
90
- error(`feishu: error handling message event: ${String(err)}`);
100
+ error(`feishu[${accountId}]: error handling message event: ${String(err)}`);
91
101
  }
92
102
  },
93
103
  "im.message.message_read_v1": async () => {
@@ -96,30 +106,30 @@ async function monitorWebSocket(params: {
96
106
  "im.chat.member.bot.added_v1": async (data) => {
97
107
  try {
98
108
  const event = data as unknown as FeishuBotAddedEvent;
99
- log(`feishu: bot added to chat ${event.chat_id}`);
109
+ log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
100
110
  } catch (err) {
101
- error(`feishu: error handling bot added event: ${String(err)}`);
111
+ error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
102
112
  }
103
113
  },
104
114
  "im.chat.member.bot.deleted_v1": async (data) => {
105
115
  try {
106
116
  const event = data as unknown as { chat_id: string };
107
- log(`feishu: bot removed from chat ${event.chat_id}`);
117
+ log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
108
118
  } catch (err) {
109
- error(`feishu: error handling bot removed event: ${String(err)}`);
119
+ error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
110
120
  }
111
121
  },
112
122
  });
113
123
 
114
124
  return new Promise((resolve, reject) => {
115
125
  const cleanup = () => {
116
- if (currentWsClient === wsClient) {
117
- currentWsClient = null;
126
+ if (wsClients.get(accountId) === wsClient) {
127
+ wsClients.delete(accountId);
118
128
  }
119
129
  };
120
130
 
121
131
  const handleAbort = () => {
122
- log("feishu: abort signal received, stopping WebSocket client");
132
+ log(`feishu[${accountId}]: abort signal received, stopping WebSocket client`);
123
133
  cleanup();
124
134
  resolve();
125
135
  };
@@ -137,7 +147,7 @@ async function monitorWebSocket(params: {
137
147
  eventDispatcher,
138
148
  });
139
149
 
140
- log("feishu: WebSocket client started");
150
+ log(`feishu[${accountId}]: WebSocket client started`);
141
151
  } catch (err) {
142
152
  cleanup();
143
153
  abortSignal?.removeEventListener("abort", handleAbort);
@@ -146,13 +156,22 @@ async function monitorWebSocket(params: {
146
156
  });
147
157
  }
148
158
 
149
- export function stopFeishuMonitor(): void {
150
- if (currentWsClient) {
151
- currentWsClient = null;
152
- }
153
- if (currentHttpServer) {
154
- currentHttpServer.close();
155
- currentHttpServer = null;
159
+ export function stopFeishuMonitor(accountId?: string): void {
160
+ if (accountId) {
161
+ wsClients.delete(accountId);
162
+ botOpenIds.delete(accountId);
163
+ const server = httpServers.get(accountId);
164
+ if (server) {
165
+ server.close();
166
+ httpServers.delete(accountId);
167
+ }
168
+ } else {
169
+ wsClients.clear();
170
+ botOpenIds.clear();
171
+ for (const server of httpServers.values()) {
172
+ server.close();
173
+ }
174
+ httpServers.clear();
156
175
  }
157
176
  }
158
177
 
@@ -161,17 +180,19 @@ async function monitorWebhook(params: {
161
180
  feishuCfg: FeishuConfig;
162
181
  runtime?: RuntimeEnv;
163
182
  abortSignal?: AbortSignal;
183
+ accountId: string;
164
184
  }): Promise<void> {
165
- const { cfg, feishuCfg, runtime, abortSignal } = params;
185
+ const { cfg, feishuCfg, runtime, abortSignal, accountId } = params;
166
186
  const log = runtime?.log ?? console.log;
167
187
  const error = runtime?.error ?? console.error;
168
188
 
169
189
  const webhookPath = feishuCfg.webhookPath ?? "/feishu/events";
170
190
  const webhookPort = feishuCfg.webhookPort ?? 3000;
171
191
 
172
- log(`feishu: starting webhook server on port ${webhookPort}, path ${webhookPath}...`);
192
+ log(`feishu[${accountId}]: starting webhook server on port ${webhookPort}, path ${webhookPath}...`);
173
193
 
174
194
  const chatHistories = new Map<string, HistoryEntry[]>();
195
+ const botOpenId = botOpenIds.get(accountId);
175
196
  const eventDispatcher = createEventDispatcher(feishuCfg);
176
197
 
177
198
  eventDispatcher.register({
@@ -184,9 +205,10 @@ async function monitorWebhook(params: {
184
205
  botOpenId,
185
206
  runtime,
186
207
  chatHistories,
208
+ accountId,
187
209
  });
188
210
  } catch (err) {
189
- error(`feishu: error handling message event: ${String(err)}`);
211
+ error(`feishu[${accountId}]: error handling message event: ${String(err)}`);
190
212
  }
191
213
  },
192
214
  "im.message.message_read_v1": async () => {
@@ -195,33 +217,33 @@ async function monitorWebhook(params: {
195
217
  "im.chat.member.bot.added_v1": async (data) => {
196
218
  try {
197
219
  const event = data as unknown as FeishuBotAddedEvent;
198
- log(`feishu: bot added to chat ${event.chat_id}`);
220
+ log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
199
221
  } catch (err) {
200
- error(`feishu: error handling bot added event: ${String(err)}`);
222
+ error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
201
223
  }
202
224
  },
203
225
  "im.chat.member.bot.deleted_v1": async (data) => {
204
226
  try {
205
227
  const event = data as unknown as { chat_id: string };
206
- log(`feishu: bot removed from chat ${event.chat_id}`);
228
+ log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
207
229
  } catch (err) {
208
- error(`feishu: error handling bot removed event: ${String(err)}`);
230
+ error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
209
231
  }
210
232
  },
211
233
  });
212
234
 
213
235
  return new Promise((resolve, reject) => {
214
236
  const server = http.createServer();
215
- currentHttpServer = server;
237
+ httpServers.set(accountId, server);
216
238
 
217
239
  const cleanup = () => {
218
- if (currentHttpServer === server) {
219
- currentHttpServer = null;
240
+ if (httpServers.get(accountId) === server) {
241
+ httpServers.delete(accountId);
220
242
  }
221
243
  };
222
244
 
223
245
  const handleAbort = () => {
224
- log("feishu: abort signal received, stopping webhook server");
246
+ log(`feishu[${accountId}]: abort signal received, stopping webhook server`);
225
247
  server.close(() => {
226
248
  cleanup();
227
249
  resolve();
@@ -239,7 +261,7 @@ async function monitorWebhook(params: {
239
261
  server.on("error", (err) => {
240
262
  cleanup();
241
263
  abortSignal?.removeEventListener("abort", handleAbort);
242
- error(`feishu: webhook server error: ${String(err)}`);
264
+ error(`feishu[${accountId}]: webhook server error: ${String(err)}`);
243
265
  reject(err);
244
266
  });
245
267
 
@@ -249,7 +271,7 @@ async function monitorWebhook(params: {
249
271
  }));
250
272
 
251
273
  server.listen(webhookPort, () => {
252
- log(`feishu: webhook server started on port ${webhookPort}`);
274
+ log(`feishu[${accountId}]: webhook server started on port ${webhookPort}`);
253
275
  });
254
276
  });
255
277
  }
package/src/types.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { FeishuConfigSchema, FeishuGroupSchema, z } from "./config-schema.js";
1
+ import type { FeishuConfigSchema, FeishuGroupSchema, FeishuAccountOverrideSchema, z } from "./config-schema.js";
2
2
  import type { MentionTarget } from "./mention.js";
3
3
 
4
4
  export type FeishuConfig = z.infer<typeof FeishuConfigSchema>;
5
5
  export type FeishuGroupConfig = z.infer<typeof FeishuGroupSchema>;
6
+ export type FeishuAccountOverride = z.infer<typeof FeishuAccountOverrideSchema>;
6
7
 
7
8
  export type FeishuDomain = "feishu" | "lark";
8
9
  export type FeishuConnectionMode = "websocket" | "webhook";