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:
+120
-4
@@ -14,6 +14,33 @@ function decodePathSegment(segment) {
|
||||
}
|
||||
}
|
||||
|
||||
function encodeUrlPath(relativePath) {
|
||||
return String(relativePath ?? '')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
export function normalizeStaticHtmlRelativePath(relativePath) {
|
||||
const parts = String(relativePath ?? '')
|
||||
.replace(/^\/+/, '')
|
||||
.split('/')
|
||||
.filter((part) => part && part !== '.' && part !== '..');
|
||||
if (parts.length === 0) return '';
|
||||
if (parts[0].toLowerCase() === 'public') return ['public', ...parts.slice(1)].join('/');
|
||||
if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`;
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function canonicalizeStaticPageUrl(publicUrl, originalRelativePath, canonicalRelativePath) {
|
||||
if (!canonicalRelativePath || canonicalRelativePath === String(originalRelativePath ?? '').replace(/^\/+/, '')) {
|
||||
return publicUrl;
|
||||
}
|
||||
const suffix = encodeUrlPath(originalRelativePath).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return String(publicUrl).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath));
|
||||
}
|
||||
|
||||
export function extractStaticPageLinks(content, { userId, username } = {}) {
|
||||
const text = String(content ?? '');
|
||||
const links = [];
|
||||
@@ -22,9 +49,10 @@ export function extractStaticPageLinks(content, { userId, username } = {}) {
|
||||
const normalizedUsername = username ? String(username).trim().toLowerCase() : null;
|
||||
for (const match of text.matchAll(URL_PATTERN)) {
|
||||
const owner = decodePathSegment(match[1]).toLowerCase();
|
||||
const relativePath = decodePathSegment(match[2]);
|
||||
const originalRelativePath = decodePathSegment(match[2]);
|
||||
const relativePath = normalizeStaticHtmlRelativePath(originalRelativePath);
|
||||
if (normalizedUserId) {
|
||||
if (owner !== normalizedUserId) continue;
|
||||
if (owner !== normalizedUserId && owner !== normalizedUsername) continue;
|
||||
} else if (normalizedUsername && owner !== normalizedUsername) {
|
||||
continue;
|
||||
}
|
||||
@@ -32,7 +60,7 @@ export function extractStaticPageLinks(content, { userId, username } = {}) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
links.push({
|
||||
publicUrl: match[0],
|
||||
publicUrl: canonicalizeStaticPageUrl(match[0], originalRelativePath, relativePath),
|
||||
owner,
|
||||
relativePath,
|
||||
filename: path.basename(relativePath),
|
||||
@@ -114,8 +142,91 @@ async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, de
|
||||
return null;
|
||||
}
|
||||
|
||||
async function collectPublishHtmlPaths(publishRoot, results, maxDepth = 6, depth = 0) {
|
||||
if (depth > maxDepth || results.length >= 200) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(publishRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
const full = path.join(publishRoot, entry.name);
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
|
||||
results.push(full);
|
||||
if (results.length >= 200) return;
|
||||
}
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
await collectPublishHtmlPaths(path.join(publishRoot, entry.name), results, maxDepth, depth + 1);
|
||||
if (results.length >= 200) return;
|
||||
}
|
||||
}
|
||||
|
||||
function levenshteinDistance(left, right) {
|
||||
if (left === right) return 0;
|
||||
if (!left) return right.length;
|
||||
if (!right) return left.length;
|
||||
const prev = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
const next = new Array(right.length + 1);
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
next[0] = i + 1;
|
||||
for (let j = 0; j < right.length; j += 1) {
|
||||
const cost = left[i] === right[j] ? 0 : 1;
|
||||
next[j + 1] = Math.min(
|
||||
next[j] + 1,
|
||||
prev[j + 1] + 1,
|
||||
prev[j] + cost,
|
||||
);
|
||||
}
|
||||
for (let j = 0; j <= right.length; j += 1) {
|
||||
prev[j] = next[j];
|
||||
}
|
||||
}
|
||||
return prev[right.length];
|
||||
}
|
||||
|
||||
function htmlNameForSimilarity(relativePath) {
|
||||
return path.basename(String(relativePath ?? ''), '.html').toLowerCase();
|
||||
}
|
||||
|
||||
function isAcceptableSimilarHtmlMatch(requested, candidate, score, nextScore = Infinity) {
|
||||
if (!requested || !candidate || requested === candidate) return false;
|
||||
const longest = Math.max(requested.length, candidate.length);
|
||||
const allowedDistance = longest >= 18 ? 2 : 1;
|
||||
if (score > allowedDistance) return false;
|
||||
return nextScore > score;
|
||||
}
|
||||
|
||||
export async function resolveClosestHtmlRelativePath(rootDir, relativePath) {
|
||||
const normalized = normalizeStaticHtmlRelativePath(relativePath);
|
||||
if (!normalized.toLowerCase().endsWith('.html')) return null;
|
||||
const requestedName = htmlNameForSimilarity(normalized);
|
||||
const candidates = [];
|
||||
await collectPublishHtmlPaths(rootDir, candidates);
|
||||
const ranked = candidates
|
||||
.map((absolute) => {
|
||||
const candidateRelative = path.relative(rootDir, absolute).split(path.sep).join('/');
|
||||
const candidateName = htmlNameForSimilarity(candidateRelative);
|
||||
return {
|
||||
relativePath: candidateRelative,
|
||||
score: levenshteinDistance(requestedName, candidateName),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.score - right.score || left.relativePath.localeCompare(right.relativePath));
|
||||
if (ranked.length === 0) return null;
|
||||
const best = ranked[0];
|
||||
const nextBestScore = ranked[1]?.score ?? Infinity;
|
||||
if (!isAcceptableSimilarHtmlMatch(requestedName, htmlNameForSimilarity(best.relativePath), best.score, nextBestScore)) {
|
||||
return null;
|
||||
}
|
||||
return best.relativePath;
|
||||
}
|
||||
|
||||
export async function findPublishHtml(h5Root, userId, relativePath) {
|
||||
const normalized = String(relativePath ?? '').replace(/^\/+/, '');
|
||||
const normalized = normalizeStaticHtmlRelativePath(relativePath);
|
||||
const basename = path.basename(normalized);
|
||||
const candidates = [
|
||||
normalized,
|
||||
@@ -142,6 +253,11 @@ export async function findPublishHtml(h5Root, userId, relativePath) {
|
||||
return readPublishHtml(h5Root, userId, resolvedRelativePath);
|
||||
}
|
||||
|
||||
const similarRelativePath = await resolveClosestHtmlRelativePath(publishRoot, normalized);
|
||||
if (similarRelativePath) {
|
||||
return readPublishHtml(h5Root, userId, similarRelativePath);
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user