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
+396 -3
View File
@@ -11,6 +11,9 @@
import path from 'node:path';
import fs from 'node:fs';
import readline from 'node:readline';
import { execFileSync } from 'node:child_process';
import mysql from 'mysql2/promise';
import { createScheduleService } from './schedule-service.mjs';
const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
if (!SANDBOX_ROOT) {
@@ -19,6 +22,13 @@ if (!SANDBOX_ROOT) {
}
const SANDBOX = path.resolve(SANDBOX_ROOT);
const PRIVATE_DATA_DIR = path.join(SANDBOX, '.mindspace');
const PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, 'private-data.sqlite');
const SQLITE_BIN = process.env.SQLITE_BIN?.trim() || 'sqlite3';
const PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024);
const PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5000);
const PRIVATE_DATA_MAX_ROWS = Number(process.env.PRIVATE_DATA_MAX_ROWS ?? 200);
const PRIVATE_DATA_USER_ID = process.env.PRIVATE_DATA_USER_ID?.trim();
const allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim();
const ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(',').map((s) => s.trim())) : null;
@@ -94,11 +104,314 @@ const ALL_TOOLS = [
required: ['path'],
},
},
{
name: 'private_data_info',
description:
'查看当前用户“用户私有数据空间”的状态。每个用户只有一个私有 SQLite 数据库,适合保存问卷、表单、清单、调研数据和分析中间表;不要创建额外 SQLite 文件。',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'private_data_schema',
description:
'查看用户私有数据空间中的表和字段。用于了解当前用户自己的 SQLite 表结构。',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'private_data_query',
description:
'只读查询用户私有数据空间。仅允许 SELECT/WITH,默认最多返回 200 行,适合统计问卷回答、筛选表单数据、分析用户私有数据。',
inputSchema: {
type: 'object',
properties: {
sql: { type: 'string', description: '只读 SQL,必须是 SELECT/WITH' },
},
required: ['sql'],
},
},
{
name: 'private_data_execute',
description:
'写入或变更用户私有数据空间。可用于创建问卷/表单/清单等私有表并写入数据;禁止 ATTACH、load_extension、PRAGMA writable_schema、VACUUM INTO 等越界或危险操作。',
inputSchema: {
type: 'object',
properties: {
sql: { type: 'string', description: '要执行的 SQLite SQL' },
},
required: ['sql'],
},
},
];
let quotaPool = null;
let scheduleService = null;
function isQuotaSyncConfigured() {
return Boolean(
PRIVATE_DATA_USER_ID &&
(process.env.DATABASE_URL ||
(process.env.MYSQL_HOST && process.env.MYSQL_DATABASE)),
);
}
function getQuotaPool() {
if (!isQuotaSyncConfigured()) return null;
if (quotaPool) return quotaPool;
quotaPool = process.env.DATABASE_URL
? mysql.createPool(process.env.DATABASE_URL)
: mysql.createPool({
host: process.env.MYSQL_HOST ?? 'localhost',
port: Number(process.env.MYSQL_PORT ?? 3306),
user: process.env.MYSQL_USER ?? 'boot',
password: process.env.MYSQL_PASSWORD ?? '',
database: process.env.MYSQL_DATABASE ?? 'tkmind',
waitForConnections: true,
connectionLimit: 2,
});
return quotaPool;
}
function isScheduleConfigured() {
return isQuotaSyncConfigured();
}
function getScheduleService() {
if (!PRIVATE_DATA_USER_ID) throw new Error('当前会话没有用户上下文,不能操作待办');
const pool = getQuotaPool();
if (!pool || !isScheduleConfigured()) {
throw new Error('当前环境未配置待办数据库连接');
}
if (!scheduleService) {
scheduleService = createScheduleService(pool, {
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
});
}
return scheduleService;
}
if (isScheduleConfigured()) {
ALL_TOOLS.push(
{
name: 'schedule_create_item',
description:
'为当前用户创建待办或日程事项。仅写入当前用户自己的 h5_schedule_items,不可操作其他用户数据。',
inputSchema: {
type: 'object',
properties: {
kind: { type: 'string', description: 'task 或 event,默认 task' },
title: { type: 'string', description: '事项标题' },
description: { type: 'string', description: '事项描述,可选' },
startAt: { type: 'number', description: '开始时间 Unix 毫秒时间戳,可选' },
endAt: { type: 'number', description: '结束时间 Unix 毫秒时间戳,可选' },
dueAt: { type: 'number', description: '截止时间 Unix 毫秒时间戳,可选' },
allDay: { type: 'boolean', description: '是否全天事项,可选' },
timezone: { type: 'string', description: '时区,可选' },
location: { type: 'string', description: '地点,可选' },
sourceMessageId: { type: 'string', description: '服务号消息 ID;有值时必须原样传入' },
sourceText: { type: 'string', description: '原始用户文本,可选' },
},
required: ['title'],
},
},
{
name: 'schedule_create_reminder',
description:
'为当前用户已有事项创建单次提醒。必须先有 itemId,再写入 h5_schedule_reminders。',
inputSchema: {
type: 'object',
properties: {
itemId: { type: 'string', description: '事项 ID' },
remindAt: { type: 'number', description: '提醒触发时间 Unix 毫秒时间戳' },
offsetMinutes: { type: 'number', description: '相对事项时间的提前分钟数,可选' },
channel: { type: 'string', description: '提醒通道,默认 wechat' },
},
required: ['itemId', 'remindAt'],
},
},
{
name: 'schedule_list_items',
description:
'查询当前用户的待办/日程事项列表。仅返回当前用户数据,可按时间范围过滤。',
inputSchema: {
type: 'object',
properties: {
from: { type: 'number', description: '起始时间 Unix 毫秒时间戳,可选' },
to: { type: 'number', description: '结束时间 Unix 毫秒时间戳,可选' },
status: { type: 'string', description: '事项状态,默认 active,可选' },
limit: { type: 'number', description: '返回数量,默认 50,可选' },
},
},
},
);
}
const TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS;
function callTool(name, args) {
function ensurePrivateDataDb() {
fs.mkdirSync(PRIVATE_DATA_DIR, { recursive: true });
if (!fs.existsSync(PRIVATE_DATA_DB)) {
runSqlite(['-batch', PRIVATE_DATA_DB, 'PRAGMA journal_mode=WAL; PRAGMA user_version = 1;']);
}
return PRIVATE_DATA_DB;
}
function privateDataSize() {
let total = 0;
for (const file of fs.existsSync(PRIVATE_DATA_DIR) ? fs.readdirSync(PRIVATE_DATA_DIR) : []) {
if (file === 'private-data.sqlite' || file.startsWith('private-data.sqlite-')) {
total += fs.statSync(path.join(PRIVATE_DATA_DIR, file)).size;
}
}
return total;
}
function runSqlite(args) {
return execFileSync(SQLITE_BIN, args, {
encoding: 'utf8',
timeout: PRIVATE_DATA_QUERY_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
});
}
function sqliteScalar(sql) {
return Number(runSqlite(['-batch', '-noheader', PRIVATE_DATA_DB, sql]).trim() || 0);
}
async function getQuotaState({ forUpdate = false, conn = null } = {}) {
const pool = getQuotaPool();
if (!pool) return null;
const db = conn ?? pool;
const [rows] = await db.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
FROM h5_user_spaces
WHERE user_id = ?
LIMIT 1 ${forUpdate ? 'FOR UPDATE' : ''}`,
[PRIVATE_DATA_USER_ID],
);
const row = rows[0];
if (!row) return null;
const quotaBytes = Number(row.quota_bytes ?? 0);
const usedBytes = Number(row.used_bytes ?? 0);
const reservedBytes = Number(row.reserved_bytes ?? 0);
return {
id: row.id,
status: row.status,
quotaBytes,
usedBytes,
reservedBytes,
availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes),
};
}
async function sqliteMaxPagePragmaForQuota(currentBytes) {
const quota = await getQuotaState();
if (!quota) return '';
if (quota.status !== 'active') throw new Error('用户空间不可写');
if (quota.availableBytes <= 0 && currentBytes >= PRIVATE_DATA_MAX_BYTES) {
throw new Error('用户私有数据空间配额不足');
}
const allowedSize = Math.min(PRIVATE_DATA_MAX_BYTES, currentBytes + quota.availableBytes);
const pageSize = sqliteScalar('PRAGMA page_size;') || 4096;
const currentPages = sqliteScalar('PRAGMA page_count;') || 1;
const maxPages = Math.max(currentPages, Math.max(1, Math.floor(allowedSize / pageSize)));
return `PRAGMA max_page_count=${maxPages};`;
}
async function syncPrivateDataQuota(deltaBytes) {
const pool = getQuotaPool();
if (!pool || !deltaBytes) return null;
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const quota = await getQuotaState({ forUpdate: true, conn });
if (!quota) {
await conn.rollback();
return null;
}
if (quota.status !== 'active') throw new Error('用户空间不可写');
if (deltaBytes > quota.availableBytes) {
throw new Error(`用户私有数据空间配额不足:需要 ${deltaBytes} 字节,可用 ${quota.availableBytes} 字节`);
}
await conn.query(
`UPDATE h5_user_spaces
SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ?
WHERE id = ? AND user_id = ?`,
[deltaBytes, Date.now(), quota.id, PRIVATE_DATA_USER_ID],
);
await conn.commit();
return { deltaBytes };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
function stripSqlComments(sql) {
return String(sql ?? '')
.replace(/--.*$/gm, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim();
}
function rejectDangerousSql(sql, { readonly = false } = {}) {
const cleaned = stripSqlComments(sql);
if (!cleaned) throw new Error('SQL 不能为空');
if (cleaned.length > 20000) throw new Error('SQL 过长');
if (/^\s*\./m.test(cleaned)) {
throw new Error('SQL 不允许使用 sqlite3 dot command');
}
if (/\b(ATTACH|DETACH|LOAD_EXTENSION|VACUUM\s+INTO)\b/i.test(cleaned)) {
throw new Error('SQL 包含用户私有数据空间不允许的操作');
}
if (/\bPRAGMA\s+writable_schema\b/i.test(cleaned)) {
throw new Error('SQL 包含不允许的 PRAGMA');
}
if (readonly && !/^\s*(SELECT|WITH)\b/i.test(cleaned)) {
throw new Error('private_data_query 只允许 SELECT/WITH');
}
return cleaned;
}
async function queryPrivateData(sql) {
const db = ensurePrivateDataDb();
const cleaned = rejectDangerousSql(sql, { readonly: true });
const limited = `SELECT * FROM (${cleaned.replace(/;\s*$/, '')}) LIMIT ${PRIVATE_DATA_MAX_ROWS}`;
const output = runSqlite(['-json', db, limited]);
return output.trim() || '[]';
}
async function executePrivateData(sql) {
const db = ensurePrivateDataDb();
const before = privateDataSize();
if (before > PRIVATE_DATA_MAX_BYTES) throw new Error('用户私有数据空间已超过大小限制');
const cleaned = rejectDangerousSql(sql);
const quotaPragma = await sqliteMaxPagePragmaForQuota(before);
runSqlite(['-batch', db, `${quotaPragma}\n${cleaned}`]);
const after = privateDataSize();
if (after > PRIVATE_DATA_MAX_BYTES) {
throw new Error(`用户私有数据空间超过大小限制:${after}/${PRIVATE_DATA_MAX_BYTES} 字节`);
}
const delta = after - before;
const quotaSync = await syncPrivateDataQuota(delta);
return `已执行。当前数据空间大小 ${after} 字节${quotaSync ? `,已同步空间占用 ${delta} 字节` : ''}`;
}
async function privateDataSchema() {
const db = ensurePrivateDataDb();
const output = runSqlite([
'-json',
db,
`SELECT m.name AS table_name, p.cid, p.name AS column_name, p.type, p."notnull" AS not_null, p.pk
FROM sqlite_master m
JOIN pragma_table_info(m.name) p
WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
ORDER BY m.name, p.cid`,
]);
return output.trim() || '[]';
}
async function callTool(name, args) {
if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) {
throw new Error(`工具 ${name} 未授权`);
}
@@ -137,6 +450,86 @@ function callTool(name, args) {
fs.mkdirSync(abs, { recursive: true });
return [{ type: 'text', text: `已创建目录 ${args.path}` }];
}
case 'private_data_info': {
ensurePrivateDataDb();
const size = privateDataSize();
const quota = await getQuotaState().catch(() => null);
return [
{
type: 'text',
text: JSON.stringify(
{
name: '用户私有数据空间',
database: '.mindspace/private-data.sqlite',
maxBytes: PRIVATE_DATA_MAX_BYTES,
sizeBytes: size,
quotaSyncEnabled: Boolean(getQuotaPool()),
quota: quota
? {
status: quota.status,
quotaBytes: quota.quotaBytes,
usedBytes: quota.usedBytes,
reservedBytes: quota.reservedBytes,
availableBytes: quota.availableBytes,
}
: null,
rules: [
'每个用户只能使用这个唯一 SQLite 数据库',
'适合问卷、表单、清单、调研数据和分析中间表',
'不要存账号、计费、权限、审计、公开平台数据或跨用户数据',
],
},
null,
2,
),
},
];
}
case 'private_data_schema':
return [{ type: 'text', text: await privateDataSchema() }];
case 'private_data_query':
return [{ type: 'text', text: await queryPrivateData(args.sql) }];
case 'private_data_execute':
return [{ type: 'text', text: await executePrivateData(args.sql) }];
case 'schedule_create_item': {
const item = await getScheduleService().createItem({
userId: PRIVATE_DATA_USER_ID,
kind: args.kind,
title: args.title,
description: args.description ?? null,
startAt: args.startAt ?? null,
endAt: args.endAt ?? null,
dueAt: args.dueAt ?? null,
allDay: Boolean(args.allDay),
timezone: args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai',
location: args.location ?? null,
sourceChannel: 'agent',
sourceMessageId: args.sourceMessageId ?? null,
sourceText: args.sourceText ?? null,
metadata: { source: 'schedule_assistant_skill' },
});
return [{ type: 'text', text: JSON.stringify(item, null, 2) }];
}
case 'schedule_create_reminder': {
const reminder = await getScheduleService().createReminder({
userId: PRIVATE_DATA_USER_ID,
itemId: args.itemId,
remindAt: args.remindAt,
offsetMinutes: args.offsetMinutes ?? null,
channel: args.channel ?? 'wechat',
});
return [{ type: 'text', text: JSON.stringify(reminder, null, 2) }];
}
case 'schedule_list_items': {
const items = await getScheduleService().listItems({
userId: PRIVATE_DATA_USER_ID,
from: args.from ?? null,
to: args.to ?? null,
status: args.status ?? 'active',
limit: args.limit ?? 50,
});
return [{ type: 'text', text: JSON.stringify(items, null, 2) }];
}
default:
throw new Error(`未知工具:${name}`);
}
@@ -158,7 +551,7 @@ function respondError(id, message, code = -32603) {
const rl = readline.createInterface({ input: process.stdin, terminal: false });
rl.on('line', (raw) => {
rl.on('line', async (raw) => {
const line = raw.trim();
if (!line) return;
@@ -192,7 +585,7 @@ rl.on('line', (raw) => {
case 'tools/call': {
const { name, arguments: toolArgs } = params ?? {};
try {
const content = callTool(name, toolArgs ?? {});
const content = await callTool(name, toolArgs ?? {});
respond(id, { content, isError: false });
} catch (err) {
respond(id, {