@lmcl/ailo-mcp-feishu 0.0.8 → 0.1.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.
@@ -9,22 +9,17 @@ export class FeishuHandler {
9
9
  config;
10
10
  client;
11
11
  wsClient = null;
12
- onMessageHandler = null;
12
+ ctx = null;
13
13
  onP2PChatEntered = null;
14
14
  onBotAddedToGroup = null;
15
15
  onBotRemovedFromGroup = null;
16
16
  onMessageRead = null;
17
- // bot 自身的 open_id,用于检测 mentionsSelf
18
17
  botOpenId = "";
19
- // @提及名称 → open_id 缓存,用于将出站文本中的 @Name 转为飞书 <at> 标签
20
18
  mentionNameToId = new Map();
21
- // 懒加载缓存
22
19
  userCache = new Map();
23
20
  chatCache = new Map();
24
- // 外部用户顺序编号(userId → 编号标签),通过 ChannelStorage 持久化,跨重启保持稳定
25
21
  externalUserCounter = 0;
26
22
  externalUserLabels = new Map();
27
- storage = null;
28
23
  // messageId → 消息元数据映射缓存(供撤回/Reaction 等事件查找)
29
24
  msgMetaMap = new Map();
30
25
  MSG_META_MAP_MAX = 2000;
@@ -42,12 +37,8 @@ export class FeishuHandler {
42
37
  loggerLevel: Lark.LoggerLevel.fatal,
43
38
  });
44
39
  }
45
- /**
46
- * 注入持久化存储(SDK 连接后注入,含 getData/setData/deleteData)。
47
- */
48
- setDataProvider(provider) {
49
- this.storage = provider;
50
- this.loadExternalUserLabels();
40
+ get storage() {
41
+ return this.ctx?.storage ?? null;
51
42
  }
52
43
  /** 外部用户数据单 key:{ _counter, [userId]: label } */
53
44
  static EXTERNAL_USERS_KEY = "external_users";
@@ -293,8 +284,10 @@ export class FeishuHandler {
293
284
  }
294
285
  return { text: resolved, mentionsSelf };
295
286
  }
296
- setOnMessage(handler) {
297
- this.onMessageHandler = handler;
287
+ acceptMessage(msg) {
288
+ if (!this.ctx)
289
+ return;
290
+ this.ctx.accept(msg).catch((err) => console.error("[feishu] accept failed:", err));
298
291
  }
299
292
  setOnP2PChatEntered(handler) {
300
293
  this.onP2PChatEntered = handler;
@@ -309,30 +302,30 @@ export class FeishuHandler {
309
302
  this.onMessageRead = handler;
310
303
  }
311
304
  buildBridgeMessage(opts) {
312
- const { chatId, text, chatType, senderId = "", senderName = "", chatName, mentionsSelf, timestamp, attachments } = opts;
305
+ const { chatId, text, chatType, senderId = "", senderName = "", chatName, attachments } = opts;
313
306
  const isPrivate = chatType === "私聊";
307
+ // 新 ContextTag 格式(kind/streamKey/routing):
308
+ // - TagChannel 由网关根据 displayName 自动注入,飞书不重复添加
309
+ // - chat_id 参与 stream_key 推导(streamKey=true),仅路由(routing=true)
310
+ // - 私聊:stream_key = feishu:{ou_xxx}(用户 ID)
311
+ // - 群聊:stream_key = feishu:{oc_xxx}(群 ID)
312
+ // - "@我" 是内容语义不是时空场,不放 contextTags
313
+ // - "时间" 由框架在接收时自动渲染,不由通道传入
314
314
  const tags = [
315
- { desc: "类型", value: chatType, core: true },
316
- { desc: "会话", value: chatId, core: true },
315
+ { kind: "conv_type", value: chatType, streamKey: false },
316
+ { kind: "chat_id", value: chatId, streamKey: true, routing: true },
317
317
  ];
318
- if (!isPrivate && chatName) {
319
- tags.push({ desc: "群名", value: chatName, core: true });
318
+ if (!isPrivate) {
319
+ // 群聊:群名作为 stream_key 语义补充,group 标签不参与 stream_key(chat_id 已足够)
320
+ const groupName = chatName || `群${chatId.slice(-8)}`;
321
+ tags.push({ kind: "group", value: groupName, streamKey: false });
320
322
  }
321
- else if (!isPrivate && chatId) {
322
- tags.push({ desc: "群名", value: `群${chatId.slice(-8)}`, core: true });
323
+ if (senderName) {
324
+ tags.push({ kind: "participant", value: senderName, streamKey: false });
323
325
  }
324
- tags.push({ desc: "昵称", value: senderName, core: isPrivate }, { desc: "用户", value: senderId, core: isPrivate });
325
- if (mentionsSelf) {
326
- tags.push({ desc: "@我", value: "是", core: isPrivate });
327
- }
328
- if (timestamp != null && timestamp > 0) {
329
- const d = new Date(timestamp);
330
- const pad = (n) => String(n).padStart(2, "0");
331
- tags.push({
332
- desc: "时间",
333
- value: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`,
334
- core: false,
335
- });
326
+ if (senderId) {
327
+ // sender_id 仅路由备用(recall agent 按参与者查询时使用),不展示在历史邮戳
328
+ tags.push({ kind: "sender_id", value: senderId, streamKey: false, routing: true });
336
329
  }
337
330
  return { text, contextTags: tags, attachments };
338
331
  }
@@ -533,8 +526,9 @@ export class FeishuHandler {
533
526
  * 使用飞书 WebSocket 长连接接收事件,无需配置回调地址。
534
527
  * 需在飞书开放平台「事件与回调」中选择「使用长连接接收事件」并保存(本客户端需在线)。
535
528
  */
536
- start() {
537
- // 获取 bot 自身的 open_id(异步,不阻塞启动)
529
+ start(ctx) {
530
+ this.ctx = ctx;
531
+ this.loadExternalUserLabels();
538
532
  this.fetchBotOpenId();
539
533
  // MCP stdio 占用 stdout,Lark SDK 的 logger 必须写到 stderr
540
534
  const stderrLogger = {
@@ -561,7 +555,7 @@ export class FeishuHandler {
561
555
  eventDispatcher.register({
562
556
  "im.message.receive_v1": async (data) => {
563
557
  const event = data;
564
- if (!event.message || !this.onMessageHandler)
558
+ if (!event.message || !this.ctx)
565
559
  return;
566
560
  const msg = event.message;
567
561
  const chatId = msg.chat_id ?? "";
@@ -665,7 +659,7 @@ export class FeishuHandler {
665
659
  }
666
660
  if (chatId || messageId) {
667
661
  const isP2p = chatType === "p2p";
668
- this.onMessageHandler(this.buildBridgeMessage({
662
+ this.acceptMessage(this.buildBridgeMessage({
669
663
  chatId: chatId ?? messageId ?? "",
670
664
  text,
671
665
  chatType: isP2p ? "私聊" : "群聊",
@@ -701,7 +695,7 @@ export class FeishuHandler {
701
695
  this.onMessageRead(data);
702
696
  },
703
697
  "im.message.recalled_v1": async (data) => {
704
- if (!this.onMessageHandler)
698
+ if (!this.ctx)
705
699
  return;
706
700
  const event = data;
707
701
  const messageId = event.message_id ?? "";
@@ -769,7 +763,7 @@ export class FeishuHandler {
769
763
  }
770
764
  console.error(`[feishu] message recalled: ${messageId} by ${msgSenderName || "(unknown)"}(${msgSenderId}) recall_type=${recallType} in ${chatId}`);
771
765
  const isP2pRecall = chatType === "p2p";
772
- this.onMessageHandler(this.buildBridgeMessage({
766
+ this.acceptMessage(this.buildBridgeMessage({
773
767
  chatId,
774
768
  text,
775
769
  chatType: isP2pRecall ? "私聊" : "群聊",
@@ -779,7 +773,7 @@ export class FeishuHandler {
779
773
  }));
780
774
  },
781
775
  "im.message.reaction.created_v1": async (data) => {
782
- if (!this.onMessageHandler)
776
+ if (!this.ctx)
783
777
  return;
784
778
  const event = data;
785
779
  const messageId = event.message_id ?? "";
@@ -796,7 +790,7 @@ export class FeishuHandler {
796
790
  const chatType = this.inferChatType(chatId);
797
791
  console.error(`[feishu] reaction ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
798
792
  const isP2pReaction = chatType === "p2p";
799
- this.onMessageHandler(this.buildBridgeMessage({
793
+ this.acceptMessage(this.buildBridgeMessage({
800
794
  chatId,
801
795
  text: `[${reactorName} 对一条消息贴了表情 ${emoji} (message_id: ${messageId})]`,
802
796
  chatType: isP2pReaction ? "私聊" : "群聊",
@@ -806,7 +800,7 @@ export class FeishuHandler {
806
800
  }));
807
801
  },
808
802
  "im.message.reaction.deleted_v1": async (data) => {
809
- if (!this.onMessageHandler)
803
+ if (!this.ctx)
810
804
  return;
811
805
  const event = data;
812
806
  const messageId = event.message_id ?? "";
@@ -823,7 +817,7 @@ export class FeishuHandler {
823
817
  const chatType = this.inferChatType(chatId);
824
818
  console.error(`[feishu] reaction removed ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
825
819
  const isP2pDel = chatType === "p2p";
826
- this.onMessageHandler(this.buildBridgeMessage({
820
+ this.acceptMessage(this.buildBridgeMessage({
827
821
  chatId,
828
822
  text: `[${reactorName} 移除了表情 ${emoji} (message_id: ${messageId})]`,
829
823
  chatType: isP2pDel ? "私聊" : "群聊",
@@ -1047,6 +1041,9 @@ export class FeishuHandler {
1047
1041
  });
1048
1042
  }
1049
1043
  }
1044
+ async stop() {
1045
+ this.ctx = null;
1046
+ }
1050
1047
  /** 处理来自 MCP 工具 feishu action=set_nickname 的命令 */
1051
1048
  async onCommand(command, params) {
1052
1049
  if (command !== "set_nickname")
package/dist/index.js CHANGED
@@ -25,6 +25,8 @@ function feishuBuildChannelPrompt() {
25
25
  const mcpServer = createFeishuMcpServer(handler);
26
26
  runMcpChannel({
27
27
  handler,
28
+ displayName: "飞书",
29
+ defaultRequiresResponse: true,
28
30
  buildChannelPrompt: feishuBuildChannelPrompt,
29
31
  mcpServer,
30
32
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lmcl/ailo-mcp-feishu",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "description": "Ailo 飞书/Lark 通道 MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@larksuiteoapi/node-sdk": "^1.56.1",
17
- "@lmcl/ailo-mcp-sdk": "^0.0.3",
17
+ "@lmcl/ailo-mcp-sdk": "^0.1.0",
18
18
  "@modelcontextprotocol/sdk": "^1.26.0",
19
19
  "dotenv": "^16.4.5",
20
20
  "form-data": "^4.0.0",
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as Lark from "@larksuiteoapi/node-sdk";
5
- import { getWorkDir, type BridgeHandler, type BridgeMessage, type ContextTag } from "@lmcl/ailo-mcp-sdk";
5
+ import { getWorkDir, type BridgeHandler, type BridgeMessage, type ChannelContext, type ChannelStorage, type ContextTag } from "@lmcl/ailo-mcp-sdk";
6
6
  import {
7
7
  type CacheEntry,
8
8
  type ChatInfo,
@@ -29,35 +29,21 @@ import {
29
29
 
30
30
  export type { FeishuConfig, FeishuAttachment, OnChatId } from "./feishu-types.js";
31
31
 
32
- type ChannelStorage = {
33
- getData(key: string): Promise<string | null>;
34
- setData(key: string, value: string): Promise<void>;
35
- deleteData(key: string): Promise<void>;
36
- };
37
-
38
32
  export class FeishuHandler implements BridgeHandler {
39
33
  private client: Lark.Client;
40
34
  private wsClient: Lark.WSClient | null = null;
41
- private onMessageHandler: ((msg: BridgeMessage) => void | Promise<void>) | null = null;
35
+ private ctx: ChannelContext | null = null;
42
36
  private onP2PChatEntered: OnChatId | null = null;
43
37
  private onBotAddedToGroup: OnChatId | null = null;
44
38
  private onBotRemovedFromGroup: OnChatId | null = null;
45
39
  private onMessageRead: ((data: unknown) => void) | null = null;
46
40
 
47
- // bot 自身的 open_id,用于检测 mentionsSelf
48
41
  private botOpenId: string = "";
49
-
50
- // @提及名称 → open_id 缓存,用于将出站文本中的 @Name 转为飞书 <at> 标签
51
42
  private mentionNameToId = new Map<string, string>();
52
-
53
- // 懒加载缓存
54
43
  private userCache = new Map<string, CacheEntry<UserInfo>>();
55
44
  private chatCache = new Map<string, CacheEntry<ChatInfo>>();
56
-
57
- // 外部用户顺序编号(userId → 编号标签),通过 ChannelStorage 持久化,跨重启保持稳定
58
45
  private externalUserCounter = 0;
59
46
  private externalUserLabels = new Map<string, string>();
60
- private storage: ChannelStorage | null = null;
61
47
 
62
48
  // messageId → 消息元数据映射缓存(供撤回/Reaction 等事件查找)
63
49
  private msgMetaMap = new Map<string, { chatId: string; senderId: string; senderName: string }>();
@@ -79,12 +65,8 @@ export class FeishuHandler implements BridgeHandler {
79
65
 
80
66
  }
81
67
 
82
- /**
83
- * 注入持久化存储(SDK 连接后注入,含 getData/setData/deleteData)。
84
- */
85
- setDataProvider?(provider: ChannelStorage): void {
86
- this.storage = provider;
87
- this.loadExternalUserLabels();
68
+ private get storage(): ChannelStorage | null {
69
+ return this.ctx?.storage ?? null;
88
70
  }
89
71
 
90
72
  /** 外部用户数据单 key:{ _counter, [userId]: label } */
@@ -350,8 +332,9 @@ export class FeishuHandler implements BridgeHandler {
350
332
  return { text: resolved, mentionsSelf };
351
333
  }
352
334
 
353
- setOnMessage(handler: (msg: BridgeMessage) => void | Promise<void>): void {
354
- this.onMessageHandler = handler;
335
+ private acceptMessage(msg: BridgeMessage): void {
336
+ if (!this.ctx) return;
337
+ this.ctx.accept(msg).catch((err) => console.error("[feishu] accept failed:", err));
355
338
  }
356
339
 
357
340
  setOnP2PChatEntered(handler: OnChatId | null): void {
@@ -381,33 +364,36 @@ export class FeishuHandler implements BridgeHandler {
381
364
  timestamp?: number;
382
365
  attachments?: Array<{ type: string; path?: string; url?: string; base64?: string; mime?: string; name?: string }>;
383
366
  }): BridgeMessage {
384
- const { chatId, text, chatType, senderId = "", senderName = "", chatName, mentionsSelf, timestamp, attachments } = opts;
367
+ const { chatId, text, chatType, senderId = "", senderName = "", chatName, attachments } = opts;
385
368
  const isPrivate = chatType === "私聊";
369
+
370
+ // 新 ContextTag 格式(kind/streamKey/routing):
371
+ // - TagChannel 由网关根据 displayName 自动注入,飞书不重复添加
372
+ // - chat_id 参与 stream_key 推导(streamKey=true),仅路由(routing=true)
373
+ // - 私聊:stream_key = feishu:{ou_xxx}(用户 ID)
374
+ // - 群聊:stream_key = feishu:{oc_xxx}(群 ID)
375
+ // - "@我" 是内容语义不是时空场,不放 contextTags
376
+ // - "时间" 由框架在接收时自动渲染,不由通道传入
386
377
  const tags: ContextTag[] = [
387
- { desc: "类型", value: chatType, core: true },
388
- { desc: "会话", value: chatId, core: true },
378
+ { kind: "conv_type", value: chatType, streamKey: false },
379
+ { kind: "chat_id", value: chatId, streamKey: true, routing: true },
389
380
  ];
390
- if (!isPrivate && chatName) {
391
- tags.push({ desc: "群名", value: chatName, core: true });
392
- } else if (!isPrivate && chatId) {
393
- tags.push({ desc: "群名", value: `群${chatId.slice(-8)}`, core: true });
381
+
382
+ if (!isPrivate) {
383
+ // 群聊:群名作为 stream_key 语义补充,group 标签不参与 stream_key(chat_id 已足够)
384
+ const groupName = chatName || `群${chatId.slice(-8)}`;
385
+ tags.push({ kind: "group", value: groupName, streamKey: false });
394
386
  }
395
- tags.push(
396
- { desc: "昵称", value: senderName, core: isPrivate },
397
- { desc: "用户", value: senderId, core: isPrivate }
398
- );
399
- if (mentionsSelf) {
400
- tags.push({ desc: "@我", value: "是", core: isPrivate });
387
+
388
+ if (senderName) {
389
+ tags.push({ kind: "participant", value: senderName, streamKey: false });
401
390
  }
402
- if (timestamp != null && timestamp > 0) {
403
- const d = new Date(timestamp);
404
- const pad = (n: number) => String(n).padStart(2, "0");
405
- tags.push({
406
- desc: "时间",
407
- value: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`,
408
- core: false,
409
- });
391
+
392
+ if (senderId) {
393
+ // sender_id 仅路由备用(recall agent 按参与者查询时使用),不展示在历史邮戳
394
+ tags.push({ kind: "sender_id", value: senderId, streamKey: false, routing: true });
410
395
  }
396
+
411
397
  return { text, contextTags: tags, attachments };
412
398
  }
413
399
 
@@ -625,8 +611,9 @@ export class FeishuHandler implements BridgeHandler {
625
611
  * 使用飞书 WebSocket 长连接接收事件,无需配置回调地址。
626
612
  * 需在飞书开放平台「事件与回调」中选择「使用长连接接收事件」并保存(本客户端需在线)。
627
613
  */
628
- start(): void {
629
- // 获取 bot 自身的 open_id(异步,不阻塞启动)
614
+ start(ctx: ChannelContext): void {
615
+ this.ctx = ctx;
616
+ this.loadExternalUserLabels();
630
617
  this.fetchBotOpenId();
631
618
 
632
619
  // MCP stdio 占用 stdout,Lark SDK 的 logger 必须写到 stderr
@@ -660,7 +647,7 @@ export class FeishuHandler implements BridgeHandler {
660
647
  eventDispatcher.register({
661
648
  "im.message.receive_v1": async (data: unknown) => {
662
649
  const event = data as FeishuMessageEvent;
663
- if (!event.message || !this.onMessageHandler) return;
650
+ if (!event.message || !this.ctx) return;
664
651
 
665
652
  const msg = event.message;
666
653
  const chatId = msg.chat_id ?? "";
@@ -777,7 +764,7 @@ export class FeishuHandler implements BridgeHandler {
777
764
 
778
765
  if (chatId || messageId) {
779
766
  const isP2p = chatType === "p2p";
780
- this.onMessageHandler!(this.buildBridgeMessage({
767
+ this.acceptMessage(this.buildBridgeMessage({
781
768
  chatId: chatId ?? messageId ?? "",
782
769
  text,
783
770
  chatType: isP2p ? "私聊" : "群聊",
@@ -809,7 +796,7 @@ export class FeishuHandler implements BridgeHandler {
809
796
  if (this.onMessageRead) this.onMessageRead(data);
810
797
  },
811
798
  "im.message.recalled_v1": async (data: unknown) => {
812
- if (!this.onMessageHandler) return;
799
+ if (!this.ctx) return;
813
800
  const event = data as FeishuRecalledEvent;
814
801
  const messageId = event.message_id ?? "";
815
802
  const recallType = event.recall_type ?? "unknown";
@@ -877,7 +864,7 @@ export class FeishuHandler implements BridgeHandler {
877
864
  console.error(`[feishu] message recalled: ${messageId} by ${msgSenderName || "(unknown)"}(${msgSenderId}) recall_type=${recallType} in ${chatId}`);
878
865
 
879
866
  const isP2pRecall = chatType === "p2p";
880
- this.onMessageHandler!(this.buildBridgeMessage({
867
+ this.acceptMessage(this.buildBridgeMessage({
881
868
  chatId,
882
869
  text,
883
870
  chatType: isP2pRecall ? "私聊" : "群聊",
@@ -887,7 +874,7 @@ export class FeishuHandler implements BridgeHandler {
887
874
  }));
888
875
  },
889
876
  "im.message.reaction.created_v1": async (data: unknown) => {
890
- if (!this.onMessageHandler) return;
877
+ if (!this.ctx) return;
891
878
  const event = data as FeishuReactionEvent;
892
879
  const messageId = event.message_id ?? "";
893
880
  const emoji = event.reaction_type?.emoji_type ?? "UNKNOWN";
@@ -907,7 +894,7 @@ export class FeishuHandler implements BridgeHandler {
907
894
  console.error(`[feishu] reaction ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
908
895
 
909
896
  const isP2pReaction = chatType === "p2p";
910
- this.onMessageHandler!(this.buildBridgeMessage({
897
+ this.acceptMessage(this.buildBridgeMessage({
911
898
  chatId,
912
899
  text: `[${reactorName} 对一条消息贴了表情 ${emoji} (message_id: ${messageId})]`,
913
900
  chatType: isP2pReaction ? "私聊" : "群聊",
@@ -917,7 +904,7 @@ export class FeishuHandler implements BridgeHandler {
917
904
  }));
918
905
  },
919
906
  "im.message.reaction.deleted_v1": async (data: unknown) => {
920
- if (!this.onMessageHandler) return;
907
+ if (!this.ctx) return;
921
908
  const event = data as FeishuReactionEvent;
922
909
  const messageId = event.message_id ?? "";
923
910
  const emoji = event.reaction_type?.emoji_type ?? "UNKNOWN";
@@ -937,7 +924,7 @@ export class FeishuHandler implements BridgeHandler {
937
924
  console.error(`[feishu] reaction removed ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
938
925
 
939
926
  const isP2pDel = chatType === "p2p";
940
- this.onMessageHandler!(this.buildBridgeMessage({
927
+ this.acceptMessage(this.buildBridgeMessage({
941
928
  chatId,
942
929
  text: `[${reactorName} 移除了表情 ${emoji} (message_id: ${messageId})]`,
943
930
  chatType: isP2pDel ? "私聊" : "群聊",
@@ -1183,6 +1170,10 @@ export class FeishuHandler implements BridgeHandler {
1183
1170
  }
1184
1171
  }
1185
1172
 
1173
+ async stop(): Promise<void> {
1174
+ this.ctx = null;
1175
+ }
1176
+
1186
1177
  /** 处理来自 MCP 工具 feishu action=set_nickname 的命令 */
1187
1178
  async onCommand(command: string, params: Record<string, unknown>): Promise<void> {
1188
1179
  if (command !== "set_nickname") return;
package/src/index.ts CHANGED
@@ -32,6 +32,8 @@ const mcpServer = createFeishuMcpServer(handler);
32
32
 
33
33
  runMcpChannel({
34
34
  handler,
35
+ displayName: "飞书",
36
+ defaultRequiresResponse: true,
35
37
  buildChannelPrompt: feishuBuildChannelPrompt,
36
38
  mcpServer,
37
39
  });