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:
+127
-14
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user