@m1heng-clawd/feishu 0.1.17 → 0.1.18

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": "@m1heng-clawd/feishu",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Feishu/Lark channel plugin",
6
6
  "scripts": {
package/src/accounts.ts CHANGED
@@ -10,7 +10,11 @@ function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
10
10
  if (!accounts || typeof accounts !== "object") {
11
11
  return [];
12
12
  }
13
- return Object.keys(accounts).filter(Boolean);
13
+ return [...new Set(
14
+ Object.keys(accounts)
15
+ .filter((accountId) => accountId.trim())
16
+ .map((accountId) => normalizeAccountId(accountId)),
17
+ )];
14
18
  }
15
19
 
16
20
  /**
@@ -38,17 +42,35 @@ export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
38
42
  }
39
43
 
40
44
  /**
41
- * Get the raw account-specific config.
45
+ * Resolve the configured account key for a normalized account id.
46
+ * Preserves the original config key casing so write paths can avoid shadow entries.
42
47
  */
43
- function resolveAccountConfig(
48
+ export function resolveConfiguredFeishuAccountKey(
44
49
  cfg: ClawdbotConfig,
45
50
  accountId: string,
46
- ): FeishuAccountConfig | undefined {
51
+ ): string | undefined {
47
52
  const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
48
53
  if (!accounts || typeof accounts !== "object") {
49
54
  return undefined;
50
55
  }
51
- return accounts[accountId];
56
+ if (Object.prototype.hasOwnProperty.call(accounts, accountId)) {
57
+ return accountId;
58
+ }
59
+ const normalizedId = normalizeAccountId(accountId);
60
+ return Object.keys(accounts).find((key) => normalizeAccountId(key) === normalizedId);
61
+ }
62
+
63
+ /**
64
+ * Get the raw account-specific config.
65
+ * Preserves mixed-case config keys by resolving through the normalized account id.
66
+ */
67
+ function resolveAccountConfig(
68
+ cfg: ClawdbotConfig,
69
+ accountId: string,
70
+ ): FeishuAccountConfig | undefined {
71
+ const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts;
72
+ const matchedKey = resolveConfiguredFeishuAccountKey(cfg, accountId);
73
+ return matchedKey && accounts ? accounts[matchedKey] : undefined;
52
74
  }
53
75
 
54
76
  /**
package/src/bot.ts CHANGED
@@ -94,6 +94,7 @@ const senderNameCache = new Map<string, { name: string; expireAt: number }>();
94
94
  // Key: appId or "default", Value: timestamp of last notification
95
95
  const permissionErrorNotifiedAt = new Map<string, number>();
96
96
  const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
97
+ const DEFAULT_MAX_MESSAGE_AGE_MS = 300_000; // 5 minutes
97
98
 
98
99
  type SenderNameResult = {
99
100
  name?: string;
@@ -918,13 +919,20 @@ export async function handleFeishuMessage(params: {
918
919
  return;
919
920
  }
920
921
 
921
- let ctx = parseFeishuMessageEvent(event, botOpenId);
922
-
923
922
  // Parse message create_time (Feishu uses millisecond epoch string).
924
923
  const messageCreateTimeMs = event.message.create_time
925
924
  ? parseInt(event.message.create_time, 10)
926
925
  : undefined;
927
926
 
927
+ // Discard stale messages (e.g. replayed after gateway restart).
928
+ const maxAgeMs = feishuCfg.maxMessageAgeMs ?? DEFAULT_MAX_MESSAGE_AGE_MS;
929
+ if (messageCreateTimeMs && Date.now() - messageCreateTimeMs > maxAgeMs) {
930
+ log(`feishu[${account.accountId}]: discarding stale message ${messageId} (age: ${Math.round((Date.now() - messageCreateTimeMs) / 1000)}s, max: ${maxAgeMs / 1000}s)`);
931
+ return;
932
+ }
933
+
934
+ let ctx = parseFeishuMessageEvent(event, botOpenId);
935
+
928
936
  const messageType = event.message.message_type;
929
937
  const isForwardedInbound = isForwardedMessageType(messageType);
930
938
  const forwardedDispatchKey = getForwardedKey({
@@ -1304,9 +1312,12 @@ export async function handleFeishuMessage(params: {
1304
1312
  }
1305
1313
  }
1306
1314
 
1307
- // In group chats, the session is scoped to the group, but the *speaker* is the sender.
1308
- // Using a group-scoped From causes the agent to treat different users as the same person.
1309
- const feishuFrom = `feishu:${ctx.senderOpenId}`;
1315
+ // In group chats, From must be group-scoped so the core's resolveGroupSessionKey can
1316
+ // detect group context and route replies back to the group not to a user's DM session.
1317
+ // We include senderOpenId to preserve per-speaker identity within the group.
1318
+ const feishuFrom = isGroup
1319
+ ? `feishu:${ctx.chatId}:${ctx.senderOpenId}`
1320
+ : `feishu:${ctx.senderOpenId}`;
1310
1321
  const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
1311
1322
 
1312
1323
  // Resolve peer ID for session routing
package/src/channel.ts CHANGED
@@ -2,11 +2,13 @@ import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
2
2
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
3
3
  import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
4
4
  import {
5
+ resolveConfiguredFeishuAccountKey,
5
6
  resolveFeishuAccount,
6
7
  resolveFeishuCredentials,
7
8
  listFeishuAccountIds,
8
9
  resolveDefaultFeishuAccountId,
9
10
  } from "./accounts.js";
11
+ import { monitorFeishuProvider } from "./monitor.js";
10
12
  import { feishuOutbound } from "./outbound.js";
11
13
  import { probeFeishu } from "./probe.js";
12
14
  import { resolveFeishuGroupToolPolicy } from "./policy.js";
@@ -129,7 +131,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
129
131
  resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
130
132
  defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
131
133
  setAccountEnabled: ({ cfg, accountId, enabled }) => {
132
- const account = resolveFeishuAccount({ cfg, accountId });
133
134
  const isDefault = accountId === DEFAULT_ACCOUNT_ID;
134
135
 
135
136
  if (isDefault) {
@@ -148,6 +149,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
148
149
 
149
150
  // For named accounts, set enabled in accounts[accountId]
150
151
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
152
+ const accountKey = resolveConfiguredFeishuAccountKey(cfg, accountId) ?? accountId;
151
153
  return {
152
154
  ...cfg,
153
155
  channels: {
@@ -156,8 +158,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
156
158
  ...feishuCfg,
157
159
  accounts: {
158
160
  ...feishuCfg?.accounts,
159
- [accountId]: {
160
- ...feishuCfg?.accounts?.[accountId],
161
+ [accountKey]: {
162
+ ...feishuCfg?.accounts?.[accountKey],
161
163
  enabled,
162
164
  },
163
165
  },
@@ -184,7 +186,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
184
186
  // Delete specific account from accounts
185
187
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
186
188
  const accounts = { ...feishuCfg?.accounts };
187
- delete accounts[accountId];
189
+ const accountKey = resolveConfiguredFeishuAccountKey(cfg, accountId) ?? accountId;
190
+ delete accounts[accountKey];
188
191
 
189
192
  return {
190
193
  ...cfg,
@@ -247,6 +250,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
247
250
  }
248
251
 
249
252
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
253
+ const accountKey = resolveConfiguredFeishuAccountKey(cfg, accountId) ?? accountId;
250
254
  return {
251
255
  ...cfg,
252
256
  channels: {
@@ -255,8 +259,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
255
259
  ...feishuCfg,
256
260
  accounts: {
257
261
  ...feishuCfg?.accounts,
258
- [accountId]: {
259
- ...feishuCfg?.accounts?.[accountId],
262
+ [accountKey]: {
263
+ ...feishuCfg?.accounts?.[accountKey],
260
264
  enabled: true,
261
265
  },
262
266
  },
@@ -324,7 +328,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
324
328
  },
325
329
  gateway: {
326
330
  startAccount: async (ctx) => {
327
- const { monitorFeishuProvider } = await import("./monitor.js");
328
331
  const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
329
332
  const port = account.config?.webhookPort ?? null;
330
333
  ctx.setStatus({ accountId: ctx.accountId, port });
@@ -1,3 +1,4 @@
1
+ import { normalizeAccountId } from "openclaw/plugin-sdk";
1
2
  import { z } from "zod";
2
3
  export { z };
3
4
 
@@ -157,6 +158,7 @@ export const FeishuAccountConfigSchema = z
157
158
  mediaMaxMb: z.number().positive().optional(),
158
159
  mediaLocalRoots: z.array(z.string()).optional(),
159
160
  heartbeat: ChannelHeartbeatVisibilitySchema,
161
+ maxMessageAgeMs: z.number().positive().optional(),
160
162
  renderMode: RenderModeSchema,
161
163
  streaming: StreamingModeSchema,
162
164
  tools: FeishuToolsConfigSchema,
@@ -196,6 +198,7 @@ export const FeishuConfigSchema = z
196
198
  mediaMaxMb: z.number().positive().optional(),
197
199
  mediaLocalRoots: z.array(z.string()).optional(),
198
200
  heartbeat: ChannelHeartbeatVisibilitySchema,
201
+ maxMessageAgeMs: z.number().positive().optional(),
199
202
  renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
200
203
  streaming: StreamingModeSchema,
201
204
  tools: FeishuToolsConfigSchema,
@@ -206,6 +209,23 @@ export const FeishuConfigSchema = z
206
209
  })
207
210
  .strict()
208
211
  .superRefine((value, ctx) => {
212
+ const normalizedAccountIds = new Map<string, string>();
213
+ for (const accountId of Object.keys(value.accounts ?? {})) {
214
+ const normalizedAccountId = normalizeAccountId(accountId);
215
+ const previousAccountId = normalizedAccountIds.get(normalizedAccountId);
216
+ if (previousAccountId && previousAccountId !== accountId) {
217
+ ctx.addIssue({
218
+ code: z.ZodIssueCode.custom,
219
+ path: ["accounts", accountId],
220
+ message:
221
+ `channels.feishu.accounts contains duplicate account ids after normalization: ` +
222
+ `"${previousAccountId}" and "${accountId}" both normalize to "${normalizedAccountId}"`,
223
+ });
224
+ continue;
225
+ }
226
+ normalizedAccountIds.set(normalizedAccountId, accountId);
227
+ }
228
+
209
229
  if (value.dmPolicy === "open") {
210
230
  const allowFrom = value.allowFrom ?? [];
211
231
  const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
package/src/media.ts CHANGED
@@ -514,7 +514,14 @@ export async function sendMediaFeishu(params: {
514
514
  ...(mergedLocalRoots.length > 0 ? { localRoots: mergedLocalRoots } : {}),
515
515
  });
516
516
  buffer = loaded.buffer;
517
- name = fileName ?? loaded.fileName ?? "file";
517
+ const loadedFileName = loaded.fileName ?? "file";
518
+ let decoded: string;
519
+ try {
520
+ decoded = decodeURIComponent(loadedFileName);
521
+ } catch {
522
+ decoded = loadedFileName;
523
+ }
524
+ name = fileName ?? decoded;
518
525
  contentType = loaded.contentType;
519
526
  } else {
520
527
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
package/src/onboarding.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
 
16
16
  import {
17
17
  listFeishuAccountIds,
18
+ resolveConfiguredFeishuAccountKey,
18
19
  resolveDefaultFeishuAccountId,
19
20
  resolveFeishuAccount,
20
21
  resolveFeishuCredentials,
@@ -58,7 +59,8 @@ function upsertFeishuAccountConfig(
58
59
  };
59
60
  }
60
61
 
61
- const existingAccount = feishuCfg?.accounts?.[normalizedAccountId];
62
+ const accountKey = resolveConfiguredFeishuAccountKey(cfg, normalizedAccountId) ?? normalizedAccountId;
63
+ const existingAccount = feishuCfg?.accounts?.[accountKey];
62
64
  return {
63
65
  ...cfg,
64
66
  channels: {
@@ -68,7 +70,7 @@ function upsertFeishuAccountConfig(
68
70
  enabled: true,
69
71
  accounts: {
70
72
  ...feishuCfg?.accounts,
71
- [normalizedAccountId]: {
73
+ [accountKey]: {
72
74
  ...existingAccount,
73
75
  enabled: existingAccount?.enabled ?? true,
74
76
  ...patch,
@@ -91,8 +91,23 @@ function normalizeNonCodeSegments(text: string): string {
91
91
  .join("");
92
92
  }
93
93
 
94
+ function normalizeCodeBlockFences(block: string): string {
95
+ const lines = block.split("\n");
96
+ return lines
97
+ .map((line) => {
98
+ // Fix opening and closing fences: remove leading whitespace
99
+ if (line.match(/^\s*```/)) {
100
+ return line.replace(/^\s+/, "");
101
+ }
102
+ return line;
103
+ })
104
+ .join("\n");
105
+ }
106
+
94
107
  export function normalizeFeishuMarkdownLinks(text: string): string {
95
- if (!text || (!text.includes("http://") && !text.includes("https://"))) {
108
+ if (!text) return text;
109
+ text = normalizeCodeBlockFences(text);
110
+ if (!text.includes("http://") && !text.includes("https://")) {
96
111
  return text;
97
112
  }
98
113