@openclaw-channel/socket-chat 1.0.2 → 1.0.4

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/README.md CHANGED
@@ -30,6 +30,16 @@ openclaw plugins install /path/to/socket-chat
30
30
 
31
31
  ---
32
32
 
33
+ ## 通过 CLI 添加账号
34
+
35
+ apiKey 从微秘书平台[个人中心](https://wechat.aibotk.com/user/info)获取
36
+
37
+ ```bash
38
+ openclaw channels add --channel socket-chat --token <apiKey>
39
+ ```
40
+
41
+ ---
42
+
33
43
  ## 配置
34
44
 
35
45
  在 `~/.openclaw/config.yaml` 中添加:
@@ -39,11 +49,63 @@ channels:
39
49
  socket-chat:
40
50
  apiKey: "your-api-key"
41
51
  enabled: true
42
- dmPolicy: "pairing" # pairing | open | allowlist
43
- allowFrom: [] # 允许触发 AI 的发送者 ID 白名单
44
- requireMention: true # 群组消息是否需要 @提及机器人
52
+
53
+ # DM(私聊)访问策略
54
+ dmPolicy: "pairing" # pairing | open | allowlist
55
+ allowFrom: [] # DM 白名单(dmPolicy=allowlist/pairing 时生效)
56
+
57
+ # 群组访问策略
58
+ requireMention: true # 群消息是否需要 @提及机器人
59
+ groupPolicy: "open" # open | allowlist | disabled(见下方说明)
60
+ groups: [] # 允许的群 ID 列表(groupPolicy=allowlist 时生效)
61
+ groupAllowFrom: [] # 群内允许触发 AI 的发送者 ID/昵称列表
45
62
  ```
46
63
 
64
+ ### 群组访问控制
65
+
66
+ 群消息经过**三层**依次检查,任意一层不通过则丢弃该消息:
67
+
68
+ #### 第一层:群级(哪些群允许触发 AI)
69
+
70
+ | `groupPolicy` | 行为 |
71
+ |--------------|------|
72
+ | `open`(默认)| bot 所在所有群均可触发 |
73
+ | `allowlist` | 仅 `groups` 列表中的群可触发;首次被拦截时向该群发一条提醒(进程内只发一次) |
74
+ | `disabled` | 禁止所有群消息触发 AI,静默丢弃 |
75
+
76
+ ```yaml
77
+ channels:
78
+ socket-chat:
79
+ groupPolicy: allowlist
80
+ groups:
81
+ - "R:10804599808581977"
82
+ - "R:another_group_id"
83
+ ```
84
+
85
+ #### 第二层:sender 级(群内哪些人可以触发 AI)
86
+
87
+ `groupAllowFrom` 为空时不限制(允许群内所有成员)。支持按 `senderId` 或 `senderName` 匹配,大小写不敏感,支持通配符 `*`。
88
+
89
+ ```yaml
90
+ channels:
91
+ socket-chat:
92
+ groupAllowFrom:
93
+ - "wxid_123456" # 按 senderId 精确匹配
94
+ - "Alice" # 按 senderName 匹配
95
+ - "*" # 允许所有成员
96
+ ```
97
+
98
+ 被拦截的 sender 静默丢弃,不向群发任何提示。
99
+
100
+ #### 第三层:@提及检查
101
+
102
+ `requireMention: true`(默认)时,群消息必须满足以下任意条件之一:
103
+ - 平台传来 `isGroupMention: true`
104
+ - 消息内容包含 `@{robotId}`
105
+
106
+ > **注意**:媒体消息(图片、视频等)无法携带 @,若平台侧开启了 `forwardMediaMsg`,
107
+ > 应在发布 MQTT 消息时主动将 `isGroupMention` 设为 `true`,避免被此层拦截。
108
+
47
109
  ### 多账号配置
48
110
 
49
111
  ```yaml
@@ -62,19 +124,19 @@ channels:
62
124
 
63
125
  | 字段 | 默认值 | 说明 |
64
126
  |------|--------|------|
127
+ | `dmPolicy` | `pairing` | DM 访问策略:`pairing` / `open` / `allowlist` |
128
+ | `allowFrom` | `[]` | DM 白名单,senderId 或 senderName 列表 |
129
+ | `requireMention` | `true` | 群消息是否需要 @提及机器人 |
130
+ | `groupPolicy` | `open` | 群访问策略:`open` / `allowlist` / `disabled` |
131
+ | `groups` | `[]` | 允许的群 ID 列表(`groupPolicy=allowlist` 时生效) |
132
+ | `groupAllowFrom` | `[]` | 群内允许触发 AI 的发送者 ID/昵称列表,空=不限制 |
65
133
  | `mqttConfigTtlSec` | `300` | MQTT 配置缓存时间(秒) |
66
134
  | `maxReconnectAttempts` | `10` | MQTT 断线最大重连次数 |
67
135
  | `reconnectBaseDelayMs` | `2000` | 重连基础延迟(毫秒,指数退避) |
68
136
 
69
137
  ---
70
138
 
71
- ## 通过 CLI 添加账号
72
-
73
- ```bash
74
- openclaw channels add socket-chat --token <apiKey>
75
- ```
76
139
 
77
- ---
78
140
 
79
141
  ## 消息格式
80
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-channel/socket-chat",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "OpenClaw Socket Chat channel plugin — MQTT-based IM bridge",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -7,6 +7,9 @@
7
7
  */
8
8
  export { resolveAllowlistMatchByCandidates } from "../../openclaw/src/channels/allowlist-match.js";
9
9
  export { createNormalizedOutboundDeliverer } from "../../openclaw/src/plugin-sdk/reply-payload.js";
10
+ export { buildMediaPayload } from "../../openclaw/src/channels/plugins/media-payload.js";
11
+ export { resolveChannelMediaMaxBytes } from "../../openclaw/src/channels/plugins/media-limits.js";
12
+ export { detectMime } from "../../openclaw/src/media/mime.js";
10
13
 
11
14
  // ---- type-only re-exports (erased at runtime) ----
12
15
  export type { ChannelGatewayContext } from "../../openclaw/src/plugin-sdk/index.js";
@@ -32,12 +32,32 @@ export const SocketChatAccountConfigSchema = z.object({
32
32
  enabled: z.boolean().optional(),
33
33
  /** DM 安全策略 */
34
34
  dmPolicy: z.enum(["pairing", "open", "allowlist"]).optional(),
35
- /** 允许触发 AI 的发送者 ID 列表 */
35
+ /** 允许触发 AI 的发送者 ID 列表(DM 用) */
36
36
  allowFrom: z.array(z.string()).optional(),
37
37
  /** 默认发消息目标(contactId 或 group:groupId) */
38
38
  defaultTo: z.string().optional(),
39
39
  /** 群组消息是否需要 @提及 bot 才触发 */
40
40
  requireMention: z.boolean().optional(),
41
+ /**
42
+ * 群组访问策略(第一层:哪些群允许触发 AI)
43
+ * - "open"(默认):bot 所在所有群均可触发
44
+ * - "allowlist":仅 groups 列表中的群可触发
45
+ * - "disabled":禁止所有群消息触发 AI
46
+ */
47
+ groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional(),
48
+ /**
49
+ * 允许触发 AI 的群 ID 列表(第一层群级白名单)
50
+ * groupPolicy="allowlist" 时生效;groupPolicy="open" 时忽略
51
+ * 示例:["R:10804599808581977", "R:xxx"]
52
+ */
53
+ groups: z.array(z.string()).optional(),
54
+ /**
55
+ * 群内允许触发 AI 的发送者 ID 列表(第二层 sender 级白名单)
56
+ * 不配置或为空则允许群内所有成员触发(受 requireMention 约束)
57
+ */
58
+ groupAllowFrom: z.array(z.string()).optional(),
59
+ /** 媒体文件大小上限(MB)。未配置时使用框架全局默认值 */
60
+ mediaMaxMb: z.number().optional(),
41
61
  /** MQTT 连接配置缓存 TTL(秒),默认 300 */
42
62
  mqttConfigTtlSec: z.number().optional(),
43
63
  /** MQTT 重连最大次数,默认 10 */
@@ -1,5 +1,5 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { handleInboundMessage } from "./inbound.js";
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { handleInboundMessage, _resetNotifiedGroupsForTest } from "./inbound.js";
3
3
  import type { SocketChatInboundMessage } from "./types.js";
4
4
  import type { CoreConfig } from "./config.js";
5
5
 
@@ -40,6 +40,16 @@ type MockChannelRuntime = {
40
40
  activity: {
41
41
  record: ReturnType<typeof vi.fn>;
42
42
  };
43
+ channel: {
44
+ media: {
45
+ fetchRemoteMedia: ReturnType<typeof vi.fn>;
46
+ saveMediaBuffer: ReturnType<typeof vi.fn>;
47
+ };
48
+ };
49
+ media: {
50
+ fetchRemoteMedia: ReturnType<typeof vi.fn>;
51
+ saveMediaBuffer: ReturnType<typeof vi.fn>;
52
+ };
43
53
  };
44
54
 
45
55
  function makeMockRuntime(overrides: Partial<MockChannelRuntime> = {}): MockChannelRuntime {
@@ -72,6 +82,30 @@ function makeMockRuntime(overrides: Partial<MockChannelRuntime> = {}): MockChann
72
82
  record: vi.fn(),
73
83
  ...overrides.activity,
74
84
  },
85
+ channel: {
86
+ media: {
87
+ fetchRemoteMedia: vi.fn(async () => ({
88
+ buffer: Buffer.from("fake-image-data"),
89
+ contentType: "image/jpeg",
90
+ })),
91
+ saveMediaBuffer: vi.fn(async () => ({
92
+ path: "/tmp/openclaw/inbound/saved-img.jpg",
93
+ contentType: "image/jpeg",
94
+ })),
95
+ ...overrides.channel?.media,
96
+ },
97
+ },
98
+ media: {
99
+ fetchRemoteMedia: vi.fn(async () => ({
100
+ buffer: Buffer.from("fake-image-data"),
101
+ contentType: "image/jpeg",
102
+ })),
103
+ saveMediaBuffer: vi.fn(async () => ({
104
+ path: "/tmp/openclaw/inbound/saved-img.jpg",
105
+ contentType: "image/jpeg",
106
+ })),
107
+ ...overrides.media,
108
+ },
75
109
  };
76
110
  }
77
111
 
@@ -508,8 +542,13 @@ describe("handleInboundMessage — group messages", () => {
508
542
  // ---------------------------------------------------------------------------
509
543
 
510
544
  describe("handleInboundMessage — media messages", () => {
511
- it("passes MediaUrl fields when msg has an HTTP url (image)", async () => {
545
+ it("downloads image URL and passes local path as MediaPath/MediaUrl", async () => {
512
546
  const runtime = makeMockRuntime();
547
+ runtime.media.saveMediaBuffer.mockResolvedValue({
548
+ path: "/tmp/openclaw/inbound/img-001.jpg",
549
+ contentType: "image/jpeg",
550
+ });
551
+
513
552
  const ctx = makeCtx(runtime, {
514
553
  channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
515
554
  });
@@ -518,7 +557,7 @@ describe("handleInboundMessage — media messages", () => {
518
557
  msg: makeMsg({
519
558
  type: "图片",
520
559
  url: "https://oss.example.com/img.jpg",
521
- content: "【图片消息】\n文件名:img.jpg\n下载链接:https://oss.example.com/img.jpg",
560
+ content: "【图片消息】\n文件名:img.jpg",
522
561
  }),
523
562
  accountId: "default",
524
563
  ctx: ctx as never,
@@ -526,19 +565,36 @@ describe("handleInboundMessage — media messages", () => {
526
565
  sendReply: vi.fn(async () => {}),
527
566
  });
528
567
 
568
+ // fetchRemoteMedia should have been called with the original URL
569
+ expect(runtime.media.fetchRemoteMedia).toHaveBeenCalledWith(
570
+ expect.objectContaining({ url: "https://oss.example.com/img.jpg" }),
571
+ );
572
+ // saveMediaBuffer should have been called
573
+ expect(runtime.media.saveMediaBuffer).toHaveBeenCalledOnce();
574
+ // ctxPayload should carry the saved local path, not the original URL
529
575
  expect(runtime.reply.finalizeInboundContext).toHaveBeenCalledWith(
530
576
  expect.objectContaining({
531
- MediaUrl: "https://oss.example.com/img.jpg",
532
- MediaUrls: ["https://oss.example.com/img.jpg"],
533
- MediaPath: "https://oss.example.com/img.jpg",
577
+ MediaPath: "/tmp/openclaw/inbound/img-001.jpg",
578
+ MediaUrl: "/tmp/openclaw/inbound/img-001.jpg",
579
+ MediaPaths: ["/tmp/openclaw/inbound/img-001.jpg"],
580
+ MediaUrls: ["/tmp/openclaw/inbound/img-001.jpg"],
534
581
  MediaType: "image/jpeg",
535
582
  }),
536
583
  );
537
584
  expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
538
585
  });
539
586
 
540
- it("passes MediaUrl fields when msg has an HTTP url (video)", async () => {
587
+ it("downloads video URL and detects correct content type", async () => {
541
588
  const runtime = makeMockRuntime();
589
+ runtime.media.fetchRemoteMedia.mockResolvedValue({
590
+ buffer: Buffer.from("fake-video-data"),
591
+ contentType: "video/mp4",
592
+ });
593
+ runtime.media.saveMediaBuffer.mockResolvedValue({
594
+ path: "/tmp/openclaw/inbound/video-001.mp4",
595
+ contentType: "video/mp4",
596
+ });
597
+
542
598
  const ctx = makeCtx(runtime, {
543
599
  channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
544
600
  });
@@ -547,7 +603,7 @@ describe("handleInboundMessage — media messages", () => {
547
603
  msg: makeMsg({
548
604
  type: "视频",
549
605
  url: "https://oss.example.com/video.mp4",
550
- content: "【视频消息】\n文件名:video.mp4\n下载链接:https://oss.example.com/video.mp4",
606
+ content: "【视频消息】",
551
607
  }),
552
608
  accountId: "default",
553
609
  ctx: ctx as never,
@@ -557,33 +613,186 @@ describe("handleInboundMessage — media messages", () => {
557
613
 
558
614
  expect(runtime.reply.finalizeInboundContext).toHaveBeenCalledWith(
559
615
  expect.objectContaining({
560
- MediaUrl: "https://oss.example.com/video.mp4",
616
+ MediaPath: "/tmp/openclaw/inbound/video-001.mp4",
561
617
  MediaType: "video/mp4",
562
618
  }),
563
619
  );
564
620
  });
565
621
 
566
- it("does NOT pass MediaUrl for base64 url (no OSS configured)", async () => {
622
+ it("decodes base64 data URL and saves to local file", async () => {
567
623
  const runtime = makeMockRuntime();
624
+ runtime.media.saveMediaBuffer.mockResolvedValue({
625
+ path: "/tmp/openclaw/inbound/b64-img.jpg",
626
+ contentType: "image/jpeg",
627
+ });
628
+
568
629
  const ctx = makeCtx(runtime, {
569
630
  channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
570
631
  });
571
632
 
633
+ // minimal valid JPEG-ish base64
634
+ const fakeBase64 = Buffer.from("fake-jpeg-bytes").toString("base64");
572
635
  await handleInboundMessage({
573
636
  msg: makeMsg({
574
637
  type: "图片",
575
- url: "data:image/jpeg;base64,/9j/4AAQ...",
576
- content: "【图片消息】\n文件名:img.jpg\n文件大小:12345 bytes",
638
+ url: `data:image/jpeg;base64,${fakeBase64}`,
639
+ content: "【图片消息】",
640
+ }),
641
+ accountId: "default",
642
+ ctx: ctx as never,
643
+ log: ctx.log,
644
+ sendReply: vi.fn(async () => {}),
645
+ });
646
+
647
+ // Should NOT call fetchRemoteMedia for data URLs
648
+ expect(runtime.media.fetchRemoteMedia).not.toHaveBeenCalled();
649
+ // Should call saveMediaBuffer with decoded buffer
650
+ expect(runtime.media.saveMediaBuffer).toHaveBeenCalledOnce();
651
+ const [savedBuf, savedMime] = runtime.media.saveMediaBuffer.mock.calls[0] as [Buffer, string];
652
+ expect(Buffer.isBuffer(savedBuf)).toBe(true);
653
+ expect(savedBuf.toString()).toBe("fake-jpeg-bytes");
654
+ expect(savedMime).toBe("image/jpeg");
655
+ // ctxPayload should carry the local path
656
+ expect(runtime.reply.finalizeInboundContext).toHaveBeenCalledWith(
657
+ expect.objectContaining({
658
+ MediaPath: "/tmp/openclaw/inbound/b64-img.jpg",
659
+ MediaUrl: "/tmp/openclaw/inbound/b64-img.jpg",
577
660
  }),
661
+ );
662
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
663
+ });
664
+
665
+ it("skips base64 media that exceeds maxBytes and continues dispatch", async () => {
666
+ const runtime = makeMockRuntime();
667
+ const ctx = makeCtx(runtime, {
668
+ // 1 MB limit
669
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open", mediaMaxMb: 1 } },
670
+ });
671
+ const log = { ...ctx.log, warn: vi.fn() };
672
+
673
+ // ~2 MB of base64 data (each char ≈ 0.75 bytes → need > 1.4M chars)
674
+ const bigBase64 = "A".repeat(1_500_000);
675
+ await handleInboundMessage({
676
+ msg: makeMsg({
677
+ type: "图片",
678
+ url: `data:image/jpeg;base64,${bigBase64}`,
679
+ content: "【图片消息】",
680
+ }),
681
+ accountId: "default",
682
+ ctx: ctx as never,
683
+ log,
684
+ sendReply: vi.fn(async () => {}),
685
+ });
686
+
687
+ expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("media localization failed"));
688
+ expect(runtime.media.saveMediaBuffer).not.toHaveBeenCalled();
689
+ // Dispatch still proceeds without media fields
690
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
691
+ const callArg = runtime.reply.finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
692
+ expect(callArg).not.toHaveProperty("MediaPath");
693
+ });
694
+
695
+ it("does not call fetchRemoteMedia for base64 data URLs (uses saveMediaBuffer directly)", async () => {
696
+ const runtime = makeMockRuntime();
697
+ const ctx = makeCtx(runtime, {
698
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
699
+ });
700
+
701
+ const fakeBase64 = Buffer.from("img-bytes").toString("base64");
702
+ await handleInboundMessage({
703
+ msg: makeMsg({
704
+ type: "图片",
705
+ url: `data:image/jpeg;base64,${fakeBase64}`,
706
+ content: "【图片消息】",
707
+ }),
708
+ accountId: "default",
709
+ ctx: ctx as never,
710
+ log: ctx.log,
711
+ sendReply: vi.fn(async () => {}),
712
+ });
713
+
714
+ expect(runtime.media.fetchRemoteMedia).not.toHaveBeenCalled();
715
+ expect(runtime.media.saveMediaBuffer).toHaveBeenCalledOnce();
716
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
717
+ });
718
+
719
+ it("does not call fetchRemoteMedia when url is absent", async () => {
720
+ const runtime = makeMockRuntime();
721
+ const ctx = makeCtx(runtime, {
722
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
723
+ });
724
+
725
+ await handleInboundMessage({
726
+ msg: makeMsg({ content: "plain text, no media" }),
578
727
  accountId: "default",
579
728
  ctx: ctx as never,
580
729
  log: ctx.log,
581
730
  sendReply: vi.fn(async () => {}),
582
731
  });
583
732
 
733
+ expect(runtime.media.fetchRemoteMedia).not.toHaveBeenCalled();
734
+ expect(runtime.media.saveMediaBuffer).not.toHaveBeenCalled();
584
735
  const callArg = runtime.reply.finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
736
+ expect(callArg).not.toHaveProperty("MediaPath");
737
+ });
738
+
739
+ it("continues dispatch and logs warning when media download fails", async () => {
740
+ const runtime = makeMockRuntime();
741
+ runtime.media.fetchRemoteMedia.mockRejectedValue(new Error("network timeout"));
742
+
743
+ const ctx = makeCtx(runtime, {
744
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
745
+ });
746
+ const log = { ...ctx.log, warn: vi.fn() };
747
+
748
+ await handleInboundMessage({
749
+ msg: makeMsg({
750
+ type: "图片",
751
+ url: "https://oss.example.com/img.jpg",
752
+ content: "【图片消息】",
753
+ }),
754
+ accountId: "default",
755
+ ctx: ctx as never,
756
+ log,
757
+ sendReply: vi.fn(async () => {}),
758
+ });
759
+
760
+ // Warning logged
761
+ expect(log.warn).toHaveBeenCalledWith(
762
+ expect.stringContaining("media localization failed"),
763
+ );
764
+ // Dispatch still proceeds (text body)
765
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
766
+ // No media fields in ctxPayload
767
+ const callArg = runtime.reply.finalizeInboundContext.mock.calls[0]?.[0] as Record<string, unknown>;
768
+ expect(callArg).not.toHaveProperty("MediaPath");
585
769
  expect(callArg).not.toHaveProperty("MediaUrl");
586
- expect(callArg).not.toHaveProperty("MediaUrls");
770
+ });
771
+
772
+ it("continues dispatch and logs warning when saveMediaBuffer fails", async () => {
773
+ const runtime = makeMockRuntime();
774
+ runtime.media.saveMediaBuffer.mockRejectedValue(new Error("disk full"));
775
+
776
+ const ctx = makeCtx(runtime, {
777
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
778
+ });
779
+ const log = { ...ctx.log, warn: vi.fn() };
780
+
781
+ await handleInboundMessage({
782
+ msg: makeMsg({
783
+ type: "图片",
784
+ url: "https://oss.example.com/img.jpg",
785
+ content: "【图片消息】",
786
+ }),
787
+ accountId: "default",
788
+ ctx: ctx as never,
789
+ log,
790
+ sendReply: vi.fn(async () => {}),
791
+ });
792
+
793
+ expect(log.warn).toHaveBeenCalledWith(
794
+ expect.stringContaining("media localization failed"),
795
+ );
587
796
  expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
588
797
  });
589
798
 
@@ -605,7 +814,6 @@ describe("handleInboundMessage — media messages", () => {
605
814
  sendReply: vi.fn(async () => {}),
606
815
  });
607
816
 
608
- // Should not be skipped — dispatches even without text content
609
817
  expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
610
818
  });
611
819
 
@@ -666,6 +874,7 @@ describe("handleInboundMessage — media messages", () => {
666
874
  });
667
875
 
668
876
  expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
877
+ expect(runtime.media.fetchRemoteMedia).not.toHaveBeenCalled();
669
878
  });
670
879
  });
671
880
 
@@ -737,3 +946,175 @@ describe("handleInboundMessage — edge cases", () => {
737
946
  );
738
947
  });
739
948
  });
949
+
950
+ // ---------------------------------------------------------------------------
951
+ // Group access control — tier 1 (groupId) + tier 2 (sender)
952
+ // ---------------------------------------------------------------------------
953
+
954
+ describe("handleInboundMessage — group access control (tier 1: groupId)", () => {
955
+ beforeEach(() => {
956
+ _resetNotifiedGroupsForTest();
957
+ });
958
+
959
+ it("allows all groups when groupPolicy=open (default)", async () => {
960
+ const runtime = makeMockRuntime();
961
+ const ctx = makeCtx(runtime, {
962
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", requireMention: false } },
963
+ });
964
+ await handleInboundMessage({
965
+ msg: makeMsg({ isGroup: true, groupId: "R:any_group", robotId: "robot_abc", isGroupMention: true }),
966
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
967
+ });
968
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
969
+ });
970
+
971
+ it("blocks all groups when groupPolicy=disabled (no notification)", async () => {
972
+ const runtime = makeMockRuntime();
973
+ const ctx = makeCtx(runtime, {
974
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "disabled" } },
975
+ });
976
+ const sendReply = vi.fn(async () => {});
977
+ await handleInboundMessage({
978
+ msg: makeMsg({ isGroup: true, groupId: "R:any_group", isGroupMention: true }),
979
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
980
+ });
981
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
982
+ expect(sendReply).not.toHaveBeenCalled();
983
+ });
984
+
985
+ it("allows group in allowlist when groupPolicy=allowlist", async () => {
986
+ const runtime = makeMockRuntime();
987
+ const ctx = makeCtx(runtime, {
988
+ channels: {
989
+ "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"], requireMention: false },
990
+ },
991
+ });
992
+ await handleInboundMessage({
993
+ msg: makeMsg({ isGroup: true, groupId: "R:allowed_group", robotId: "robot_abc", isGroupMention: true }),
994
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
995
+ });
996
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
997
+ });
998
+
999
+ it("blocks unlisted group without sending notification (notify branch commented out)", async () => {
1000
+ const runtime = makeMockRuntime();
1001
+ const ctx = makeCtx(runtime, {
1002
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"] } },
1003
+ });
1004
+ const sendReply = vi.fn(async () => {});
1005
+ await handleInboundMessage({
1006
+ msg: makeMsg({ isGroup: true, groupId: "R:other_group", groupName: "测试群", isGroupMention: true }),
1007
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
1008
+ });
1009
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
1010
+ // Notification is currently disabled (commented out in inbound.ts)
1011
+ expect(sendReply).not.toHaveBeenCalled();
1012
+ });
1013
+
1014
+ it("silently blocks repeated messages from same unlisted group", async () => {
1015
+ const runtime = makeMockRuntime();
1016
+ const ctx = makeCtx(runtime, {
1017
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"] } },
1018
+ });
1019
+ const sendReply = vi.fn(async () => {});
1020
+ const msg = makeMsg({ isGroup: true, groupId: "R:notify_once_group", isGroupMention: true });
1021
+ await handleInboundMessage({ msg, accountId: "default", ctx: ctx as never, log: ctx.log, sendReply });
1022
+ await handleInboundMessage({ msg, accountId: "default", ctx: ctx as never, log: ctx.log, sendReply });
1023
+ expect(sendReply).not.toHaveBeenCalled();
1024
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
1025
+ });
1026
+
1027
+ it("blocks when groupPolicy=allowlist and groups is empty (no notification)", async () => {
1028
+ const runtime = makeMockRuntime();
1029
+ const ctx = makeCtx(runtime, {
1030
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: [] } },
1031
+ });
1032
+ const sendReply = vi.fn(async () => {});
1033
+ await handleInboundMessage({
1034
+ msg: makeMsg({ isGroup: true, groupId: "R:any_group", isGroupMention: true }),
1035
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
1036
+ });
1037
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
1038
+ expect(sendReply).not.toHaveBeenCalled();
1039
+ });
1040
+
1041
+ it("allows wildcard '*' in groups list", async () => {
1042
+ const runtime = makeMockRuntime();
1043
+ const ctx = makeCtx(runtime, {
1044
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["*"], requireMention: false } },
1045
+ });
1046
+ await handleInboundMessage({
1047
+ msg: makeMsg({ isGroup: true, groupId: "R:any_group", robotId: "robot_abc", isGroupMention: true }),
1048
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
1049
+ });
1050
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
1051
+ });
1052
+ });
1053
+
1054
+ describe("handleInboundMessage — group access control (tier 2: sender)", () => {
1055
+ beforeEach(() => {
1056
+ _resetNotifiedGroupsForTest();
1057
+ });
1058
+
1059
+ it("allows all senders when groupAllowFrom is empty", async () => {
1060
+ const runtime = makeMockRuntime();
1061
+ const ctx = makeCtx(runtime, {
1062
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", requireMention: false } },
1063
+ });
1064
+ await handleInboundMessage({
1065
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_anyone", isGroupMention: true }),
1066
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
1067
+ });
1068
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
1069
+ });
1070
+
1071
+ it("allows sender matching groupAllowFrom by ID", async () => {
1072
+ const runtime = makeMockRuntime();
1073
+ const ctx = makeCtx(runtime, {
1074
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["wxid_allowed"], requireMention: false } },
1075
+ });
1076
+ await handleInboundMessage({
1077
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_allowed", isGroupMention: true }),
1078
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
1079
+ });
1080
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
1081
+ });
1082
+
1083
+ it("allows sender matching groupAllowFrom by name (case-insensitive)", async () => {
1084
+ const runtime = makeMockRuntime();
1085
+ const ctx = makeCtx(runtime, {
1086
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["alice"], requireMention: false } },
1087
+ });
1088
+ await handleInboundMessage({
1089
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_unknown", senderName: "Alice", isGroupMention: true }),
1090
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
1091
+ });
1092
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
1093
+ });
1094
+
1095
+ it("blocks sender not in groupAllowFrom (silent drop)", async () => {
1096
+ const runtime = makeMockRuntime();
1097
+ const ctx = makeCtx(runtime, {
1098
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["wxid_allowed"], requireMention: false } },
1099
+ });
1100
+ const sendReply = vi.fn(async () => {});
1101
+ await handleInboundMessage({
1102
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_stranger", senderName: "Stranger", isGroupMention: true }),
1103
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
1104
+ });
1105
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
1106
+ expect(sendReply).not.toHaveBeenCalled();
1107
+ });
1108
+
1109
+ it("allows wildcard '*' in groupAllowFrom", async () => {
1110
+ const runtime = makeMockRuntime();
1111
+ const ctx = makeCtx(runtime, {
1112
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["*"], requireMention: false } },
1113
+ });
1114
+ await handleInboundMessage({
1115
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_anyone", isGroupMention: true }),
1116
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
1117
+ });
1118
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
1119
+ });
1120
+ });
package/src/inbound.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { ChannelGatewayContext } from "openclaw/plugin-sdk";
2
- import { resolveAllowlistMatchByCandidates, createNormalizedOutboundDeliverer } from "openclaw/plugin-sdk";
2
+ import {
3
+ resolveAllowlistMatchByCandidates,
4
+ createNormalizedOutboundDeliverer,
5
+ buildMediaPayload,
6
+ resolveChannelMediaMaxBytes,
7
+ detectMime,
8
+ } from "openclaw/plugin-sdk";
3
9
  import { resolveSocketChatAccount, type CoreConfig } from "./config.js";
4
10
  import type { ResolvedSocketChatAccount } from "./config.js";
5
11
  import type { SocketChatInboundMessage } from "./types.js";
@@ -113,6 +119,114 @@ async function enforceDmAccess(params: {
113
119
  return false;
114
120
  }
115
121
 
122
+ // ---------------------------------------------------------------------------
123
+ // 群组访问控制 — 第一层(groupId 级别)+ 第二层(sender 级别)
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * 记录已发送过"群未授权"提醒的群,键为 `${accountId}:${groupId}`。
128
+ * 进程内只提醒一次,避免每条消息都重复发送。
129
+ */
130
+ const notifiedGroups = new Set<string>();
131
+
132
+ /** 仅供测试使用:重置一次性提醒状态。 */
133
+ export function _resetNotifiedGroupsForTest(): void {
134
+ notifiedGroups.clear();
135
+ }
136
+
137
+ /**
138
+ * 规范化群/发送者 ID,去除前缀、空格、转小写,便于白名单对比。
139
+ */
140
+ function normalizeSocketChatId(raw: string): string {
141
+ return raw.replace(/^(socket-chat|sc):/i, "").trim().toLowerCase();
142
+ }
143
+
144
+ /**
145
+ * 第一层:检查当前群是否被允许触发 AI。
146
+ *
147
+ * - groupPolicy="open"(默认):所有群均可触发
148
+ * - groupPolicy="allowlist":仅 groups 列表中的群可触发,不在列表的群收到一次提醒
149
+ * - groupPolicy="disabled":禁止所有群消息触发 AI
150
+ *
151
+ * 返回 `{ allowed, notify }`:
152
+ * - allowed=false + notify=true 表示首次拦截,调用方应发送提醒消息
153
+ * - allowed=false + notify=false 表示已提醒过,静默忽略
154
+ */
155
+ function checkGroupAccess(params: {
156
+ groupId: string;
157
+ account: ResolvedSocketChatAccount;
158
+ log: LogSink;
159
+ accountId: string;
160
+ }): { allowed: boolean; notify: boolean } {
161
+ const { groupId, account, log, accountId } = params;
162
+ const groupPolicy = account.config.groupPolicy ?? "open";
163
+
164
+ if (groupPolicy === "disabled") {
165
+ log.info(`[${accountId}] group msg blocked (groupPolicy=disabled)`);
166
+ return { allowed: false, notify: false };
167
+ }
168
+
169
+ if (groupPolicy === "open") {
170
+ return { allowed: true, notify: false };
171
+ }
172
+
173
+ // allowlist:检查 groupId 是否在 groups 名单中
174
+ const groups = (account.config.groups ?? []).map(normalizeSocketChatId);
175
+ if (groups.length === 0) {
176
+ log.info(`[${accountId}] group ${groupId} blocked (groupPolicy=allowlist, groups is empty)`);
177
+ return { allowed: false, notify: false };
178
+ }
179
+
180
+ const normalizedGroupId = normalizeSocketChatId(groupId);
181
+ if (groups.includes("*") || groups.includes(normalizedGroupId)) {
182
+ return { allowed: true, notify: false };
183
+ }
184
+
185
+ log.info(`[${accountId}] group ${groupId} not in groups allowlist (groupPolicy=allowlist)`);
186
+
187
+ // 判断是否需要发送一次性提醒
188
+ const notifyKey = `${accountId}:${groupId}`;
189
+ if (notifiedGroups.has(notifyKey)) {
190
+ return { allowed: false, notify: false };
191
+ }
192
+ notifiedGroups.add(notifyKey);
193
+ return { allowed: false, notify: true };
194
+ }
195
+
196
+ /**
197
+ * 第二层:检查群内发送者是否被允许触发 AI。
198
+ *
199
+ * 仅当 groupAllowFrom 非空时生效;为空则允许群内所有成员。
200
+ */
201
+ function checkGroupSenderAccess(params: {
202
+ senderId: string;
203
+ senderName: string | undefined;
204
+ account: ResolvedSocketChatAccount;
205
+ log: LogSink;
206
+ accountId: string;
207
+ }): boolean {
208
+ const { senderId, senderName, account, log, accountId } = params;
209
+ const groupAllowFrom = (account.config.groupAllowFrom ?? []).map(normalizeSocketChatId);
210
+
211
+ // 未配置 sender 白名单 → 不限制
212
+ if (groupAllowFrom.length === 0) return true;
213
+ if (groupAllowFrom.includes("*")) return true;
214
+
215
+ const normalizedSenderId = normalizeSocketChatId(senderId);
216
+ const normalizedSenderName = senderName ? senderName.trim().toLowerCase() : undefined;
217
+
218
+ const allowed =
219
+ groupAllowFrom.includes(normalizedSenderId) ||
220
+ (normalizedSenderName !== undefined && groupAllowFrom.includes(normalizedSenderName));
221
+
222
+ if (!allowed) {
223
+ log.info(
224
+ `[${accountId}] group sender ${senderId} not in groupAllowFrom`,
225
+ );
226
+ }
227
+ return allowed;
228
+ }
229
+
116
230
  // ---------------------------------------------------------------------------
117
231
  // 群组消息 @提及检查
118
232
  // ---------------------------------------------------------------------------
@@ -166,8 +280,9 @@ type LogSink = {
166
280
  * 处理 MQTT 入站消息:
167
281
  * 1. 安全策略检查(allowlist / pairing)
168
282
  * 2. 群组 @提及检查
169
- * 3. 路由 + 记录 session
170
- * 4. 派发 AI 回复
283
+ * 3. 媒体文件下载到本地
284
+ * 4. 路由 + 记录 session
285
+ * 5. 派发 AI 回复
171
286
  */
172
287
  export async function handleInboundMessage(params: {
173
288
  msg: SocketChatInboundMessage;
@@ -208,7 +323,34 @@ export async function handleInboundMessage(params: {
208
323
  return;
209
324
  }
210
325
 
211
- // ---- 2. 群组 @提及检查 ----
326
+ // ---- 2. 群组访问控制(第一层:群级 + 第二层:sender 级)----
327
+ if (msg.isGroup) {
328
+ const groupId = msg.groupId ?? msg.senderId;
329
+
330
+ // 第一层:groupId 是否在允许列表中
331
+ const groupAccess = checkGroupAccess({ groupId, account, log, accountId });
332
+ if (!groupAccess.allowed) {
333
+ // if (groupAccess.notify) {
334
+ // await sendReply(
335
+ // `group:${groupId}`,
336
+ // `此群(${msg.groupName ?? groupId})未获授权使用 AI 服务。如需开启,请联系管理员将群 ID "${groupId}" 加入 groups 配置。`,
337
+ // );
338
+ // }
339
+ return;
340
+ }
341
+
342
+ // 第二层:群内发送者是否被允许
343
+ const senderAccessAllowed = checkGroupSenderAccess({
344
+ senderId: msg.senderId,
345
+ senderName: msg.senderName,
346
+ account,
347
+ log,
348
+ accountId,
349
+ });
350
+ if (!senderAccessAllowed) return;
351
+ }
352
+
353
+ // ---- 3. 群组 @提及检查 ----
212
354
  // robotId 从 MQTT config 传入(由 mqtt-client.ts 调用时提供)
213
355
  // 此处从 msg.robotId 字段取(平台在每条消息中会带上 robotId)
214
356
  const mentionAllowed = checkGroupMention({
@@ -226,13 +368,78 @@ export async function handleInboundMessage(params: {
226
368
  const peerId = msg.isGroup ? (msg.groupId ?? msg.senderId) : msg.senderId;
227
369
 
228
370
  // 判断是否为媒体消息(图片/视频/文件等)
229
- const mediaUrl = msg.url && !msg.url.startsWith("data:") ? msg.url : undefined;
230
- // base64 内容不传给 agent(避免超长),仅标记 placeholder
231
371
  const isMediaMsg = !!msg.type && msg.type !== "文字";
232
372
  // BodyForAgent:媒体消息在 content 中已包含描述文字(如"【图片消息】\n下载链接:..."),直接使用
233
373
  const body = msg.content?.trim() || (isMediaMsg ? `<media:${msg.type}>` : "");
234
374
 
235
- // ---- 3. 路由 ----
375
+ // ---- 3. 媒体文件本地化 ----
376
+ // 将媒体 URL 下载到本地,让 agent 能直接读取文件而不依赖外部 URL 的可用性。
377
+ // HTTP/HTTPS URL:通过 fetchRemoteMedia 下载;data: URL:直接解码 base64。
378
+ let resolvedMediaPayload = {};
379
+ const mediaUrl = msg.url?.trim();
380
+ if (mediaUrl) {
381
+ try {
382
+ const maxBytes = resolveChannelMediaMaxBytes({
383
+ cfg: ctx.cfg,
384
+ resolveChannelLimitMb: ({ cfg }) =>
385
+ (cfg as CoreConfig).channels?.["socket-chat"]?.mediaMaxMb,
386
+ accountId,
387
+ });
388
+
389
+ let buffer: Buffer;
390
+ let contentTypeHint: string | undefined;
391
+
392
+ if (mediaUrl.startsWith("data:")) {
393
+ // data URL:解析 MIME 和 base64 载荷
394
+ const commaIdx = mediaUrl.indexOf(",");
395
+ const meta = commaIdx > 0 ? mediaUrl.slice(5, commaIdx) : "";
396
+ const base64Data = commaIdx > 0 ? mediaUrl.slice(commaIdx + 1) : "";
397
+ contentTypeHint = meta.split(";")[0] || undefined;
398
+
399
+ // 大小预检(base64 字节数 × 0.75 ≈ 原始字节数)
400
+ const estimatedBytes = Math.ceil(base64Data.length * 0.75);
401
+ if (maxBytes && estimatedBytes > maxBytes) {
402
+ log.warn(
403
+ `[${accountId}] base64 media too large for ${msg.messageId} (est. ${estimatedBytes} bytes, limit ${maxBytes})`,
404
+ );
405
+ throw new Error("base64 media exceeds maxBytes limit");
406
+ }
407
+
408
+ buffer = Buffer.from(base64Data, "base64");
409
+ } else {
410
+ // HTTP/HTTPS URL:通过网络下载
411
+ const fetched = await channelRuntime.media.fetchRemoteMedia({
412
+ url: mediaUrl,
413
+ filePathHint: mediaUrl,
414
+ maxBytes,
415
+ });
416
+ buffer = fetched.buffer;
417
+ contentTypeHint = fetched.contentType;
418
+ }
419
+
420
+ const mime = await detectMime({
421
+ buffer,
422
+ headerMime: contentTypeHint,
423
+ filePath: mediaUrl,
424
+ });
425
+ const saved = await channelRuntime.media.saveMediaBuffer(
426
+ buffer,
427
+ mime ?? contentTypeHint,
428
+ "inbound",
429
+ maxBytes,
430
+ );
431
+ resolvedMediaPayload = buildMediaPayload([
432
+ { path: saved.path, contentType: saved.contentType },
433
+ ]);
434
+ } catch (err) {
435
+ // 下载/解码失败不阻断消息处理,仍正常派发文字部分
436
+ log.warn(
437
+ `[${accountId}] media localization failed for ${msg.messageId}: ${err instanceof Error ? err.message : String(err)}`,
438
+ );
439
+ }
440
+ }
441
+
442
+ // ---- 4. 路由 ----
236
443
  const route = channelRuntime.routing.resolveAgentRoute({
237
444
  cfg: ctx.cfg,
238
445
  channel: "socket-chat",
@@ -247,7 +454,7 @@ export async function handleInboundMessage(params: {
247
454
  `[${accountId}] dispatch ${msg.messageId} from ${msg.senderId} → agent ${route.agentId}`,
248
455
  );
249
456
 
250
- // ---- 4. 构建 MsgContext ----
457
+ // ---- 5. 构建 MsgContext ----
251
458
  const fromLabel = msg.isGroup
252
459
  ? `socket-chat:room:${peerId}`
253
460
  : `socket-chat:${msg.senderId}`;
@@ -261,15 +468,7 @@ export async function handleInboundMessage(params: {
261
468
  RawBody: msg.content || (msg.url ?? ""),
262
469
  BodyForAgent: body,
263
470
  CommandBody: body,
264
- ...(mediaUrl
265
- ? {
266
- MediaUrl: mediaUrl,
267
- MediaUrls: [mediaUrl],
268
- MediaPath: mediaUrl,
269
- // 尽量从 type 推断 MIME,否则留空由框架处理
270
- MediaType: msg.type === "图片" ? "image/jpeg" : msg.type === "视频" ? "video/mp4" : undefined,
271
- }
272
- : {}),
471
+ ...resolvedMediaPayload,
273
472
  From: fromLabel,
274
473
  To: toLabel,
275
474
  SessionKey: route.sessionKey,
@@ -290,7 +489,7 @@ export async function handleInboundMessage(params: {
290
489
  });
291
490
 
292
491
  try {
293
- // ---- 5. 记录 session 元数据 ----
492
+ // ---- 6. 记录 session 元数据 ----
294
493
  const storePath = channelRuntime.session.resolveStorePath(undefined, {
295
494
  agentId: route.agentId,
296
495
  });
@@ -310,7 +509,7 @@ export async function handleInboundMessage(params: {
310
509
  direction: "inbound",
311
510
  });
312
511
 
313
- // ---- 6. 派发 AI 回复 ----
512
+ // ---- 7. 派发 AI 回复 ----
314
513
  // deliver 负责实际发送:框架当 originatingChannel === currentSurface 时
315
514
  // 不走 outbound adapter,直接调用此函数投递回复。
316
515
  const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {