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
+60 -10
View File
@@ -1,8 +1,13 @@
const URL_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
/https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
export type MessageSaveKind = 'page' | 'article';
export type MessageSaveOwner = {
userId?: string | null;
username?: string | null;
};
export type MessageSaveActions = {
kind: MessageSaveKind;
previewUrl: string | null;
@@ -17,22 +22,67 @@ function decodeSegment(segment: string) {
}
}
export function extractStaticPageLinks(content: string, username?: string) {
function normalizeOwnerOptions(owner?: string | MessageSaveOwner): MessageSaveOwner {
if (typeof owner === 'string') return { username: owner };
return owner ?? {};
}
function normalizeStaticHtmlRelativePath(relativePath: string) {
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 escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function encodeUrlPath(relativePath: string) {
return relativePath
.split('/')
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join('/');
}
function canonicalizeStaticPageUrl(publicUrl: string, originalRelativePath: string) {
const canonicalRelativePath = normalizeStaticHtmlRelativePath(originalRelativePath);
const originalClean = originalRelativePath.replace(/^\/+/, '');
if (!canonicalRelativePath || canonicalRelativePath === originalClean) return publicUrl;
const suffix = escapeRegExp(encodeUrlPath(originalClean));
return publicUrl.replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath));
}
export function extractStaticPageLinks(content: string, owner?: string | MessageSaveOwner) {
const { userId, username } = normalizeOwnerOptions(owner);
const normalizedUserId = userId?.trim().toLowerCase();
const normalizedUsername = username?.trim().toLowerCase();
const links: Array<{ publicUrl: string; filename: string }> = [];
const seen = new Set<string>();
for (const match of content.matchAll(URL_PATTERN)) {
const owner = decodeSegment(match[1]).toLowerCase();
const filename = decodeSegment(match[2]).split('/').pop() ?? match[2];
if (username && owner !== username.trim().toLowerCase()) continue;
if (seen.has(match[0])) continue;
seen.add(match[0]);
links.push({ publicUrl: match[0], filename });
const linkOwner = decodeSegment(match[1]).toLowerCase();
const relativePath = decodeSegment(match[2]);
const filename = relativePath.split('/').pop() ?? relativePath;
if (normalizedUserId) {
if (linkOwner !== normalizedUserId && linkOwner !== normalizedUsername) continue;
} else if (normalizedUsername && linkOwner !== normalizedUsername) {
continue;
}
const publicUrl = canonicalizeStaticPageUrl(match[0], relativePath);
if (seen.has(publicUrl)) continue;
seen.add(publicUrl);
links.push({ publicUrl, filename });
}
return links;
}
export function getMessageSaveActions(content: string, username?: string): MessageSaveActions {
const links = extractStaticPageLinks(content, username);
export function getMessageSaveActions(content: string, owner?: string | MessageSaveOwner): MessageSaveActions {
const links = extractStaticPageLinks(content, owner);
return {
kind: links.length > 0 ? 'page' : 'article',
previewUrl: links[0]?.publicUrl ?? null,
+6 -2
View File
@@ -56,11 +56,15 @@ export function isImageAsset(asset: MindSpaceAsset) {
return asset.assetType === 'image' || asset.mimeType.startsWith('image/');
}
export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt'>) {
export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt' | 'publicUrl'>) {
if (asset.publicUrl) return asset.publicUrl;
return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&v=${asset.updatedAt}`;
}
export function buildAbsoluteAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt'>): string {
export function buildAbsoluteAssetImageUrl(
asset: Pick<MindSpaceAsset, 'id' | 'updatedAt' | 'publicUrl'>,
): string {
if (asset.publicUrl) return new URL(asset.publicUrl, window.location.origin).toString();
return `${window.location.origin}${buildAssetImageUrl(asset)}`;
}
+9 -1
View File
@@ -95,9 +95,17 @@ export async function nativeShare(payload: SharePayload) {
}
export function buildAssetSharePayload(
asset: { displayName: string },
asset: { displayName: string; publicUrl?: string | null },
categoryCode?: string,
): SharePayload {
if (asset.publicUrl) {
const publicUrl = new URL(asset.publicUrl, window.location.origin).toString();
return {
title: asset.displayName,
url: publicUrl,
description: `看看我在 TKMind 空间里的图片「${asset.displayName}`,
};
}
const url = new URL(resolveMindSpaceHomeUrl());
if (categoryCode) {
url.searchParams.set('category', categoryCode);
+5 -1
View File
@@ -1,12 +1,16 @@
import type { PortalUser } from '../types';
function isInternalWechatUsername(value?: string | null): boolean {
return /^wx_[a-z0-9_]{4,64}$/i.test(String(value ?? '').trim());
}
export function resolveUserAddressName(
user?: Pick<PortalUser, 'displayName' | 'username'> | null,
): string {
const preferred = user?.displayName?.trim();
if (preferred) return preferred;
const username = user?.username?.trim();
if (username) return username;
if (username && !isInternalWechatUsername(username)) return username;
return '用户';
}
+29
View File
@@ -0,0 +1,29 @@
type WordEntry = { word: string; replacement: string };
let blockedWords: WordEntry[] = [];
export function setBlockedWords(words: WordEntry[]) {
blockedWords = words ?? [];
}
export function filterText(text: string): string {
if (!text || !blockedWords.length) return text;
let result = text;
for (const entry of blockedWords) {
if (!entry.word) continue;
const escaped = entry.word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
result = result.replace(new RegExp(escaped, 'gi'), entry.replacement);
}
return result;
}
export async function loadBlockedWords(): Promise<void> {
try {
const res = await fetch('/api/config/blocked-words', { credentials: 'include' });
if (!res.ok) return;
const data = (await res.json()) as { words?: WordEntry[] };
setBlockedWords(data.words ?? []);
} catch {
// fail silently — filtering is best-effort
}
}