@openclaw-channel/socket-chat 1.0.3 → 1.0.5

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` 中添加:
@@ -126,15 +136,7 @@ channels:
126
136
 
127
137
  ---
128
138
 
129
- ## 通过 CLI 添加账号
130
-
131
- apiKey 从微秘书平台[个人中心](https://wechat.aibotk.com/user/info)获取
132
-
133
- ```bash
134
- openclaw channels add socket-chat --token <apiKey>
135
- ```
136
139
 
137
- ---
138
140
 
139
141
  ## 消息格式
140
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-channel/socket-chat",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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";
package/src/channel.ts CHANGED
@@ -242,7 +242,7 @@ export const socketChatPlugin: ChannelPlugin<ResolvedSocketChatAccount> = {
242
242
  normalizeTarget: normalizeSocketChatTarget,
243
243
  targetResolver: {
244
244
  looksLikeId: looksLikeSocketChatTargetId,
245
- hint: "<contactId|group:groupId|group:groupId@wxid_mention1,wxid_mention2>",
245
+ hint: "<contactId|group:groupId|group:groupId@userId1,userId2>",
246
246
  },
247
247
  },
248
248
 
@@ -386,7 +386,7 @@ export const socketChatPlugin: ChannelPlugin<ResolvedSocketChatAccount> = {
386
386
  "- socket-chat: to send to a group, use target format `group:<groupId>`. " +
387
387
  "To @mention users in a group: `group:<groupId>@<userId1>,<userId2>`.",
388
388
  "- socket-chat: to send an image, provide a public HTTP URL as the media parameter.",
389
- "- socket-chat: direct messages use the sender's contactId (e.g. `wxid_xxx`) as the target.",
389
+ "- socket-chat: direct messages use the sender's contactId as the target.",
390
390
  ],
391
391
  },
392
392
  };
@@ -56,6 +56,8 @@ export const SocketChatAccountConfigSchema = z.object({
56
56
  * 不配置或为空则允许群内所有成员触发(受 requireMention 约束)
57
57
  */
58
58
  groupAllowFrom: z.array(z.string()).optional(),
59
+ /** 媒体文件大小上限(MB)。未配置时使用框架全局默认值 */
60
+ mediaMaxMb: z.number().optional(),
59
61
  /** MQTT 连接配置缓存 TTL(秒),默认 300 */
60
62
  mqttConfigTtlSec: z.number().optional(),
61
63
  /** MQTT 重连最大次数,默认 10 */
@@ -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,23 +613,97 @@ 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 () => {
623
+ const runtime = makeMockRuntime();
624
+ runtime.media.saveMediaBuffer.mockResolvedValue({
625
+ path: "/tmp/openclaw/inbound/b64-img.jpg",
626
+ contentType: "image/jpeg",
627
+ });
628
+
629
+ const ctx = makeCtx(runtime, {
630
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
631
+ });
632
+
633
+ // minimal valid JPEG-ish base64
634
+ const fakeBase64 = Buffer.from("fake-jpeg-bytes").toString("base64");
635
+ await handleInboundMessage({
636
+ msg: makeMsg({
637
+ type: "图片",
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",
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 () => {
567
696
  const runtime = makeMockRuntime();
568
697
  const ctx = makeCtx(runtime, {
569
698
  channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", dmPolicy: "open" } },
570
699
  });
571
700
 
701
+ const fakeBase64 = Buffer.from("img-bytes").toString("base64");
572
702
  await handleInboundMessage({
573
703
  msg: makeMsg({
574
704
  type: "图片",
575
- url: "data:image/jpeg;base64,/9j/4AAQ...",
576
- content: "【图片消息】\n文件名:img.jpg\n文件大小:12345 bytes",
705
+ url: `data:image/jpeg;base64,${fakeBase64}`,
706
+ content: "【图片消息】",
577
707
  }),
578
708
  accountId: "default",
579
709
  ctx: ctx as never,
@@ -581,9 +711,88 @@ describe("handleInboundMessage — media messages", () => {
581
711
  sendReply: vi.fn(async () => {}),
582
712
  });
583
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" }),
727
+ accountId: "default",
728
+ ctx: ctx as never,
729
+ log: ctx.log,
730
+ sendReply: vi.fn(async () => {}),
731
+ });
732
+
733
+ expect(runtime.media.fetchRemoteMedia).not.toHaveBeenCalled();
734
+ expect(runtime.media.saveMediaBuffer).not.toHaveBeenCalled();
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
584
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
 
@@ -787,7 +996,7 @@ describe("handleInboundMessage — group access control (tier 1: groupId)", () =
787
996
  expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
788
997
  });
789
998
 
790
- it("blocks unlisted group and sends one-time notification", async () => {
999
+ it("blocks unlisted group without sending notification (notify branch commented out)", async () => {
791
1000
  const runtime = makeMockRuntime();
792
1001
  const ctx = makeCtx(runtime, {
793
1002
  channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"] } },
@@ -798,12 +1007,11 @@ describe("handleInboundMessage — group access control (tier 1: groupId)", () =
798
1007
  accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
799
1008
  });
800
1009
  expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
801
- expect(sendReply).toHaveBeenCalledOnce();
802
- expect(sendReply.mock.calls[0]?.[0]).toBe("group:R:other_group");
803
- expect(sendReply.mock.calls[0]?.[1]).toContain("R:other_group");
1010
+ // Notification is currently disabled (commented out in inbound.ts)
1011
+ expect(sendReply).not.toHaveBeenCalled();
804
1012
  });
805
1013
 
806
- it("does NOT send second notification for same blocked group", async () => {
1014
+ it("silently blocks repeated messages from same unlisted group", async () => {
807
1015
  const runtime = makeMockRuntime();
808
1016
  const ctx = makeCtx(runtime, {
809
1017
  channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"] } },
@@ -812,7 +1020,7 @@ describe("handleInboundMessage — group access control (tier 1: groupId)", () =
812
1020
  const msg = makeMsg({ isGroup: true, groupId: "R:notify_once_group", isGroupMention: true });
813
1021
  await handleInboundMessage({ msg, accountId: "default", ctx: ctx as never, log: ctx.log, sendReply });
814
1022
  await handleInboundMessage({ msg, accountId: "default", ctx: ctx as never, log: ctx.log, sendReply });
815
- expect(sendReply).toHaveBeenCalledOnce();
1023
+ expect(sendReply).not.toHaveBeenCalled();
816
1024
  expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
817
1025
  });
818
1026
 
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";
@@ -274,8 +280,9 @@ type LogSink = {
274
280
  * 处理 MQTT 入站消息:
275
281
  * 1. 安全策略检查(allowlist / pairing)
276
282
  * 2. 群组 @提及检查
277
- * 3. 路由 + 记录 session
278
- * 4. 派发 AI 回复
283
+ * 3. 媒体文件下载到本地
284
+ * 4. 路由 + 记录 session
285
+ * 5. 派发 AI 回复
279
286
  */
280
287
  export async function handleInboundMessage(params: {
281
288
  msg: SocketChatInboundMessage;
@@ -323,12 +330,12 @@ export async function handleInboundMessage(params: {
323
330
  // 第一层:groupId 是否在允许列表中
324
331
  const groupAccess = checkGroupAccess({ groupId, account, log, accountId });
325
332
  if (!groupAccess.allowed) {
326
- if (groupAccess.notify) {
327
- await sendReply(
328
- `group:${groupId}`,
329
- `此群(${msg.groupName ?? groupId})未获授权使用 AI 服务。如需开启,请联系管理员将群 ID "${groupId}" 加入 groups 配置。`,
330
- );
331
- }
333
+ // if (groupAccess.notify) {
334
+ // await sendReply(
335
+ // `group:${groupId}`,
336
+ // `此群(${msg.groupName ?? groupId})未获授权使用 AI 服务。如需开启,请联系管理员将群 ID "${groupId}" 加入 groups 配置。`,
337
+ // );
338
+ // }
332
339
  return;
333
340
  }
334
341
 
@@ -361,13 +368,78 @@ export async function handleInboundMessage(params: {
361
368
  const peerId = msg.isGroup ? (msg.groupId ?? msg.senderId) : msg.senderId;
362
369
 
363
370
  // 判断是否为媒体消息(图片/视频/文件等)
364
- const mediaUrl = msg.url && !msg.url.startsWith("data:") ? msg.url : undefined;
365
- // base64 内容不传给 agent(避免超长),仅标记 placeholder
366
371
  const isMediaMsg = !!msg.type && msg.type !== "文字";
367
372
  // BodyForAgent:媒体消息在 content 中已包含描述文字(如"【图片消息】\n下载链接:..."),直接使用
368
373
  const body = msg.content?.trim() || (isMediaMsg ? `<media:${msg.type}>` : "");
369
374
 
370
- // ---- 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. 路由 ----
371
443
  const route = channelRuntime.routing.resolveAgentRoute({
372
444
  cfg: ctx.cfg,
373
445
  channel: "socket-chat",
@@ -382,7 +454,7 @@ export async function handleInboundMessage(params: {
382
454
  `[${accountId}] dispatch ${msg.messageId} from ${msg.senderId} → agent ${route.agentId}`,
383
455
  );
384
456
 
385
- // ---- 4. 构建 MsgContext ----
457
+ // ---- 5. 构建 MsgContext ----
386
458
  const fromLabel = msg.isGroup
387
459
  ? `socket-chat:room:${peerId}`
388
460
  : `socket-chat:${msg.senderId}`;
@@ -396,15 +468,7 @@ export async function handleInboundMessage(params: {
396
468
  RawBody: msg.content || (msg.url ?? ""),
397
469
  BodyForAgent: body,
398
470
  CommandBody: body,
399
- ...(mediaUrl
400
- ? {
401
- MediaUrl: mediaUrl,
402
- MediaUrls: [mediaUrl],
403
- MediaPath: mediaUrl,
404
- // 尽量从 type 推断 MIME,否则留空由框架处理
405
- MediaType: msg.type === "图片" ? "image/jpeg" : msg.type === "视频" ? "video/mp4" : undefined,
406
- }
407
- : {}),
471
+ ...resolvedMediaPayload,
408
472
  From: fromLabel,
409
473
  To: toLabel,
410
474
  SessionKey: route.sessionKey,
@@ -425,7 +489,7 @@ export async function handleInboundMessage(params: {
425
489
  });
426
490
 
427
491
  try {
428
- // ---- 5. 记录 session 元数据 ----
492
+ // ---- 6. 记录 session 元数据 ----
429
493
  const storePath = channelRuntime.session.resolveStorePath(undefined, {
430
494
  agentId: route.agentId,
431
495
  });
@@ -445,7 +509,7 @@ export async function handleInboundMessage(params: {
445
509
  direction: "inbound",
446
510
  });
447
511
 
448
- // ---- 6. 派发 AI 回复 ----
512
+ // ---- 7. 派发 AI 回复 ----
449
513
  // deliver 负责实际发送:框架当 originatingChannel === currentSurface 时
450
514
  // 不走 outbound adapter,直接调用此函数投递回复。
451
515
  const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
@@ -81,25 +81,23 @@ describe("normalizeSocketChatTarget", () => {
81
81
  // ---------------------------------------------------------------------------
82
82
 
83
83
  describe("looksLikeSocketChatTargetId", () => {
84
- it("recognizes wxid_ prefix", () => {
84
+ it("accepts any non-empty id", () => {
85
85
  expect(looksLikeSocketChatTargetId("wxid_abc123")).toBe(true);
86
- });
87
-
88
- it("recognizes roomid_ prefix", () => {
89
86
  expect(looksLikeSocketChatTargetId("roomid_xyz")).toBe(true);
90
- });
91
-
92
- it("recognizes group: prefix", () => {
93
87
  expect(looksLikeSocketChatTargetId("group:roomid_xxx")).toBe(true);
88
+ expect(looksLikeSocketChatTargetId("alice")).toBe(true);
89
+ expect(looksLikeSocketChatTargetId("user@example.com")).toBe(true);
94
90
  });
95
91
 
96
- it("rejects arbitrary strings", () => {
97
- expect(looksLikeSocketChatTargetId("alice")).toBe(false);
98
- expect(looksLikeSocketChatTargetId("user@example.com")).toBe(false);
92
+ it("rejects empty string", () => {
99
93
  expect(looksLikeSocketChatTargetId("")).toBe(false);
100
94
  });
101
95
 
102
- it("trims leading whitespace before checking", () => {
96
+ it("rejects whitespace-only string", () => {
97
+ expect(looksLikeSocketChatTargetId(" ")).toBe(false);
98
+ });
99
+
100
+ it("trims before checking", () => {
103
101
  expect(looksLikeSocketChatTargetId(" wxid_abc")).toBe(true);
104
102
  });
105
103
  });
package/src/outbound.ts CHANGED
@@ -46,10 +46,10 @@ export function normalizeSocketChatTarget(raw: string): string | undefined {
46
46
 
47
47
  /**
48
48
  * 判断字符串是否像一个 socket-chat 原生 ID
49
- * wxid_xxx / roomid_xxx 格式,或带 group: 前缀
49
+ * socket-chat chatId 格式不固定,任何非空字符串均视为原生 ID
50
50
  */
51
51
  export function looksLikeSocketChatTargetId(s: string): boolean {
52
- return /^(wxid_|roomid_|group:)/.test(s.trim());
52
+ return s.trim().length > 0;
53
53
  }
54
54
 
55
55
  /**