From cb344f9c174e371ad35b762bae19035d5d5010fc Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Sat, 1 Aug 2026 22:47:58 +0800 Subject: [PATCH 1/4] feat: use pttElement:pttTranscript --- apps/desktop/src/main/ipc/routers/account.ts | 20 ++++++++++-- .../src/renderer/src/components/QqMedia.tsx | 12 ++++--- .../src/components/QqMessageContent.tsx | 2 +- .../src/account/export/media_export.ts | 27 +++++++++++++--- .../service/src/account/export/media_scan.ts | 5 +++ .../src/account/export/task_manager.ts | 6 +++- packages/service/src/account/msg.ts | 32 +++++++++++++++++++ packages/service/src/account/msg_view.ts | 3 ++ 8 files changed, 94 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/main/ipc/routers/account.ts b/apps/desktop/src/main/ipc/routers/account.ts index d954153b..8a4c0b91 100644 --- a/apps/desktop/src/main/ipc/routers/account.ts +++ b/apps/desktop/src/main/ipc/routers/account.ts @@ -2349,9 +2349,18 @@ export const accountRouter = router({ * Returns `{ success:false, error }` for every failure mode (no model chosen, * model not downloaded, silk missing, decode/engine error) so the bubble can * show a friendly message instead of throwing. + * + * On success the text is written back onto the element's `pttTranscript` + * (wire tag 45923) when `msgId` is supplied — the same field QQ's own 转文字 + * fills in, so the result survives a reload and QQ itself shows it. */ transcribeVoice: procedure - .input(z.object({ t: z.number(), name: z.string(), token: z.string().default('') })) + .input(z.object({ + t: z.number(), + name: z.string(), + token: z.string().default(''), + msgId: z.string().default(''), + })) .mutation(async ({ input }): Promise<{ success: boolean; text?: string; error?: string }> => { const ctx = getAppContext(); const boot = ctx.bootstrap; @@ -2393,6 +2402,13 @@ export const accountRouter = router({ { engine: model.engine, languages: model.languages }, ); if (!result.success) return { success: false, error: result.error ?? '识别失败' }; - return { success: true, text: result.text ?? '' }; + const text = result.text ?? ''; + if (input.msgId) { + // Best-effort: a failed back-write must not lose the text we just got. + await services.msgs + .setPttTranscript(BigInt(input.msgId), input.name, text) + .catch(() => false); + } + return { success: true, text }; }), }); diff --git a/apps/desktop/src/renderer/src/components/QqMedia.tsx b/apps/desktop/src/renderer/src/components/QqMedia.tsx index cff52f67..e87392a4 100644 --- a/apps/desktop/src/renderer/src/components/QqMedia.tsx +++ b/apps/desktop/src/renderer/src/components/QqMedia.tsx @@ -352,7 +352,7 @@ export function QqOnlineFile({ data, kind }: { data: Data; kind: 'file' | 'folde // ---- voice (ptt) -------------------------------------------------------- -export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number }) { +export function QqVoice({ data, sendTimeMs, msgId }: { data: Data; sendTimeMs: number; msgId: string }) { const name = str(data, 'fileName'); const token = str(data, 'fileToken'); const waveform = Array.isArray(data.waveform) ? (data.waveform as number[]) : []; @@ -375,6 +375,9 @@ export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number } }); const canTranscribe = Boolean(settings.data?.voiceTranscribe.modelId); const transcribe = trpc.account.transcribeVoice.useMutation(); + // A transcript already on the element (wire tag 45923) — written either by + // QQ's own 转文字 or by a previous WeQ run, which writes back to the same field. + const storedTranscript = str(data, 'pttTranscript'); const [transcript, setTranscript] = useState(null); const [transcribeError, setTranscribeError] = useState(null); @@ -410,7 +413,7 @@ export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number } if (transcribe.isLoading) return; setTranscribeError(null); transcribe - .mutateAsync({ t: sendTimeMs, name, token }) + .mutateAsync({ t: sendTimeMs, name, token, msgId }) .then((res) => { if (res.success) setTranscript(res.text ?? ''); else setTranscribeError(res.error ?? '识别失败'); @@ -424,7 +427,8 @@ export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number } // wave area also clips as a hard safety net; see .qq-media-voice-wave). const barCount = Math.max(12, Math.min(28, 8 + Math.round(seconds))); const bars = sampleBars(waveform, barCount); - const hasResult = transcript !== null || transcribeError !== null; + const shownTranscript = transcript ?? (storedTranscript || null); + const hasResult = shownTranscript !== null || transcribeError !== null; return (
@@ -474,7 +478,7 @@ export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number } {hasResult ? (
- {transcribeError ?? (transcript ? transcript : '(未识别到内容)')} + {transcribeError ?? (shownTranscript ? shownTranscript : '(未识别到内容)')}
) : null}
diff --git a/apps/desktop/src/renderer/src/components/QqMessageContent.tsx b/apps/desktop/src/renderer/src/components/QqMessageContent.tsx index d64e1f70..2454b975 100644 --- a/apps/desktop/src/renderer/src/components/QqMessageContent.tsx +++ b/apps/desktop/src/renderer/src/components/QqMessageContent.tsx @@ -178,7 +178,7 @@ function MediaNode({ case 'file': return ; case 'ptt': - return ; + return ; case 'mface': return ; case 'onlineFile': diff --git a/packages/service/src/account/export/media_export.ts b/packages/service/src/account/export/media_export.ts index ad8a9ebd..f4f527a0 100644 --- a/packages/service/src/account/export/media_export.ts +++ b/packages/service/src/account/export/media_export.ts @@ -222,6 +222,11 @@ export async function decodeFoundVoices( * each voice file name → recognized text. Concurrency is kept low because each * call forks a native sherpa-onnx worker (CPU-heavy). The JSON is written even * when there are no voices (an empty map), so the artifact is always present. + * + * Clips that already carry a transcript on the element (wire tag 45923 — QQ's + * own 转文字, or a previous WeQ run that wrote back) are reused as-is and never + * re-recognized. `onTranscribed` persists a fresh result back onto the element + * so the next export skips it too. */ export async function transcribeFoundVoices( scan: MediaScanResult, @@ -229,26 +234,38 @@ export async function transcribeFoundVoices( transcribe: TranscribeVoiceFn, onProgress?: StageProgress, concurrency = 2, + onTranscribed?: (ref: MediaRef, text: string) => Promise, ): Promise { - const items = scan.found.filter((ref) => ref.kind === 'ptt' && ref.path); - const result: MediaStageResult = { total: items.length, ok: 0, failed: 0 }; + const voices = scan.found.filter((ref) => ref.kind === 'ptt'); const transcripts: Record = {}; + for (const ref of voices) { + if (ref.transcript) transcripts[ref.fileName] = ref.transcript; + } + + const items = voices.filter((ref) => ref.path && !ref.transcript); + const cached = voices.length - items.length; + const result: MediaStageResult = { total: voices.length, ok: cached, failed: 0 }; const flush = async (): Promise => { await writeFile(join(bundleDir, TRANSCRIPTS_FILE), JSON.stringify(transcripts, null, 2), 'utf-8'); }; if (items.length === 0) { await flush(); + onProgress?.(result.total, result.total); return result; } - let done = 0; + let done = cached; + onProgress?.(done, result.total); await runWithConcurrency(items, concurrency, async (ref) => { try { const r = await transcribe(ref.path!); if (r.ok) { - transcripts[ref.fileName] = r.text ?? ''; + const text = r.text ?? ''; + transcripts[ref.fileName] = text; result.ok += 1; + // Best-effort back-write; a DB failure must not fail the stage. + if (onTranscribed) await onTranscribed(ref, text).catch(() => undefined); } else { result.failed += 1; result.failures = pushFailure(result.failures, { @@ -266,7 +283,7 @@ export async function transcribeFoundVoices( }); } finally { done += 1; - onProgress?.(done, items.length); + onProgress?.(done, result.total); } }); diff --git a/packages/service/src/account/export/media_scan.ts b/packages/service/src/account/export/media_scan.ts index ea93933a..492aab64 100644 --- a/packages/service/src/account/export/media_scan.ts +++ b/packages/service/src/account/export/media_scan.ts @@ -98,6 +98,8 @@ export interface MediaRef { expired: boolean; /** Resolved absolute path once matched; null while missing. */ path: string | null; + /** ptt only: transcript already stored on the element (wire tag 45923), if any. */ + transcript?: string; } export interface KindCounts { @@ -241,6 +243,7 @@ function collectFromElements( let uploadTimestamp = 0; let fileTTL = 0; let expireTimestamp = 0; + let transcript = ''; switch (el.type) { case 'pic': kind = el.data.subType === 1 ? 'emoji' : 'pic'; @@ -267,6 +270,7 @@ function collectFromElements( uploadTime = el.data.uploadTime; uploadTimestamp = el.data.uploadTimestamp; fileTTL = el.data.fileTTL; + transcript = el.data.pttTranscript ?? ''; break; case 'file': kind = 'file'; @@ -297,6 +301,7 @@ function collectFromElements( expiresAt: computeExpiry(kind, uploadTime, uploadTimestamp, fileTTL, expireTimestamp), expired: false, path: null, + ...(transcript ? { transcript } : {}), }); } } diff --git a/packages/service/src/account/export/task_manager.ts b/packages/service/src/account/export/task_manager.ts index b0a35ca3..4bb1b1b4 100644 --- a/packages/service/src/account/export/task_manager.ts +++ b/packages/service/src/account/export/task_manager.ts @@ -634,11 +634,15 @@ export class ExportTaskManager extends EventEmitter { if (wantTranscribe && transcribe && scan) { const found = scan; jobs.push(async () => { - const voices = found.found.filter((r) => r.kind === 'ptt' && r.path); + const voices = found.found.filter((r) => r.kind === 'ptt'); this.touchStage(task, 'transcribe', { status: 'running', total: voices.length, current: 0, note: `转写 0/${voices.length}` }, { persist: true }); const r = await transcribeFoundVoices(found, outDir, transcribe, (done, total) => { if (aborted()) return; this.touchStage(task, 'transcribe', { current: done, total, note: `转写 ${done}/${total}` }); + }, 2, async (ref, text) => { + // Cache the result on the element (wire tag 45923) so this clip is + // skipped on any later export — and shows up in chat right away. + await this.msgs.setPttTranscript(BigInt(ref.msgId), ref.fileName, text); }); this.touchStage(task, 'transcribe', { status: 'completed', current: r.total, total: r.total, failed: r.failed, note: `已转写 ${r.ok}${r.failed ? ` · 失败 ${r.failed}` : ''}`, ...(r.failures ? { failures: r.failures } : {}) }, { persist: true }); }); diff --git a/packages/service/src/account/msg.ts b/packages/service/src/account/msg.ts index 799795d3..5ecc0879 100644 --- a/packages/service/src/account/msg.ts +++ b/packages/service/src/account/msg.ts @@ -268,6 +268,38 @@ export class MsgService { return affected > 0; } + /** + * Write a voice transcript back onto the message's ptt element (wire tag + * 45923) — the same field QQ's own 「转文字」fills in, so QQ reads it too and + * a re-export skips the clip. + * + * `fileName` picks the ptt element when a message carries several. Returns + * false when the row / element isn't found, or when the value is unchanged. + */ + async setPttTranscript(msgId: bigint, fileName: string, text: string): Promise { + const { decodeBody } = await import('@weq/db'); + for (const db of [this.session.c2cMsgs, this.session.datalineMsgs, this.session.groupMsgs] as const) { + const blob = await db.getMsgBody(msgId); + if (!blob) continue; + const elements = decodeBody(blob); + const ptt = elements.find( + (el): el is Extract => + el.kind === 'ptt' && (!fileName || el.fileName === fileName), + ); + if (!ptt || ptt.pttTranscript === text) return false; + ptt.pttTranscript = text; + // 45924 rides along with every QQ-written transcript (always 1 wherever + // observed), so mirror it — otherwise QQ may not treat the text as ready. + ptt.pttFlag45924 = 1; + const affected = await db.updateMsgBody( + msgId, + bodyCodec.encode({ elements: elements.map(encodeElement) }), + ); + return affected > 0; + } + return false; + } + /** * Delete a message the way QQ itself does: rewrite the type columns * 40011/40012 to `(1,1)` in place (verified against a live QQ delete — those diff --git a/packages/service/src/account/msg_view.ts b/packages/service/src/account/msg_view.ts index 16cb4603..778de62a 100644 --- a/packages/service/src/account/msg_view.ts +++ b/packages/service/src/account/msg_view.ts @@ -165,6 +165,8 @@ export interface RenderPttElement { /** Amplitude envelope; decorative only. AI 声聊 clips carry a fixed 30-byte * strip, so length/10 is NOT a reliable duration — use pttDuration. */ waveform: number[]; + /** 语音转文字结果(wire tag 45923)— QQ 自己转的,或 WeQ 转完写回的。 */ + pttTranscript?: string; // transferState?: number; // picTransferState?: number; // transferVersion?: number; @@ -663,6 +665,7 @@ function mapPtt(el: PttElement): RenderPttElement { voiceChanged: el.voiceChanged, isAiVoice: el.isAiVoice, waveform: Array.from(el.waveform), + pttTranscript: el.pttTranscript, // transferState: el.transferState, // picTransferState: el.picTransferState, // transferVersion: el.transferVersion, From e1a176680d75ec0cdb3ac4867747c7dac16a8ece Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Sat, 1 Aug 2026 23:39:11 +0800 Subject: [PATCH 2/4] fix: linux web pack release && napcat's QQ install path --- .github/workflows/release.yml | 8 +- apps/desktop/src/main/context/app_context.ts | 20 +-- apps/desktop/src/main/inject_elevation.ts | 43 ++++-- apps/desktop/src/main/stub_elevation.ts | 33 +++-- apps/web/README.md | 21 ++- apps/web/package.json | 1 + apps/web/scripts/build-server.mjs | 142 +++++++++++++++++-- apps/web/scripts/install-runtime-deps.mjs | 67 +++++++++ apps/web/scripts/pack-release.mjs | 28 +++- apps/web/scripts/smoke-dist.ts | 49 +++++-- apps/web/src/server/index.ts | 3 + packages/native/src/loader.ts | 7 + packages/platform/src/linux/paths.ts | 34 ++++- scripts/verify_inject_flow.mjs | 2 +- 14 files changed, 395 insertions(+), 63 deletions(-) create mode 100644 apps/web/scripts/install-runtime-deps.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50c9f20f..9e868722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -256,6 +256,11 @@ jobs: - name: Build + guards run: pnpm --filter @weq/web build + # 把 ws / resvg 预装进 dist/node_modules 一起发布,用户解压即可运行, + # 不需要联网 npm install。resvg 按平台选 binding,所以三平台的都装上。 + - name: Install runtime deps into dist/ + run: pnpm --filter @weq/web deps + # 发布前先启动打包产物验一遍,别把跑不起来的包传上去。 - name: Smoke-test packaged server run: pnpm --filter @weq/web test:dist @@ -308,7 +313,8 @@ jobs: - **Linux x64**: \`weQ-${TAG#v}-linux-x64.AppImage\` / \`.tar.gz\` - **Linux arm64(未实测)**: \`weQ-${TAG#v}-linux-arm64.AppImage\` / \`.tar.gz\` - **浏览器版**: \`weq-web-${TAG#v}.tar.gz\` —— 三平台通用,需自备 Node ≥22。 - 解压后 \`npm install --omit=dev && node server.mjs\`, + 解压后 Windows 双击 \`start.bat\`、Linux 跑 \`./start.sh\`(依赖已预装)。 + Linux 建议用 root 运行, 详见[使用说明](https://github.com/${REPO}/blob/main/apps/web/README.md) ### 变更 diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index 02aef54e..803ff192 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -30,9 +30,9 @@ import { ensureDefaultTweets, tweetsStorePath } from '../weq_assistant/tweets'; import { aiToolSpecs, runAiTool } from '../mcp/openai_tools'; import { getExternalMcpHub, disposeExternalMcp } from '../mcp/external'; import { sampleHitokoto } from '../hitokoto'; -import { pkexecStubHooks } from '../stub_elevation'; +import { linuxStubHooks } from '../stub_elevation'; import { getQqProtocolExe } from './qq_protocol_cache'; -import { createPkexecInjectHook } from '../inject_elevation'; +import { createLinuxInjectHook } from '../inject_elevation'; import { accountConfigId, UserConfigService, @@ -578,16 +578,18 @@ export function initAppContext(): AppContext { } // Linux drops a ninebird entry stub into QQ's root-owned resources/app, so - // it needs a pkexec-elevated writer. Windows uses the fs default (undefined). - const stubHooks = process.platform === 'linux' ? pkexecStubHooks : undefined; + // it needs an elevated writer unless the host is already root. Windows uses + // the fs default (undefined). + const stubHooks = process.platform === 'linux' ? linuxStubHooks : undefined; - // Injecting the hook into a running QQ needs root (ptrace) on linux, so it - // goes through a pkexec child + a wait-for-packet step; other platforms - // inject in-process. One shared instance so its per-pid idempotency spans the - // bootstrap router and every account monitor. + // Injecting the hook into a running QQ needs root (ptrace) on linux, and the + // hook must then observe a real post-login packet before it can send; both + // halves live in the linux hook. Other platforms inject in-process with no + // wait. One shared instance so its per-pid idempotency spans the bootstrap + // router and every account monitor. const injectHook: InjectHook = process.platform === 'linux' - ? createPkexecInjectHook(platform.native.ntHelper, userConfig) + ? createLinuxInjectHook(platform.native.ntHelper, userConfig) : createDirectInjectHook(platform.native.ntHelper); const bootstrap: BootstrapServices = { diff --git a/apps/desktop/src/main/inject_elevation.ts b/apps/desktop/src/main/inject_elevation.ts index 526a6643..a413b0c9 100644 --- a/apps/desktop/src/main/inject_elevation.ts +++ b/apps/desktop/src/main/inject_elevation.ts @@ -1,12 +1,16 @@ /** - * Linux privilege-escalated injection — the `InjectHook` used on linux. + * Linux injection — the `InjectHook` used on linux. * * The instance key/rkey/clientkey flows need a QQ process that (a) has the hook * injected and (b) has told the hook its MSF service address. On linux those are * two distinct, differently-privileged steps: * - * 1. INJECT (root) — ptrace-based, so it runs in a short-lived pkexec child - * (`inject_worker`). A graphical polkit password dialog pops once per pid. + * 1. INJECT (root) — ptrace-based. Under Electron (which refuses to run as + * root) this means a short-lived pkexec child (`inject_worker`), and a + * graphical polkit password dialog pops once per pid. When the host is + * already running as root — the web server on a headless box — we ptrace + * in-process instead; pkexec would be pointless and, with no polkit agent + * to authenticate against, impossible. * 2. WAIT-FOR-PACKET (unprivileged) — the hook only learns the service * address from a genuine post-login recv packet, so no OIDB packet can be * sent until one arrives. This runs here in the main process (no root). @@ -110,17 +114,28 @@ function pkexecInject(pid: number): Promise { } /** - * Build the linux `InjectHook`: pkexec-elevated inject + unprivileged - * wait-for-packet, with per-pid idempotency backed by persisted records. + * Build the linux `InjectHook`: inject + unprivileged wait-for-packet, with + * per-pid idempotency backed by persisted records. + * + * The inject half is elevated only when it has to be. Electron refuses to run + * as root, so the desktop app is always unprivileged and must shell out to + * pkexec. The web server has no such constraint and is typically run as root + * on a headless box — where pkexec is both unnecessary (we already have the + * ptrace privilege) and unusable (no graphical polkit agent to authenticate + * against). So when euid is 0 we ptrace in-process instead. + * + * The wait-for-packet half is unprivileged either way and always runs here. * * @param userConfig Persists inject records to config.json so a WeQ restart * reuses an already-hooked, still-running QQ instead of re-injecting it. */ -export function createPkexecInjectHook( +export function createLinuxInjectHook( nt: NtHelperBinding, userConfig: UserConfigService, ): InjectHook { - /** pids whose pkexec ptrace inject has completed. */ + const isRoot = process.geteuid?.() === 0; + + /** pids whose ptrace inject has completed. */ const injected = new Set(); /** pids that are injected AND have observed a real post-login packet. */ const ready = new Set(); @@ -159,7 +174,7 @@ export function createPkexecInjectHook( } } - /** The pkexec ptrace inject half — pops the polkit dialog. Untimed by callers. */ + /** The ptrace inject half — pops the polkit dialog unless we're already root. */ async function doInject(pid: number): Promise { if (injected.has(pid)) return; const existing = injectInflight.get(pid); @@ -168,8 +183,16 @@ export function createPkexecInjectHook( return existing; } const task = (async (): Promise => { - logger.info('injecting into qq via pkexec (root)', { event: 'inject-pkexec', pid }); - await pkexecInject(pid); + if (isRoot) { + logger.info('injecting into qq in-process (already root)', { + event: 'inject-direct-root', + pid, + }); + await nt.injectAndGetStatusEmbedded(pid); + } else { + logger.info('injecting into qq via pkexec (root)', { event: 'inject-pkexec', pid }); + await pkexecInject(pid); + } injected.add(pid); // Persist so a WeQ restart reuses this hook instead of re-injecting. // Skip if the pid vanished between inject and stat (record would be junk). diff --git a/apps/desktop/src/main/stub_elevation.ts b/apps/desktop/src/main/stub_elevation.ts index 5510d120..16234acd 100644 --- a/apps/desktop/src/main/stub_elevation.ts +++ b/apps/desktop/src/main/stub_elevation.ts @@ -1,18 +1,21 @@ /** - * Linux privilege-escalated stub hooks for ninebird. + * Linux stub hooks for ninebird, elevating only when the host isn't root. * * The ninebird launch flow drops a tiny entry stub (`loadNineBird.js`) into * QQ's `resources/app` so QQ's Electron entry resolves a real file (a raw * statx probe that `LD_PRELOAD` can't fake). That directory is root-owned * (root:root 0755) on a normal QQ install, so writing the stub needs - * elevation. We shell out to `pkexec`, which pops the desktop's graphical - * polkit auth dialog. + * elevation — unless we're already root, in which case a plain `fs` write + * does. Electron refuses to run as root, so the desktop app always takes the + * elevated path; the headless web server usually doesn't need it. * - * Frequency is low — a given account only needs a dbkey once — so a password - * prompt per drop is acceptable. We deliberately do NOT clean the stub up: - * polkit's default policy for `org.freedesktop.policykit.exec` is `auth_admin` - * (no credential caching), so a later cleanup would just pop the dialog again. - * The stub is a harmless self-`require` shim; the next drop overwrites it. + * When elevation IS needed we shell out to `pkexec`, which pops the desktop's + * graphical polkit auth dialog. Frequency is low — a given account only needs + * a dbkey once — so a password prompt per drop is acceptable. We deliberately + * do NOT clean the stub up in that path: polkit's default policy for + * `org.freedesktop.policykit.exec` is `auth_admin` (no credential caching), so + * a later cleanup would just pop the dialog again. The stub is a harmless + * self-`require` shim; the next drop overwrites it. * * Windows never uses these hooks — `@weq/native` falls back to a direct `fs` * write there. @@ -70,8 +73,10 @@ function pkexecWriteFile(path: string, content: string): Promise { * StubHooks backed by pkexec. `removeStub` is intentionally a no-op (see the * module header): the stub is harmless and left in place, overwritten on the * next drop. + * + * Only used when the host is NOT already root — see `linuxStubHooks`. */ -export const pkexecStubHooks: StubHooks = { +const pkexecStubHooks: StubHooks = { dropStub: async (path: string, content: string): Promise => { logger.info('dropping ninebird entry stub via pkexec', { event: 'stub-drop-pkexec', @@ -83,3 +88,13 @@ export const pkexecStubHooks: StubHooks = { /* intentionally not cleaned up — see module header */ }, }; + +/** + * The linux stub hooks. Running as root (typical for the headless web server) + * means we can write QQ's root-owned `resources/app` directly — `undefined` + * selects `@weq/native`'s plain-`fs` default, which also cleans the stub up. + * Only an unprivileged host (the desktop app, since Electron refuses to run as + * root) needs the pkexec detour. + */ +export const linuxStubHooks: StubHooks | undefined = + process.geteuid?.() === 0 ? undefined : pkexecStubHooks; diff --git a/apps/web/README.md b/apps/web/README.md index c0ada4d7..a0102137 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -18,10 +18,15 @@ 从 [Releases](../../releases) 下载 `weq-web-<版本>.tar.gz`,解压后: ```bash -npm install --omit=dev # 装 3 个依赖,约 10 秒 -node server.mjs +# Linux(建议 root,原因见下) +sudo ./start.sh + +# Windows:双击 start.bat 即可 ``` +依赖已随包预装,无需联网 `npm install`。启动脚本会先检查 Node 是否存在、 +版本是否 ≥ 22,缺失时给出安装指引。想手动启动就 `node server.mjs`。 + 终端会打印地址和访问令牌: ``` @@ -35,6 +40,14 @@ node server.mjs 一个压缩包同时支持 **Windows x64 / Linux x64 / Linux arm64**,启动时按当前平台 自动选择 `native/` 下对应的原生模块。 +### Linux 为什么建议 root + +取密钥要往运行中的 QQ 进程里注入 hook,这需要 `ptrace` 权限。非 root 时会 +退回 `pkexec` 图形授权 —— 而无桌面环境的服务器根本弹不出授权框,只会报 +`pkexec 无法启动`。root 运行则直接在进程内注入,不经过 pkexec。 + +(桌面版没有这个选项:Electron 拒绝以 root 运行,所以只能走 pkexec。) + --- ## 配置 @@ -49,6 +62,10 @@ node server.mjs | `WEQ_EXPORT_DIR` | `./weq-exports` | 导出文件落盘目录 | | `WEQ_DATA_DIR` | `./weq-data` | 日志目录 | | `WEQ_NATIVE_DIR` | 随包的 `native/` | 原生模块目录(一般不用管) | +| `WEQ_QQ_EXE` | 自动探测 | QQ 可执行文件路径。装在非常规位置时手动指定 | + +Linux 会依次探测 `/opt/QQ/qq`、`/usr/share/QQ/qq`、`/usr/lib/QQ/qq`、 +`~/NapCat/opt/QQ/qq`;都不匹配时用 `WEQ_QQ_EXE` 指过去。 固定令牌的例子: diff --git a/apps/web/package.json b/apps/web/package.json index 8d556f6d..15d4c201 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build && node scripts/build-server.mjs && pnpm check", + "deps": "node scripts/install-runtime-deps.mjs", "pack:release": "node scripts/pack-release.mjs", "check": "tsx scripts/check-electron-free.ts && tsx scripts/check-bundle.ts", "start": "node dist/server.mjs", diff --git a/apps/web/scripts/build-server.mjs b/apps/web/scripts/build-server.mjs index f261cdd4..eb3d2060 100644 --- a/apps/web/scripts/build-server.mjs +++ b/apps/web/scripts/build-server.mjs @@ -1,5 +1,5 @@ /** - * Bundle the server into a single `dist/server.mjs`. + * Bundle the server into `dist/server.mjs` (plus its two sidecar workers). * * Universal by design: `native/` ships all platform/arch subtrees and * `@weq/native`'s loader picks `native//` at @@ -19,10 +19,14 @@ const here = dirname(fileURLToPath(import.meta.url)); const appRoot = resolve(here, '..'); const repoRoot = resolve(appRoot, '../..'); const dist = join(appRoot, 'dist'); +const desktopMain = join(repoRoot, 'apps/desktop/src/main'); const NATIVE_SRC = join(repoRoot, 'native'); const RESOURCES_SRC = join(repoRoot, 'resources'); +/** Minimum Node major the launchers accept — matches the esbuild `target`. */ +const NODE_MIN_MAJOR = 22; + // `scripts/set-version.mjs` stamps this from the release tag; baked in so the // running server can report its own version without reading package.json. const { version } = JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8')); @@ -36,14 +40,28 @@ const NATIVE_DEPS = { '@resvg/resvg-js': '^2.6.2', }; -await build({ - entryPoints: [join(appRoot, 'src/server/index.ts')], - outfile: join(dist, 'server.mjs'), +/** + * Entries that must stay SEPARATE files on disk, because the server spawns them + * by path rather than importing them: + * - injectWorker — run as a pkexec child (linux, unprivileged host only) + * - transcribeWorker — `fork`ed so a sherpa-onnx SIGSEGV can't take the server + * down with it + * Both are `.mjs` so Node loads them as ESM regardless of the nearest + * package.json. `inject_elevation.ts` / `transcribe/engine.ts` look for these + * exact names next to the main bundle. + */ +const ENTRIES = { + server: join(appRoot, 'src/server/index.ts'), + injectWorker: join(desktopMain, 'inject_worker.ts'), + transcribeWorker: join(desktopMain, 'transcribe/worker.ts'), +}; + +const shared = { bundle: true, platform: 'node', target: 'node22', format: 'esm', - external: [...Object.keys(NATIVE_DEPS), '*.node'], + external: [...Object.keys(NATIVE_DEPS), 'sherpa-onnx-node', '*.node'], banner: { // Bundled CJS deps reach for CJS globals that don't exist in an ESM bundle. // - `require`: esbuild's dynamic-require stub checks `typeof require` and @@ -67,7 +85,11 @@ await build({ __WEQ_VERSION__: JSON.stringify(version), }, logLevel: 'info', -}); +}; + +for (const [name, entry] of Object.entries(ENTRIES)) { + await build({ ...shared, entryPoints: [entry], outfile: join(dist, `${name}.mjs`) }); +} // native/ — every platform, so one archive covers them all. if (!existsSync(NATIVE_SRC)) { @@ -90,6 +112,8 @@ writeFileSync( `${JSON.stringify({ name: 'weq-web', private: true, type: 'module', dependencies: NATIVE_DEPS }, null, 2)}\n`, ); +writeLaunchers(); + writeFileSync( join(dist, 'README.txt'), [ @@ -98,8 +122,10 @@ writeFileSync( '需要 Node.js 22 或更高版本(不内置)。', '', '启动:', - ' npm install --omit=dev # 装 3 个依赖', - ' node server.mjs', + ' Windows:双击 start.bat', + ' Linux :./start.sh', + '', + '(依赖已随包预装。若自行删了 node_modules,跑 npm install --omit=dev 补回。)', '', '然后浏览器打开终端里打印的地址,用同时打印的访问令牌登录。', '', @@ -113,6 +139,9 @@ writeFileSync( '⚠ 对外暴露前请务必放在 HTTPS 反向代理之后,并设置一个足够长的 WEQ_TOKEN。', ' 这个服务能读取该机器上 QQ 的全部本地聊天记录。', '', + 'Linux 建议直接用 root 运行:注入 QQ 进程需要 ptrace 权限,非 root 时会', + '改走 pkexec 提权,而无图形界面的服务器弹不出授权框。', + '', 'native/ 同时包含 win32-x64 / linux-x64 / linux-arm64 三份原生模块,', '运行时按当前平台自动选择,因此同一个压缩包三平台通用。', '', @@ -120,5 +149,100 @@ writeFileSync( ].join('\n'), ); +/** + * Emit the two launchers. They exist so a user can double-click / `./start.sh` + * instead of remembering the `node` invocation — and, more usefully, so a + * missing or too-old Node fails with an actionable message rather than + * "command not found" or a syntax error from inside the bundle. + */ +function writeLaunchers() { + writeFileSync( + join(dist, 'start.bat'), + [ + '@echo off', + 'setlocal', + 'cd /d "%~dp0"', + '', + 'where node >nul 2>nul', + 'if errorlevel 1 (', + ' echo.', + ' echo 没有找到 Node.js。', + ' echo 请先安装 Node.js 22 或更高版本: https://nodejs.org/', + ' echo.', + ' pause', + ' exit /b 1', + ')', + '', + 'rem 取主版本号(v22.16.0 -> 22)并要求 >= 22。', + `for /f "tokens=1 delims=." %%v in ('node -p "process.versions.node"') do set NODE_MAJOR=%%v`, + `if %NODE_MAJOR% LSS ${NODE_MIN_MAJOR} (`, + ' echo.', + ' echo Node.js 版本过低(当前 v%NODE_MAJOR%),需要 22 或更高。', + ' echo 请到 https://nodejs.org/ 升级。', + ' echo.', + ' pause', + ' exit /b 1', + ')', + '', + 'if not exist "node_modules" (', + ' echo 正在安装依赖...', + ' call npm install --omit=dev --no-audit --no-fund || exit /b 1', + ')', + '', + 'node server.mjs %*', + 'pause', + '', + ].join('\r\n'), + ); + + writeFileSync( + join(dist, 'start.sh'), + [ + '#!/bin/sh', + 'set -e', + 'cd "$(dirname "$0")"', + '', + 'if ! command -v node >/dev/null 2>&1; then', + ' echo', + ' echo " 没有找到 Node.js。"', + ' echo " 请先安装 Node.js 22 或更高版本:"', + ' echo " Debian/Ubuntu: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs"', + ' echo " 其它发行版: https://nodejs.org/"', + ' echo', + ' exit 1', + 'fi', + '', + '# 主版本号(v22.16.0 -> 22)。', + 'NODE_MAJOR=$(node -p "process.versions.node.split(\'.\')[0]")', + `if [ "$NODE_MAJOR" -lt ${NODE_MIN_MAJOR} ]; then`, + ' echo', + ' echo " Node.js 版本过低(当前 v$NODE_MAJOR),需要 22 或更高。"', + ' echo " 升级方式见 https://nodejs.org/"', + ' echo', + ' exit 1', + 'fi', + '', + '# 注入 QQ 进程要 ptrace 权限。非 root 时会退回 pkexec 提权,而无图形', + '# 会话的服务器根本弹不出授权框,所以这里提前提示。', + 'if [ "$(id -u)" -ne 0 ]; then', + ' echo', + ' echo " 提示:当前不是 root。获取密钥需要注入 QQ 进程(ptrace),"', + ' echo " 非 root 会改走 pkexec 图形授权,无桌面环境时会失败。"', + ' echo " 建议改用:sudo ./start.sh"', + ' echo', + 'fi', + '', + 'if [ ! -d node_modules ]; then', + ' echo "正在安装依赖..."', + ' npm install --omit=dev --no-audit --no-fund', + 'fi', + '', + 'exec node server.mjs "$@"', + '', + ].join('\n'), + { mode: 0o755 }, + ); +} + console.log(`\n built → ${dist}`); -console.log(' contents: server.mjs + public/ + native/ + resources/\n'); +console.log(' contents: server.mjs + workers + public/ + native/ + resources/ + start.sh/bat\n'); diff --git a/apps/web/scripts/install-runtime-deps.mjs b/apps/web/scripts/install-runtime-deps.mjs new file mode 100644 index 00000000..725229dc --- /dev/null +++ b/apps/web/scripts/install-runtime-deps.mjs @@ -0,0 +1,67 @@ +/** + * Install the bundle's external runtime deps into `dist/node_modules`. + * + * The esbuild bundle keeps `ws` and `@resvg/resvg-js` external (they load + * `.node` bindings), so the release archive has to carry them pre-installed — + * a server with no npm registry access, or no network at all, still has to + * start. + * + * The catch is resvg: it picks its binding from an OPTIONAL dependency chosen + * by the installing machine's platform, but our archive is universal. So we + * install the host's own set first, then force-add the bindings for every + * platform we ship. `js-binding.js` requires them by name at runtime, so + * whichever one matches the running machine resolves and the rest sit unused. + * + * node scripts/install-runtime-deps.mjs + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const dist = resolve(here, '../dist'); + +/** + * resvg bindings for every platform/arch the archive claims to support. Kept + * in sync with `native/` (win32-x64, linux-x64, linux-arm64); the `-gnu` + * variants cover glibc, which is what the AppImage/tarball target anyway. + */ +const RESVG_BINDINGS = [ + '@resvg/resvg-js-win32-x64-msvc', + '@resvg/resvg-js-linux-x64-gnu', + '@resvg/resvg-js-linux-arm64-gnu', +]; + +const RESVG_VERSION = '2.6.2'; + +if (!existsSync(join(dist, 'package.json'))) { + console.error('dist/package.json missing — run `pnpm build` first'); + process.exit(1); +} + +function npm(args, label) { + const res = spawnSync('npm', args, { cwd: dist, stdio: 'inherit', shell: true }); + if (res.status !== 0) { + console.error(`\n${label} failed (npm exited ${res.status})`); + process.exit(1); + } +} + +npm(['install', '--omit=dev', '--no-audit', '--no-fund'], 'installing runtime deps'); + +// `--force` because npm refuses to add a package whose `os`/`cpu` fields don't +// match the host — which is exactly what we're doing on purpose. +npm( + [ + 'install', + '--no-audit', + '--no-fund', + '--force', + ...RESVG_BINDINGS.map((p) => `${p}@${RESVG_VERSION}`), + ], + 'installing cross-platform resvg bindings', +); + +console.log(`\n runtime deps installed → ${join(dist, 'node_modules')}\n`); diff --git a/apps/web/scripts/pack-release.mjs b/apps/web/scripts/pack-release.mjs index 235a176a..eb059236 100644 --- a/apps/web/scripts/pack-release.mjs +++ b/apps/web/scripts/pack-release.mjs @@ -5,7 +5,9 @@ * compression cost — only the release workflow calls this. * * One archive covers every platform: `native/` ships all three platform/arch - * subtrees and the loader picks the right one at runtime. + * subtrees and the loader picks the right one at runtime. `node_modules` is + * shipped pre-installed (see `install-runtime-deps.mjs`) so an offline server + * can start straight out of the tarball. * * node scripts/pack-release.mjs [version] */ @@ -24,9 +26,20 @@ const version = process.argv[2]?.replace(/^v/, '') ?? JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8')).version; -for (const entry of ['server.mjs', 'public', 'native', 'resources', 'package.json']) { +for (const entry of [ + 'server.mjs', + 'injectWorker.mjs', + 'transcribeWorker.mjs', + 'start.sh', + 'start.bat', + 'public', + 'native', + 'resources', + 'node_modules', + 'package.json', +]) { if (existsSync(join(dist, entry))) continue; - console.error(`dist/${entry} missing — run \`pnpm build\` first`); + console.error(`dist/${entry} missing — run \`pnpm build && pnpm deps\` first`); process.exit(1); } @@ -44,11 +57,16 @@ const fd = openSync(archive, 'w'); const res = spawnSync( 'tar', [ - // Skip anything a local run may have left in dist/. - '--exclude=./node_modules', + // Runtime droppings from a local run or the pre-pack smoke test. `logs/` + // is the native addon's own log dir, which lands in cwd unless WEQ_LOG_DIR + // says otherwise — belt and braces, since the smoke test now sets it. '--exclude=./weq-exports', '--exclude=./weq-data', + '--exclude=./logs', '--exclude=./package-lock.json', + // node_modules IS shipped (pre-installed), but npm's own metadata isn't. + '--exclude=./node_modules/.package-lock.json', + '--exclude=./node_modules/.bin', '-czf', '-', '-C', diff --git a/apps/web/scripts/smoke-dist.ts b/apps/web/scripts/smoke-dist.ts index 38c199c9..68b90129 100644 --- a/apps/web/scripts/smoke-dist.ts +++ b/apps/web/scripts/smoke-dist.ts @@ -10,13 +10,17 @@ */ import { spawn, spawnSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const dist = resolve(here, '../dist'); +/** Scratch dir for the run's logs/exports, so dist/ stays exactly as built. */ +const smokeScratch = mkdtempSync(join(tmpdir(), 'weq-smoke-')); + const TOKEN = 'smoke-dist-token'; const PORT = 39955; @@ -26,7 +30,16 @@ function check(ok: boolean, label: string): void { console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`); } -for (const entry of ['server.mjs', 'public/index.html', 'native', 'resources']) { +for (const entry of [ + 'server.mjs', + 'injectWorker.mjs', + 'transcribeWorker.mjs', + 'start.sh', + 'start.bat', + 'public/index.html', + 'native', + 'resources', +]) { if (existsSync(join(dist, entry))) continue; console.error(`FAIL dist/${entry} missing — run \`pnpm build\` first`); process.exit(1); @@ -37,25 +50,39 @@ for (const platform of ['win32/x64', 'linux/x64', 'linux/arm64']) { check(existsSync(join(dist, 'native', platform)), `native/${platform} shipped (universal)`); } -// The bundle keeps native-loading packages external, so a release needs the -// same `npm install` the README tells users to run. Do it here, both to test -// that instruction and because the server won't start without it. +// The release ships node_modules pre-installed. If a local build skipped that +// step, run it here — the server won't start without it. if (!existsSync(join(dist, 'node_modules'))) { - console.log('installing runtime deps into dist/ (as the README instructs)…'); - const install = spawnSync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { - cwd: dist, + console.log('installing runtime deps into dist/…'); + const install = spawnSync(process.execPath, [join(here, 'install-runtime-deps.mjs')], { stdio: 'inherit', - shell: true, }); if (install.status !== 0) { - console.error('FAIL npm install in dist/ failed'); + console.error('FAIL runtime dep install failed'); process.exit(1); } } +// resvg picks its binding by platform at require() time, so the universal +// archive has to carry one per platform we claim to support. +for (const binding of [ + '@resvg/resvg-js-win32-x64-msvc', + '@resvg/resvg-js-linux-x64-gnu', + '@resvg/resvg-js-linux-arm64-gnu', +]) { + check(existsSync(join(dist, 'node_modules', binding)), `${binding} shipped (universal)`); +} + const server = spawn(process.execPath, [join(dist, 'server.mjs')], { cwd: dist, - env: { ...process.env, WEQ_TOKEN: TOKEN, WEQ_PORT: String(PORT) }, + env: { + ...process.env, + WEQ_TOKEN: TOKEN, + WEQ_PORT: String(PORT), + // Keep the run's droppings out of dist/, which is about to be tarballed. + WEQ_DATA_DIR: join(smokeScratch, 'weq-data'), + WEQ_EXPORT_DIR: join(smokeScratch, 'weq-exports'), + }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/apps/web/src/server/index.ts b/apps/web/src/server/index.ts index 4c018cf8..65a3a2f8 100644 --- a/apps/web/src/server/index.ts +++ b/apps/web/src/server/index.ts @@ -71,6 +71,9 @@ function resolveToken(): string { async function main(): Promise { initLogger(DATA_DIR); + // The native addon writes its own log through a separate path that would + // otherwise default to `/logs`, dropping files into the release bundle. + process.env.WEQ_LOG_DIR ??= join(DATA_DIR, 'logs'); const token = resolveToken(); const generated = !process.env.WEQ_TOKEN?.trim(); diff --git a/packages/native/src/loader.ts b/packages/native/src/loader.ts index 83c12217..3550f8e9 100644 --- a/packages/native/src/loader.ts +++ b/packages/native/src/loader.ts @@ -263,6 +263,13 @@ function configureNtHelperLogging(ntHelper: NtHelperBinding): void { } function resolveNativeLogRoot(): string { + // Explicit override wins. Hosts that already own a data directory (the web + // server's WEQ_DATA_DIR) set this so the addon logs land beside the app's + // own logs instead of wherever the process happens to be cwd'd — which, + // absent this, can be the release bundle itself. + const override = process.env.WEQ_LOG_DIR; + if (override) return override; + const candidates = new Set(); const electronAppData = process.env.APPDATA; diff --git a/packages/platform/src/linux/paths.ts b/packages/platform/src/linux/paths.ts index eb51860b..1d23df1d 100644 --- a/packages/platform/src/linux/paths.ts +++ b/packages/platform/src/linux/paths.ts @@ -28,7 +28,10 @@ * Callers decrypt both and merge, preferring `global/nt_db`. * * QQ install (for the launch-based key flows): - * /opt/QQ/qq (binary; no registry on linux) + * /opt/QQ/qq (binary; no registry on linux. + * NapCat-style `~/NapCat/opt/QQ` + * is probed too — see + * `candidateQqExePaths`) * /resources/app/wrapper.node (protobuf descriptors) * /resources/app/major.node (appid/qua anchor) * /resources/app/package.json (client `version`, read uniformly @@ -240,14 +243,33 @@ export function findFileDir(uid: string, home = homedir(), overrideRoot?: string // ---------- QQ install (binary / wrapper.node / version) ------------------ -/** Candidate QQ binary locations on linux, in priority order. */ -export function candidateQqExePaths(): string[] { - return ['/opt/QQ/qq', '/usr/share/QQ/qq', '/usr/lib/QQ/qq']; +/** + * Candidate QQ binary locations on linux, in priority order. + * + * The first three are where a distro package puts QQ. The NapCat entries cover + * NapCat-style installs, which unpack QQ into the user's home instead — worth + * probing because that layout is also the one a headless server most often has + * (no root install, no desktop session). Both casings are tried since linux + * paths are case-sensitive and the directory is created by hand as often as by + * the installer. + * + * `WEQ_QQ_EXE` short-circuits the whole list for anything more exotic. + */ +export function candidateQqExePaths(home = homedir()): string[] { + const override = process.env.WEQ_QQ_EXE; + return [ + ...(override ? [override] : []), + '/opt/QQ/qq', + '/usr/share/QQ/qq', + '/usr/lib/QQ/qq', + join(home, 'NapCat', 'opt', 'QQ', 'qq'), + join(home, 'Napcat', 'opt', 'QQ', 'qq'), + ]; } /** First QQ binary that exists on disk, or null. No registry on linux. */ -export function findQqExe(): string | null { - for (const p of candidateQqExePaths()) { +export function findQqExe(home = homedir()): string | null { + for (const p of candidateQqExePaths(home)) { if (existsSync(p)) return p; } return null; diff --git a/scripts/verify_inject_flow.mjs b/scripts/verify_inject_flow.mjs index f88a8a2d..b6be9df4 100644 --- a/scripts/verify_inject_flow.mjs +++ b/scripts/verify_inject_flow.mjs @@ -5,7 +5,7 @@ // 2. pkexec env ELECTRON_RUN_AS_NODE=1 injectWorker.mjs (ROOT) // 3. waitForRealPacket + fetchClientKey (unprivileged) // -// This mirrors exactly what createPkexecInjectHook does. Run it AFTER +// This mirrors the unprivileged-host branch of createLinuxInjectHook. Run it AFTER // `electron-vite build` (needs out/main/injectWorker.mjs). Requires a graphical // polkit agent (a password dialog pops once) and a logged-in QQ. // From f5cf2ce2e89b5c3e900ff488b088cfa6e76ed2f1 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Sun, 2 Aug 2026 00:46:46 +0800 Subject: [PATCH 3/4] feat: fetch link --- apps/desktop/src/main/context/app_context.ts | 6 + apps/desktop/src/main/index.ts | 5 +- .../desktop/src/main/ipc/routers/bootstrap.ts | 24 + apps/desktop/src/main/link_shot.ts | 73 +++ apps/desktop/src/main/media_protocol.ts | 13 + apps/desktop/src/renderer/src/App.tsx | 8 +- .../renderer/src/components/QqLinkCard.tsx | 69 +++ .../src/components/QqMessageContent.tsx | 56 +- .../src/components/SettingsDialog.tsx | 65 ++ apps/desktop/src/renderer/src/lib/linkify.ts | 108 ++++ .../src/renderer/src/lib/resourceUrl.ts | 12 +- .../desktop/src/renderer/src/styles/index.css | 23 +- .../service/src/bootstrap/link_preview.ts | 581 ++++++++++++++++++ packages/service/src/bootstrap/user_config.ts | 18 + packages/service/src/index.ts | 4 + 15 files changed, 1056 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/main/link_shot.ts create mode 100644 apps/desktop/src/renderer/src/components/QqLinkCard.tsx create mode 100644 apps/desktop/src/renderer/src/lib/linkify.ts create mode 100644 packages/service/src/bootstrap/link_preview.ts diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index 803ff192..afc8d3c0 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -40,6 +40,7 @@ import { Win32KeyService, GlobalConfigService, AvatarCacheService, + LinkPreviewService, AgentLabConfigService, VoiceTranscribeService, TtsService, @@ -339,6 +340,8 @@ export interface BootstrapServices { userConfig: UserConfigService; globalConfig: GlobalConfigService; avatarCache: AvatarCacheService; + /** 聊天里裸链接 → og 卡片(抓取带 SSRF 闸门,见 service 侧)。Account-independent。 */ + linkPreview: LinkPreviewService; agentLabConfig: AgentLabConfigService; /** Voice-transcription model management (download/select). Account-independent. */ voiceTranscribe: VoiceTranscribeService; @@ -592,12 +595,15 @@ export function initAppContext(): AppContext { ? createLinuxInjectHook(platform.native.ntHelper, userConfig) : createDirectInjectHook(platform.native.ntHelper); + const linkPreview = new LinkPreviewService(userConfig); + const bootstrap: BootstrapServices = { detect: new Win32DetectService(platform, stubHooks), keys: new Win32KeyService(platform, stubHooks), userConfig, globalConfig: new GlobalConfigService(platform, userConfig), avatarCache: new AvatarCacheService(platform, userConfig), + linkPreview, agentLabConfig: new AgentLabConfigService(userConfig), voiceTranscribe: new VoiceTranscribeService(platform), tts: new TtsService(), diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6e7bc656..5c28dc6d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -33,7 +33,7 @@ import { } from '@weq/service'; import { electronHost } from './host'; import { systemAuthService } from './system_auth'; - +import { screenshotPage } from './link_shot'; const __dirname = dirname(fileURLToPath(import.meta.url)); // Privileged-scheme registration must happen before app `ready`, and Electron @@ -474,6 +474,9 @@ void app.whenReady().then(async () => { setHost(electronHost); installUpdateActions(); initAppContext(); + // 链接卡片抓不到 og:image 时的兜底封面。截图要跑一个真浏览器,服务层不认识 + // Electron,所以实现在这里注入(app_context 保持 Electron-free,web 端共用它)。 + getAppContext().bootstrap?.linkPreview.setScreenshotHook(screenshotPage); logger.info('electron app ready', { event: 'app-ready' }); registerResourceProtocol(); diff --git a/apps/desktop/src/main/ipc/routers/bootstrap.ts b/apps/desktop/src/main/ipc/routers/bootstrap.ts index cf5e4481..9b5a610d 100644 --- a/apps/desktop/src/main/ipc/routers/bootstrap.ts +++ b/apps/desktop/src/main/ipc/routers/bootstrap.ts @@ -367,6 +367,30 @@ export const bootstrapRouter = router({ return true; }), + /** + * 聊天里裸链接的展示方式。`enabled` 关掉后只做蓝色下划线、不出网;`screenshot` + * 决定页面没有 og:image 时要不要用离屏窗口截一张。纯持久化。 + */ + setLinkPreview: procedure + .input(z.object({ enabled: z.boolean().optional(), screenshot: z.boolean().optional() })) + .mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ linkPreview: input }); + return true; + }), + + /** + * 取一条链接的预览卡片(标题/描述/站点/封面)。抓取全程带 SSRF 闸门 —— 只放行 + * 公网 http(s) 的 80/443,重定向逐跳复检,正文只收 text/html(对方给二进制时 + * body 根本不读)。结果按 URL 落盘缓存,命中不出网。不可预览返回 null。 + */ + linkPreview: procedure + .input(z.object({ url: z.string().trim().max(2048) })) + .query(async ({ input }) => { + const boot = requireBootstrap(); + if (!boot.userConfig.getSettings().linkPreview.enabled) return null; + return boot.linkPreview.get(input.url); + }), + // ---- MCP server (account-bound) ---- /** diff --git a/apps/desktop/src/main/link_shot.ts b/apps/desktop/src/main/link_shot.ts new file mode 100644 index 00000000..901607c7 --- /dev/null +++ b/apps/desktop/src/main/link_shot.ts @@ -0,0 +1,73 @@ +/** + * 网页预截图 —— 链接卡片抓不到 og:image 时的兜底封面。 + * + * 一个 URL 走到这里,意味着我们真的要把陌生网页**跑起来**(脚本会执行)。所以这个窗口 + * 按「一次性沙盒」造: + * · `show: false` + 离屏,用户看不到、也点不到; + * · 独立 `partition`(非 persist:)—— cookie / storage 进程退出即蒸发,不碰账号会话; + * · 无 preload、`nodeIntegration` 关、`contextIsolation` 开 —— 页面拿不到任何 bridge; + * · `setWindowOpenHandler` 一律 deny、`will-navigate` 只准同源跳转 —— 页面不能自己开窗 + * 或把我们导去别处; + * · session 的 download 全部取消、权限请求(地理位置/摄像头/通知…)全部拒绝 + * —— 「打开链接自动下载木马」在这里是走不通的:字节根本不落盘。 + * · 8 秒硬超时,无论加载完没有都截图并销毁窗口,不留后台页面。 + * + * 调用方是 LinkPreviewService(通过 setScreenshotHook 注入),URL 在那边已过完 SSRF 闸门。 + */ + +import { BrowserWindow, session } from 'electron'; + +const WIDTH = 1000; +const HEIGHT = 640; +const LOAD_TIMEOUT_MS = 8000; +/** 首屏之后再等一拍,让懒加载的图片/字体落位(截白图的主要原因)。 */ +const SETTLE_MS = 900; + +let seq = 0; + +/** 在一次性沙盒窗口里加载 url 并截取首屏,返回 PNG 字节;失败返回 null。 */ +export async function screenshotPage(url: string): Promise { + seq += 1; + const partition = `link-shot-${seq}`; + const ses = session.fromPartition(partition, { cache: false }); + ses.setPermissionRequestHandler((_wc, _perm, cb) => cb(false)); + // 页面若试图触发下载,直接掐掉——预览不需要任何文件落盘。 + ses.on('will-download', (event) => event.preventDefault()); + + const win = new BrowserWindow({ + width: WIDTH, + height: HEIGHT, + show: false, + webPreferences: { + partition, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + webSecurity: true, + javascript: true, + offscreen: true, + images: true, + }, + }); + win.webContents.setAudioMuted(true); + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + const origin = new URL(url).origin; + win.webContents.on('will-navigate', (event, next) => { + if (!next.startsWith(origin)) event.preventDefault(); + }); + + try { + const loaded = win.loadURL(url).catch(() => {}); + await Promise.race([loaded, new Promise((r) => setTimeout(r, LOAD_TIMEOUT_MS))]); + if (win.isDestroyed()) return null; + await new Promise((r) => setTimeout(r, SETTLE_MS)); + if (win.isDestroyed()) return null; + const image = await win.webContents.capturePage(); + return image.isEmpty() ? null : image.toPNG(); + } catch { + return null; + } finally { + if (!win.isDestroyed()) win.destroy(); + void ses.clearStorageData().catch(() => {}); + } +} diff --git a/apps/desktop/src/main/media_protocol.ts b/apps/desktop/src/main/media_protocol.ts index 37e62a83..4ac448ae 100644 --- a/apps/desktop/src/main/media_protocol.ts +++ b/apps/desktop/src/main/media_protocol.ts @@ -24,6 +24,7 @@ * weq-media://dressfont?id= → 已安装的装扮字体 ttf * weq-media://dressbubble?id= → 走 protocol 装的气泡九宫格(本地 PNG) * weq-media://dressbg?v= → 用户自选的聊天背景(本地图) + * weq-media://linkpreview?id= → 链接卡片封面(已落盘、验过魔数) * * Like the other custom schemes: `registerMediaScheme()` runs before app * `ready`; `registerMediaProtocol()` runs after. @@ -228,6 +229,18 @@ export function handleMediaRequest(request: Request): Promise { } } + // 链接卡片的封面图:字节是 LinkPreviewService 抓来验过魔数后落的盘,只按 id 取, + // 不接受 url —— 渲染层无法用它当任意 URL 的代理。同样不需要打开的账号。 + if (kind === 'linkpreview') { + const svc = getAppContext().bootstrap?.linkPreview; + const blob = svc ? await svc.readImage(q.get('id') ?? '') : null; + if (!blob) return notFound('link preview image not found'); + return new Response(new Uint8Array(blob.data), { + status: 200, + headers: { 'Content-Type': blob.contentType, 'Cache-Control': 'public, max-age=86400' }, + }); + } + const services = getAppContext().services; if (!services) return notFound('no account session'); diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 4f2e0017..5ba81fb7 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -19,7 +19,7 @@ import { VideoLightbox } from './components/VideoLightbox'; import { MarketFaceLightbox } from './components/MarketFaceLightbox'; import { ForwardWindowHost } from './components/ForwardWindow'; import { AppLockOverlay } from './components/AppLockOverlay'; -import { TextMarkdownContext } from './components/QqMessageContent'; +import { TextMarkdownContext, LinkPreviewContext } from './components/QqMessageContent'; import { SelfPendantContext } from './hooks/useSelfPendant'; import { WarmupSplash } from './components/WarmupSplash'; import { trpc } from './trpc/client'; @@ -30,7 +30,7 @@ import { usePrivacyStore } from './state/privacy'; import { useAccountSwitch } from './state/accountSwitch'; /** - * 把「纯文本消息渲染 Markdown」开关广播给所有消息气泡。 + * 把两个「气泡渲染」开关广播给所有消息气泡:纯文本 Markdown、链接预览卡片。 * * 查询只在这一层做一次——QqMessageContent 每条消息一个实例,让它们各自 useQuery 会挂 * 几百个订阅。必须包住 ForwardWindowHost(它在 MainView 之外,转发窗口里的气泡同样 @@ -44,7 +44,9 @@ function TextMarkdownProvider({ children }: { children: ReactNode }): ReactEleme }); return ( - {children} + + {children} + ); } diff --git a/apps/desktop/src/renderer/src/components/QqLinkCard.tsx b/apps/desktop/src/renderer/src/components/QqLinkCard.tsx new file mode 100644 index 00000000..beebfd61 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/QqLinkCard.tsx @@ -0,0 +1,69 @@ +/** + * 链接卡片 —— 一条消息只有一个裸链接时,把它渲染成「标题 + 描述 + 封面 + 站点」。 + * + * 数据由主进程的 LinkPreviewService 抓(og/twitter meta,公众号另走 msg_* 内联变量), + * 那边带着 SSRF 闸门和内容类型白名单;这里只负责画,以及在抓取失败/未开启时安静地 + * 退回成一条普通的蓝色链接。 + * + * 视觉沿用 ARK 卡片那一套 class(weq-ark-*),因为它们在聊天流里已经是「卡片」的既定 + * 语言,没必要再造一套。 + */ + +import { memo, type ReactElement } from 'react'; +import { Link2 } from 'lucide-react'; +import { trpc } from '../trpc/client'; +import { linkPreviewImageUrl } from '../lib/resourceUrl'; +import { openLink } from '../lib/linkify'; + +export const QqLinkCard = memo(function QqLinkCard({ url }: { url: string }): ReactElement { + // 结果在主进程按 URL 落盘缓存,所以这里放心地按 url 做 query key;抓取失败返回 + // null(短 TTL 内不再重试),此时退回成一条普通链接。 + const preview = trpc.bootstrap.linkPreview.useQuery( + { url }, + { staleTime: Number.POSITIVE_INFINITY, retry: false }, + ); + const data = preview.data; + + if (!data) { + return ( + + + + ); + } + + const cover = data.image ? linkPreviewImageUrl(data.image) : ''; + return ( +
openLink(data.url)} + > +
+
{data.title || data.url}
+ {cover ? ( + <> + {data.desc ?
{data.desc}
: null} + + + ) : ( +
{data.desc || data.url}
+ )} +
+
+ + {data.siteName} +
+
+ ); +}); diff --git a/apps/desktop/src/renderer/src/components/QqMessageContent.tsx b/apps/desktop/src/renderer/src/components/QqMessageContent.tsx index 2454b975..0eb2bb88 100644 --- a/apps/desktop/src/renderer/src/components/QqMessageContent.tsx +++ b/apps/desktop/src/renderer/src/components/QqMessageContent.tsx @@ -30,6 +30,8 @@ import { QqCall } from './QqCall'; import { QqShareLocation } from './QqShareLocation'; import { QqDynamic } from './QqDynamic'; import { QqEmojiBounce } from './QqEmojiBounce'; +import { QqLinkCard } from './QqLinkCard'; +import { splitLinks, soleLink, openLink } from '../lib/linkify'; import { cn } from '@renderer/lib/utils'; /** @@ -74,6 +76,13 @@ export const ConvContext = createContext(''); */ export const TextMarkdownContext = createContext(true); +/** + * 「整条消息只有一个链接时出预览卡片」开关(AppSettings.linkPreview.enabled)。 + * 同 {@link TextMarkdownContext} 走 context 的理由——每条气泡一个实例,不能各自订阅。 + * 关掉后链接仍会标蓝可点,只是不再抓取远端页面(也就完全不出网)。 + */ +export const LinkPreviewContext = createContext(true); + /** Element kinds that render as standalone, borderless media (no bubble). */ const BORDERLESS_MEDIA = new Set(['pic', 'video', 'mface']); /** Element kinds handled by a dedicated media component. */ @@ -249,7 +258,36 @@ function ElementNode({ ); } const text = inlineLabel(element); - return text ? {text} : null; + if (!text) return null; + // 纯文本里的链接标蓝加下划线并可点开(走系统浏览器,见 lib/linkify)。用 + ) : ( + // biome-ignore lint/suspicious/noArrayIndexKey: 按位置切分,无稳定唯一键 + {part.text} + ), + )} + + ); } /** @@ -502,6 +540,7 @@ export function QqMessageContent({ const arkElement = elements.find((element) => element.type === 'ark'); const forwardKind = useContext(ForwardKindContext); const textMarkdownOn = useContext(TextMarkdownContext); + const linkPreviewOn = useContext(LinkPreviewContext); if (arkElement && isArkMultiMsg(arkElement.data?.arkData)) { return (
@@ -671,6 +710,21 @@ export function QqMessageContent({ } } + // 整条消息**只是**一个链接 → 出一张预览卡(标题/描述/封面/站点)。混了别的文字就 + // 不出卡,那种消息里链接只是句子的一部分,行内标蓝就够了。抓取失败或开关关掉时 + // QqLinkCard 自己退回成一条普通蓝链接。 + if (linkPreviewOn && meaningful.length > 0 && meaningful.every((element) => element.type === 'text')) { + const body = meaningful.map((element) => String(element.data?.textContent ?? '')).join(''); + const only = soleLink(body); + if (only) { + return ( +
+ +
+ ); + } + } + // WeQ feature(可在设置里关掉):一条**全是纯文本**的消息,如果看起来像 Markdown, // 就整段交给 streamdown。两道闸都是必需的: // · 全 text —— 一旦混有 at/face,整段当 Markdown 渲染会吞掉 @ 高亮和表情图; diff --git a/apps/desktop/src/renderer/src/components/SettingsDialog.tsx b/apps/desktop/src/renderer/src/components/SettingsDialog.tsx index fabc5282..60e22d31 100644 --- a/apps/desktop/src/renderer/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsDialog.tsx @@ -203,8 +203,11 @@ function AppearanceSection(): ReactElement { }); const setRenderTextMarkdown = trpc.bootstrap.setRenderTextMarkdown.useMutation(); const setShowAvatarPendant = trpc.bootstrap.setShowAvatarPendant.useMutation(); + const setLinkPreview = trpc.bootstrap.setLinkPreview.useMutation(); const [textMarkdown, setTextMarkdown] = useState(true); const [pendant, setPendant] = useState(true); + const [linkCard, setLinkCard] = useState(true); + const [linkShot, setLinkShot] = useState(false); useEffect(() => { const enabled = settings.data?.renderTextMarkdown; @@ -216,6 +219,12 @@ function AppearanceSection(): ReactElement { if (typeof enabled === 'boolean') setPendant(enabled); }, [settings.data?.showAvatarPendant]); + useEffect(() => { + const cfg = settings.data?.linkPreview; + if (typeof cfg?.enabled === 'boolean') setLinkCard(cfg.enabled); + if (typeof cfg?.screenshot === 'boolean') setLinkShot(cfg.screenshot); + }, [settings.data?.linkPreview]); + // 本地先翻转求手感,失败回滚 + 重新拉服务端值。 async function onSetTextMarkdown(next: boolean): Promise { const prev = textMarkdown; @@ -239,6 +248,28 @@ function AppearanceSection(): ReactElement { await settings.refetch(); } + async function onSetLinkCard(next: boolean): Promise { + const prev = linkCard; + setLinkCard(next); + try { + await setLinkPreview.mutateAsync({ enabled: next }); + } catch { + setLinkCard(prev); + } + await settings.refetch(); + } + + async function onSetLinkShot(next: boolean): Promise { + const prev = linkShot; + setLinkShot(next); + try { + await setLinkPreview.mutateAsync({ screenshot: next }); + } catch { + setLinkShot(prev); + } + await settings.refetch(); + } + return (

个性显示

@@ -319,6 +350,40 @@ function AppearanceSection(): ReactElement { />
+
+
+
+ 链接预览卡片 + + 一条消息只有一个链接时,抓取网页标题、简介和封面渲染成卡片。会按链接访问 + 对应网页(结果本地缓存),关掉后链接仍标蓝可点,但完全不出网。 + +
+ void onSetLinkCard(next)} + label="链接预览卡片" + /> +
+
+
+
+
+ 无封面时网页截图 + + 网页没提供封面图时,在一个隔离的后台窗口里把它打开并截取首屏当封面。这会 + 真正运行对方页面的脚本(沙盒内、不落盘、退出即清),默认关闭。 + +
+ void onSetLinkShot(next)} + label="无封面时网页截图" + /> +
+
); } diff --git a/apps/desktop/src/renderer/src/lib/linkify.ts b/apps/desktop/src/renderer/src/lib/linkify.ts new file mode 100644 index 00000000..5f0c225d --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/linkify.ts @@ -0,0 +1,108 @@ +/** + * 聊天文本里的链接识别与打开。 + * + * 两件事:把一段纯文本切成「文字 / 链接」片段({@link splitLinks}),以及安全地把一个 + * 链接交给系统浏览器({@link openLink})。 + * + * 打开这一侧的边界: + * · 只放行 http(s)。`file://` / `javascript:` / `mqqapi://` 这类一律不开——聊天文本 + * 是完全不可信的输入,让它触发本机协议处理器等于把攻击面直接送出去。 + * · 用户点的链接若指向可执行/安装包后缀,先弹一次确认。浏览器不会自动运行下载物, + * 但「点一下就开始下 .exe」本身值得让人先看清域名——这一步是给人看的,不是给 + * 浏览器看的。 + * · 走 `window.open` → 主进程 `setWindowOpenHandler` 已把所有 target=_blank 转成 + * `shell.openExternal` 并 deny 掉窗口,所以应用内不会有任何远程页面被加载。 + */ + +/** + * 链接匹配。刻意不追求 RFC 完备,只认聊天里真正会出现的两种写法:带 scheme 的 + * http(s),以及裸 `www.` 开头的域名。写成无回溯的字符类扫描(避免 catastrophic + * backtracking——这条正则要跑在每一条消息上)。 + */ +const LINK_RE = /(?:https?:\/\/|www\.)[^\s<>"'“”,。!?;:、)】》]+/gi; + +/** 结尾的成对标点通常是句子的一部分而非 URL 的(`(见 https://a.com/x)`)。 */ +function trimTrailing(raw: string): string { + let out = raw; + while (out.length > 0) { + const last = out[out.length - 1]!; + if ('.,;:!?)]}\'"'.includes(last)) { + // 括号只在没有配对的开括号时才剥掉(维基链接常自带括号)。 + if (last === ')' && (out.match(/\(/g)?.length ?? 0) >= (out.match(/\)/g)?.length ?? 0)) break; + out = out.slice(0, -1); + continue; + } + break; + } + return out; +} + +export interface LinkPart { + kind: 'text' | 'link'; + /** 原文(链接片段保留用户写的原样,用于显示)。 */ + text: string; + /** 链接片段的规范化地址(裸 www. 补上 https://);文字片段为空串。 */ + href: string; +} + +/** 把一段文本切成文字/链接片段。没有链接时返回单个 text 片段。 */ +export function splitLinks(text: string): LinkPart[] { + const parts: LinkPart[] = []; + let cursor = 0; + LINK_RE.lastIndex = 0; + let m = LINK_RE.exec(text); + while (m) { + const raw = trimTrailing(m[0]); + if (raw.length >= 8) { + const start = m.index; + if (start > cursor) parts.push({ kind: 'text', text: text.slice(cursor, start), href: '' }); + const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; + parts.push({ kind: 'link', text: raw, href }); + cursor = start + raw.length; + } + LINK_RE.lastIndex = m.index + m[0].length; + m = LINK_RE.exec(text); + } + if (cursor < text.length) parts.push({ kind: 'text', text: text.slice(cursor), href: '' }); + return parts; +} + +/** 整段文本是否**只是**一个链接(决定要不要出卡片)。 */ +export function soleLink(text: string): string | null { + const parts = splitLinks(text.trim()); + const links = parts.filter((p) => p.kind === 'link'); + if (links.length !== 1) return null; + const rest = parts + .filter((p) => p.kind === 'text') + .map((p) => p.text.trim()) + .join(''); + return rest === '' ? links[0]!.href : null; +} + +/** 点开会直接开始下载的后缀 —— 打开前要用户再确认一次。 */ +const RISKY_EXT = + /\.(exe|msi|msix|appx|bat|cmd|com|scr|pif|ps1|vbs|vbe|js|jse|wsf|hta|jar|apk|dmg|pkg|deb|rpm|sh|run|iso|img|lnk|reg|dll|zip|rar|7z|gz)(?:$|[?#])/i; + +/** + * 用系统浏览器打开一个链接。非 http(s) 直接拒绝;指向可执行/压缩包后缀时先确认。 + * 返回是否真的打开了。 + */ +export function openLink(href: string): boolean { + let url: URL; + try { + url = new URL(href); + } catch { + return false; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + if (RISKY_EXT.test(url.pathname + url.search)) { + const name = decodeURIComponent(url.pathname.split('/').pop() ?? ''); + const ok = window.confirm( + `这个链接指向一个可执行文件或压缩包,打开后浏览器可能直接开始下载。\n\n` + + `站点:${url.hostname}\n文件:${name}\n\n确定要在浏览器中打开吗?`, + ); + if (!ok) return false; + } + window.open(url.toString(), '_blank', 'noopener,noreferrer'); + return true; +} diff --git a/apps/desktop/src/renderer/src/lib/resourceUrl.ts b/apps/desktop/src/renderer/src/lib/resourceUrl.ts index c82d7d55..2b2dd6f7 100644 --- a/apps/desktop/src/renderer/src/lib/resourceUrl.ts +++ b/apps/desktop/src/renderer/src/lib/resourceUrl.ts @@ -121,8 +121,16 @@ export function screenWidgetUrl(widgetId: string, ...segments: string[]): string return resourceUrl('dress', 'screen', widgetId, ...segments); } -/** Preview a local file under `nt_data/File/Ori` by absolute path (image thumbnails). */ -export function localFileUrl(absPath: string): string { +/** + * 链接卡片的封面图。`id` 是主进程落盘时给的缓存名 —— 这里刻意**不接受 URL**: + * 图片字节由 LinkPreviewService 抓取并验过魔数,渲染层只能取已缓存的那些, + * 不能拿这条协议当任意 URL 的代理。 + */ +export function linkPreviewImageUrl(id: string): string { + return mediaUrl('linkpreview', { id }); +} + +/** Preview a local file under `nt_data/File/Ori` by absolute path (image thumbnails). */export function localFileUrl(absPath: string): string { return mediaUrl('localfile', { path: absPath }); } diff --git a/apps/desktop/src/renderer/src/styles/index.css b/apps/desktop/src/renderer/src/styles/index.css index ac0fdc2c..428037b4 100644 --- a/apps/desktop/src/renderer/src/styles/index.css +++ b/apps/desktop/src/renderer/src/styles/index.css @@ -6636,9 +6636,28 @@ html[data-theme="dark"] .weq-ark-contact-avatar { background: rgba(255, 255, 255 html[data-theme="dark"] .weq-ark-contact-name { color: var(--weq-fg-primary); } html[data-theme="dark"] .weq-ark-contact-sub { color: var(--weq-fg-muted); } +/* ---- 聊天文本里的链接 ---- 用
- - ); + let data: CardData | null = null; + if (info) { + data = { + url, + title: info.title, + // QQ 给的 desc 在页面没写描述时会退化成主机名,那就跟页脚重复了,不如不显示。 + desc: info.desc === hostOf(url) ? '' : info.desc, + // 站点名 QQ 没给,从地址取 host。 + siteName: hostOf(url), + cover: cover.data ? linkPreviewImageUrl(cover.data) : '', + imageKind: 'og', + }; + } else if (preview.data) { + const p = preview.data; + data = { + url: p.url, + title: p.title, + desc: p.desc, + siteName: p.siteName, + cover: p.image ? linkPreviewImageUrl(p.image) : '', + imageKind: p.imageKind, + }; } - const cover = data.image ? linkPreviewImageUrl(data.image) : ''; + if (!data) return null; + + const card = data; return (
openLink(data.url)} + title={card.url} + onClick={() => openLink(card.url)} >
-
{data.title || data.url}
- {cover ? ( +
{card.title || card.url}
+ {card.cover ? ( <> - {data.desc ?
{data.desc}
: null} + {card.desc ?
{card.desc}
: null} ) : ( -
{data.desc || data.url}
+
{card.desc || card.url}
)}
- {data.siteName} + {card.siteName}
); diff --git a/apps/desktop/src/renderer/src/components/QqMessageContent.tsx b/apps/desktop/src/renderer/src/components/QqMessageContent.tsx index 0eb2bb88..d5be1500 100644 --- a/apps/desktop/src/renderer/src/components/QqMessageContent.tsx +++ b/apps/desktop/src/renderer/src/components/QqMessageContent.tsx @@ -18,6 +18,7 @@ import { createContext, useContext, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { ArrowUp } from 'lucide-react'; +import type { UrlVerifyInfo } from '@weq/service'; import type { MessageRenderer } from '../im-template/template'; import { FaceEmoji } from './FaceEmoji'; import { QqImage, QqVideo, QqFile, QqVoice, QqMarketFace, QqOnlineFile } from './QqMedia'; @@ -710,19 +711,31 @@ export function QqMessageContent({ } } - // 整条消息**只是**一个链接 → 出一张预览卡(标题/描述/封面/站点)。混了别的文字就 - // 不出卡,那种消息里链接只是句子的一部分,行内标蓝就够了。抓取失败或开关关掉时 - // QqLinkCard 自己退回成一条普通蓝链接。 - if (linkPreviewOn && meaningful.length > 0 && meaningful.every((element) => element.type === 'text')) { + // 整条消息**只是**一个链接 → 在气泡下方补一张预览卡(标题/封面/站点)。混了别的 + // 文字就不出卡,那种消息里链接只是句子的一部分,行内标蓝就够了。 + // + // 卡片挂在气泡下面(而不是取代气泡)—— 同语音转录的处理:原文始终看得见,卡片只是 + // 附加信息。数据优先取 QQ 自己扫出来的 urlVerify(本地就有,不出网),没有才让 + // QqLinkCard 去抓;两个都没有它返回 null,只剩气泡里那条蓝链接。 + const soleLinkCard = ((): ReactNode => { + if (!linkPreviewOn) return null; + if (meaningful.length === 0 || !meaningful.every((element) => element.type === 'text')) return null; const body = meaningful.map((element) => String(element.data?.textContent ?? '')).join(''); const only = soleLink(body); - if (only) { - return ( -
- -
- ); - } + if (!only) return null; + const verified = meaningful + .map((element) => element.data?.urlVerify as UrlVerifyInfo | undefined) + .find(Boolean); + return ; + })(); + + if (soleLinkCard) { + return ( +
+ {renderElementNodes(meaningful, sendTimeMs, msgId, isSender)} + {soleLinkCard} +
+ ); } // WeQ feature(可在设置里关掉):一条**全是纯文本**的消息,如果看起来像 Markdown, diff --git a/apps/desktop/src/renderer/src/styles/index.css b/apps/desktop/src/renderer/src/styles/index.css index 428037b4..332b38ab 100644 --- a/apps/desktop/src/renderer/src/styles/index.css +++ b/apps/desktop/src/renderer/src/styles/index.css @@ -6653,8 +6653,17 @@ html[data-theme="dark"] .qq-link:hover { color: #7cbaff; } /* 链接卡片:复用 ark 卡片的外壳,只调几处间距 + 页脚图标。 */ .weq-link-card .weq-link-desc { margin-bottom: 0; } .weq-link-footer-icon { flex-shrink: 0; } -/* 预览抓取失败时退回的一条普通链接——不要卡片的 padding/边框。 */ -.qq-link-fallback { display: inline; } + +/* 卡片挂在气泡下方(同语音转录):原文的蓝链接始终看得见,卡片只是附加信息。 + 卡片自带白底/边框,压在我方的强调色气泡上也读得清,不用另配色。 */ +.message-content.qq-has-linkcard { + display: flex; + flex-direction: column; + gap: 6px; +} +.message-content.qq-has-linkcard .weq-link-card { max-width: 280px; } +/* 我方气泡是强调色,链接文本得跟着翻白才看得见。 */ +.message-line.mine .message-content.qq-has-linkcard .qq-link { color: #ffffff; } /* ---- Ark 卡片深色模式 ---- The demo only ships light colors; map them onto the theme tokens so the structured card reads correctly on the dark canvas. */html[data-theme="dark"] .weq-ark-container { diff --git a/docs/database/nt_msg/elements/ptt.md b/docs/database/nt_msg/elements/ptt.md index c48bb3af..3eac36a8 100644 --- a/docs/database/nt_msg/elements/ptt.md +++ b/docs/database/nt_msg/elements/ptt.md @@ -51,11 +51,25 @@ | --- | ------ | ---- | ---- | | 45915 | `isAiVoice` | bool | **AI 声聊标记**,见上 | | 45923 | `pttTranscript` | string | **QQ 自带的语音转文字结果**,缓存在行上。跑过一次「转文字」之后才有;转录结果为空时是空字符串 | +| 45924 | — | uint32 | **转录完成标记**,恒为 1,见下节 | +| 45926 | — | uint32 | **转录完成标记**,恒为 2,见下节 | | 45905 | `pttVoiceId` | string | 服务端语音 id,如 `98PO#bjWUtk8qVPcpAiG57xZrOeS28AXRNmR` | | 45550 | `transferState` | uint32 | 传输状态 | | 45511 | `picTransferState` | uint32 | 传输状态 | | 45513 | `transferVersion` | uint32 | 传输版本 | +### 写转录结果必须同时写 45924 / 45926 + +`45923` 单写是不够的:`45924 = 1` 和 `45926 = 2` 才是 QQ 判断「这条已经转过文字了」的 +依据,只写文本 QQ 本体不认,还会再转一遍。 + +全库 324 个 ptt 元素的交叉验证:**51 条带 `45923` 的行,无一例外全是 +`45924=1` + `45926=2`**;反过来 25 条只有 `45924=1` 而没有 `45926` 的行,`45923` 全部 +缺席(转录发起了但没落结果)。参考行 `c2c_msg_table:7669105866341663773`。 + +WeQ 的写回实现见 `MsgService.setPttTranscript`(`packages/service/src/account/msg.ts`), +三个字段一起写。 + ## 四、观测到但语义未验证 | tag | 观测情况 | @@ -65,8 +79,6 @@ | 45909 | uint32,语义未知 | | 45912 | 取值 2(×39)/ 1(×1) | | 45922 | uint32,语义未知 | -| 45924 | 只要出现就恒为 1,疑似「转录结果可用」 | -| 45926 | 只要出现就恒为 2 | | 45908 | 嵌套 `{1, 5, 7}`,所有观测行三项全为 0 | | 45601 | 嵌套 `{2:{37}, 4:{1,2}}`,所有观测行均为空 / 0,保留为原始字节 | diff --git a/docs/database/nt_msg/elements/text.md b/docs/database/nt_msg/elements/text.md index cfcbb535..584f23a1 100644 --- a/docs/database/nt_msg/elements/text.md +++ b/docs/database/nt_msg/elements/text.md @@ -35,10 +35,33 @@ const kind = wire.bubbleId ? 'at' : 'text'; | 值 | 名称 | 说明 | | -- | ---- | ---- | | 0 | PLAIN | 普通文本,不含链接 | -| 1 | EXTERNAL_LINK | 外部链接。会附带 `45112 (urlVerifyFlag)` —— QQ 扫描域名后附的 12 / 24 / 248 字节安全校验负载 | +| 1 | EXTERNAL_LINK | 外部链接。会附带 `45112 (urlVerifyFlag)` —— QQ 扫描域名后附的安全校验 + 抓取结果,见下节 | | 2 | TRUSTED_LINK | 可信链接(腾讯系域名:`docs.qq.com` / `mp.weixin.qq.com` …)。**不带 45112**,QQ 对自家域名跳过安全检查 | -## 三、字段 +## 三、45112 — QQ 自己抓好的链接元数据 + +`45112 (urlVerifyFlag)` 不是一坨不透明的校验负载,而是一段嵌套 protobuf:QQ 服务端 +扫描链接时顺手抓了页面,标题 / 描述 / 封面图都在里面。解析实现见 +`packages/codec/src/element/url_verify.ts`(`decodeUrlVerify`)。 + +| tag | 字段名 | 类型 | 全库出现次数 | 含义 | +| --- | ------ | ---- | ------------ | ---- | +| 50200 | `title` | string | 311 | 页面标题(长了 QQ 会截断加 `...`) | +| 50201 | `imageUrl` | string | 311 | 封面图 URL | +| 50202 | `scannedAt` | uint32 | 1087 | 扫描时间(unix 秒),**恒有** | +| 50204 | `desc` | string | 311 | 页面描述(og:description;页面没写描述时 QQ 拿主机名顶上) | +| 50205 | — | uint32 | 1087 | 恒为 2,语义未验证 | +| 50206 | — | uint32 | 19 | 0 或 1,语义未验证 | + +两个要点: + +- **50204 是描述,不是站点名。** 容易误读成域名,是因为「页面没给描述」时它确实填的是 + 主机名。站点名 QQ 根本没给,要显示得自己从 URL 取 host。 +- **只有约两成带元数据。** 1087 条 payload 里只有 229 条有标题;其余是 12 字节的短包, + 只有 `50202` + `50205`。所以 `decodeUrlVerify` 在无标题时返回 null,调用方据此决定 + 是直接渲染卡片,还是退回去自己抓(见 `LinkPreviewService`)。 + +## 四、字段 ### 核心 @@ -62,7 +85,7 @@ const kind = wire.bubbleId ? 'at' : 'text'; | 45109 | `linkDetectionFlag` | uint32 | 链接识别标志 | | 45110 | `atMentionMask` | string | @ 相关位掩码(字符串编码) | | 45111 | `walletFlag` | uint32 | 红包 / 钱包含义标志 | -| 45112 | `urlVerifyFlag` | bytes | 网址校验字段,见上方 subType=1 | +| 45112 | `urlVerifyFlag` | bytes | 网址校验 + 抓取结果,见第三节 | > `45107` 至今未观测到。 diff --git a/packages/codec/src/element/index.ts b/packages/codec/src/element/index.ts index 0ecf949b..9dcb197b 100644 --- a/packages/codec/src/element/index.ts +++ b/packages/codec/src/element/index.ts @@ -2,3 +2,4 @@ export * from './types'; export * from './registry'; export * from './spec'; export * from './compose'; +export * from './url_verify'; diff --git a/packages/codec/src/element/url_verify.ts b/packages/codec/src/element/url_verify.ts new file mode 100644 index 00000000..acfedf94 --- /dev/null +++ b/packages/codec/src/element/url_verify.ts @@ -0,0 +1,72 @@ +/** + * `urlVerifyFlag` (text tag 45112) — QQ 服务端扫描链接后附在文本元素上的抓取结果。 + * + * 全库 1087 条样本的 tag 分布(`50202`/`50205` 恒有,其余成组出现): + * + * | tag | 出现次数 | 含义 | + * | ----- | -------- | ---- | + * | 50200 | 311 | 页面标题(og:title / ,长了会被 QQ 截断加 `...`) | + * | 50201 | 311 | 封面图 URL | + * | 50202 | 1087 | 扫描时间(unix 秒) | + * | 50204 | 311 | 页面描述(og:description;页面没给描述时 QQ 填主机名) | + * | 50205 | 1087 | 恒为 2 | + * | 50206 | 19 | 0 或 1,语义未验证 | + * + * 注意 **50204 是描述不是站点名** —— 早期误读是因为撞上了 `mp.weixin.qq.com` 那条 + * 的描述恰好就是域名。站点名 QQ 根本没给,要显示得自己从 URL 取 host。 + * + * 只有约两成带链接的消息带得全(1087 条里 229 条有标题),其余是 12 字节的短包, + * 只有扫描时间和那个常量 2。`decodeUrlVerify` 因此在没有标题时返回 null —— 调用方 + * 据此决定「直接渲染」还是「自己去抓」。 + */ + +import { ProtoField, ProtoMsg, ScalarType } from '../core'; + +/** 45112 的嵌套结构,见文件头注释。 */ +export const UrlVerifyWire = { + /** 页面标题。 */ + title: ProtoField(50200, ScalarType.STRING, { optional: true }), + /** 封面图 URL(og:image 之类,QQ 抓页面时一起带回来)。 */ + imageUrl: ProtoField(50201, ScalarType.STRING, { optional: true }), + /** 扫描时间(unix 秒)。恒有。 */ + scannedAt: ProtoField(50202, ScalarType.UINT32, { optional: true }), + /** 页面描述。页面没给描述时 QQ 拿主机名顶上。 */ + desc: ProtoField(50204, ScalarType.STRING, { optional: true }), + /** 恒为 2,语义未验证。 */ + flag50205: ProtoField(50205, ScalarType.UINT32, { optional: true }), + /** 0 或 1,语义未验证。 */ + flag50206: ProtoField(50206, ScalarType.UINT32, { optional: true }), +}; + +const urlVerifyCodec = new ProtoMsg(UrlVerifyWire); + +/** QQ 自带的链接元数据,已确认可直接渲染(至少有标题)。 */ +export interface UrlVerifyInfo { + title: string; + desc: string; + imageUrl: string; + /** 扫描时间(unix 秒),0 表示缺失。 */ + scannedAt: number; +} + +/** + * 解析 45112。返回 null 表示「这条 payload 没有可渲染的元数据」—— + * 无标题(占八成的 12 字节短包)或字节根本解不开都算。 + */ +export function decodeUrlVerify(bytes: Uint8Array | undefined): UrlVerifyInfo | null { + if (!bytes || bytes.byteLength === 0) return null; + let wire: ReturnType<typeof urlVerifyCodec.decode>; + try { + wire = urlVerifyCodec.decode(bytes); + } catch { + return null; + } + const title = wire.title ?? ''; + if (!title) return null; + return { + title, + desc: wire.desc ?? '', + imageUrl: wire.imageUrl ?? '', + scannedAt: wire.scannedAt ?? 0, + }; +} diff --git a/packages/db/tools/verify_url_verify.ts b/packages/db/tools/verify_url_verify.ts new file mode 100644 index 00000000..b0d0038a --- /dev/null +++ b/packages/db/tools/verify_url_verify.ts @@ -0,0 +1,60 @@ +/** + * Verify `decodeUrlVerify` (codec) against every real 45112 payload in the DB: + * how many carry renderable metadata, and does the decode match a raw walk. + * + * Run: pnpm tsx packages/db/tools/verify_url_verify.ts + */ +import { loadNative } from '@weq/native'; +import { decodeUrlVerify } from '@weq/codec'; +import { QqDb } from '../src/qq_db'; +import { decodeBody } from '../src/msg/util'; +import { testEnv } from '@weq/testkit'; + +async function main(): Promise<void> { + const native = loadNative(); + const db = new QqDb(native.ntHelper, { + dbPath: testEnv.msgDbPath, + key: testEnv.key, + algo: { pageHmacAlgorithm: 'SHA1', kdfHmacAlgorithm: 'SHA512' }, + }); + + let total = 0; + let withMeta = 0; + const samples: string[] = []; + for (const table of ['c2c_msg_table', 'group_msg_table'] as const) { + for (const r of await db.query(`SELECT "40800" FROM "${table}" WHERE "40011" IN (2,9)`)) { + let els: Array<Record<string, unknown>>; + try { + els = decodeBody(r[0] as Uint8Array) as never; + } catch { + continue; + } + for (const e of els) { + const raw = e.urlVerifyFlag as Uint8Array | undefined; + if (!raw) continue; + total++; + const info = decodeUrlVerify(raw); + if (!info) continue; + withMeta++; + if (samples.length < 6) { + samples.push( + ` title=${JSON.stringify(info.title.slice(0, 36))} desc=${JSON.stringify(info.desc.slice(0, 36))} img=${info.imageUrl ? 'yes' : 'no'} at=${info.scannedAt}`, + ); + } + } + } + } + + console.log(`45112 payloads: ${total}`); + console.log( + `decodeUrlVerify -> renderable: ${withMeta} (${((withMeta / total) * 100).toFixed(1)}%)`, + ); + console.log('samples:'); + for (const s of samples) console.log(s); + db.close(); +} + +main().catch((e) => { + console.error('failed:', e); + process.exit(1); +}); diff --git a/packages/service/src/account/msg.ts b/packages/service/src/account/msg.ts index 5ecc0879..eff1a09d 100644 --- a/packages/service/src/account/msg.ts +++ b/packages/service/src/account/msg.ts @@ -288,9 +288,11 @@ export class MsgService { ); if (!ptt || ptt.pttTranscript === text) return false; ptt.pttTranscript = text; - // 45924 rides along with every QQ-written transcript (always 1 wherever - // observed), so mirror it — otherwise QQ may not treat the text as ready. + // QQ 写转录结果时同时置这两个 flag(参考行 c2c 7669105866341663773,全库 + // 观测到 45924 恒 1 / 45926 恒 2)。它们是 QQ 判断「这条已经转过文字了」的 + // 依据 —— 只写 45923 的话 QQ 本体不认,还会再转一次。 ptt.pttFlag45924 = 1; + ptt.pttFlag45926 = 2; const affected = await db.updateMsgBody( msgId, bodyCodec.encode({ elements: elements.map(encodeElement) }), diff --git a/packages/service/src/account/msg_view.ts b/packages/service/src/account/msg_view.ts index 778de62a..f785ae16 100644 --- a/packages/service/src/account/msg_view.ts +++ b/packages/service/src/account/msg_view.ts @@ -2,7 +2,7 @@ * Render View Model — defines simplified, front-end-friendly Element shapes. */ -import { decodeElement } from '@weq/codec'; +import { decodeElement, decodeUrlVerify } from '@weq/codec'; import type { Element, TextElement, @@ -31,6 +31,7 @@ import type { GrayTipTempSessionElement, UnknownElement, AtElement, + UrlVerifyInfo, } from '@weq/codec'; /** Common metadata fields moved inside the 'data' property. */ @@ -50,6 +51,12 @@ export interface RenderTextElement { type: 'text'; data: BaseRenderData & { textContent: string; + /** + * QQ 服务端扫描链接时自己抓好的元数据(wire tag 45112 的嵌套结构)。只有约 + * 三成带链接的消息带得全(标题/封面/站点);带了前端就直接画卡片,不必再 + * 自己出网抓一次。 + */ + urlVerify?: UrlVerifyInfo; }; } @@ -542,10 +549,12 @@ export function toRenderElements(elements: Element[]): RenderElement[] { } function mapText(el: TextElement): RenderTextElement { + const urlVerify = decodeUrlVerify(el.urlVerifyFlag); return { type: 'text', data: { textContent: el.textContent, + ...(urlVerify ? { urlVerify } : {}), elementId: el.elementId, isSender: el.isSender, subType: el.subType, diff --git a/packages/service/src/bootstrap/link_preview.ts b/packages/service/src/bootstrap/link_preview.ts index f0dc1069..728ffb41 100644 --- a/packages/service/src/bootstrap/link_preview.ts +++ b/packages/service/src/bootstrap/link_preview.ts @@ -477,6 +477,29 @@ export class LinkPreviewService { } } + /** + * 只落一张封面图,不抓页面 —— 给「QQ 自己已经把标题/封面存在消息里」的那条路 + * (text 元素 wire tag 45112)用:元数据本地就有,缺的只是图的字节。 + * + * 走的是和抓取路径同一个 {@link cacheImage},所以 SSRF 闸门、魔数校验、大小上限 + * 一个都不少 —— 这个 URL 来自聊天消息,跟远端页面给的一样不可信。返回缓存 id, + * 任何一步不达标返回空串。 + */ + async cacheCover(rawUrl: string): Promise<string> { + const url = rawUrl.trim(); + if (!url) return ''; + const name = `${this.key(url)}`; + for (const ext of ['jpg', 'png', 'gif', 'webp', 'bmp']) { + try { + await readFile(join(this.dir(), `${name}.${ext}`)); + return `${name}.${ext}`; + } catch { + // 没这个扩展名,试下一个。 + } + } + return this.cacheImage(url); + } + private async readDisk(url: string): Promise<CacheEntry | null> { try { const raw = await readFile(join(this.dir(), `${this.key(url)}.json`), 'utf-8'); diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 94c01b68..a9365a1b 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -159,6 +159,8 @@ export { AntiRecallService } from './account/anti_recall'; export type { AntiRecallConfig, AntiRecallStatus } from './account/anti_recall'; export { toRenderElements } from './account/msg_view'; export type { RenderElement, RenderTextElement } from './account/msg_view'; +// 渲染层要按这个类型读 RenderTextElement.urlVerify,从 codec 借道转出去。 +export type { UrlVerifyInfo } from '@weq/codec'; export { MsgSearchService } from './account/msg_search'; export { UnreadInfoService } from './account/unread_info'; export { DbDecryptService } from './account/db_decrypt';