Add smart ACK provider for WeChat MP replies

Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
john
2026-06-26 15:19:03 +08:00
parent 9ed4fd48d7
commit 9b4a25799f
162 changed files with 17276 additions and 2054 deletions
+62
View File
@@ -0,0 +1,62 @@
import { TEMPLATES } from './ack-templates.mjs';
function pickRandom(arr, randomEnabled) {
if (!randomEnabled || arr.length === 1) return arr[0];
return arr[Math.floor(Math.random() * arr.length)];
}
function withNickname(text, nickname, nicknameEnabled) {
if (!nicknameEnabled || !nickname) return text;
return `${nickname}${text}`;
}
function buildText(pool, ctx) {
const text = pickRandom(pool, ctx.randomEnabled);
return withNickname(text, ctx.nickname, ctx.nicknameEnabled);
}
const ImageBuilder = {
priority: 80,
support: (ctx) => ctx.msgType === 'image',
build: (ctx) => buildText(TEMPLATES.image, ctx),
};
const VoiceBuilder = {
priority: 80,
support: (ctx) => ctx.msgType === 'voice',
build: (ctx) => buildText(TEMPLATES.voice, ctx),
};
const LocationBuilder = {
priority: 80,
support: (ctx) => ctx.msgType === 'location',
build: (ctx) => buildText(TEMPLATES.location, ctx),
};
const LinkBuilder = {
priority: 80,
support: (ctx) => ctx.msgType === 'link',
build: (ctx) => buildText(TEMPLATES.link, ctx),
};
const IntentBuilder = {
priority: 50,
support: (ctx) => {
const task = ctx.intent?.task;
return task && task !== 'unknown' && TEMPLATES.intent[task];
},
build: (ctx) => buildText(TEMPLATES.intent[ctx.intent.task], ctx),
};
const DefaultBuilder = {
priority: 0,
support: () => true,
build: (ctx) => buildText(TEMPLATES.default, ctx),
};
const BUILDERS = [ImageBuilder, VoiceBuilder, LocationBuilder, LinkBuilder, IntentBuilder, DefaultBuilder]
.sort((a, b) => b.priority - a.priority);
export function selectBuilder(ctx) {
return BUILDERS.find((b) => b.support(ctx)) ?? DefaultBuilder;
}
+30
View File
@@ -0,0 +1,30 @@
import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs';
const INTENT_RULES = [
{ task: 'translate', pattern: /翻译|译成|translate/i },
{ task: 'summary', pattern: /总结|概括|摘要|提炼|归纳/ },
{ task: 'rewrite', pattern: /改写|润色|优化文|帮我写|写一[篇封份个]/ },
{ task: 'poster', pattern: /海报|宣传图|banner/i },
{ task: 'ppt', pattern: /ppt|PPT|幻灯片/ },
{ task: 'mindmap', pattern: /思维导图|脑图/ },
{ task: 'code', pattern: /代码|写个函数|写个方法|debug|报错|bug/i },
{ task: 'search', pattern: /查一下|搜索|帮我找|查找/ },
];
export function resolveIntent(msgType, text) {
if (msgType === 'image') return { task: 'image_analysis' };
if (msgType === 'voice') return { task: 'voice_analysis' };
if (msgType === 'location') return { task: 'location' };
if (msgType === 'link') return { task: 'link' };
const t = String(text ?? '').trim();
if (!t) return { task: 'unknown' };
if (shouldUseScheduleAssistant(t)) return { task: 'schedule' };
for (const rule of INTENT_RULES) {
if (rule.pattern.test(t)) return { task: rule.task };
}
return { task: 'unknown' };
}
+29
View File
@@ -0,0 +1,29 @@
import { resolveIntent } from './ack-intent.mjs';
import { selectBuilder } from './ack-builders.mjs';
function readConfig(config) {
return {
randomEnabled: config?.ackRandomEnabled !== false,
nicknameEnabled: config?.ackNicknameEnabled !== false,
};
}
// intent: normalizeWechatInboundIntent output ({ msgType, agentText, ... })
// nickname: resolved display name string (empty string if unavailable)
// config: loadWechatMpConfig output (plus ackRandomEnabled, ackNicknameEnabled)
// fallbackText: config.ackText — returned on any error
export function buildAckText({ intent, nickname, config, fallbackText }) {
try {
const cfg = readConfig(config);
const ctx = {
msgType: String(intent?.msgType ?? 'text'),
nickname: String(nickname ?? ''),
intent: resolveIntent(intent?.msgType, intent?.agentText),
randomEnabled: cfg.randomEnabled,
nicknameEnabled: cfg.nicknameEnabled,
};
return selectBuilder(ctx).build(ctx);
} catch {
return fallbackText ?? '已收到,正在处理,完成后发到这里。';
}
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildAckText } from './ack-provider.mjs';
const cfg = { ackRandomEnabled: false, ackNicknameEnabled: false, ackText: '已收到,正在处理,完成后发到这里。' };
const cfgNick = { ...cfg, ackNicknameEnabled: true };
function intent(msgType, agentText = '') {
return { msgType, agentText };
}
describe('buildAckText', () => {
it('returns fallback when config throws', () => {
const badConfig = { get ackRandomEnabled() { throw new Error('bad'); } };
const result = buildAckText({ intent: intent('text', '你好'), nickname: '', config: badConfig, fallbackText: '兜底' });
assert.equal(result, '兜底');
});
it('null intent falls through to default template gracefully', () => {
const result = buildAckText({ intent: null, nickname: '', config: cfg, fallbackText: '兜底' });
assert.equal(result, '收到,我马上处理。');
});
it('image → image template', () => {
const result = buildAckText({ intent: intent('image'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '图片收到,我先看看。');
});
it('voice → voice template', () => {
const result = buildAckText({ intent: intent('voice'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到语音,我先听一下。');
});
it('location → location template', () => {
const result = buildAckText({ intent: intent('location'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到位置,我来看看。');
});
it('link → link template', () => {
const result = buildAckText({ intent: intent('link'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到链接,我去读一下。');
});
it('text with translate intent', () => {
const result = buildAckText({ intent: intent('text', '帮我翻译一段文字'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到,我开始翻译。');
});
it('text with summary intent', () => {
const result = buildAckText({ intent: intent('text', '帮我总结一下这篇文章'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到,我开始整理重点。');
});
it('text with rewrite intent', () => {
const result = buildAckText({ intent: intent('text', '帮我写一篇文章'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到,我开始写。');
});
it('text with ppt intent', () => {
const result = buildAckText({ intent: intent('text', '帮我做个PPT'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到,我开始制作 PPT。');
});
it('text with mindmap intent', () => {
const result = buildAckText({ intent: intent('text', '帮我画个思维导图'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到,我来整理思维导图。');
});
it('text with code intent', () => {
const result = buildAckText({ intent: intent('text', '帮我看看这段代码'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到,我来看看代码。');
});
it('unknown text → default template', () => {
const result = buildAckText({ intent: intent('text', '随便说点什么'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到,我马上处理。');
});
it('nickname prepended when enabled', () => {
const result = buildAckText({ intent: intent('text', '随便'), nickname: '小明', config: cfgNick, fallbackText: '' });
assert.equal(result, '小明,收到,我马上处理。');
});
it('no nickname prefix when nickname is empty', () => {
const result = buildAckText({ intent: intent('text', '随便'), nickname: '', config: cfgNick, fallbackText: '' });
assert.equal(result, '收到,我马上处理。');
});
it('voice with agentText still uses voice template (msgType wins)', () => {
const result = buildAckText({ intent: intent('voice', '帮我翻译一下'), nickname: '', config: cfg, fallbackText: '' });
assert.equal(result, '收到语音,我先听一下。');
});
});
+33
View File
@@ -0,0 +1,33 @@
export const TEMPLATES = {
default: [
'收到,我马上处理。',
'收到,我这就开始。',
'好的,我先处理一下。',
'明白,我开始了。',
'收到,很快给你结果。',
],
image: [
'图片收到,我先看看。',
'收到图片,我分析一下。',
'让我看看这张图片。',
'图片已收到,我来处理。',
],
voice: [
'收到语音,我先听一下。',
'听到啦,我马上处理。',
'语音收到,我先转文字。',
],
location: ['收到位置,我来看看。'],
link: ['收到链接,我去读一下。'],
intent: {
translate: ['收到,我开始翻译。', '好的,我来翻译一下。'],
summary: ['收到,我开始整理重点。', '好的,我来提炼一下。'],
rewrite: ['收到,我开始写。', '好的,我来改一下。'],
poster: ['收到,我来设计海报。'],
ppt: ['收到,我开始制作 PPT。'],
mindmap: ['收到,我来整理思维导图。'],
code: ['收到,我来看看代码。', '好的,我开始处理。'],
search: ['收到,我去查一下。', '好的,我来搜索一下。'],
schedule: ['收到,我来帮你设置。'],
},
};