@m1heng-clawd/feishu 0.1.13 → 0.1.14

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/bot.ts +181 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m1heng-clawd/feishu",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Feishu/Lark channel plugin",
6
6
  "scripts": {
package/src/bot.ts CHANGED
@@ -99,6 +99,137 @@ type SenderNameResult = {
99
99
  permissionError?: PermissionError;
100
100
  };
101
101
 
102
+ type FeishuPairingApiMode = "legacy" | "scoped";
103
+ let detectedFeishuPairingApiMode: FeishuPairingApiMode | null = null;
104
+
105
+ async function resolveFeishuPairingApiMode(params: {
106
+ readAllowFromStore: (...args: any[]) => Promise<Array<string | number>>;
107
+ }): Promise<FeishuPairingApiMode> {
108
+ if (detectedFeishuPairingApiMode) return detectedFeishuPairingApiMode;
109
+
110
+ let channelObserved = false;
111
+ const probe = new Proxy<Record<PropertyKey, unknown>>(
112
+ {},
113
+ {
114
+ get(_target, prop) {
115
+ if (prop === "channel") {
116
+ channelObserved = true;
117
+ return "__feishu_pairing_probe__";
118
+ }
119
+ if (prop === "accountId") return "__feishu_pairing_probe__";
120
+ if (prop === "env") return undefined;
121
+ if (prop === Symbol.toPrimitive) return () => "__feishu_pairing_probe__";
122
+ if (prop === "toString") return () => "__feishu_pairing_probe__";
123
+ return undefined;
124
+ },
125
+ has(_target, prop) {
126
+ if (prop === "channel") {
127
+ channelObserved = true;
128
+ return true;
129
+ }
130
+ return false;
131
+ },
132
+ },
133
+ );
134
+
135
+ try {
136
+ // Probe once to detect whether runtime reads object-style params (scoped API)
137
+ // or positional params (legacy API).
138
+ await params.readAllowFromStore(probe as any, undefined, "__feishu_pairing_probe__");
139
+ } catch {
140
+ // Ignore probe errors; fallback below still keeps behavior safe.
141
+ }
142
+
143
+ detectedFeishuPairingApiMode = channelObserved ? "scoped" : "legacy";
144
+ return detectedFeishuPairingApiMode;
145
+ }
146
+
147
+ async function readFeishuPairingAllowFrom(params: {
148
+ core: ReturnType<typeof getFeishuRuntime>;
149
+ accountId: string;
150
+ }): Promise<Array<string | number>> {
151
+ const pairing = params.core.channel.pairing as {
152
+ readAllowFromStore: (...args: any[]) => Promise<Array<string | number>>;
153
+ };
154
+ const pairingMode = await resolveFeishuPairingApiMode({
155
+ readAllowFromStore: pairing.readAllowFromStore,
156
+ });
157
+
158
+ if (pairingMode === "scoped") {
159
+ try {
160
+ return await pairing.readAllowFromStore({
161
+ channel: "feishu",
162
+ accountId: params.accountId,
163
+ });
164
+ } catch {
165
+ // Compatibility fallback for older OpenClaw implementations.
166
+ return await pairing.readAllowFromStore("feishu", undefined, params.accountId);
167
+ }
168
+ }
169
+
170
+ try {
171
+ // OpenClaw <= 2026.2.19 signature: readAllowFromStore(channel, env?, accountId?)
172
+ return await pairing.readAllowFromStore("feishu", undefined, params.accountId);
173
+ } catch {
174
+ // Compatibility fallback for newer OpenClaw implementations.
175
+ return await pairing.readAllowFromStore({
176
+ channel: "feishu",
177
+ accountId: params.accountId,
178
+ });
179
+ }
180
+ }
181
+
182
+ async function upsertFeishuPairingRequest(params: {
183
+ core: ReturnType<typeof getFeishuRuntime>;
184
+ accountId: string;
185
+ senderId: string;
186
+ senderName?: string;
187
+ }): Promise<{ code: string; created: boolean }> {
188
+ const pairing = params.core.channel.pairing as {
189
+ upsertPairingRequest: (...args: any[]) => Promise<{ code: string; created: boolean }>;
190
+ };
191
+ const meta = params.senderName ? { name: params.senderName } : undefined;
192
+ const pairingMode = await resolveFeishuPairingApiMode({
193
+ readAllowFromStore: params.core.channel.pairing.readAllowFromStore as (...args: any[]) => Promise<
194
+ Array<string | number>
195
+ >,
196
+ });
197
+
198
+ if (pairingMode === "scoped") {
199
+ try {
200
+ return await pairing.upsertPairingRequest({
201
+ channel: "feishu",
202
+ id: params.senderId,
203
+ accountId: params.accountId,
204
+ meta,
205
+ });
206
+ } catch {
207
+ // Compatibility fallback for older OpenClaw implementations.
208
+ return await pairing.upsertPairingRequest({
209
+ channel: "feishu",
210
+ id: params.senderId,
211
+ meta,
212
+ });
213
+ }
214
+ }
215
+
216
+ try {
217
+ return await pairing.upsertPairingRequest({
218
+ channel: "feishu",
219
+ id: params.senderId,
220
+ meta,
221
+ });
222
+ } catch {
223
+ // Compatibility fallback for newer OpenClaw implementations.
224
+ return await pairing.upsertPairingRequest({
225
+ channel: "feishu",
226
+ id: params.senderId,
227
+ accountId: params.accountId,
228
+ meta,
229
+ });
230
+ }
231
+ }
232
+
102
233
  async function resolveFeishuSenderName(params: {
103
234
  account: ResolvedFeishuAccount;
104
235
  senderOpenId: string;
@@ -226,20 +357,48 @@ export type FeishuBotAddedEvent = {
226
357
  function parseMessageContent(content: string, messageType: string): string {
227
358
  try {
228
359
  const parsed = JSON.parse(content);
229
- if (messageType === "text") {
360
+ const normalizedMessageType = normalizeFeishuInboundMessageType(messageType);
361
+ if (normalizedMessageType === "text") {
230
362
  return parsed.text || "";
231
363
  }
232
- if (messageType === "post") {
364
+ if (normalizedMessageType === "post") {
233
365
  // Extract text content from rich text post
234
366
  const { textContent } = parsePostContent(content);
235
367
  return textContent;
236
368
  }
369
+ if (["image", "file", "audio", "video", "sticker"].includes(normalizedMessageType)) {
370
+ return inferPlaceholder(normalizedMessageType);
371
+ }
237
372
  return content;
238
373
  } catch {
239
374
  return content;
240
375
  }
241
376
  }
242
377
 
378
+ /**
379
+ * Feishu may use "media" for inbound video messages.
380
+ * Normalize to "video" so downstream media handling is consistent.
381
+ */
382
+ export function normalizeFeishuInboundMessageType(messageType: string): string {
383
+ return messageType === "media" ? "video" : messageType;
384
+ }
385
+
386
+ /**
387
+ * Select the correct resource key for messageResource download.
388
+ * - image message: prefer image_key
389
+ * - other media (video/audio/file/sticker): prefer file_key
390
+ */
391
+ export function selectFeishuMessageResourceKey(
392
+ messageType: string,
393
+ keys: { imageKey?: string; fileKey?: string },
394
+ ): string | undefined {
395
+ const normalized = normalizeFeishuInboundMessageType(messageType);
396
+ if (normalized === "image") {
397
+ return keys.imageKey ?? keys.fileKey;
398
+ }
399
+ return keys.fileKey ?? keys.imageKey;
400
+ }
401
+
243
402
  function checkBotMentioned(
244
403
  event: FeishuMessageEvent,
245
404
  botOpenId?: string,
@@ -288,6 +447,7 @@ function parseMediaKeys(
288
447
  case "audio":
289
448
  return { fileKey: parsed.file_key };
290
449
  case "video":
450
+ case "media":
291
451
  // Video has both file_key (video) and image_key (thumbnail)
292
452
  return { fileKey: parsed.file_key, imageKey: parsed.image_key };
293
453
  case "sticker":
@@ -363,6 +523,7 @@ function inferPlaceholder(messageType: string): string {
363
523
  case "audio":
364
524
  return "<media:audio>";
365
525
  case "video":
526
+ case "media":
366
527
  return "<media:video>";
367
528
  case "sticker":
368
529
  return "<media:sticker>";
@@ -385,10 +546,11 @@ async function resolveFeishuMediaList(params: {
385
546
  accountId?: string;
386
547
  }): Promise<FeishuMediaInfo[]> {
387
548
  const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
549
+ const normalizedMessageType = normalizeFeishuInboundMessageType(messageType);
388
550
 
389
551
  // Only process media message types (including post for embedded images)
390
552
  const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
391
- if (!mediaTypes.includes(messageType)) {
553
+ if (!mediaTypes.includes(normalizedMessageType)) {
392
554
  return [];
393
555
  }
394
556
 
@@ -396,7 +558,7 @@ async function resolveFeishuMediaList(params: {
396
558
  const core = getFeishuRuntime();
397
559
 
398
560
  // Handle post (rich text) messages with embedded images
399
- if (messageType === "post") {
561
+ if (normalizedMessageType === "post") {
400
562
  const { imageKeys } = parsePostContent(content);
401
563
  if (imageKeys.length === 0) {
402
564
  return [];
@@ -443,7 +605,7 @@ async function resolveFeishuMediaList(params: {
443
605
  }
444
606
 
445
607
  // Handle other media types
446
- const mediaKeys = parseMediaKeys(content, messageType);
608
+ const mediaKeys = parseMediaKeys(content, normalizedMessageType);
447
609
  if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
448
610
  return [];
449
611
  }
@@ -455,12 +617,12 @@ async function resolveFeishuMediaList(params: {
455
617
 
456
618
  // For message media, always use messageResource API
457
619
  // The image.get API is only for images uploaded via im/v1/images, not for message attachments
458
- const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
620
+ const fileKey = selectFeishuMessageResourceKey(normalizedMessageType, mediaKeys);
459
621
  if (!fileKey) {
460
622
  return [];
461
623
  }
462
624
 
463
- const resourceType = messageType === "image" ? "image" : "file";
625
+ const resourceType = normalizedMessageType === "image" ? "image" : "file";
464
626
  const result = await downloadMessageResourceFeishu({
465
627
  cfg,
466
628
  messageId,
@@ -489,12 +651,12 @@ async function resolveFeishuMediaList(params: {
489
651
  out.push({
490
652
  path: saved.path,
491
653
  contentType: saved.contentType,
492
- placeholder: inferPlaceholder(messageType),
654
+ placeholder: inferPlaceholder(normalizedMessageType),
493
655
  });
494
656
 
495
- log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
657
+ log?.(`feishu: downloaded ${normalizedMessageType} media, saved to ${saved.path}`);
496
658
  } catch (err) {
497
- log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
659
+ log?.(`feishu: failed to download ${normalizedMessageType} media: ${String(err)}`);
498
660
  }
499
661
 
500
662
  return out;
@@ -648,7 +810,10 @@ export async function handleFeishuMessage(params: {
648
810
  const hasControlCommand = core.channel.text.hasControlCommand(ctx.content, cfg);
649
811
  const storeAllowFrom =
650
812
  !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
651
- ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
813
+ ? await readFeishuPairingAllowFrom({
814
+ core,
815
+ accountId: account.accountId,
816
+ }).catch(() => [])
652
817
  : [];
653
818
  const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
654
819
  const dmAllowed = resolveFeishuAllowlistMatch({
@@ -659,10 +824,11 @@ export async function handleFeishuMessage(params: {
659
824
 
660
825
  if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
661
826
  if (dmPolicy === "pairing") {
662
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
663
- channel: "feishu",
664
- id: ctx.senderOpenId,
665
- meta: { name: ctx.senderName },
827
+ const { code, created } = await upsertFeishuPairingRequest({
828
+ core,
829
+ accountId: account.accountId,
830
+ senderId: ctx.senderOpenId,
831
+ senderName: ctx.senderName,
666
832
  });
667
833
  if (created) {
668
834
  log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);