monitor.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. /**
  2. * Inbound monitor — polls the wechat-agent backend and dispatches new
  3. * messages through the OpenClaw agent reply pipeline.
  4. *
  5. * Dispatch sequence mirrors @openclaw/msteams and @openclaw/zalo:
  6. * 1. resolveAgentRoute → session key + agent id
  7. * 2. activity.record → inbound counter
  8. * 3. enqueueSystemEvent → heartbeat preview
  9. * 4. formatInboundEnvelope → wrapped body
  10. * 5. finalizeInboundContext → ctx payload
  11. * 6. updateLastRoute → stick session to this channel
  12. * 7. createReplyDispatcherWithTyping + deliver callback
  13. * 8. withReplyDispatcher → dispatchReplyFromConfig
  14. *
  15. * FAQ fast-path (D1=B): rules run before step 1 and can short-circuit
  16. * the entire pipeline by sending a canned reply directly via sendText.
  17. *
  18. * Batch window (D3=B): messages are accumulated per-conversation for
  19. * batchWindowMs and dispatched as one agent turn.
  20. */
  21. import type {
  22. ChannelAccountSnapshot,
  23. OpenClawConfig,
  24. ReplyPayload,
  25. RuntimeEnv,
  26. } from "openclaw/plugin-sdk";
  27. import { DEFAULT_ACCOUNT_ID, createReplyPrefixOptions } from "./sdk.js";
  28. import { getRuntime } from "./runtime.js";
  29. import {
  30. createClient,
  31. type WechatAgentClient,
  32. } from "./client.js";
  33. import { matchFaq, shouldAlsoDispatchAgent } from "./faq.js";
  34. import { CooldownManager, mergeBatchText } from "./cooldown.js";
  35. import {
  36. CHANNEL_ID,
  37. getChannelCfg,
  38. normalizeAllowFrom,
  39. resolveApiBase,
  40. } from "./config.js";
  41. import type { Logger, RawWechatMessage, WechatAgentConfig } from "./types.js";
  42. import {
  43. advanceCursor,
  44. compareMsgId,
  45. isAfterCursor,
  46. loadCursor,
  47. saveCursor,
  48. toEpochMs,
  49. type InboundCursor,
  50. } from "./cursor-store.js";
  51. import { join } from "node:path";
  52. import { withRetry } from "./retry.js";
  53. export type MonitorOpts = {
  54. cfg: OpenClawConfig;
  55. runtime?: RuntimeEnv;
  56. abortSignal?: AbortSignal;
  57. /**
  58. * Shallow-merge patch into the ChannelAccountSnapshot. Provided by the
  59. * caller (gateway.startAccount) which wraps ctx.getStatus+ctx.setStatus.
  60. */
  61. patchStatus: (patch: Partial<ChannelAccountSnapshot>) => void;
  62. accountId: string;
  63. };
  64. export type MonitorResult = {
  65. shutdown: () => Promise<void>;
  66. };
  67. const POLL_MIN_INTERVAL_MS = 3000;
  68. const SEEN_MAX = 2000;
  69. const SEEN_TRIM_TO = 1000;
  70. const DEFAULT_GROUP_KEYWORDS = ["帮我", "请问"];
  71. const DEFAULT_GROUP_MENTION_TOKENS = ["@bot", "@助手"];
  72. export async function startMonitor(opts: MonitorOpts): Promise<MonitorResult> {
  73. const channelCfg = getChannelCfg(opts.cfg);
  74. const log = makeLogger(opts.runtime);
  75. if (!channelCfg) {
  76. log.info("no channel config; poller not started");
  77. return noopResult();
  78. }
  79. if (channelCfg.enabled === false) {
  80. log.info("channel disabled; poller not started");
  81. return noopResult();
  82. }
  83. if (!channelCfg.apiBase) {
  84. log.warn(`channels.${CHANNEL_ID}.apiBase is missing; poller not started`);
  85. return noopResult();
  86. }
  87. const apiBase = resolveApiBase(channelCfg);
  88. const client = createClient(apiBase);
  89. const pollIntervalMs = Math.max(
  90. POLL_MIN_INTERVAL_MS,
  91. channelCfg.pollIntervalMs ?? 10000
  92. );
  93. const batchWindowMs = Math.max(0, channelCfg.batchWindowMs ?? 3000);
  94. const replyCooldownMs = Math.max(0, (channelCfg.replyCooldownSecPerWxid ?? 5) * 1000);
  95. // Persistent inbound cursor: best-effort resume after restarts.
  96. // We store it alongside OpenClaw's session store so admins can back it up
  97. // together with other channel state.
  98. const core = getRuntime();
  99. const sessionCfg = (opts.cfg as Record<string, unknown>).session as
  100. | { store?: string }
  101. | undefined;
  102. const cursorStoreDir = core.channel.session.resolveStorePath(sessionCfg?.store, {
  103. agentId: CHANNEL_ID,
  104. });
  105. const cursorPath = join(cursorStoreDir, `${CHANNEL_ID}.inbound-cursor.json`);
  106. let cursor: InboundCursor | null = await loadCursor(cursorPath);
  107. // Resolve bot's own wxid so we can filter self-echoes.
  108. let selfWxid: string | null = channelCfg.selfWxid?.trim() || null;
  109. if (!selfWxid) {
  110. try {
  111. selfWxid = await client.getSelfWxid();
  112. if (selfWxid) log.info(`detected self wxid: ${selfWxid}`);
  113. } catch {
  114. /* ignore — backend may not expose it */
  115. }
  116. }
  117. log.info(
  118. `starting poller apiBase=${apiBase} interval=${pollIntervalMs}ms batch=${batchWindowMs}ms cooldown=${replyCooldownMs}ms selfWxid=${selfWxid ?? "<auto>"}`
  119. );
  120. opts.patchStatus({
  121. running: true,
  122. lastStartAt: Date.now(),
  123. lastError: null,
  124. });
  125. // Local counters: surfaced via logs only. ChannelAccountSnapshot does not
  126. // carry channel-specific metrics, so we keep them out of the snapshot to
  127. // stay schema-clean.
  128. const counters = {
  129. pollsOk: 0,
  130. pollsFail: 0,
  131. inboundCount: 0,
  132. outboundCount: 0,
  133. faqHits: 0,
  134. };
  135. // Message dedupe window
  136. const seenMsgIds = new Set<string>();
  137. const markSeen = (id: string): boolean => {
  138. if (seenMsgIds.has(id)) return false;
  139. seenMsgIds.add(id);
  140. if (seenMsgIds.size > SEEN_MAX) {
  141. const overflow = seenMsgIds.size - SEEN_TRIM_TO;
  142. const iter = seenMsgIds.values();
  143. for (let i = 0; i < overflow; i++) {
  144. const next = iter.next();
  145. if (next.done) break;
  146. seenMsgIds.delete(next.value);
  147. }
  148. }
  149. return true;
  150. };
  151. // Batch + cooldown per wxid
  152. const cooldown = new CooldownManager({
  153. batchWindowMs,
  154. replyCooldownMs,
  155. onFlush: async (batch) => {
  156. try {
  157. await dispatchBatchToAgent({
  158. batch,
  159. cfg: opts.cfg,
  160. channelCfg,
  161. client,
  162. log,
  163. onOutboundSent: () => {
  164. counters.outboundCount++;
  165. opts.patchStatus({ lastOutboundAt: Date.now() });
  166. if (batch[0]) cooldown.noteReply(resolveConversationId(batch[0]));
  167. },
  168. });
  169. } catch (err) {
  170. log.error(`dispatch error: ${String(err)}`);
  171. }
  172. },
  173. });
  174. // Polling task
  175. let pollInFlight = false;
  176. const tick = async (): Promise<void> => {
  177. if (pollInFlight) return;
  178. pollInFlight = true;
  179. try {
  180. const online = await client.checkOnline();
  181. opts.patchStatus({ connected: online });
  182. if (!online) {
  183. counters.pollsFail++;
  184. log.debug(`poll fail (offline) pollsFail=${counters.pollsFail}`);
  185. return;
  186. }
  187. const messages = await client.getMessages({
  188. direction: "received",
  189. limit: 50,
  190. signal: opts.abortSignal,
  191. });
  192. counters.pollsOk++;
  193. // Always process in stable order so agent context is consistent.
  194. messages.sort((a, b) => {
  195. const ta = toEpochMs(a.timestamp);
  196. const tb = toEpochMs(b.timestamp);
  197. if (ta !== tb) return ta - tb;
  198. return compareMsgId(String(a.msgId), String(b.msgId));
  199. });
  200. for (const m of messages) {
  201. // Cursor gate first: don't let old messages fill the dedupe window.
  202. if (!isAfterCursor(m, cursor)) continue;
  203. if (!markSeen(m.msgId)) continue;
  204. const ignoredReason = shouldIgnoreMessage(m, channelCfg, selfWxid);
  205. if (ignoredReason) {
  206. log.debug(`message dropped ${m.msgId}: ${ignoredReason}`);
  207. continue;
  208. }
  209. if (!m.isGroup && !passesDmPolicy(m, channelCfg)) {
  210. log.debug(`dm policy drop ${m.fromWxid}`);
  211. continue;
  212. }
  213. if (m.isGroup && !isGroupTriggered(m, channelCfg, selfWxid)) {
  214. log.debug(`group not triggered room=${resolveConversationId(m)} msgId=${m.msgId}`);
  215. continue;
  216. }
  217. counters.inboundCount++;
  218. opts.patchStatus({ lastInboundAt: Date.now() });
  219. // FAQ fast-path
  220. const match = matchFaq(m.content, channelCfg.faq);
  221. if (match) {
  222. counters.faqHits++;
  223. log.info(
  224. `faq hit id=${match.rule.id ?? "-"} keyword=${match.matchedKeyword} hits=${counters.faqHits}`
  225. );
  226. await sendFaqReply(client, m, match.rule.reply, log, channelCfg);
  227. cooldown.noteReply(resolveConversationId(m));
  228. counters.outboundCount++;
  229. opts.patchStatus({ lastOutboundAt: Date.now() });
  230. if (!shouldAlsoDispatchAgent(match)) {
  231. continue;
  232. }
  233. // stop=false: also forward to agent
  234. }
  235. // Batch + dispatch
  236. cooldown.enqueue(m, resolveConversationId(m));
  237. // Advance cursor after enqueue. The actual agent dispatch is async,
  238. // but enqueueing is the "accepted for processing" point for this
  239. // channel. We still keep a bounded dedupe window to tolerate crashes
  240. // between enqueue and outbound delivery.
  241. cursor = advanceCursor(cursor, m);
  242. }
  243. // Best-effort persist cursor once per tick.
  244. if (cursor) {
  245. await saveCursor(cursorPath, cursor);
  246. }
  247. } catch (err) {
  248. counters.pollsFail++;
  249. const msg = err instanceof Error ? err.message : String(err);
  250. opts.patchStatus({ lastError: msg });
  251. log.warn(`poll error: ${msg}`);
  252. } finally {
  253. pollInFlight = false;
  254. }
  255. };
  256. const timer = setInterval(() => {
  257. tick().catch((err) => log.error(`tick crashed: ${String(err)}`));
  258. }, pollIntervalMs);
  259. // DO NOT `timer.unref()`. The poller IS the channel's reason to exist;
  260. // we want it to keep the Node event loop alive as long as the channel
  261. // is supposed to be running. Without the ref, after any period where
  262. // the agent dispatch queue drains (e.g. no new inbound, FAQ pipeline
  263. // idle), Node exits the child process and OpenClaw's health-monitor
  264. // logs `restarting (reason: stopped)` and bumps `auto-restart
  265. // attempt N/10`. Shutdown still works cleanly because `shutdown()`
  266. // calls `clearInterval(timer)` which removes the ref.
  267. // Kick off first poll immediately (async, no await so startAccount returns).
  268. tick().catch((err) => log.error(`first tick crashed: ${String(err)}`));
  269. let shuttingDown = false;
  270. const shutdown = async (): Promise<void> => {
  271. if (shuttingDown) return;
  272. shuttingDown = true;
  273. log.info(
  274. `shutting down poller (pollsOk=${counters.pollsOk} pollsFail=${counters.pollsFail} inbound=${counters.inboundCount} outbound=${counters.outboundCount} faqHits=${counters.faqHits})`
  275. );
  276. clearInterval(timer);
  277. cooldown.stop();
  278. opts.patchStatus({ running: false, lastStopAt: Date.now() });
  279. };
  280. if (opts.abortSignal) {
  281. if (opts.abortSignal.aborted) {
  282. await shutdown();
  283. } else {
  284. opts.abortSignal.addEventListener(
  285. "abort",
  286. () => {
  287. void shutdown();
  288. },
  289. { once: true }
  290. );
  291. }
  292. }
  293. return { shutdown };
  294. }
  295. // ----------------------------- dispatch helpers ----------------------------
  296. type DispatchParams = {
  297. batch: RawWechatMessage[];
  298. cfg: OpenClawConfig;
  299. channelCfg: WechatAgentConfig;
  300. client: WechatAgentClient;
  301. log: Logger;
  302. onOutboundSent: () => void;
  303. };
  304. async function dispatchBatchToAgent(params: DispatchParams): Promise<void> {
  305. const { batch, cfg, client, log, onOutboundSent, channelCfg } = params;
  306. if (batch.length === 0) return;
  307. const core = getRuntime();
  308. const accountId = DEFAULT_ACCOUNT_ID;
  309. const first = batch[0]!;
  310. const last = batch[batch.length - 1]!;
  311. const isGroup = first.isGroup;
  312. const conversationId = resolveConversationId(first);
  313. const senderWxid = resolveSenderWxid(first);
  314. const senderName = first.fromName?.trim() || senderWxid;
  315. const mergedText = mergeBatchText(batch);
  316. const conversationLabel = isGroup
  317. ? `${conversationId} · ${senderName}`
  318. : senderName === conversationId
  319. ? conversationId
  320. : `${senderName} (${conversationId})`;
  321. // 1. Route resolution
  322. const route = core.channel.routing.resolveAgentRoute({
  323. cfg,
  324. channel: CHANNEL_ID,
  325. accountId,
  326. peer: { kind: isGroup ? "group" : "direct", id: conversationId },
  327. });
  328. const sessionKey = route.sessionKey;
  329. // 2. Record activity
  330. core.channel.activity.record({
  331. channel: CHANNEL_ID,
  332. accountId,
  333. direction: "inbound",
  334. });
  335. // 3. System event (heartbeat preview)
  336. const preview = mergedText.replace(/\s+/g, " ").slice(0, 160);
  337. const countSuffix = batch.length > 1 ? ` (×${batch.length})` : "";
  338. core.system.enqueueSystemEvent(
  339. `${isGroup ? "WeChat group" : "WeChat DM"} from ${senderName}${countSuffix}: ${preview}`,
  340. {
  341. sessionKey,
  342. contextKey: `${CHANNEL_ID}:message:${conversationId}:${last.msgId}`,
  343. }
  344. );
  345. // 4. Format envelope
  346. const body = core.channel.reply.formatInboundEnvelope({
  347. channel: "WeChat",
  348. from: conversationLabel,
  349. body: mergedText,
  350. chatType: isGroup ? "group" : "direct",
  351. sender: { name: senderName, id: senderWxid },
  352. });
  353. // 5. Finalize context
  354. const ctxPayload = core.channel.reply.finalizeInboundContext({
  355. Body: body,
  356. BodyForAgent: mergedText,
  357. RawBody: mergedText,
  358. From: `${CHANNEL_ID}:${conversationId}`,
  359. To: conversationId,
  360. SessionKey: sessionKey,
  361. AccountId: route.accountId,
  362. ChatType: isGroup ? "group" : "direct",
  363. ConversationLabel: conversationLabel,
  364. SenderName: senderName,
  365. SenderId: senderWxid,
  366. Provider: CHANNEL_ID,
  367. Surface: CHANNEL_ID,
  368. MessageSid: last.msgId,
  369. OriginatingChannel: CHANNEL_ID,
  370. OriginatingTo: conversationId,
  371. });
  372. // 6. Sticky route for DMs
  373. const sessionCfg = (cfg as Record<string, unknown>).session as
  374. | { store?: string }
  375. | undefined;
  376. const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, {
  377. agentId: route.agentId,
  378. });
  379. await core.channel.session.updateLastRoute({
  380. storePath,
  381. sessionKey: route.mainSessionKey,
  382. deliveryContext: {
  383. channel: CHANNEL_ID,
  384. to: conversationId,
  385. accountId: route.accountId,
  386. },
  387. });
  388. // 7. Reply dispatcher with deliver callback
  389. const textLimit = core.channel.text.resolveTextChunkLimit(
  390. cfg,
  391. CHANNEL_ID,
  392. accountId,
  393. { fallbackLimit: 4000 }
  394. );
  395. const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
  396. cfg,
  397. agentId: route.agentId,
  398. channel: CHANNEL_ID,
  399. accountId,
  400. });
  401. const { dispatcher, replyOptions, markDispatchIdle } =
  402. core.channel.reply.createReplyDispatcherWithTyping({
  403. ...prefixOptions,
  404. humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
  405. deliver: async (payload: ReplyPayload) => {
  406. const text = payload.text ?? "";
  407. if (!text.trim()) return;
  408. const chunkMode = core.channel.text.resolveChunkMode(
  409. cfg,
  410. CHANNEL_ID,
  411. accountId
  412. );
  413. const chunks = core.channel.text.chunkMarkdownTextWithMode(
  414. text,
  415. textLimit,
  416. chunkMode
  417. );
  418. const parts = chunks.length > 0 ? chunks : [text];
  419. for (const chunk of parts) {
  420. if (!chunk) continue;
  421. const result = await withRetry(
  422. () =>
  423. client.sendText({
  424. toWxid: conversationId,
  425. content: chunk,
  426. ats:
  427. isGroup && channelCfg.groupReplyAtSender && senderWxid ? senderWxid : "",
  428. }),
  429. {
  430. tries: 4,
  431. baseDelayMs: 500,
  432. maxDelayMs: 4000,
  433. isRetryable: (err) => {
  434. const msg = err instanceof Error ? err.message : String(err);
  435. if (/timeout|aborted|ECONN|ENOTFOUND|EAI_AGAIN/i.test(msg)) return true;
  436. if (/HTTP 5\d\d/i.test(msg)) return true;
  437. return false;
  438. },
  439. }
  440. );
  441. if (!result.ok) {
  442. log.error(`send-text failed ret=${String(result.ret)} msg=${result.msg ?? ""}`);
  443. throw new Error(
  444. `send-text failed: ${result.msg ?? `ret=${String(result.ret)}`}`
  445. );
  446. }
  447. }
  448. log.info(`delivered reply to ${conversationId} (${text.length} chars)`);
  449. onOutboundSent();
  450. },
  451. onError: (err: unknown, info: { kind: string }) => {
  452. log.error(`reply delivery failed (${info.kind}): ${String(err)}`);
  453. },
  454. });
  455. // 8. Run the LLM pipeline
  456. await core.channel.reply.withReplyDispatcher({
  457. dispatcher,
  458. onSettled: () => markDispatchIdle(),
  459. run: () =>
  460. core.channel.reply.dispatchReplyFromConfig({
  461. ctx: ctxPayload,
  462. cfg,
  463. dispatcher,
  464. replyOptions: {
  465. ...replyOptions,
  466. onModelSelected,
  467. },
  468. }),
  469. });
  470. }
  471. async function sendFaqReply(
  472. client: WechatAgentClient,
  473. m: RawWechatMessage,
  474. reply: string,
  475. log: Logger,
  476. cfg: WechatAgentConfig
  477. ): Promise<void> {
  478. const conversationId = resolveConversationId(m);
  479. const senderWxid = resolveSenderWxid(m);
  480. const result = await client.sendText({
  481. toWxid: conversationId,
  482. content: reply,
  483. ats: m.isGroup && cfg.groupReplyAtSender && senderWxid ? senderWxid : "",
  484. });
  485. if (result.ok) {
  486. log.info(`faq reply sent to ${conversationId}`);
  487. } else {
  488. log.warn(
  489. `faq reply failed to ${conversationId}: ret=${String(result.ret)} msg=${result.msg ?? ""}`
  490. );
  491. }
  492. }
  493. // ----------------------------- filters ----------------------------
  494. function shouldIgnoreMessage(
  495. m: RawWechatMessage,
  496. cfg: WechatAgentConfig,
  497. selfWxid: string | null
  498. ): string | null {
  499. // Self-echoes
  500. if (selfWxid && m.fromWxid === selfWxid) return "self-echo";
  501. if (selfWxid && m.chatroomMemberWxid === selfWxid) return "self-group-echo";
  502. // Group switch
  503. if (m.isGroup && cfg.groupEnabled === false) return "group-disabled";
  504. // Type filter
  505. const ignoreTypes = cfg.ignoreMessageTypes ?? [];
  506. if (ignoreTypes.includes(m.type)) return `ignored-type:${m.type}`;
  507. // v0.2: inbound still text-only
  508. if (m.type !== "text") return "non-text";
  509. // Wxid prefix filter (official accounts etc.)
  510. const prefixes = cfg.ignoreWxidPrefixes ?? [];
  511. for (const p of prefixes) {
  512. if (p && m.fromWxid.startsWith(p)) return `ignored-prefix:${p}`;
  513. }
  514. // Exact wxid filter (system contacts)
  515. const exacts = cfg.ignoreWxidExact ?? [];
  516. if (exacts.includes(m.fromWxid)) return `ignored-wxid:${m.fromWxid}`;
  517. // Empty content
  518. if (!m.content?.trim()) return "empty-content";
  519. return null;
  520. }
  521. function passesDmPolicy(m: RawWechatMessage, cfg: WechatAgentConfig): boolean {
  522. const dmPolicy = cfg.dmPolicy ?? "open";
  523. if (dmPolicy === "open") return true;
  524. const allowFrom = normalizeAllowFrom(cfg.allowFrom);
  525. if (allowFrom.includes(m.fromWxid.toLowerCase())) return true;
  526. // pairing is handled upstream in core; here we treat it like allowlist.
  527. return false;
  528. }
  529. function resolveConversationId(m: RawWechatMessage): string {
  530. if (!m.isGroup) return m.fromWxid;
  531. if (m.chatroomId?.trim()) return m.chatroomId.trim();
  532. if (m.fromWxid.endsWith("@chatroom")) return m.fromWxid;
  533. if (m.toWxid.endsWith("@chatroom")) return m.toWxid;
  534. return m.fromWxid;
  535. }
  536. function resolveSenderWxid(m: RawWechatMessage): string {
  537. if (!m.isGroup) return m.fromWxid;
  538. return m.chatroomMemberWxid?.trim() || m.fromWxid;
  539. }
  540. function isGroupTriggered(
  541. m: RawWechatMessage,
  542. cfg: WechatAgentConfig,
  543. selfWxid: string | null
  544. ): boolean {
  545. if (!m.isGroup) return true;
  546. const content = m.content.toLowerCase();
  547. const keywords = (cfg.groupKeywords?.length ? cfg.groupKeywords : DEFAULT_GROUP_KEYWORDS)
  548. .map((s) => s.trim().toLowerCase())
  549. .filter(Boolean);
  550. if (keywords.some((kw) => content.includes(kw))) return true;
  551. const mentionTokens = (
  552. cfg.groupMentionTokens?.length ? cfg.groupMentionTokens : DEFAULT_GROUP_MENTION_TOKENS
  553. )
  554. .map((s) => s.trim().toLowerCase())
  555. .filter(Boolean);
  556. if (mentionTokens.some((token) => content.includes(token))) return true;
  557. if (!selfWxid) return false;
  558. const atWxids = extractAtWxids(m.raw);
  559. return atWxids.includes(selfWxid.toLowerCase());
  560. }
  561. function extractAtWxids(raw: unknown): string[] {
  562. if (!raw || typeof raw !== "object") return [];
  563. const o = raw as Record<string, unknown>;
  564. const direct = o.atWxids ?? o.ats ?? o.atList ?? o.at;
  565. if (Array.isArray(direct)) {
  566. return direct
  567. .map((v) => String(v).trim().toLowerCase())
  568. .filter(Boolean);
  569. }
  570. if (typeof direct === "string") {
  571. return direct
  572. .split(",")
  573. .map((s) => s.trim().toLowerCase())
  574. .filter(Boolean);
  575. }
  576. return [];
  577. }
  578. // ----------------------------- helpers ----------------------------
  579. function makeLogger(runtime: RuntimeEnv | undefined): Logger {
  580. const log = (prefix: string) =>
  581. (...args: unknown[]) => {
  582. const line = `[${CHANNEL_ID}${prefix}] ${args.map(stringify).join(" ")}`;
  583. runtime?.log?.(line);
  584. };
  585. const err = (...args: unknown[]) => {
  586. const line = `[${CHANNEL_ID}] ${args.map(stringify).join(" ")}`;
  587. (runtime?.error ?? runtime?.log)?.(line);
  588. };
  589. return {
  590. info: log(""),
  591. warn: log(":warn"),
  592. error: err,
  593. debug: log(":debug"),
  594. };
  595. }
  596. function stringify(x: unknown): string {
  597. if (typeof x === "string") return x;
  598. if (x instanceof Error) return x.message;
  599. try {
  600. return JSON.stringify(x);
  601. } catch {
  602. return String(x);
  603. }
  604. }
  605. function noopResult(): MonitorResult {
  606. return { shutdown: async () => {} };
  607. }