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
+127 -14
View File
@@ -10,6 +10,7 @@ import {
scheduleHtmlThumbnail,
} from './mindspace-thumbnails.mjs';
import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs';
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from './user-publish.mjs';
import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
@@ -32,6 +33,13 @@ const ALLOWED_EXTENSIONS = new Map([
['.htm', 'text/html'],
]);
const MAX_IMAGE_UPLOAD_BYTES = 1536 * 1024;
const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images';
const PUBLIC_IMAGE_EXTENSIONS = new Map([
['image/png', '.png'],
['image/jpeg', '.jpg'],
['image/webp', '.webp'],
]);
function asNumber(value) {
return Number(value ?? 0);
@@ -95,6 +103,31 @@ function visibilityForCategory(categoryCode) {
return categoryCode === 'public' ? 'public_candidate' : 'private';
}
function isPublicImageAsset(row) {
return row?.category_code === 'public' && String(row?.mime_type ?? '').startsWith('image/');
}
function publicTempImageFilename(assetId, mimeType, fallbackFilename = '') {
const fallbackExtension = path.extname(String(fallbackFilename)).toLowerCase();
const extension = PUBLIC_IMAGE_EXTENSIONS.get(mimeType) || fallbackExtension || '.img';
return `${assetId}${extension}`;
}
function publicTempImageStorageKey(userId, assetId, mimeType, fallbackFilename = '') {
return path.posix.join(
'users',
userId,
PUBLIC_ZONE_DIR,
PUBLIC_TEMP_IMAGE_DIR,
publicTempImageFilename(assetId, mimeType, fallbackFilename),
);
}
function publicTempImageUrl(userId, assetId, mimeType, fallbackFilename = '') {
const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
return `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(userId)}/${PUBLIC_ZONE_DIR}/${PUBLIC_TEMP_IMAGE_DIR}/${encodeURIComponent(filename)}`;
}
function assetResponse(row) {
return {
id: row.id,
@@ -112,6 +145,9 @@ function assetResponse(row) {
scanStatus: row.scan_status ?? 'passed',
sourceType: row.source_type,
hasThumbnail: Boolean(row.has_thumbnail ?? row.mime_type === 'text/html'),
publicUrl: isPublicImageAsset(row)
? publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename)
: null,
sourcePageId: row.source_page_id ?? null,
createdAt: asNumber(row.created_at),
updatedAt: asNumber(row.updated_at),
@@ -173,6 +209,24 @@ export function createAssetService(pool, options = {}) {
});
};
const writePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename, sourcePath) => {
if (!h5Root) return null;
const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.copyFile(sourcePath, target);
return target;
};
const removePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename) => {
if (!h5Root) return;
const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename);
await fs.rm(target, { force: true });
};
const absoluteStoragePath = (storageKey) => {
const resolved = path.resolve(storageRoot, storageKey);
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
@@ -352,6 +406,8 @@ export function createAssetService(pool, options = {}) {
const completeUpload = async (userId, uploadId) => {
const conn = await pool.getConnection();
let temporaryPath;
let finalPath;
let publicMirror = null;
try {
await conn.beginTransaction();
const [rows] = await conn.query(
@@ -389,7 +445,6 @@ export function createAssetService(pool, options = {}) {
const assetId = idFactory();
const versionId = idFactory();
const finalStorageKey = upload.temporary_storage_key;
temporaryPath = absoluteStoragePath(upload.temporary_storage_key);
const fileBuffer = await fs.readFile(temporaryPath);
const scan = runBasicFileScan(fileBuffer, {
@@ -398,6 +453,27 @@ export function createAssetService(pool, options = {}) {
});
const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
const shouldPublishTempImage =
upload.category_code === 'public' &&
upload.detected_mime_type.startsWith('image/') &&
scan.scanStatus === 'passed';
const finalStorageKey = shouldPublishTempImage
? publicTempImageStorageKey(userId, assetId, upload.detected_mime_type, upload.filename)
: upload.temporary_storage_key;
finalPath = absoluteStoragePath(finalStorageKey);
if (finalStorageKey !== upload.temporary_storage_key) {
await fs.mkdir(path.dirname(finalPath), { recursive: true });
await fs.rename(temporaryPath, finalPath);
}
if (shouldPublishTempImage) {
publicMirror = await writePublicTempImageMirror(
userId,
assetId,
upload.detected_mime_type,
upload.filename,
finalPath,
);
}
const now = Date.now();
const visibility = visibilityForCategory(upload.category_code);
@@ -460,6 +536,7 @@ export function createAssetService(pool, options = {}) {
await conn.commit();
return {
id: assetId,
user_id: userId,
categoryId: upload.category_id,
categoryCode: upload.category_code,
assetType: assetTypeForMime(upload.detected_mime_type),
@@ -475,9 +552,14 @@ export function createAssetService(pool, options = {}) {
sourceType: 'upload',
createdAt: now,
updatedAt: now,
publicUrl: shouldPublishTempImage
? publicTempImageUrl(userId, assetId, upload.detected_mime_type, upload.filename)
: null,
};
} catch (error) {
await conn.rollback();
if (finalPath && finalPath !== temporaryPath) await fs.rm(finalPath, { force: true }).catch(() => {});
if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {});
throw error;
} finally {
conn.release();
@@ -559,12 +641,15 @@ export function createAssetService(pool, options = {}) {
const deleteAsset = async (userId, assetId) => {
const conn = await pool.getConnection();
let mirrorCleanup = null;
let storageCleanup = null;
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, c.category_code
`SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, a.mime_type,
c.category_code, v.storage_key
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
LEFT JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.id = ? AND a.user_id = ?
LIMIT 1
FOR UPDATE`,
@@ -617,10 +702,13 @@ export function createAssetService(pool, options = {}) {
await conn.commit();
if (h5Root) {
mirrorCleanup = {
id: asset.id,
categoryCode: asset.category_code,
filename: asset.original_filename,
mimeType: asset.mime_type,
};
}
if (asset.storage_key) storageCleanup = asset.storage_key;
} catch (error) {
await conn.rollback();
throw error;
@@ -642,10 +730,25 @@ export function createAssetService(pool, options = {}) {
filename: thumbName,
});
}
if (mirrorCleanup.categoryCode === 'public' && mirrorCleanup.mimeType?.startsWith?.('image/')) {
await removePublicTempImageMirror(
userId,
mirrorCleanup.id,
mirrorCleanup.mimeType,
mirrorCleanup.filename,
);
}
} catch {
// Best-effort workspace mirror cleanup; DB delete already committed.
}
}
if (storageCleanup) {
try {
await fs.rm(absoluteStoragePath(storageCleanup), { force: true });
} catch {
// Best-effort physical storage cleanup; DB delete already committed.
}
}
return { deleted: true };
};
@@ -688,6 +791,7 @@ export function createAssetService(pool, options = {}) {
}
const conn = await pool.getConnection();
let finalPath;
let publicMirror = null;
try {
await conn.beginTransaction();
const [categories] = await conn.query(
@@ -730,18 +834,6 @@ export function createAssetService(pool, options = {}) {
const assetId = idFactory();
const versionId = idFactory();
const finalStorageKey = path.posix.join(
'users',
userId,
'assets',
assetId,
'versions',
versionId,
);
finalPath = absoluteStoragePath(finalStorageKey);
await fs.mkdir(path.dirname(finalPath), { recursive: true });
await fs.writeFile(finalPath, buffer, { flag: 'wx' });
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
const scan = runBasicFileScan(buffer, {
filename: normalizedFilename,
@@ -749,6 +841,25 @@ export function createAssetService(pool, options = {}) {
});
const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
const shouldPublishTempImage =
category.category_code === 'public' &&
detectedMimeType.startsWith('image/') &&
scan.scanStatus === 'passed';
const finalStorageKey = shouldPublishTempImage
? publicTempImageStorageKey(userId, assetId, detectedMimeType, normalizedFilename)
: path.posix.join('users', userId, 'assets', assetId, 'versions', versionId);
finalPath = absoluteStoragePath(finalStorageKey);
await fs.mkdir(path.dirname(finalPath), { recursive: true });
await fs.writeFile(finalPath, buffer, { flag: 'wx' });
if (shouldPublishTempImage) {
publicMirror = await writePublicTempImageMirror(
userId,
assetId,
detectedMimeType,
normalizedFilename,
finalPath,
);
}
const now = Date.now();
const visibility = visibilityForCategory(category.category_code);
await conn.query(
@@ -809,6 +920,7 @@ export function createAssetService(pool, options = {}) {
}
return assetResponse({
id: assetId,
user_id: userId,
category_id: category.id,
category_code: category.category_code,
asset_type: assetTypeForMime(detectedMimeType),
@@ -829,6 +941,7 @@ export function createAssetService(pool, options = {}) {
} catch (error) {
await conn.rollback();
if (finalPath) await fs.rm(finalPath, { force: true }).catch(() => {});
if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {});
throw error;
} finally {
conn.release();