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:
+108
-16
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user