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:
@@ -0,0 +1,156 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fetch as undiciFetch } from 'undici';
|
||||
import { buildPublicUrl, PUBLISH_ROOT_DIR, PUBLIC_ZONE_DIR } from './user-publish.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const DEFAULT_WECHAT_MEDIA_URL = 'https://api.weixin.qq.com/cgi-bin/media/get';
|
||||
const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
const ALLOWED_IMAGE_MIME_TYPES = new Map([
|
||||
['image/jpeg', 'jpg'],
|
||||
['image/png', 'png'],
|
||||
['image/webp', 'webp'],
|
||||
['image/gif', 'gif'],
|
||||
]);
|
||||
|
||||
function resolveImageExtension(contentType = '', fallbackUrl = '') {
|
||||
const normalized = String(contentType ?? '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (ALLOWED_IMAGE_MIME_TYPES.has(normalized)) {
|
||||
return {
|
||||
mimeType: normalized,
|
||||
extension: ALLOWED_IMAGE_MIME_TYPES.get(normalized),
|
||||
};
|
||||
}
|
||||
const ext = path.extname(String(fallbackUrl ?? '').split('?')[0]).replace(/^\./, '').toLowerCase();
|
||||
if (ext === 'jpg' || ext === 'jpeg') return { mimeType: 'image/jpeg', extension: 'jpg' };
|
||||
if (ext === 'png') return { mimeType: 'image/png', extension: 'png' };
|
||||
if (ext === 'webp') return { mimeType: 'image/webp', extension: 'webp' };
|
||||
if (ext === 'gif') return { mimeType: 'image/gif', extension: 'gif' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureImageWithinLimit(buffer, maxBytes) {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
||||
throw new Error('微信图片内容为空');
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
throw new Error(`图片超过大小限制(${maxBytes} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadTemporaryMedia(accessToken, mediaId, { wechatFetch = undiciFetch } = {}) {
|
||||
if (!accessToken) throw new Error('缺少微信 access_token');
|
||||
if (!mediaId) throw new Error('缺少微信 mediaId');
|
||||
const url = new URL(DEFAULT_WECHAT_MEDIA_URL);
|
||||
url.searchParams.set('access_token', accessToken);
|
||||
url.searchParams.set('media_id', mediaId);
|
||||
const response = await wechatFetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: { Accept: '*/*' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `微信临时素材下载失败 (${response.status})`);
|
||||
}
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return { buffer, contentType };
|
||||
}
|
||||
|
||||
export function buildWechatImagePublicUrl({
|
||||
publicBaseUrl,
|
||||
publishKey,
|
||||
filename,
|
||||
publicBasePath = `${PUBLIC_ZONE_DIR}/wechat-mp`,
|
||||
}) {
|
||||
if (!publicBaseUrl) throw new Error('缺少图片公网基础地址');
|
||||
if (!publishKey) throw new Error('缺少用户 publish key');
|
||||
if (!filename) throw new Error('缺少图片文件名');
|
||||
return buildPublicUrl(publicBaseUrl, publishKey, `${publicBasePath}/${filename}`);
|
||||
}
|
||||
|
||||
export async function persistWechatImage(
|
||||
{
|
||||
userId,
|
||||
appId,
|
||||
openid,
|
||||
msgId,
|
||||
mediaId,
|
||||
picUrl,
|
||||
publicBaseUrl,
|
||||
maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
|
||||
},
|
||||
{
|
||||
wechatFetch = undiciFetch,
|
||||
accessToken,
|
||||
h5Root = __dirname,
|
||||
} = {},
|
||||
) {
|
||||
if (!userId) throw new Error('缺少 userId');
|
||||
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, 'wechat-mp');
|
||||
fs.mkdirSync(publishDir, { recursive: true });
|
||||
|
||||
let source = 'wechat_media';
|
||||
let buffer;
|
||||
let contentType = '';
|
||||
|
||||
if (mediaId && accessToken) {
|
||||
try {
|
||||
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
|
||||
buffer = downloaded.buffer;
|
||||
contentType = downloaded.contentType;
|
||||
} catch (error) {
|
||||
if (!picUrl) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!buffer && picUrl) {
|
||||
source = 'wechat_pic_url';
|
||||
const response = await wechatFetch(picUrl, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'image/*,*/*;q=0.8' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `微信图片下载失败 (${response.status})`);
|
||||
}
|
||||
contentType = response.headers.get('content-type') || contentType;
|
||||
buffer = Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
ensureImageWithinLimit(buffer, maxImageBytes);
|
||||
const resolved = resolveImageExtension(contentType, picUrl);
|
||||
if (!resolved) {
|
||||
throw new Error(`暂不支持的图片类型:${contentType || 'unknown'}`);
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const hash = crypto.createHash('sha1').update(buffer).digest('hex').slice(0, 12);
|
||||
const fileBase = [appId || 'wx', openid || 'openid', msgId || timestamp, mediaId || hash]
|
||||
.filter(Boolean)
|
||||
.join('-')
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
||||
.slice(0, 120);
|
||||
const filename = `${fileBase}.${resolved.extension}`;
|
||||
const absolutePath = path.join(publishDir, filename);
|
||||
fs.writeFileSync(absolutePath, buffer);
|
||||
|
||||
return {
|
||||
absolutePath,
|
||||
bytes: buffer.length,
|
||||
contentType: resolved.mimeType,
|
||||
filename,
|
||||
publicUrl: buildWechatImagePublicUrl({
|
||||
publicBaseUrl,
|
||||
publishKey: String(userId),
|
||||
filename,
|
||||
}),
|
||||
source,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user