@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 +71 -9
- package/package.json +1 -1
- package/src/__sdk-stub__.ts +3 -0
- package/src/config-schema.ts +21 -1
- package/src/inbound.test.ts +396 -15
- package/src/inbound.ts +218 -19
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
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/config-schema.ts
CHANGED
|
@@ -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 */
|
package/src/inbound.test.ts
CHANGED
|
@@ -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("
|
|
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,33 +613,186 @@ 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 () => {
|
|
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:
|
|
576
|
-
content: "
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
170
|
-
* 4.
|
|
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
|
-
// ----
|
|
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
|
-
...
|
|
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
|
-
// ----
|
|
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
|
-
// ----
|
|
512
|
+
// ---- 7. 派发 AI 回复 ----
|
|
314
513
|
// deliver 负责实际发送:框架当 originatingChannel === currentSurface 时
|
|
315
514
|
// 不走 outbound adapter,直接调用此函数投递回复。
|
|
316
515
|
const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
|