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:
@@ -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;
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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 ?? '已收到,正在处理,完成后发到这里。';
|
||||
}
|
||||
}
|
||||
@@ -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, '收到语音,我先听一下。');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
export const TEMPLATES = {
|
||||
default: [
|
||||
'收到,我马上处理。',
|
||||
'收到,我这就开始。',
|
||||
'好的,我先处理一下。',
|
||||
'明白,我开始了。',
|
||||
'收到,很快给你结果。',
|
||||
],
|
||||
image: [
|
||||
'图片收到,我先看看。',
|
||||
'收到图片,我分析一下。',
|
||||
'让我看看这张图片。',
|
||||
'图片已收到,我来处理。',
|
||||
],
|
||||
voice: [
|
||||
'收到语音,我先听一下。',
|
||||
'听到啦,我马上处理。',
|
||||
'语音收到,我先转文字。',
|
||||
],
|
||||
location: ['收到位置,我来看看。'],
|
||||
link: ['收到链接,我去读一下。'],
|
||||
intent: {
|
||||
translate: ['收到,我开始翻译。', '好的,我来翻译一下。'],
|
||||
summary: ['收到,我开始整理重点。', '好的,我来提炼一下。'],
|
||||
rewrite: ['收到,我开始写。', '好的,我来改一下。'],
|
||||
poster: ['收到,我来设计海报。'],
|
||||
ppt: ['收到,我开始制作 PPT。'],
|
||||
mindmap: ['收到,我来整理思维导图。'],
|
||||
code: ['收到,我来看看代码。', '好的,我开始处理。'],
|
||||
search: ['收到,我去查一下。', '好的,我来搜索一下。'],
|
||||
schedule: ['收到,我来帮你设置。'],
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user