mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 19:43:43 +08:00
103 lines
3.8 KiB
TypeScript
103 lines
3.8 KiB
TypeScript
import {
|
||
matchKeyword,
|
||
type AutomationMessageScope,
|
||
type AutomationRule
|
||
} from './automation'
|
||
|
||
/**
|
||
* TriggerMatcher —— 判断「这条消息是否命中这条规则」。
|
||
*
|
||
* **纯函数**,放在 shared 而不是 main,理由是它有两个消费方:
|
||
* 1. 真实执行链路(AutomationService);
|
||
* 2. 规则编辑页的「效果模拟预览」。
|
||
*
|
||
* 如果预览自己写一套判断,界面演示和真实行为必然逐渐分叉 ——
|
||
* 那种分叉用户看不出来,但会导致"预览能过、实际不触发"。
|
||
*/
|
||
|
||
/**
|
||
* 判定所需的**最小**消息输入。
|
||
*
|
||
* 刻意不直接依赖 main 侧的 `NormalizedIncomingMessage`:shared 不允许反向依赖 main。
|
||
* 结构化的类型让 main 侧可以直接把那个 DTO 传进来。
|
||
*/
|
||
export interface AutomationMatchInput {
|
||
content?: string
|
||
/** **真 @ 目标**(由 `source → <atuserlist>` 解析,不是正文里的 `@昵称`)。 */
|
||
mentionTargets: string[]
|
||
isSelf: boolean
|
||
isGroup: boolean
|
||
/** 会话标识(群 md5)。 */
|
||
conversationId: string
|
||
}
|
||
|
||
export type AutomationMatchFailure =
|
||
| 'rule_type'
|
||
| 'disabled'
|
||
| 'self'
|
||
| 'scope'
|
||
| 'conversation'
|
||
| 'mention'
|
||
| 'keyword'
|
||
|
||
export interface AutomationMatchResult {
|
||
matched: boolean
|
||
/** 未命中的原因。仅用于内部日志与调优,不展示给用户。 */
|
||
reason?: AutomationMatchFailure
|
||
}
|
||
|
||
/** 判定顺序:越靠前的越便宜,且都是「一票否决」。 */
|
||
export function matchAutomationRule(
|
||
rule: AutomationRule,
|
||
input: AutomationMatchInput,
|
||
/** 自己的 username 候选集(`getMyUsernameCandidates()`)。 */
|
||
selfUsernames: readonly string[]
|
||
): AutomationMatchResult {
|
||
// **规则类型闸门放在最前面。**
|
||
//
|
||
// 「退群通知」与「定时日报」的空关键词 + 不要求 @我,会让它们命中**每一条群消息** ——
|
||
// 那是最坏的一类 bug:用户什么都没配,规则却开始乱跑。
|
||
// 类型分派必须在这里一处解决,预览与真实执行才会一致。
|
||
if (rule.ruleType !== 'daily_report') {
|
||
return { matched: false, reason: 'rule_type' }
|
||
}
|
||
|
||
if (!rule.enabled) return { matched: false, reason: 'disabled' }
|
||
|
||
// 防死循环第一道闸:自己发的消息默认不触发。
|
||
// TraceMemo 回复的那句「收到,正在生成今日日报」会被 MessageListener 再次读到,
|
||
// 没有这道闸就会自己触发自己。
|
||
if (rule.conditions.ignoreSelf !== false && input.isSelf) {
|
||
return { matched: false, reason: 'self' }
|
||
}
|
||
|
||
const scope: AutomationMessageScope = rule.scope
|
||
if (scope === 'group' && !input.isGroup) return { matched: false, reason: 'scope' }
|
||
if (scope === 'direct' && input.isGroup) return { matched: false, reason: 'scope' }
|
||
|
||
// 生效范围:**空数组 = 不限会话**。
|
||
const conversationIds = rule.conditions.conversationIds ?? []
|
||
if (conversationIds.length && !conversationIds.includes(input.conversationId)) {
|
||
return { matched: false, reason: 'conversation' }
|
||
}
|
||
|
||
// 真 @ 判定:只认 `source → atuserlist` 解析出的 username,
|
||
// 与自己的候选 username 做**精确匹配**。
|
||
//
|
||
// 三条禁止的做法(Spike 已实证):
|
||
// - `content.includes('@我的昵称')` —— 正文里的 @ 是纯文本,与真 @ 无关;
|
||
// - 昵称匹配 —— 昵称会改;
|
||
// - `target.startsWith('wxid_')` —— 自己的 username 可能压根不是这个前缀。
|
||
if (rule.conditions.requireMentionMe) {
|
||
const self = new Set(selfUsernames.filter(Boolean))
|
||
const mentioned = input.mentionTargets.some((target) => self.has(target))
|
||
if (!mentioned) return { matched: false, reason: 'mention' }
|
||
}
|
||
|
||
if (!matchKeyword(input.content, rule.conditions.keyword, rule.conditions.keywordMatchMode)) {
|
||
return { matched: false, reason: 'keyword' }
|
||
}
|
||
|
||
return { matched: true }
|
||
}
|