@max1874/feishu 0.2.28 → 0.3.1

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.28",
3
+ "version": "0.3.1",
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;
@@ -624,6 +625,7 @@ export async function handleFeishuMessage(params: {
624
625
  const route = core.channel.routing.resolveAgentRoute({
625
626
  cfg: routingCfg,
626
627
  channel: "feishu",
628
+ accountId,
627
629
  peer: {
628
630
  kind: isGroup ? "group" : "dm",
629
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
  }
@@ -79,6 +79,21 @@ export const FeishuGroupSchema = z
79
79
  })
80
80
  .strict();
81
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
+
82
97
  export const FeishuConfigSchema = z
83
98
  .object({
84
99
  enabled: z.boolean().optional(),
@@ -111,6 +126,7 @@ export const FeishuConfigSchema = z
111
126
  replyToMode: ReplyToModeSchema, // "all" = always thread (default), "off" = main chat unless already in thread
112
127
  replyToModeByChatType: ReplyToModeByChatTypeSchema, // per-chat-type overrides for replyToMode
113
128
  dmScope: DmScopeSchema, // override session.dmScope for this channel (default: per-channel-peer)
129
+ accounts: z.record(z.string(), FeishuAccountOverrideSchema.optional()).optional(),
114
130
  })
115
131
  .strict()
116
132
  .superRefine((value, ctx) => {
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,35 @@ 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>();
21
+
22
+ // --- Message dedup ---
23
+ // Feishu may deliver the same event multiple times (webhook retries, websocket reconnects).
24
+ // Track seen message_ids for a short window to skip duplicates.
25
+ const DEDUP_TTL_MS = 10 * 60 * 1000; // 10 minutes
26
+ const seenMessageIds = new Map<string, number>();
27
+
28
+ function isDuplicateMessage(messageId: string | undefined): boolean {
29
+ if (!messageId) return false;
30
+ const now = Date.now();
31
+
32
+ // Lazy cleanup: prune expired entries when map grows
33
+ if (seenMessageIds.size > 200) {
34
+ for (const [id, ts] of seenMessageIds) {
35
+ if (now - ts > DEDUP_TTL_MS) seenMessageIds.delete(id);
36
+ }
37
+ }
38
+
39
+ const seenAt = seenMessageIds.get(messageId);
40
+ if (seenAt !== undefined) {
41
+ if (now - seenAt <= DEDUP_TTL_MS) return true;
42
+ // Expired — treat as new message
43
+ }
44
+ seenMessageIds.set(messageId, now);
45
+ return false;
46
+ }
20
47
 
21
48
  async function fetchBotOpenId(cfg: FeishuConfig): Promise<string | undefined> {
22
49
  try {
@@ -33,6 +60,7 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
33
60
  throw new Error("Config is required for Feishu monitor");
34
61
  }
35
62
 
63
+ const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID;
36
64
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
37
65
  const creds = resolveFeishuCredentials(feishuCfg);
38
66
  if (!creds) {
@@ -43,17 +71,22 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
43
71
  const error = opts.runtime?.error ?? console.error;
44
72
 
45
73
  if (feishuCfg) {
46
- botOpenId = await fetchBotOpenId(feishuCfg);
47
- log(`feishu: bot open_id resolved: ${botOpenId ?? "unknown"}`);
74
+ const openId = await fetchBotOpenId(feishuCfg);
75
+ if (openId) {
76
+ botOpenIds.set(accountId, openId);
77
+ } else {
78
+ botOpenIds.delete(accountId);
79
+ }
80
+ log(`feishu[${accountId}]: bot open_id resolved: ${openId ?? "unknown"}`);
48
81
  }
49
82
 
50
83
  const connectionMode = feishuCfg?.connectionMode ?? "websocket";
51
84
 
52
85
  if (connectionMode === "websocket") {
53
- return monitorWebSocket({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal });
86
+ return monitorWebSocket({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal, accountId });
54
87
  }
55
88
 
56
- return monitorWebhook({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal });
89
+ return monitorWebhook({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal, accountId });
57
90
  }
58
91
 
59
92
  async function monitorWebSocket(params: {
@@ -61,17 +94,19 @@ async function monitorWebSocket(params: {
61
94
  feishuCfg: FeishuConfig;
62
95
  runtime?: RuntimeEnv;
63
96
  abortSignal?: AbortSignal;
97
+ accountId: string;
64
98
  }): Promise<void> {
65
- const { cfg, feishuCfg, runtime, abortSignal } = params;
99
+ const { cfg, feishuCfg, runtime, abortSignal, accountId } = params;
66
100
  const log = runtime?.log ?? console.log;
67
101
  const error = runtime?.error ?? console.error;
68
102
 
69
- log("feishu: starting WebSocket connection...");
103
+ log(`feishu[${accountId}]: starting WebSocket connection...`);
70
104
 
71
105
  const wsClient = createFeishuWSClient(feishuCfg);
72
- currentWsClient = wsClient;
106
+ wsClients.set(accountId, wsClient);
73
107
 
74
108
  const chatHistories = new Map<string, HistoryEntry[]>();
109
+ const botOpenId = botOpenIds.get(accountId);
75
110
 
76
111
  const eventDispatcher = createEventDispatcher(feishuCfg);
77
112
 
@@ -79,15 +114,20 @@ async function monitorWebSocket(params: {
79
114
  "im.message.receive_v1": async (data) => {
80
115
  try {
81
116
  const event = data as unknown as FeishuMessageEvent;
117
+ if (isDuplicateMessage(event.message?.message_id)) {
118
+ log(`feishu[${accountId}]: skipping duplicate message ${event.message.message_id}`);
119
+ return;
120
+ }
82
121
  await handleFeishuMessage({
83
122
  cfg,
84
123
  event,
85
124
  botOpenId,
86
125
  runtime,
87
126
  chatHistories,
127
+ accountId,
88
128
  });
89
129
  } catch (err) {
90
- error(`feishu: error handling message event: ${String(err)}`);
130
+ error(`feishu[${accountId}]: error handling message event: ${String(err)}`);
91
131
  }
92
132
  },
93
133
  "im.message.message_read_v1": async () => {
@@ -96,30 +136,30 @@ async function monitorWebSocket(params: {
96
136
  "im.chat.member.bot.added_v1": async (data) => {
97
137
  try {
98
138
  const event = data as unknown as FeishuBotAddedEvent;
99
- log(`feishu: bot added to chat ${event.chat_id}`);
139
+ log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
100
140
  } catch (err) {
101
- error(`feishu: error handling bot added event: ${String(err)}`);
141
+ error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
102
142
  }
103
143
  },
104
144
  "im.chat.member.bot.deleted_v1": async (data) => {
105
145
  try {
106
146
  const event = data as unknown as { chat_id: string };
107
- log(`feishu: bot removed from chat ${event.chat_id}`);
147
+ log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
108
148
  } catch (err) {
109
- error(`feishu: error handling bot removed event: ${String(err)}`);
149
+ error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
110
150
  }
111
151
  },
112
152
  });
113
153
 
114
154
  return new Promise((resolve, reject) => {
115
155
  const cleanup = () => {
116
- if (currentWsClient === wsClient) {
117
- currentWsClient = null;
156
+ if (wsClients.get(accountId) === wsClient) {
157
+ wsClients.delete(accountId);
118
158
  }
119
159
  };
120
160
 
121
161
  const handleAbort = () => {
122
- log("feishu: abort signal received, stopping WebSocket client");
162
+ log(`feishu[${accountId}]: abort signal received, stopping WebSocket client`);
123
163
  cleanup();
124
164
  resolve();
125
165
  };
@@ -137,7 +177,7 @@ async function monitorWebSocket(params: {
137
177
  eventDispatcher,
138
178
  });
139
179
 
140
- log("feishu: WebSocket client started");
180
+ log(`feishu[${accountId}]: WebSocket client started`);
141
181
  } catch (err) {
142
182
  cleanup();
143
183
  abortSignal?.removeEventListener("abort", handleAbort);
@@ -146,13 +186,22 @@ async function monitorWebSocket(params: {
146
186
  });
147
187
  }
148
188
 
149
- export function stopFeishuMonitor(): void {
150
- if (currentWsClient) {
151
- currentWsClient = null;
152
- }
153
- if (currentHttpServer) {
154
- currentHttpServer.close();
155
- currentHttpServer = null;
189
+ export function stopFeishuMonitor(accountId?: string): void {
190
+ if (accountId) {
191
+ wsClients.delete(accountId);
192
+ botOpenIds.delete(accountId);
193
+ const server = httpServers.get(accountId);
194
+ if (server) {
195
+ server.close();
196
+ httpServers.delete(accountId);
197
+ }
198
+ } else {
199
+ wsClients.clear();
200
+ botOpenIds.clear();
201
+ for (const server of httpServers.values()) {
202
+ server.close();
203
+ }
204
+ httpServers.clear();
156
205
  }
157
206
  }
158
207
 
@@ -161,32 +210,39 @@ async function monitorWebhook(params: {
161
210
  feishuCfg: FeishuConfig;
162
211
  runtime?: RuntimeEnv;
163
212
  abortSignal?: AbortSignal;
213
+ accountId: string;
164
214
  }): Promise<void> {
165
- const { cfg, feishuCfg, runtime, abortSignal } = params;
215
+ const { cfg, feishuCfg, runtime, abortSignal, accountId } = params;
166
216
  const log = runtime?.log ?? console.log;
167
217
  const error = runtime?.error ?? console.error;
168
218
 
169
219
  const webhookPath = feishuCfg.webhookPath ?? "/feishu/events";
170
220
  const webhookPort = feishuCfg.webhookPort ?? 3000;
171
221
 
172
- log(`feishu: starting webhook server on port ${webhookPort}, path ${webhookPath}...`);
222
+ log(`feishu[${accountId}]: starting webhook server on port ${webhookPort}, path ${webhookPath}...`);
173
223
 
174
224
  const chatHistories = new Map<string, HistoryEntry[]>();
225
+ const botOpenId = botOpenIds.get(accountId);
175
226
  const eventDispatcher = createEventDispatcher(feishuCfg);
176
227
 
177
228
  eventDispatcher.register({
178
229
  "im.message.receive_v1": async (data) => {
179
230
  try {
180
231
  const event = data as unknown as FeishuMessageEvent;
232
+ if (isDuplicateMessage(event.message?.message_id)) {
233
+ log(`feishu[${accountId}]: skipping duplicate message ${event.message.message_id}`);
234
+ return;
235
+ }
181
236
  await handleFeishuMessage({
182
237
  cfg,
183
238
  event,
184
239
  botOpenId,
185
240
  runtime,
186
241
  chatHistories,
242
+ accountId,
187
243
  });
188
244
  } catch (err) {
189
- error(`feishu: error handling message event: ${String(err)}`);
245
+ error(`feishu[${accountId}]: error handling message event: ${String(err)}`);
190
246
  }
191
247
  },
192
248
  "im.message.message_read_v1": async () => {
@@ -195,33 +251,33 @@ async function monitorWebhook(params: {
195
251
  "im.chat.member.bot.added_v1": async (data) => {
196
252
  try {
197
253
  const event = data as unknown as FeishuBotAddedEvent;
198
- log(`feishu: bot added to chat ${event.chat_id}`);
254
+ log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
199
255
  } catch (err) {
200
- error(`feishu: error handling bot added event: ${String(err)}`);
256
+ error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
201
257
  }
202
258
  },
203
259
  "im.chat.member.bot.deleted_v1": async (data) => {
204
260
  try {
205
261
  const event = data as unknown as { chat_id: string };
206
- log(`feishu: bot removed from chat ${event.chat_id}`);
262
+ log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
207
263
  } catch (err) {
208
- error(`feishu: error handling bot removed event: ${String(err)}`);
264
+ error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
209
265
  }
210
266
  },
211
267
  });
212
268
 
213
269
  return new Promise((resolve, reject) => {
214
270
  const server = http.createServer();
215
- currentHttpServer = server;
271
+ httpServers.set(accountId, server);
216
272
 
217
273
  const cleanup = () => {
218
- if (currentHttpServer === server) {
219
- currentHttpServer = null;
274
+ if (httpServers.get(accountId) === server) {
275
+ httpServers.delete(accountId);
220
276
  }
221
277
  };
222
278
 
223
279
  const handleAbort = () => {
224
- log("feishu: abort signal received, stopping webhook server");
280
+ log(`feishu[${accountId}]: abort signal received, stopping webhook server`);
225
281
  server.close(() => {
226
282
  cleanup();
227
283
  resolve();
@@ -239,7 +295,7 @@ async function monitorWebhook(params: {
239
295
  server.on("error", (err) => {
240
296
  cleanup();
241
297
  abortSignal?.removeEventListener("abort", handleAbort);
242
- error(`feishu: webhook server error: ${String(err)}`);
298
+ error(`feishu[${accountId}]: webhook server error: ${String(err)}`);
243
299
  reject(err);
244
300
  });
245
301
 
@@ -249,7 +305,7 @@ async function monitorWebhook(params: {
249
305
  }));
250
306
 
251
307
  server.listen(webhookPort, () => {
252
- log(`feishu: webhook server started on port ${webhookPort}`);
308
+ log(`feishu[${accountId}]: webhook server started on port ${webhookPort}`);
253
309
  });
254
310
  });
255
311
  }
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";