@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 +10 -8
- package/package.json +1 -1
- package/src/__sdk-stub__.ts +3 -0
- package/src/channel.ts +2 -2
- package/src/config-schema.ts +2 -0
- package/src/inbound.test.ts +227 -19
- package/src/inbound.ts +88 -24
- package/src/outbound.test.ts +9 -11
- package/src/outbound.ts +2 -2
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
package/src/__sdk-stub__.ts
CHANGED
|
@@ -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@
|
|
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
|
|
389
|
+
"- socket-chat: direct messages use the sender's contactId as the target.",
|
|
390
390
|
],
|
|
391
391
|
},
|
|
392
392
|
};
|
package/src/config-schema.ts
CHANGED
|
@@ -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 */
|
package/src/inbound.test.ts
CHANGED
|
@@ -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("
|
|
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
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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("
|
|
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: "
|
|
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
|
-
|
|
616
|
+
MediaPath: "/tmp/openclaw/inbound/video-001.mp4",
|
|
561
617
|
MediaType: "video/mp4",
|
|
562
618
|
}),
|
|
563
619
|
);
|
|
564
620
|
});
|
|
565
621
|
|
|
566
|
-
it("
|
|
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:
|
|
576
|
-
content: "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
802
|
-
expect(sendReply.
|
|
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("
|
|
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).
|
|
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 {
|
|
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.
|
|
278
|
-
* 4.
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
// ----
|
|
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
|
-
...
|
|
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
|
-
// ----
|
|
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
|
-
// ----
|
|
512
|
+
// ---- 7. 派发 AI 回复 ----
|
|
449
513
|
// deliver 负责实际发送:框架当 originatingChannel === currentSurface 时
|
|
450
514
|
// 不走 outbound adapter,直接调用此函数投递回复。
|
|
451
515
|
const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
|
package/src/outbound.test.ts
CHANGED
|
@@ -81,25 +81,23 @@ describe("normalizeSocketChatTarget", () => {
|
|
|
81
81
|
// ---------------------------------------------------------------------------
|
|
82
82
|
|
|
83
83
|
describe("looksLikeSocketChatTargetId", () => {
|
|
84
|
-
it("
|
|
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
|
|
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("
|
|
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
|
-
*
|
|
49
|
+
* socket-chat 的 chatId 格式不固定,任何非空字符串均视为原生 ID
|
|
50
50
|
*/
|
|
51
51
|
export function looksLikeSocketChatTargetId(s: string): boolean {
|
|
52
|
-
return
|
|
52
|
+
return s.trim().length > 0;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|