0235699曾露 4 месяцев назад
Родитель
Сommit
707327fbe8

+ 1 - 1
README.md

@@ -23,7 +23,7 @@
 
 ```
 openclaw-wechat-skill/
-├── channel-plugin/                  # v2.0 ChannelPlugin(MVP,已交付 0.1.1 tgz)
+├── channel-plugin/                  # v2.0 ChannelPlugin(私聊+群聊触发,已交付 tgz)
 │   ├── index.ts · openclaw.plugin.json
 │   ├── src/channel.ts · monitor.ts · outbound.ts · client.ts · faq.ts · cooldown.ts ...
 │   ├── scripts/build.mjs            # esbuild 单文件 bundle(消除 Cannot find package 'openclaw')

+ 10 - 5
channel-plugin/README.md

@@ -14,10 +14,10 @@ daemon once installed — see "Migrate from the v1.2.x daemon" below.
 
 ## Status
 
-**v0.1.0 — MVP**. Private-chat text only. Group chat, media, and
-multi-account land in v0.2+.
+**v0.2.0**. Private-chat + group-chat (mention/keyword trigger) text inbound
+are supported. Media and multi-account land in later versions.
 
-| Feature                          | v0.1.0 MVP |
+| Feature                          | v0.2.0 |
 |----------------------------------|:----------:|
 | 1-to-1 text inbound / outbound   | ✓          |
 | System / official-account filter | ✓          |
@@ -29,7 +29,7 @@ multi-account land in v0.2+.
 | Agent-driven replies (LLM + skills + memory) | ✓ |
 | DM allowlist policy              | ✓          |
 | Pairing flow                     | ✓ (core-owned) |
-| Group chat + `@mention` gating   | planned v0.2 |
+| Group chat + `@mention`/keyword gating | ✓ |
 | Image / voice / file inbound     | planned v0.2 |
 | Media outbound                   | planned v0.2 |
 | Multi-account                    | planned v0.3 |
@@ -208,6 +208,10 @@ Or edit `~/.openclaw/config.json` (or wherever your config lives):
 | `pollIntervalMs` | `10000` | How often to pull new messages (3s-120s). |
 | `batchWindowMs` | `3000` | Wait this long before dispatching, so rapid-fire messages from one wxid merge into ONE agent turn. |
 | `replyCooldownSecPerWxid` | `5` | Min seconds between two replies to the same wxid. Prevents reply bursts. |
+| `groupEnabled` | `true` | Enable group-chat handling. |
+| `groupKeywords` | `["帮我","请问"]` | Group reply trigger keywords (substring match). |
+| `groupMentionTokens` | `["@bot","@助手"]` | Extra text tokens treated as mention triggers in group messages. |
+| `groupReplyAtSender` | `false` | When true, group replies @-mention sender wxid via `ats`. |
 | `dmPolicy` | `"open"` | `"open"` \| `"allowlist"` \| `"pairing"`. |
 | `allowFrom` | `[]` | Whitelist wxids (used when `dmPolicy="allowlist"`). |
 | `ignoreWxidPrefixes` | `["gh_"]` | Drop messages from wxids starting with any prefix (e.g. `gh_` = official accounts). |
@@ -229,7 +233,8 @@ means: send the FAQ reply AND still hand the message to the agent
    ├── GET /messages?direction=received
    └── for each new msg (dedupe by msgId):
         ├── drop: system contacts / official accounts / self-echoes
-        ├── drop: group (MVP) / non-text (MVP) / empty
+        ├── drop: non-text (MVP) / empty
+        ├── if group: require @mention / keyword trigger
         ├── drop: not in allowlist (when dmPolicy=allowlist)
         ├── FAQ match? → sendText(reply) directly  ← fast path, no LLM

+ 22 - 0
channel-plugin/openclaw.plugin.json

@@ -46,6 +46,28 @@
                 "default": 5,
                 "description": "Minimum seconds between two replies to the same wxid. Prevents the bot from spamming if the agent errors repeatedly."
               },
+              "groupEnabled": {
+                "type": "boolean",
+                "default": true,
+                "description": "Enable group-chat inbound handling. When false, all group messages are ignored."
+              },
+              "groupKeywords": {
+                "type": "array",
+                "items": { "type": "string" },
+                "default": ["帮我", "请问"],
+                "description": "Group messages trigger the agent when content contains any of these keywords."
+              },
+              "groupMentionTokens": {
+                "type": "array",
+                "items": { "type": "string" },
+                "default": ["@bot", "@助手"],
+                "description": "Additional mention-like text tokens that should trigger in groups."
+              },
+              "groupReplyAtSender": {
+                "type": "boolean",
+                "default": false,
+                "description": "When replying in groups, @-mention the sender wxid (if backend supports ats)."
+              },
               "dmPolicy": {
                 "type": "string",
                 "enum": ["open", "allowlist", "pairing"],

Разница между файлами не показана из-за своего большого размера
+ 104 - 133
channel-plugin/package-lock.json


+ 10 - 10
channel-plugin/src/channel.ts

@@ -1,14 +1,15 @@
 /**
  * ChannelPlugin definition for wechat-agent.
  *
- * MVP scope (see ../docs/channel-plugin-design.md):
- *   - Private chat (1-to-1) text only
+ * Current scope:
+ *   - Private chat (1-to-1) text
+ *   - Group chat text (triggered by mention/keywords)
  *   - Single account via DEFAULT_ACCOUNT_ID
  *   - Polling-based inbound (backend polls /messages)
  *   - Outbound via POST /message/send-text
  *
  * Shape follows @openclaw/zalo and knowall-ai/openclaw-msteams.
- * Future work (group chat, media, multi-account) is flagged in TODOs.
+ * Future work (media, multi-account) is flagged in TODOs.
  */
 
 import type {
@@ -46,8 +47,8 @@ const meta = {
   order: 82,
 } as const;
 
-/** Pattern used by messaging.targetResolver for wxid detection. */
-const WXID_PATTERN = /^(?:wxid_[a-z0-9]{10,}|[a-z0-9_-]{4,})$/i;
+/** Pattern used by messaging.targetResolver for wxid/chatroom detection. */
+const WXID_PATTERN = /^(?:wxid_[a-z0-9]{10,}|[a-z0-9_-]{4,}|[a-z0-9_-]+@chatroom)$/i;
 
 export const wechatAgentPlugin: ChannelPlugin<ResolvedWechatAgentAccount> = {
   id: CHANNEL_ID,
@@ -58,8 +59,7 @@ export const wechatAgentPlugin: ChannelPlugin<ResolvedWechatAgentAccount> = {
   },
 
   capabilities: {
-    // MVP: direct only. Group chat requires mention gating; add in v0.2+.
-    chatTypes: ["direct"],
+    chatTypes: ["direct", "group"],
     polls: false,
     threads: false,
     media: false,
@@ -181,7 +181,7 @@ export const wechatAgentPlugin: ChannelPlugin<ResolvedWechatAgentAccount> = {
   },
 
   // --- Messaging adapter ---
-  // Accepts `wxid:<wxid>`, `wechat-agent:<wxid>`, or bare wxid.
+  // Accepts `wxid:<wxid|chatroom_id>`, `wechat-agent:<...>`, or bare id.
   messaging: {
     normalizeTarget: (raw: string): string | undefined => {
       const trimmed = raw.trim();
@@ -196,7 +196,7 @@ export const wechatAgentPlugin: ChannelPlugin<ResolvedWechatAgentAccount> = {
       if (scoped?.[1]) {
         return `wxid:${scoped[1].trim()}`;
       }
-      // Bare wxid
+      // Bare wxid / chatroom id
       if (WXID_PATTERN.test(trimmed)) {
         return `wxid:${trimmed}`;
       }
@@ -210,7 +210,7 @@ export const wechatAgentPlugin: ChannelPlugin<ResolvedWechatAgentAccount> = {
         if (/^(?:wechat-agent|wechat|weixin):/i.test(trimmed)) return true;
         return WXID_PATTERN.test(trimmed);
       },
-      hint: "<wxid:wxid_xxxxxxxxxx>",
+      hint: "<wxid:wxid_xxxxxxxxxx|12345@chatroom>",
     },
   },
 

+ 13 - 14
channel-plugin/src/cooldown.ts

@@ -1,11 +1,11 @@
 /**
- * Per-wxid cooldown + batch-window state machine (decisions D2=A, D3=B).
+ * Per-conversation cooldown + batch-window state machine (decisions D2=A, D3=B).
  *
  * Two cooperating mechanisms:
  *
  *   1. batchWindowMs — when a message arrives, we don't dispatch it to
  *      the agent immediately. Instead we wait up to batchWindowMs to
- *      see if more messages arrive from the same wxid. If they do, we
+ *      see if more messages arrive from the same conversation key. If they do, we
  *      merge them into ONE agent turn. This prevents "user types 3
  *      messages rapid-fire, agent replies 3 times in a row" feel.
  *
@@ -21,10 +21,10 @@
 import type { RawWechatMessage } from "./types.js";
 
 export type BatchState = {
-  wxid: string;
+  key: string;
   buffered: RawWechatMessage[];
   dispatchTimer: NodeJS.Timeout | null;
-  /** Unix ms of the last outbound reply we sent to this wxid. 0 = never. */
+  /** Unix ms of the last outbound reply we sent to this conversation key. 0 = never. */
   lastReplyAt: number;
 };
 
@@ -47,12 +47,11 @@ export class CooldownManager {
    * Ingest one message. Schedules a flush if one isn't already pending,
    * honoring the post-reply cooldown.
    */
-  enqueue(message: RawWechatMessage): void {
-    const key = message.fromWxid;
+  enqueue(message: RawWechatMessage, key: string = message.fromWxid): void {
     let st = this.states.get(key);
     if (!st) {
       st = {
-        wxid: key,
+        key,
         buffered: [],
         dispatchTimer: null,
         lastReplyAt: 0,
@@ -65,23 +64,23 @@ export class CooldownManager {
   }
 
   /**
-   * Record that we just sent a reply to this wxid. Starts the cooldown.
+   * Record that we just sent a reply to this conversation key.
    *
    * This must create the state entry on first call, otherwise a reply sent
    * before any inbound buffering (e.g. FAQ fast-path on the very first
    * message from a stranger) leaves lastReplyAt=0 and the next enqueue
    * mis-computes cooldownRemaining as negative.
    */
-  noteReply(wxid: string, at: number = Date.now()): void {
-    let st = this.states.get(wxid);
+  noteReply(key: string, at: number = Date.now()): void {
+    let st = this.states.get(key);
     if (!st) {
       st = {
-        wxid,
+        key,
         buffered: [],
         dispatchTimer: null,
         lastReplyAt: 0,
       };
-      this.states.set(wxid, st);
+      this.states.set(key, st);
     }
     st.lastReplyAt = at;
   }
@@ -99,9 +98,9 @@ export class CooldownManager {
   }
 
   /** For diagnostics. */
-  inspect(): Array<{ wxid: string; buffered: number; lastReplyAt: number }> {
+  inspect(): Array<{ key: string; buffered: number; lastReplyAt: number }> {
     return [...this.states.values()].map((st) => ({
-      wxid: st.wxid,
+      key: st.key,
       buffered: st.buffered.length,
       lastReplyAt: st.lastReplyAt,
     }));

+ 117 - 40
channel-plugin/src/monitor.ts

@@ -15,7 +15,7 @@
  * FAQ fast-path (D1=B): rules run before step 1 and can short-circuit
  * the entire pipeline by sending a canned reply directly via sendText.
  *
- * Batch window (D3=B): messages are accumulated per-wxid for
+ * Batch window (D3=B): messages are accumulated per-conversation for
  * batchWindowMs and dispatched as one agent turn.
  */
 
@@ -60,6 +60,8 @@ export type MonitorResult = {
 const POLL_MIN_INTERVAL_MS = 3000;
 const SEEN_MAX = 2000;
 const SEEN_TRIM_TO = 1000;
+const DEFAULT_GROUP_KEYWORDS = ["帮我", "请问"];
+const DEFAULT_GROUP_MENTION_TOKENS = ["@bot", "@助手"];
 
 export async function startMonitor(opts: MonitorOpts): Promise<MonitorResult> {
   const channelCfg = getChannelCfg(opts.cfg);
@@ -150,7 +152,7 @@ export async function startMonitor(opts: MonitorOpts): Promise<MonitorResult> {
           onOutboundSent: () => {
             counters.outboundCount++;
             opts.patchStatus({ lastOutboundAt: Date.now() });
-            if (batch[0]) cooldown.noteReply(batch[0].fromWxid);
+            if (batch[0]) cooldown.noteReply(resolveConversationId(batch[0]));
           },
         });
       } catch (err) {
@@ -182,11 +184,19 @@ export async function startMonitor(opts: MonitorOpts): Promise<MonitorResult> {
 
       for (const m of messages) {
         if (!markSeen(m.msgId)) continue;
-        if (shouldIgnoreMessage(m, channelCfg, selfWxid)) continue;
-        if (!passesDmPolicy(m, channelCfg)) {
+        const ignoredReason = shouldIgnoreMessage(m, channelCfg, selfWxid);
+        if (ignoredReason) {
+          log.debug(`message dropped ${m.msgId}: ${ignoredReason}`);
+          continue;
+        }
+        if (!m.isGroup && !passesDmPolicy(m, channelCfg)) {
           log.debug(`dm policy drop ${m.fromWxid}`);
           continue;
         }
+        if (m.isGroup && !isGroupTriggered(m, channelCfg, selfWxid)) {
+          log.debug(`group not triggered room=${resolveConversationId(m)} msgId=${m.msgId}`);
+          continue;
+        }
         counters.inboundCount++;
         opts.patchStatus({ lastInboundAt: Date.now() });
 
@@ -197,8 +207,8 @@ export async function startMonitor(opts: MonitorOpts): Promise<MonitorResult> {
           log.info(
             `faq hit id=${match.rule.id ?? "-"} keyword=${match.matchedKeyword} hits=${counters.faqHits}`
           );
-          await sendFaqReply(client, m, match.rule.reply, log);
-          cooldown.noteReply(m.fromWxid);
+          await sendFaqReply(client, m, match.rule.reply, log, channelCfg);
+          cooldown.noteReply(resolveConversationId(m));
           counters.outboundCount++;
           opts.patchStatus({ lastOutboundAt: Date.now() });
 
@@ -209,7 +219,7 @@ export async function startMonitor(opts: MonitorOpts): Promise<MonitorResult> {
         }
 
         // Batch + dispatch
-        cooldown.enqueue(m);
+        cooldown.enqueue(m, resolveConversationId(m));
       }
     } catch (err) {
       counters.pollsFail++;
@@ -277,24 +287,30 @@ type DispatchParams = {
 };
 
 async function dispatchBatchToAgent(params: DispatchParams): Promise<void> {
-  const { batch, cfg, client, log, onOutboundSent } = params;
+  const { batch, cfg, client, log, onOutboundSent, channelCfg } = params;
   if (batch.length === 0) return;
 
   const core = getRuntime();
   const accountId = DEFAULT_ACCOUNT_ID;
   const first = batch[0]!;
   const last = batch[batch.length - 1]!;
-  const wxid = first.fromWxid;
-  const fromName = first.fromName?.trim() || wxid;
+  const isGroup = first.isGroup;
+  const conversationId = resolveConversationId(first);
+  const senderWxid = resolveSenderWxid(first);
+  const senderName = first.fromName?.trim() || senderWxid;
   const mergedText = mergeBatchText(batch);
-  const conversationLabel = fromName === wxid ? wxid : `${fromName} (${wxid})`;
+  const conversationLabel = isGroup
+    ? `${conversationId} · ${senderName}`
+    : senderName === conversationId
+      ? conversationId
+      : `${senderName} (${conversationId})`;
 
   // 1. Route resolution
   const route = core.channel.routing.resolveAgentRoute({
     cfg,
     channel: CHANNEL_ID,
     accountId,
-    peer: { kind: "direct", id: wxid },
+    peer: { kind: isGroup ? "group" : "direct", id: conversationId },
   });
   const sessionKey = route.sessionKey;
 
@@ -309,10 +325,10 @@ async function dispatchBatchToAgent(params: DispatchParams): Promise<void> {
   const preview = mergedText.replace(/\s+/g, " ").slice(0, 160);
   const countSuffix = batch.length > 1 ? ` (×${batch.length})` : "";
   core.system.enqueueSystemEvent(
-    `WeChat DM from ${fromName}${countSuffix}: ${preview}`,
+    `${isGroup ? "WeChat group" : "WeChat DM"} from ${senderName}${countSuffix}: ${preview}`,
     {
       sessionKey,
-      contextKey: `${CHANNEL_ID}:message:${wxid}:${last.msgId}`,
+      contextKey: `${CHANNEL_ID}:message:${conversationId}:${last.msgId}`,
     }
   );
 
@@ -321,8 +337,8 @@ async function dispatchBatchToAgent(params: DispatchParams): Promise<void> {
     channel: "WeChat",
     from: conversationLabel,
     body: mergedText,
-    chatType: "direct",
-    sender: { name: fromName, id: wxid },
+    chatType: isGroup ? "group" : "direct",
+    sender: { name: senderName, id: senderWxid },
   });
 
   // 5. Finalize context
@@ -330,19 +346,19 @@ async function dispatchBatchToAgent(params: DispatchParams): Promise<void> {
     Body: body,
     BodyForAgent: mergedText,
     RawBody: mergedText,
-    From: `${CHANNEL_ID}:${wxid}`,
-    To: wxid,
+    From: `${CHANNEL_ID}:${conversationId}`,
+    To: conversationId,
     SessionKey: sessionKey,
     AccountId: route.accountId,
-    ChatType: "direct",
+    ChatType: isGroup ? "group" : "direct",
     ConversationLabel: conversationLabel,
-    SenderName: fromName,
-    SenderId: wxid,
+    SenderName: senderName,
+    SenderId: senderWxid,
     Provider: CHANNEL_ID,
     Surface: CHANNEL_ID,
     MessageSid: last.msgId,
     OriginatingChannel: CHANNEL_ID,
-    OriginatingTo: wxid,
+    OriginatingTo: conversationId,
   });
 
   // 6. Sticky route for DMs
@@ -357,7 +373,7 @@ async function dispatchBatchToAgent(params: DispatchParams): Promise<void> {
     sessionKey: route.mainSessionKey,
     deliveryContext: {
       channel: CHANNEL_ID,
-      to: wxid,
+        to: conversationId,
       accountId: route.accountId,
     },
   });
@@ -400,8 +416,9 @@ async function dispatchBatchToAgent(params: DispatchParams): Promise<void> {
         for (const chunk of parts) {
           if (!chunk) continue;
           const result = await client.sendText({
-            toWxid: wxid,
+            toWxid: conversationId,
             content: chunk,
+            ats: isGroup && channelCfg.groupReplyAtSender && senderWxid ? senderWxid : "",
           });
           if (!result.ok) {
             log.error(`send-text failed ret=${String(result.ret)} msg=${result.msg ?? ""}`);
@@ -410,7 +427,7 @@ async function dispatchBatchToAgent(params: DispatchParams): Promise<void> {
             );
           }
         }
-        log.info(`delivered reply to ${wxid} (${text.length} chars)`);
+        log.info(`delivered reply to ${conversationId} (${text.length} chars)`);
         onOutboundSent();
       },
       onError: (err: unknown, info: { kind: string }) => {
@@ -439,17 +456,21 @@ async function sendFaqReply(
   client: WechatAgentClient,
   m: RawWechatMessage,
   reply: string,
-  log: Logger
+  log: Logger,
+  cfg: WechatAgentConfig
 ): Promise<void> {
+  const conversationId = resolveConversationId(m);
+  const senderWxid = resolveSenderWxid(m);
   const result = await client.sendText({
-    toWxid: m.fromWxid,
+    toWxid: conversationId,
     content: reply,
+    ats: m.isGroup && cfg.groupReplyAtSender && senderWxid ? senderWxid : "",
   });
   if (result.ok) {
-    log.info(`faq reply sent to ${m.fromWxid}`);
+    log.info(`faq reply sent to ${conversationId}`);
   } else {
     log.warn(
-      `faq reply failed to ${m.fromWxid}: ret=${String(result.ret)} msg=${result.msg ?? ""}`
+      `faq reply failed to ${conversationId}: ret=${String(result.ret)} msg=${result.msg ?? ""}`
     );
   }
 }
@@ -460,34 +481,35 @@ function shouldIgnoreMessage(
   m: RawWechatMessage,
   cfg: WechatAgentConfig,
   selfWxid: string | null
-): boolean {
+): string | null {
   // Self-echoes
-  if (selfWxid && m.fromWxid === selfWxid) return true;
+  if (selfWxid && m.fromWxid === selfWxid) return "self-echo";
+  if (selfWxid && m.chatroomMemberWxid === selfWxid) return "self-group-echo";
 
-  // MVP: direct only
-  if (m.isGroup) return true;
+  // Group switch
+  if (m.isGroup && cfg.groupEnabled === false) return "group-disabled";
 
   // Type filter
   const ignoreTypes = cfg.ignoreMessageTypes ?? [];
-  if (ignoreTypes.includes(m.type)) return true;
+  if (ignoreTypes.includes(m.type)) return `ignored-type:${m.type}`;
 
-  // MVP: text only
-  if (m.type !== "text") return true;
+  // v0.2: inbound still text-only
+  if (m.type !== "text") return "non-text";
 
   // Wxid prefix filter (official accounts etc.)
   const prefixes = cfg.ignoreWxidPrefixes ?? [];
   for (const p of prefixes) {
-    if (p && m.fromWxid.startsWith(p)) return true;
+    if (p && m.fromWxid.startsWith(p)) return `ignored-prefix:${p}`;
   }
 
   // Exact wxid filter (system contacts)
   const exacts = cfg.ignoreWxidExact ?? [];
-  if (exacts.includes(m.fromWxid)) return true;
+  if (exacts.includes(m.fromWxid)) return `ignored-wxid:${m.fromWxid}`;
 
   // Empty content
-  if (!m.content?.trim()) return true;
+  if (!m.content?.trim()) return "empty-content";
 
-  return false;
+  return null;
 }
 
 function passesDmPolicy(m: RawWechatMessage, cfg: WechatAgentConfig): boolean {
@@ -501,6 +523,61 @@ function passesDmPolicy(m: RawWechatMessage, cfg: WechatAgentConfig): boolean {
   return false;
 }
 
+function resolveConversationId(m: RawWechatMessage): string {
+  if (!m.isGroup) return m.fromWxid;
+  if (m.chatroomId?.trim()) return m.chatroomId.trim();
+  if (m.fromWxid.endsWith("@chatroom")) return m.fromWxid;
+  if (m.toWxid.endsWith("@chatroom")) return m.toWxid;
+  return m.fromWxid;
+}
+
+function resolveSenderWxid(m: RawWechatMessage): string {
+  if (!m.isGroup) return m.fromWxid;
+  return m.chatroomMemberWxid?.trim() || m.fromWxid;
+}
+
+function isGroupTriggered(
+  m: RawWechatMessage,
+  cfg: WechatAgentConfig,
+  selfWxid: string | null
+): boolean {
+  if (!m.isGroup) return true;
+  const content = m.content.toLowerCase();
+  const keywords = (cfg.groupKeywords?.length ? cfg.groupKeywords : DEFAULT_GROUP_KEYWORDS)
+    .map((s) => s.trim().toLowerCase())
+    .filter(Boolean);
+  if (keywords.some((kw) => content.includes(kw))) return true;
+
+  const mentionTokens = (
+    cfg.groupMentionTokens?.length ? cfg.groupMentionTokens : DEFAULT_GROUP_MENTION_TOKENS
+  )
+    .map((s) => s.trim().toLowerCase())
+    .filter(Boolean);
+  if (mentionTokens.some((token) => content.includes(token))) return true;
+
+  if (!selfWxid) return false;
+  const atWxids = extractAtWxids(m.raw);
+  return atWxids.includes(selfWxid.toLowerCase());
+}
+
+function extractAtWxids(raw: unknown): string[] {
+  if (!raw || typeof raw !== "object") return [];
+  const o = raw as Record<string, unknown>;
+  const direct = o.atWxids ?? o.ats ?? o.atList ?? o.at;
+  if (Array.isArray(direct)) {
+    return direct
+      .map((v) => String(v).trim().toLowerCase())
+      .filter(Boolean);
+  }
+  if (typeof direct === "string") {
+    return direct
+      .split(",")
+      .map((s) => s.trim().toLowerCase())
+      .filter(Boolean);
+  }
+  return [];
+}
+
 // ----------------------------- helpers ----------------------------
 
 function makeLogger(runtime: RuntimeEnv | undefined): Logger {

+ 4 - 0
channel-plugin/src/types.ts

@@ -14,6 +14,10 @@ export type WechatAgentConfig = {
   pollIntervalMs?: number;
   batchWindowMs?: number;
   replyCooldownSecPerWxid?: number;
+  groupEnabled?: boolean;
+  groupKeywords?: string[];
+  groupMentionTokens?: string[];
+  groupReplyAtSender?: boolean;
   dmPolicy?: "open" | "allowlist" | "pairing";
   allowFrom?: Array<string | number>;
   ignoreWxidPrefixes?: string[];

Некоторые файлы не были показаны из-за большого количества измененных файлов