gewe-openclaw 2026.3.23 → 2026.3.25

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
@@ -141,7 +141,12 @@ openclaw onboard
141
141
  - `plain`:普通回复
142
142
  - `quote_source`:首条回复自动引用当前入站消息
143
143
  - `at_sender`:首条文本回复自动 `@` 发送者
144
- - `quote_and_at`:首条文本回复同时引用并 `@`;非文本回复会自动退化为 `quote_source`
144
+
145
+ 兼容说明:
146
+
147
+ - `quote_and_at` 已下线,不再建议继续配置
148
+ - 历史配置里的 `quote_and_at` 仍会被兼容读取,但会自动降级为 `quote_source + 可见 @昵称前缀`
149
+ - 这个兼容降级不是微信原生 `@`,只是为了避免旧配置直接失效
145
150
 
146
151
  群聊默认值会跟随 `autoQuoteReply`:
147
152
 
@@ -167,6 +172,7 @@ openclaw onboard
167
172
  - `requireMention: true/false` 仍然可用,会分别映射到群聊 `trigger.mode = "at"` / `"any_message"`
168
173
  - 新的 `trigger` / `reply` 配置优先级更高
169
174
  - `autoQuoteReply` 现在主要用于“未显式配置 `reply.mode` 时”的默认值回退
175
+ - 历史 `reply.mode = "quote_and_at"` 会自动降级为 `quote_source + 可见 @昵称前缀`
170
176
 
171
177
  示例:
172
178
 
@@ -182,7 +188,7 @@ openclaw onboard
182
188
  },
183
189
  "project-room@chatroom": {
184
190
  "trigger": { "mode": "at_or_quote" },
185
- "reply": { "mode": "quote_and_at" },
191
+ "reply": { "mode": "at_sender" },
186
192
  "skills": ["project-skill"]
187
193
  },
188
194
  "ops-room@chatroom": {
@@ -303,7 +309,7 @@ GeWe 的状态页现在会额外显示:
303
309
  "groups": {
304
310
  "ops-room@chatroom": {
305
311
  "trigger": { "mode": "at_or_quote" },
306
- "reply": { "mode": "quote_and_at" }
312
+ "reply": { "mode": "quote_source" }
307
313
  }
308
314
  }
309
315
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gewe-openclaw",
3
- "version": "2026.3.23",
3
+ "version": "2026.3.25",
4
4
  "type": "module",
5
5
  "description": "OpenClaw GeWe channel plugin",
6
6
  "license": "MIT",
@@ -7,8 +7,11 @@ import {
7
7
  ToolPolicySchema,
8
8
  requireOpenAllowFrom,
9
9
  } from "./openclaw-compat.js";
10
+ import type { GeweGroupReplyModeInput } from "./types.js";
10
11
  import { z } from "zod";
11
12
 
13
+ const GEWE_GROUP_REPLY_MODES = ["plain", "quote_source", "at_sender", "quote_and_at"] as const;
14
+
12
15
  const GeweGroupTriggerSchema = z
13
16
  .object({
14
17
  mode: z.enum(["at", "quote", "at_or_quote", "any_message"]).optional(),
@@ -23,7 +26,15 @@ const GeweDmTriggerSchema = z
23
26
 
24
27
  const GeweGroupReplySchema = z
25
28
  .object({
26
- mode: z.enum(["plain", "quote_source", "at_sender", "quote_and_at"]).optional(),
29
+ mode: z
30
+ .custom<GeweGroupReplyModeInput>(
31
+ (value) => typeof value === "string" && GEWE_GROUP_REPLY_MODES.includes(value as GeweGroupReplyModeInput),
32
+ {
33
+ message:
34
+ "invalid GeWe group reply mode; supported values are plain, quote_source, at_sender (quote_and_at is accepted only for compatibility)",
35
+ },
36
+ )
37
+ .optional(),
27
38
  })
28
39
  .strict();
29
40
 
package/src/inbound.ts CHANGED
@@ -37,9 +37,9 @@ import {
37
37
  import type {
38
38
  CoreConfig,
39
39
  GeweDmReplyMode,
40
- GeweGroupReplyMode,
41
40
  GeweInboundMessage,
42
41
  ResolvedGeweAccount,
42
+ ResolvedGeweGroupReplyMode,
43
43
  } from "./types.js";
44
44
  import {
45
45
  extractAppMsgType,
@@ -68,7 +68,7 @@ type PreparedInbound = {
68
68
  groupName?: string;
69
69
  groupSystemPrompt?: string;
70
70
  groupSkillFilter?: string[];
71
- replyMode: GeweGroupReplyMode | GeweDmReplyMode;
71
+ replyMode: ResolvedGeweGroupReplyMode | GeweDmReplyMode;
72
72
  route: ReturnType<ReturnType<typeof getGeweRuntime>["channel"]["routing"]["resolveAgentRoute"]>;
73
73
  storePath: string;
74
74
  toWxid: string;
@@ -113,6 +113,41 @@ function resolveAppMsgPlaceholder(appType?: number): string {
113
113
  return typeof appType === "number" ? `<appmsg:${appType}>` : "<appmsg>";
114
114
  }
115
115
 
116
+ function isQuoteFromBot(params: {
117
+ quoteDetails?: GeweQuoteDetails;
118
+ isGroup: boolean;
119
+ botWxid: string;
120
+ }): boolean {
121
+ const quoteDetails = params.quoteDetails;
122
+ if (!quoteDetails) return false;
123
+ if (!params.isGroup) return true;
124
+
125
+ const botWxid = params.botWxid.trim();
126
+ if (!botWxid) return false;
127
+ return [quoteDetails.fromUsr, quoteDetails.chatUsr].some(
128
+ (value) => value?.trim() === botWxid,
129
+ );
130
+ }
131
+
132
+ function summarizeUnsupportedInboundMessage(message: GeweInboundMessage): string {
133
+ const preview = message.text.replace(/\s+/g, " ").trim().slice(0, 120);
134
+ const parts = [
135
+ `msgType=${message.msgType}`,
136
+ `from=${message.fromId}`,
137
+ `to=${message.toId}`,
138
+ `sender=${message.senderId}`,
139
+ `messageId=${message.messageId}`,
140
+ `newMessageId=${message.newMessageId}`,
141
+ preview ? `text=${JSON.stringify(preview)}` : undefined,
142
+ ];
143
+ return parts.filter(Boolean).join(" ");
144
+ }
145
+
146
+ function summarizeTextPreview(text: string): string | undefined {
147
+ const preview = text.replace(/\s+/g, " ").trim().slice(0, 160);
148
+ return preview ? JSON.stringify(preview) : undefined;
149
+ }
150
+
116
151
  function resolveGewePairCodeCandidate(rawBody: string): string | null {
117
152
  const trimmed = rawBody.trim();
118
153
  if (!trimmed) return null;
@@ -342,7 +377,7 @@ function normalizeInboundEntry(params: {
342
377
  const { message, runtime } = params;
343
378
  const msgType = message.msgType;
344
379
  if (![1, 3, 34, 43, 49].includes(msgType)) {
345
- runtime.log?.(`gewe: skip unsupported msgType ${msgType}`);
380
+ runtime.log?.(`gewe: skip unsupported ${summarizeUnsupportedInboundMessage(message)}`);
346
381
  return null;
347
382
  }
348
383
 
@@ -611,6 +646,7 @@ async function dispatchGeweInbound(params: {
611
646
  mode: prepared.replyMode,
612
647
  isGroup: prepared.isGroup,
613
648
  senderId: prepared.senderId,
649
+ senderName: prepared.senderName,
614
650
  defaultReplyToId: prepared.messageSid,
615
651
  repliedRef,
616
652
  });
@@ -834,15 +870,39 @@ export async function handleGeweInboundBatch(params: {
834
870
  return;
835
871
  }
836
872
 
837
- const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
838
- const wasAtTriggered = mentionRegexes.length
873
+ const route = core.channel.routing.resolveAgentRoute({
874
+ cfg: config as OpenClawConfig,
875
+ channel: CHANNEL_ID,
876
+ accountId: account.accountId,
877
+ peer: {
878
+ kind: isGroup ? "group" : "direct",
879
+ id: isGroup ? groupId ?? "" : senderId,
880
+ },
881
+ });
882
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(
883
+ config as OpenClawConfig,
884
+ route.agentId,
885
+ );
886
+ const nativeAtWxids = Array.from(
887
+ new Set(
888
+ entries
889
+ .flatMap((entry) => entry.message.atWxids ?? [])
890
+ .map((wxid) => wxid.trim())
891
+ .filter(Boolean),
892
+ ),
893
+ );
894
+ const nativeAtAll = entries.some((entry) => entry.message.atAll === true);
895
+ const nativeAtTriggered = nativeAtWxids.includes(lastMessage.botWxid.trim());
896
+ const regexAtTriggered = mentionRegexes.length
839
897
  ? core.channel.mentions.matchesMentionPatterns(rawBodyCandidate, mentionRegexes)
840
898
  : false;
899
+ const wasAtTriggered = nativeAtTriggered || regexAtTriggered;
841
900
  const latestQuote = entries.at(-1)?.quoteDetails;
842
- const wasQuoteTriggered = Boolean(
843
- latestQuote &&
844
- (!isGroup || latestQuote.fromUsr?.trim() === lastMessage.botWxid.trim()),
845
- );
901
+ const wasQuoteTriggered = isQuoteFromBot({
902
+ quoteDetails: latestQuote,
903
+ isGroup,
904
+ botWxid: lastMessage.botWxid,
905
+ });
846
906
  const triggerMode = isGroup
847
907
  ? resolveGeweGroupTriggerMode({
848
908
  groupConfig: groupMatch?.groupConfig,
@@ -862,23 +922,43 @@ export async function handleGeweInboundBatch(params: {
862
922
  commandAuthorized,
863
923
  });
864
924
  if (triggerGate.shouldSkip) {
925
+ const detail =
926
+ triggerMode === "at"
927
+ ? [
928
+ `agent=${route.agentId ?? "default"}`,
929
+ `wasAtTriggered=${String(wasAtTriggered)}`,
930
+ `nativeAtTriggered=${String(nativeAtTriggered)}`,
931
+ `nativeAtAll=${String(nativeAtAll)}`,
932
+ `regexAtTriggered=${String(regexAtTriggered)}`,
933
+ `wasQuoteTriggered=${String(wasQuoteTriggered)}`,
934
+ `mentionRegexes=${JSON.stringify(mentionRegexes.map((regex) => regex.source))}`,
935
+ `nativeAtWxids=${JSON.stringify(nativeAtWxids)}`,
936
+ summarizeTextPreview(rawBodyCandidate)
937
+ ? `rawBody=${summarizeTextPreview(rawBodyCandidate)}`
938
+ : undefined,
939
+ ]
940
+ .filter(Boolean)
941
+ .join(" ")
942
+ : triggerMode === "quote"
943
+ ? [
944
+ `wasQuoteTriggered=${String(wasQuoteTriggered)}`,
945
+ latestQuote?.fromUsr ? `quoteFromUsr=${JSON.stringify(latestQuote.fromUsr)}` : undefined,
946
+ latestQuote?.chatUsr ? `quoteChatUsr=${JSON.stringify(latestQuote.chatUsr)}` : undefined,
947
+ summarizeTextPreview(rawBodyCandidate)
948
+ ? `rawBody=${summarizeTextPreview(rawBodyCandidate)}`
949
+ : undefined,
950
+ ]
951
+ .filter(Boolean)
952
+ .join(" ")
953
+ : undefined;
865
954
  runtime.log?.(
866
955
  isGroup
867
- ? `gewe: drop group ${groupId} (trigger=${triggerMode})`
868
- : `gewe: drop DM sender ${senderId} (trigger=${triggerMode})`,
956
+ ? `gewe: drop group ${groupId} (trigger=${triggerMode})${detail ? ` ${detail}` : ""}`
957
+ : `gewe: drop DM sender ${senderId} (trigger=${triggerMode})${detail ? ` ${detail}` : ""}`,
869
958
  );
870
959
  return;
871
960
  }
872
961
 
873
- const route = core.channel.routing.resolveAgentRoute({
874
- cfg: config as OpenClawConfig,
875
- channel: CHANNEL_ID,
876
- accountId: account.accountId,
877
- peer: {
878
- kind: isGroup ? "group" : "direct",
879
- id: isGroup ? groupId ?? "" : senderId,
880
- },
881
- });
882
962
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
883
963
  agentId: route.agentId,
884
964
  });
package/src/monitor.ts CHANGED
@@ -23,6 +23,7 @@ import type {
23
23
  GeweWebhookServerOptions,
24
24
  ResolvedGeweAccount,
25
25
  } from "./types.js";
26
+ import { extractAtUserList } from "./xml.js";
26
27
 
27
28
  const DEFAULT_WEBHOOK_PORT = 4399;
28
29
  const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
@@ -155,6 +156,8 @@ function payloadToInboundMessage(payload: GeweCallbackPayload): GeweInboundMessa
155
156
  const groupParsed = isGroupChat ? splitGroupContent(content) : { body: content };
156
157
  const senderId = (isGroupChat ? groupParsed.senderId : fromId) ?? fromId;
157
158
  const text = groupParsed.body?.trim() ?? "";
159
+ const atWxids = extractAtUserList(data.MsgSource);
160
+ const atAll = atWxids.includes("notify@all");
158
161
 
159
162
  return {
160
163
  messageId: String(msgId),
@@ -166,6 +169,8 @@ function payloadToInboundMessage(payload: GeweCallbackPayload): GeweInboundMessa
166
169
  senderId,
167
170
  senderName: resolveSenderName(data.PushContent),
168
171
  text,
172
+ atWxids: atWxids.length ? atWxids : undefined,
173
+ atAll,
169
174
  msgType,
170
175
  xml: text,
171
176
  timestamp,
@@ -178,7 +183,7 @@ export function createGeweWebhookServer(opts: GeweWebhookServerOptions): {
178
183
  start: () => Promise<void>;
179
184
  stop: () => void;
180
185
  } {
181
- const { port, host, path, mediaPath, secret, onMessage, onError, abortSignal } = opts;
186
+ const { port, host, path, mediaPath, secret, onRawPayload, onMessage, onError, abortSignal } = opts;
182
187
 
183
188
  const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
184
189
  if (req.url === HEALTH_PATH) {
@@ -230,6 +235,7 @@ export function createGeweWebhookServer(opts: GeweWebhookServerOptions): {
230
235
  return;
231
236
  }
232
237
 
238
+ onRawPayload?.(bodyResult.raw);
233
239
  const payload = parseWebhookPayload(bodyResult.raw);
234
240
  if (!payload) {
235
241
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -357,6 +363,7 @@ export async function monitorGeweProvider(
357
363
  path,
358
364
  mediaPath: shouldStartMedia ? mediaPath : undefined,
359
365
  secret,
366
+ onRawPayload: (raw) => runtime.log?.(`[${account.accountId}] GeWe webhook raw: ${raw}`),
360
367
  onMessage: async (message) => {
361
368
  const isSelf = message.fromId === message.botWxid || message.senderId === message.botWxid;
362
369
  if (isSelf) return;
package/src/policy.ts CHANGED
@@ -15,8 +15,9 @@ import type {
15
15
  GeweDmReplyMode,
16
16
  GeweDmTriggerMode,
17
17
  GeweGroupConfig,
18
- GeweGroupReplyMode,
18
+ GeweGroupReplyModeInput,
19
19
  GeweGroupTriggerMode,
20
+ ResolvedGeweGroupReplyMode,
20
21
  } from "./types.js";
21
22
 
22
23
  function normalizeAllowEntry(raw: string): string {
@@ -189,16 +190,24 @@ export function resolveGeweDmTriggerMode(params: {
189
190
  return params.dmConfig?.trigger?.mode ?? params.wildcardConfig?.trigger?.mode ?? "any_message";
190
191
  }
191
192
 
193
+ function resolveConfiguredGroupReplyMode(
194
+ configuredMode: GeweGroupReplyModeInput | undefined,
195
+ ): ResolvedGeweGroupReplyMode | undefined {
196
+ if (configuredMode === "quote_and_at") {
197
+ return "quote_and_at_compat";
198
+ }
199
+ return configuredMode;
200
+ }
201
+
192
202
  export function resolveGeweGroupReplyMode(params: {
193
203
  groupConfig?: GeweGroupConfig;
194
204
  wildcardConfig?: GeweGroupConfig;
195
205
  autoQuoteReply?: boolean;
196
- }): GeweGroupReplyMode {
197
- return (
198
- params.groupConfig?.reply?.mode ??
199
- params.wildcardConfig?.reply?.mode ??
200
- (params.autoQuoteReply === false ? "plain" : "quote_source")
206
+ }): ResolvedGeweGroupReplyMode {
207
+ const configuredMode = resolveConfiguredGroupReplyMode(
208
+ params.groupConfig?.reply?.mode ?? params.wildcardConfig?.reply?.mode,
201
209
  );
210
+ return configuredMode ?? (params.autoQuoteReply === false ? "plain" : "quote_source");
202
211
  }
203
212
 
204
213
  export function resolveGeweDmReplyMode(params: {
@@ -1,13 +1,13 @@
1
1
  import type { ReplyPayload } from "./openclaw-compat.js";
2
2
  import type {
3
3
  GeweDmReplyMode,
4
- GeweGroupReplyMode,
5
4
  ResolvedGeweAccount,
5
+ ResolvedGeweGroupReplyMode,
6
6
  } from "./types.js";
7
7
 
8
8
  const CHANNEL_DATA_KEY = "gewe-openclaw";
9
9
 
10
- type GeweReplyMode = GeweGroupReplyMode | GeweDmReplyMode;
10
+ type GeweReplyMode = ResolvedGeweGroupReplyMode | GeweDmReplyMode;
11
11
  type RepliedRef = { value: boolean };
12
12
 
13
13
  function canAttachAt(payload: ReplyPayload): boolean {
@@ -64,6 +64,38 @@ function withAtSender(payload: ReplyPayload, senderId: string | undefined): Repl
64
64
  };
65
65
  }
66
66
 
67
+ function withAtSenderPrefix(payload: ReplyPayload, senderName: string | undefined): ReplyPayload {
68
+ const trimmedSenderName = senderName?.trim();
69
+ const trimmedText = payload.text?.trim();
70
+ if (!trimmedSenderName || !trimmedText) {
71
+ return payload;
72
+ }
73
+
74
+ const mentionPrefix = `@${trimmedSenderName}`;
75
+ if (trimmedText === mentionPrefix || trimmedText.startsWith(`${mentionPrefix} `) || trimmedText.startsWith(`${mentionPrefix}\u2005`)) {
76
+ return payload;
77
+ }
78
+
79
+ return {
80
+ ...payload,
81
+ text: `${mentionPrefix}\u2005${trimmedText}`,
82
+ };
83
+ }
84
+
85
+ function withoutDefaultReplyToId(
86
+ payload: ReplyPayload,
87
+ defaultReplyToId: string | undefined,
88
+ ): ReplyPayload {
89
+ const explicitReplyToId = payload.replyToId?.trim();
90
+ const trimmedDefaultReplyToId = defaultReplyToId?.trim();
91
+ if (!explicitReplyToId || !trimmedDefaultReplyToId || explicitReplyToId !== trimmedDefaultReplyToId) {
92
+ return payload;
93
+ }
94
+
95
+ const { replyToId: _ignored, ...rest } = payload;
96
+ return rest;
97
+ }
98
+
67
99
  export function resolveGeweReplyOptions(
68
100
  account: Pick<ResolvedGeweAccount, "config">,
69
101
  opts?: { skillFilter?: string[] },
@@ -83,25 +115,33 @@ export function applyGeweReplyModeToPayload(
83
115
  mode: GeweReplyMode;
84
116
  isGroup: boolean;
85
117
  senderId?: string;
118
+ senderName?: string;
86
119
  defaultReplyToId?: string;
87
120
  repliedRef?: RepliedRef;
88
121
  },
89
122
  ): ReplyPayload {
90
123
  let nextPayload = payload;
91
124
  const effectiveMode =
92
- params.mode === "quote_and_at" && !canAttachAt(payload) ? "quote_source" : params.mode;
125
+ params.mode === "quote_and_at_compat" && !canAttachAt(payload) ? "quote_source" : params.mode;
93
126
 
94
- if (effectiveMode === "quote_source" || effectiveMode === "quote_and_at") {
127
+ if (effectiveMode === "quote_source" || effectiveMode === "quote_and_at_compat") {
95
128
  nextPayload = withReplyToId(nextPayload, params.defaultReplyToId, params.repliedRef);
96
129
  } else if (nextPayload.replyToId?.trim() && params.repliedRef) {
97
130
  params.repliedRef.value = true;
98
131
  }
99
132
 
133
+ if (effectiveMode === "plain" || effectiveMode === "at_sender") {
134
+ nextPayload = withoutDefaultReplyToId(nextPayload, params.defaultReplyToId);
135
+ }
136
+
100
137
  if (
101
138
  params.isGroup &&
102
- (effectiveMode === "at_sender" || effectiveMode === "quote_and_at")
139
+ (effectiveMode === "at_sender" || effectiveMode === "quote_and_at_compat")
103
140
  ) {
104
- nextPayload = withAtSender(nextPayload, params.senderId);
141
+ nextPayload = withAtSenderPrefix(nextPayload, params.senderName);
142
+ if (effectiveMode === "at_sender") {
143
+ nextPayload = withAtSender(nextPayload, params.senderId);
144
+ }
105
145
  }
106
146
 
107
147
  return nextPayload;
package/src/types.ts CHANGED
@@ -9,7 +9,10 @@ import type {
9
9
 
10
10
  export type GeweGroupTriggerMode = "at" | "quote" | "at_or_quote" | "any_message";
11
11
  export type GeweDmTriggerMode = "any_message" | "quote";
12
- export type GeweGroupReplyMode = "plain" | "quote_source" | "at_sender" | "quote_and_at";
12
+ export type GeweGroupReplyMode = "plain" | "quote_source" | "at_sender";
13
+ export type GeweDeprecatedGroupReplyMode = "quote_and_at";
14
+ export type GeweGroupReplyModeInput = GeweGroupReplyMode | GeweDeprecatedGroupReplyMode;
15
+ export type ResolvedGeweGroupReplyMode = GeweGroupReplyMode | "quote_and_at_compat";
13
16
  export type GeweDmReplyMode = "plain" | "quote_source";
14
17
 
15
18
  export type GeweGroupTriggerConfig = {
@@ -21,7 +24,7 @@ export type GeweDmTriggerConfig = {
21
24
  };
22
25
 
23
26
  export type GeweGroupReplyConfig = {
24
- mode?: GeweGroupReplyMode;
27
+ mode?: GeweGroupReplyModeInput;
25
28
  };
26
29
 
27
30
  export type GeweDmReplyConfig = {
@@ -175,6 +178,7 @@ export type GeweCallbackPayload = {
175
178
  ToUserName?: { string?: string };
176
179
  MsgType?: number;
177
180
  Content?: { string?: string };
181
+ MsgSource?: string;
178
182
  CreateTime?: number;
179
183
  PushContent?: string;
180
184
  };
@@ -190,6 +194,8 @@ export type GeweInboundMessage = {
190
194
  senderId: string;
191
195
  senderName?: string;
192
196
  text: string;
197
+ atWxids?: string[];
198
+ atAll?: boolean;
193
199
  msgType: number;
194
200
  xml?: string;
195
201
  timestamp: number;
@@ -202,6 +208,7 @@ export type GeweWebhookServerOptions = {
202
208
  path: string;
203
209
  mediaPath?: string;
204
210
  secret?: string;
211
+ onRawPayload?: (raw: string) => void;
205
212
  onMessage: (message: GeweInboundMessage) => void | Promise<void>;
206
213
  onError?: (error: Error) => void;
207
214
  abortSignal?: AbortSignal;
package/src/xml.ts CHANGED
@@ -120,6 +120,24 @@ export function extractXmlTag(xml: string, tag: string): string | undefined {
120
120
  return decodeEntities(raw);
121
121
  }
122
122
 
123
+ export function extractAtUserList(xml?: string): string[] {
124
+ const atUserList = xml?.trim() ? extractXmlTag(xml, "atuserlist") : undefined;
125
+ if (!atUserList) return [];
126
+
127
+ const seen = new Set<string>();
128
+ const values = atUserList
129
+ .split(/[,\uFF0C;\s]+/)
130
+ .map((value) => value.trim())
131
+ .filter(Boolean)
132
+ .filter((value) => {
133
+ if (seen.has(value)) return false;
134
+ seen.add(value);
135
+ return true;
136
+ });
137
+
138
+ return values;
139
+ }
140
+
123
141
  export function extractAppMsgType(xml: string): number | undefined {
124
142
  const match = /<appmsg[\s\S]*?<type>(\d+)<\/type>/i.exec(xml);
125
143
  if (!match?.[1]) return undefined;