|
|
@@ -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 {
|