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
+108 -16
View File
@@ -2,10 +2,14 @@ import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs';
import { scanContent } from './mindspace-content-scan.mjs';
import { replacePrivateResourceReferences, scanContent } from './mindspace-content-scan.mjs';
import { pageInternals } from './mindspace-pages.mjs';
import { loadMindSpaceConfig } from './mindspace-config.mjs';
import { resolvePublicBaseUrl } from './user-publish.mjs';
const SCANNER_VERSION = 'mindspace-content-v1';
const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN =
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
const ACCESS_MODES = new Set([
'public',
'password',
@@ -151,10 +155,76 @@ function publicHomepageResponse(owner, pages) {
};
}
function buildPublicationThumbnailFallback(ownerSlug, urlSlug) {
return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`;
}
async function localizePrivateImageReferences({ pool, userId, html, absoluteStoragePath }) {
const source = String(html ?? '');
const matches = [...source.matchAll(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN)];
if (matches.length === 0) return source;
const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))];
if (assetIds.length === 0) return source;
const [assets] = await pool.query(
`SELECT a.id, a.mime_type, v.storage_key
FROM h5_assets a
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%'
AND a.status <> 'deleted'`,
[userId, assetIds],
);
const byId = new Map(assets.map((asset) => [asset.id, asset]));
const replacements = new Map();
for (const assetId of assetIds) {
const asset = byId.get(assetId);
if (!asset) continue;
const mimeType = String(asset.mime_type || 'application/octet-stream');
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
replacements.set(assetId, `data:${mimeType};base64,${buffer.toString('base64')}`);
}
if (replacements.size === 0) return source;
return source.replace(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN, (value, assetId) => {
return replacements.get(assetId) ?? value;
});
}
async function prepareHtmlPublishContent({
pool,
userId,
html,
ownerSlug,
urlSlug,
absoluteStoragePath,
}) {
let publishContent = (await localizeGoogleFontsCss(html)).html;
publishContent = await localizePrivateImageReferences({
pool,
userId,
html: publishContent,
absoluteStoragePath,
});
return replacePrivateResourceReferences(
publishContent,
buildPublicationThumbnailFallback(ownerSlug, urlSlug),
);
}
export function createPublicationService(pool, options = {}) {
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
const publicPageLimit = Number(options.publicPageLimit ?? 5);
const publicPageLimitFallback = Number(options.publicPageLimit ?? 5);
const resolvePublicPageLimit = async () => {
try {
const config = await loadMindSpaceConfig(pool);
return Number(config.publicPageLimit ?? publicPageLimitFallback);
} catch {
return publicPageLimitFallback;
}
};
const absoluteStoragePath = (storageKey) => {
const resolved = path.resolve(storageRoot, storageKey);
@@ -216,6 +286,32 @@ export function createPublicationService(pool, options = {}) {
};
};
const loadOwnerSlug = async (userId) => {
const [rows] = await pool.query(
`SELECT COALESCE(slug, username) AS public_slug
FROM h5_users
WHERE id = ?
LIMIT 1`,
[userId],
);
const ownerSlug = String(rows[0]?.public_slug ?? '').trim();
if (!ownerSlug) throw publicationError('用户公开地址不存在', 'publication_owner_not_found');
return ownerSlug;
};
const preparePublishContent = async (page, ownerSlug, urlSlug) => {
let publishContent = page.content;
if (page.page_type !== 'html') return publishContent;
return prepareHtmlPublishContent({
pool,
userId: page.user_id,
html: publishContent,
ownerSlug,
urlSlug,
absoluteStoragePath,
});
};
const persistScan = async (conn, userId, pageVersionId, scan, now) => {
const scanId = idFactory();
await conn.query(
@@ -277,10 +373,8 @@ export function createPublicationService(pool, options = {}) {
[userId, slug, pageId],
);
const conflict = conflicts[0] ?? null;
let publishContent = page.content;
if (page.page_type === 'html') {
publishContent = (await localizeGoogleFontsCss(page.content)).html;
}
const ownerSlug = await loadOwnerSlug(userId);
const publishContent = await preparePublishContent(page, ownerSlug, slug);
const scan = scanContent(`${page.title}\n${page.summary ?? ''}\n${publishContent}`, {
format: page.page_type === 'html' ? 'html' : 'text',
allowHtmlActiveContent: page.page_type === 'html',
@@ -322,10 +416,8 @@ export function createPublicationService(pool, options = {}) {
});
}
const page = await loadVersion(userId, pageId, result.pageVersionId);
let publishContent = page.content;
if (page.page_type === 'html') {
publishContent = (await localizeGoogleFontsCss(page.content)).html;
}
const ownerSlug = await loadOwnerSlug(userId);
const publishContent = await preparePublishContent(page, ownerSlug, result.urlSlug);
const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent });
const htmlBytes = Buffer.byteLength(html);
const conn = await pool.getConnection();
@@ -354,6 +446,7 @@ export function createPublicationService(pool, options = {}) {
WHERE user_id = ? AND status = 'online'`,
[pageId, userId],
);
const publicPageLimit = await resolvePublicPageLimit();
if (
!Number(publicationUsage[0]?.page_already_online) &&
Number(publicationUsage[0]?.public_page_used) >= publicPageLimit
@@ -394,14 +487,10 @@ export function createPublicationService(pool, options = {}) {
await fs.mkdir(path.dirname(writtenPath), { recursive: true });
await fs.writeFile(writtenPath, html, { flag: 'wx' });
const checksum = crypto.createHash('sha256').update(html).digest('hex');
const [users] = await conn.query(
`SELECT COALESCE(slug, username) AS public_slug FROM h5_users WHERE id = ? LIMIT 1`,
[userId],
);
const ownerSlug = users[0]?.public_slug;
const publicBaseUrl = resolvePublicBaseUrl();
const publicUrl = privateToken
? `/s/${privateToken}`
: `/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`;
: `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`;
await conn.query(
`INSERT INTO h5_assets
@@ -765,5 +854,8 @@ export const publicationInternals = {
deviceType,
referrerHost,
publicHomepageResponse,
buildPublicationThumbnailFallback,
localizePrivateImageReferences,
prepareHtmlPublishContent,
scanContent, // re-exported from mindspace-content-scan.mjs
};