@openclaw-channel/socket-chat 1.0.2 → 1.0.3

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
@@ -39,11 +39,63 @@ channels:
39
39
  socket-chat:
40
40
  apiKey: "your-api-key"
41
41
  enabled: true
42
- dmPolicy: "pairing" # pairing | open | allowlist
43
- allowFrom: [] # 允许触发 AI 的发送者 ID 白名单
44
- requireMention: true # 群组消息是否需要 @提及机器人
42
+
43
+ # DM(私聊)访问策略
44
+ dmPolicy: "pairing" # pairing | open | allowlist
45
+ allowFrom: [] # DM 白名单(dmPolicy=allowlist/pairing 时生效)
46
+
47
+ # 群组访问策略
48
+ requireMention: true # 群消息是否需要 @提及机器人
49
+ groupPolicy: "open" # open | allowlist | disabled(见下方说明)
50
+ groups: [] # 允许的群 ID 列表(groupPolicy=allowlist 时生效)
51
+ groupAllowFrom: [] # 群内允许触发 AI 的发送者 ID/昵称列表
52
+ ```
53
+
54
+ ### 群组访问控制
55
+
56
+ 群消息经过**三层**依次检查,任意一层不通过则丢弃该消息:
57
+
58
+ #### 第一层:群级(哪些群允许触发 AI)
59
+
60
+ | `groupPolicy` | 行为 |
61
+ |--------------|------|
62
+ | `open`(默认)| bot 所在所有群均可触发 |
63
+ | `allowlist` | 仅 `groups` 列表中的群可触发;首次被拦截时向该群发一条提醒(进程内只发一次) |
64
+ | `disabled` | 禁止所有群消息触发 AI,静默丢弃 |
65
+
66
+ ```yaml
67
+ channels:
68
+ socket-chat:
69
+ groupPolicy: allowlist
70
+ groups:
71
+ - "R:10804599808581977"
72
+ - "R:another_group_id"
45
73
  ```
46
74
 
75
+ #### 第二层:sender 级(群内哪些人可以触发 AI)
76
+
77
+ `groupAllowFrom` 为空时不限制(允许群内所有成员)。支持按 `senderId` 或 `senderName` 匹配,大小写不敏感,支持通配符 `*`。
78
+
79
+ ```yaml
80
+ channels:
81
+ socket-chat:
82
+ groupAllowFrom:
83
+ - "wxid_123456" # 按 senderId 精确匹配
84
+ - "Alice" # 按 senderName 匹配
85
+ - "*" # 允许所有成员
86
+ ```
87
+
88
+ 被拦截的 sender 静默丢弃,不向群发任何提示。
89
+
90
+ #### 第三层:@提及检查
91
+
92
+ `requireMention: true`(默认)时,群消息必须满足以下任意条件之一:
93
+ - 平台传来 `isGroupMention: true`
94
+ - 消息内容包含 `@{robotId}`
95
+
96
+ > **注意**:媒体消息(图片、视频等)无法携带 @,若平台侧开启了 `forwardMediaMsg`,
97
+ > 应在发布 MQTT 消息时主动将 `isGroupMention` 设为 `true`,避免被此层拦截。
98
+
47
99
  ### 多账号配置
48
100
 
49
101
  ```yaml
@@ -62,6 +114,12 @@ channels:
62
114
 
63
115
  | 字段 | 默认值 | 说明 |
64
116
  |------|--------|------|
117
+ | `dmPolicy` | `pairing` | DM 访问策略:`pairing` / `open` / `allowlist` |
118
+ | `allowFrom` | `[]` | DM 白名单,senderId 或 senderName 列表 |
119
+ | `requireMention` | `true` | 群消息是否需要 @提及机器人 |
120
+ | `groupPolicy` | `open` | 群访问策略:`open` / `allowlist` / `disabled` |
121
+ | `groups` | `[]` | 允许的群 ID 列表(`groupPolicy=allowlist` 时生效) |
122
+ | `groupAllowFrom` | `[]` | 群内允许触发 AI 的发送者 ID/昵称列表,空=不限制 |
65
123
  | `mqttConfigTtlSec` | `300` | MQTT 配置缓存时间(秒) |
66
124
  | `maxReconnectAttempts` | `10` | MQTT 断线最大重连次数 |
67
125
  | `reconnectBaseDelayMs` | `2000` | 重连基础延迟(毫秒,指数退避) |
@@ -70,6 +128,8 @@ channels:
70
128
 
71
129
  ## 通过 CLI 添加账号
72
130
 
131
+ apiKey 从微秘书平台[个人中心](https://wechat.aibotk.com/user/info)获取
132
+
73
133
  ```bash
74
134
  openclaw channels add socket-chat --token <apiKey>
75
135
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw-channel/socket-chat",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "OpenClaw Socket Chat channel plugin — MQTT-based IM bridge",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,12 +32,30 @@ 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(),
41
59
  /** MQTT 连接配置缓存 TTL(秒),默认 300 */
42
60
  mqttConfigTtlSec: z.number().optional(),
43
61
  /** MQTT 重连最大次数,默认 10 */
@@ -1,5 +1,5 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { handleInboundMessage } from "./inbound.js";
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import { handleInboundMessage, _resetNotifiedGroupsForTest } from "./inbound.js";
3
3
  import type { SocketChatInboundMessage } from "./types.js";
4
4
  import type { CoreConfig } from "./config.js";
5
5
 
@@ -737,3 +737,176 @@ describe("handleInboundMessage — edge cases", () => {
737
737
  );
738
738
  });
739
739
  });
740
+
741
+ // ---------------------------------------------------------------------------
742
+ // Group access control — tier 1 (groupId) + tier 2 (sender)
743
+ // ---------------------------------------------------------------------------
744
+
745
+ describe("handleInboundMessage — group access control (tier 1: groupId)", () => {
746
+ beforeEach(() => {
747
+ _resetNotifiedGroupsForTest();
748
+ });
749
+
750
+ it("allows all groups when groupPolicy=open (default)", async () => {
751
+ const runtime = makeMockRuntime();
752
+ const ctx = makeCtx(runtime, {
753
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", requireMention: false } },
754
+ });
755
+ await handleInboundMessage({
756
+ msg: makeMsg({ isGroup: true, groupId: "R:any_group", robotId: "robot_abc", isGroupMention: true }),
757
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
758
+ });
759
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
760
+ });
761
+
762
+ it("blocks all groups when groupPolicy=disabled (no notification)", async () => {
763
+ const runtime = makeMockRuntime();
764
+ const ctx = makeCtx(runtime, {
765
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "disabled" } },
766
+ });
767
+ const sendReply = vi.fn(async () => {});
768
+ await handleInboundMessage({
769
+ msg: makeMsg({ isGroup: true, groupId: "R:any_group", isGroupMention: true }),
770
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
771
+ });
772
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
773
+ expect(sendReply).not.toHaveBeenCalled();
774
+ });
775
+
776
+ it("allows group in allowlist when groupPolicy=allowlist", async () => {
777
+ const runtime = makeMockRuntime();
778
+ const ctx = makeCtx(runtime, {
779
+ channels: {
780
+ "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"], requireMention: false },
781
+ },
782
+ });
783
+ await handleInboundMessage({
784
+ msg: makeMsg({ isGroup: true, groupId: "R:allowed_group", robotId: "robot_abc", isGroupMention: true }),
785
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
786
+ });
787
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
788
+ });
789
+
790
+ it("blocks unlisted group and sends one-time notification", async () => {
791
+ const runtime = makeMockRuntime();
792
+ const ctx = makeCtx(runtime, {
793
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"] } },
794
+ });
795
+ const sendReply = vi.fn(async () => {});
796
+ await handleInboundMessage({
797
+ msg: makeMsg({ isGroup: true, groupId: "R:other_group", groupName: "测试群", isGroupMention: true }),
798
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
799
+ });
800
+ 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");
804
+ });
805
+
806
+ it("does NOT send second notification for same blocked group", async () => {
807
+ const runtime = makeMockRuntime();
808
+ const ctx = makeCtx(runtime, {
809
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["R:allowed_group"] } },
810
+ });
811
+ const sendReply = vi.fn(async () => {});
812
+ const msg = makeMsg({ isGroup: true, groupId: "R:notify_once_group", isGroupMention: true });
813
+ await handleInboundMessage({ msg, accountId: "default", ctx: ctx as never, log: ctx.log, sendReply });
814
+ await handleInboundMessage({ msg, accountId: "default", ctx: ctx as never, log: ctx.log, sendReply });
815
+ expect(sendReply).toHaveBeenCalledOnce();
816
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
817
+ });
818
+
819
+ it("blocks when groupPolicy=allowlist and groups is empty (no notification)", async () => {
820
+ const runtime = makeMockRuntime();
821
+ const ctx = makeCtx(runtime, {
822
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: [] } },
823
+ });
824
+ const sendReply = vi.fn(async () => {});
825
+ await handleInboundMessage({
826
+ msg: makeMsg({ isGroup: true, groupId: "R:any_group", isGroupMention: true }),
827
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
828
+ });
829
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
830
+ expect(sendReply).not.toHaveBeenCalled();
831
+ });
832
+
833
+ it("allows wildcard '*' in groups list", async () => {
834
+ const runtime = makeMockRuntime();
835
+ const ctx = makeCtx(runtime, {
836
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupPolicy: "allowlist", groups: ["*"], requireMention: false } },
837
+ });
838
+ await handleInboundMessage({
839
+ msg: makeMsg({ isGroup: true, groupId: "R:any_group", robotId: "robot_abc", isGroupMention: true }),
840
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
841
+ });
842
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
843
+ });
844
+ });
845
+
846
+ describe("handleInboundMessage — group access control (tier 2: sender)", () => {
847
+ beforeEach(() => {
848
+ _resetNotifiedGroupsForTest();
849
+ });
850
+
851
+ it("allows all senders when groupAllowFrom is empty", async () => {
852
+ const runtime = makeMockRuntime();
853
+ const ctx = makeCtx(runtime, {
854
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", requireMention: false } },
855
+ });
856
+ await handleInboundMessage({
857
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_anyone", isGroupMention: true }),
858
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
859
+ });
860
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
861
+ });
862
+
863
+ it("allows sender matching groupAllowFrom by ID", async () => {
864
+ const runtime = makeMockRuntime();
865
+ const ctx = makeCtx(runtime, {
866
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["wxid_allowed"], requireMention: false } },
867
+ });
868
+ await handleInboundMessage({
869
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_allowed", isGroupMention: true }),
870
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
871
+ });
872
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
873
+ });
874
+
875
+ it("allows sender matching groupAllowFrom by name (case-insensitive)", async () => {
876
+ const runtime = makeMockRuntime();
877
+ const ctx = makeCtx(runtime, {
878
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["alice"], requireMention: false } },
879
+ });
880
+ await handleInboundMessage({
881
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_unknown", senderName: "Alice", isGroupMention: true }),
882
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
883
+ });
884
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
885
+ });
886
+
887
+ it("blocks sender not in groupAllowFrom (silent drop)", async () => {
888
+ const runtime = makeMockRuntime();
889
+ const ctx = makeCtx(runtime, {
890
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["wxid_allowed"], requireMention: false } },
891
+ });
892
+ const sendReply = vi.fn(async () => {});
893
+ await handleInboundMessage({
894
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_stranger", senderName: "Stranger", isGroupMention: true }),
895
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply,
896
+ });
897
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
898
+ expect(sendReply).not.toHaveBeenCalled();
899
+ });
900
+
901
+ it("allows wildcard '*' in groupAllowFrom", async () => {
902
+ const runtime = makeMockRuntime();
903
+ const ctx = makeCtx(runtime, {
904
+ channels: { "socket-chat": { apiKey: "k", apiBaseUrl: "https://x.com", groupAllowFrom: ["*"], requireMention: false } },
905
+ });
906
+ await handleInboundMessage({
907
+ msg: makeMsg({ isGroup: true, groupId: "R:g1", senderId: "wxid_anyone", isGroupMention: true }),
908
+ accountId: "default", ctx: ctx as never, log: ctx.log, sendReply: vi.fn(async () => {}),
909
+ });
910
+ expect(runtime.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledOnce();
911
+ });
912
+ });
package/src/inbound.ts CHANGED
@@ -113,6 +113,114 @@ async function enforceDmAccess(params: {
113
113
  return false;
114
114
  }
115
115
 
116
+ // ---------------------------------------------------------------------------
117
+ // 群组访问控制 — 第一层(groupId 级别)+ 第二层(sender 级别)
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * 记录已发送过"群未授权"提醒的群,键为 `${accountId}:${groupId}`。
122
+ * 进程内只提醒一次,避免每条消息都重复发送。
123
+ */
124
+ const notifiedGroups = new Set<string>();
125
+
126
+ /** 仅供测试使用:重置一次性提醒状态。 */
127
+ export function _resetNotifiedGroupsForTest(): void {
128
+ notifiedGroups.clear();
129
+ }
130
+
131
+ /**
132
+ * 规范化群/发送者 ID,去除前缀、空格、转小写,便于白名单对比。
133
+ */
134
+ function normalizeSocketChatId(raw: string): string {
135
+ return raw.replace(/^(socket-chat|sc):/i, "").trim().toLowerCase();
136
+ }
137
+
138
+ /**
139
+ * 第一层:检查当前群是否被允许触发 AI。
140
+ *
141
+ * - groupPolicy="open"(默认):所有群均可触发
142
+ * - groupPolicy="allowlist":仅 groups 列表中的群可触发,不在列表的群收到一次提醒
143
+ * - groupPolicy="disabled":禁止所有群消息触发 AI
144
+ *
145
+ * 返回 `{ allowed, notify }`:
146
+ * - allowed=false + notify=true 表示首次拦截,调用方应发送提醒消息
147
+ * - allowed=false + notify=false 表示已提醒过,静默忽略
148
+ */
149
+ function checkGroupAccess(params: {
150
+ groupId: string;
151
+ account: ResolvedSocketChatAccount;
152
+ log: LogSink;
153
+ accountId: string;
154
+ }): { allowed: boolean; notify: boolean } {
155
+ const { groupId, account, log, accountId } = params;
156
+ const groupPolicy = account.config.groupPolicy ?? "open";
157
+
158
+ if (groupPolicy === "disabled") {
159
+ log.info(`[${accountId}] group msg blocked (groupPolicy=disabled)`);
160
+ return { allowed: false, notify: false };
161
+ }
162
+
163
+ if (groupPolicy === "open") {
164
+ return { allowed: true, notify: false };
165
+ }
166
+
167
+ // allowlist:检查 groupId 是否在 groups 名单中
168
+ const groups = (account.config.groups ?? []).map(normalizeSocketChatId);
169
+ if (groups.length === 0) {
170
+ log.info(`[${accountId}] group ${groupId} blocked (groupPolicy=allowlist, groups is empty)`);
171
+ return { allowed: false, notify: false };
172
+ }
173
+
174
+ const normalizedGroupId = normalizeSocketChatId(groupId);
175
+ if (groups.includes("*") || groups.includes(normalizedGroupId)) {
176
+ return { allowed: true, notify: false };
177
+ }
178
+
179
+ log.info(`[${accountId}] group ${groupId} not in groups allowlist (groupPolicy=allowlist)`);
180
+
181
+ // 判断是否需要发送一次性提醒
182
+ const notifyKey = `${accountId}:${groupId}`;
183
+ if (notifiedGroups.has(notifyKey)) {
184
+ return { allowed: false, notify: false };
185
+ }
186
+ notifiedGroups.add(notifyKey);
187
+ return { allowed: false, notify: true };
188
+ }
189
+
190
+ /**
191
+ * 第二层:检查群内发送者是否被允许触发 AI。
192
+ *
193
+ * 仅当 groupAllowFrom 非空时生效;为空则允许群内所有成员。
194
+ */
195
+ function checkGroupSenderAccess(params: {
196
+ senderId: string;
197
+ senderName: string | undefined;
198
+ account: ResolvedSocketChatAccount;
199
+ log: LogSink;
200
+ accountId: string;
201
+ }): boolean {
202
+ const { senderId, senderName, account, log, accountId } = params;
203
+ const groupAllowFrom = (account.config.groupAllowFrom ?? []).map(normalizeSocketChatId);
204
+
205
+ // 未配置 sender 白名单 → 不限制
206
+ if (groupAllowFrom.length === 0) return true;
207
+ if (groupAllowFrom.includes("*")) return true;
208
+
209
+ const normalizedSenderId = normalizeSocketChatId(senderId);
210
+ const normalizedSenderName = senderName ? senderName.trim().toLowerCase() : undefined;
211
+
212
+ const allowed =
213
+ groupAllowFrom.includes(normalizedSenderId) ||
214
+ (normalizedSenderName !== undefined && groupAllowFrom.includes(normalizedSenderName));
215
+
216
+ if (!allowed) {
217
+ log.info(
218
+ `[${accountId}] group sender ${senderId} not in groupAllowFrom`,
219
+ );
220
+ }
221
+ return allowed;
222
+ }
223
+
116
224
  // ---------------------------------------------------------------------------
117
225
  // 群组消息 @提及检查
118
226
  // ---------------------------------------------------------------------------
@@ -208,7 +316,34 @@ export async function handleInboundMessage(params: {
208
316
  return;
209
317
  }
210
318
 
211
- // ---- 2. 群组 @提及检查 ----
319
+ // ---- 2. 群组访问控制(第一层:群级 + 第二层:sender 级)----
320
+ if (msg.isGroup) {
321
+ const groupId = msg.groupId ?? msg.senderId;
322
+
323
+ // 第一层:groupId 是否在允许列表中
324
+ const groupAccess = checkGroupAccess({ groupId, account, log, accountId });
325
+ if (!groupAccess.allowed) {
326
+ if (groupAccess.notify) {
327
+ await sendReply(
328
+ `group:${groupId}`,
329
+ `此群(${msg.groupName ?? groupId})未获授权使用 AI 服务。如需开启,请联系管理员将群 ID "${groupId}" 加入 groups 配置。`,
330
+ );
331
+ }
332
+ return;
333
+ }
334
+
335
+ // 第二层:群内发送者是否被允许
336
+ const senderAccessAllowed = checkGroupSenderAccess({
337
+ senderId: msg.senderId,
338
+ senderName: msg.senderName,
339
+ account,
340
+ log,
341
+ accountId,
342
+ });
343
+ if (!senderAccessAllowed) return;
344
+ }
345
+
346
+ // ---- 3. 群组 @提及检查 ----
212
347
  // robotId 从 MQTT config 传入(由 mqtt-client.ts 调用时提供)
213
348
  // 此处从 msg.robotId 字段取(平台在每条消息中会带上 robotId)
214
349
  const mentionAllowed = checkGroupMention({