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:
+60
-10
@@ -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,
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 '用户';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user