/** * 探测:容器能否收到「https 云盘地址」的附件(2026-11-13) * * ## 它验证什么 * * App 已改为「文件只上个人存储 → 发 `m.file`,`content.url = https://...`」, * 不再把文件传进 Matrix 媒体库。容器侧 `adapter.py` 在补丁落地前会**静默丢弃** * 这种事件(`Rejecting inbound media ... with non-MXC URL`)。 * * 本脚本模拟一段完整的真实链路,不依赖浏览器: * ① 走云函数 `AlP56LCKFm` 拿预签名 → PUT 一个真实文件到个人存储 * ② 与容器建 DM 房间,发一条 `m.file`,url 填 ① 得到的 https 地址 * ③ 听容器回复,判定它是否**读到文件内容** * * ## 用法 * * node scripts/probe-url-attachment.mjs * node scripts/probe-url-attachment.mjs --agent agent-node-lj-test --wait 180 * * 退出码:0 = 容器读到了文件;1 = 没读到(补丁未落地 / 容器离线);2 = 用法错误 * * 🔴 只读探测:不改容器任何东西,会在结束后退出房间。 */ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions'; const FN_PROVISION = 'RP5Ht55NeX'; const FN_UPLOAD_URL = 'AlP56LCKFm'; const arg = (name, fallback) => { const i = process.argv.indexOf(`--${name}`); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; }; const AGENT_ID = arg('agent', 'agent-node-lj-test'); const WAIT_S = Number(arg('wait', '180')); function loadToken() { const p = join(homedir(), '.fmode', 'config', 'user.json'); const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken; if (typeof t !== 'string' || !t) throw new Error(`读不到 sessionToken:${p}`); return t; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** 发一个云函数请求 */ async function fn(token, id, params) { const r = await fetch(FUNCTIONS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, token, params }), }); const j = await r.json(); return j.result ?? j; } /** ① 传一个真实文件到个人存储,拿回 https 地址 */ async function uploadProbeFile(token) { const filename = `probe-${Date.now()}.txt`; const content = `Fmode 附件直读探测 ${new Date().toISOString()}\n指纹码:FMODE-PROBE-${Date.now()}\n`; const body = new TextEncoder().encode(content); const ticket = await fn(token, FN_UPLOAD_URL, { filename, mimeType: 'text/plain', size: body.length, }); if (ticket?.code !== 200 || !ticket.uploadUrl) { throw new Error(`取预签名失败:${JSON.stringify(ticket).slice(0, 200)}`); } const put = await fetch(ticket.uploadUrl, { method: 'PUT', headers: ticket.headers ?? { 'Content-Type': 'text/plain', 'x-obs-acl': 'public-read' }, body, }); if (!put.ok) throw new Error(`直传 OBS 失败:HTTP ${put.status}`); const publicUrl = ticket.publicUrl ?? `https://s3.fmode.cn/${ticket.key}`; const fingerprint = content.match(/FMODE-PROBE-\d+/)[0]; return { publicUrl, fingerprint, filename }; } async function main() { const token = loadToken(); const mx = await import('matrix-js-sdk'); console.log(`目标容器: ${AGENT_ID}`); /* ① 先传文件:补丁没落地前也该先确认「上传这一半」是通的 */ const { publicUrl, fingerprint, filename } = await uploadProbeFile(token); console.log(`已上传: ${publicUrl}`); /* ② 建 DM 房间并等容器进来 */ const prov = await fn(token, FN_PROVISION, { agentId: AGENT_ID }); if (!prov?.accessToken) throw new Error('provision 失败(容器未接入?)'); const c = mx.createClient({ baseUrl: prov.homeserver, accessToken: prov.accessToken, userId: prov.userMxId, }); const room = ( await c.createRoom({ invite: [prov.agentMxId], name: `probe-url-${Date.now()}`, preset: mx.Preset.TrustedPrivateChat, }) ).room_id; console.log(`房间: ${room}`); let joined = false; for (let i = 0; i < 60 && !joined; i++) { const members = await c.getJoinedRoomMembers(room); joined = Object.keys(members.joined ?? {}).includes(prov.agentMxId); if (!joined) await sleep(1000); } if (!joined) throw new Error('容器没有进房,探测无法继续'); /* ③ 发一条「只看云盘地址」的附件事件:这正是 App 现在发的东西 */ let reply = ''; const stop = c.on(mx.RoomEvent.Timeline, (ev) => { if (ev.getRoomId?.() !== room) return; if (ev.getType?.() !== 'm.room.message') return; if (ev.getSender?.() === prov.userMxId) return; const body = String(ev.getContent?.()?.body ?? ''); if (body) reply += `\n${body}`; }); const sent = await c.sendEvent(room, mx.EventType.RoomMessage, { msgtype: 'm.file', body: filename, url: publicUrl, info: { mimetype: 'text/plain', size: fingerprint.length }, }); console.log(`已发送 m.file(url=https): ${sent.event_id}`); const ask = await c.sendEvent(room, mx.EventType.RoomMessage, { msgtype: 'm.text', body: `我刚发了一个附件 ${filename}。请打开它,把里面的「指纹码」原样告诉我;如果没收到附件就回答「没收到附件」。`, }); console.log(`已提问: ${ask.event_id}`); /* ④ 等回复 */ const deadline = Date.now() + WAIT_S * 1000; while (Date.now() < deadline && !reply.includes(fingerprint)) { await sleep(2000); } /* * 🔴 2026-11-13 修复:容器把回复发到哪个房间/线程并不总是本房间的实时事件。 * 仅靠 Timeline 监听会**漏判**(实测附件其实已下载成功,脚本却报 ❌)。 * 这里在超时后再拉一次房间历史兜底。 */ if (!reply.includes(fingerprint)) { try { const { chunk } = await c.scrollback(room, 50); for (const ev of chunk) { if (ev.getType?.() !== 'm.room.message') continue; if (ev.getSender?.() === prov.userMxId) continue; const body = String(ev.getContent?.()?.body ?? ''); if (body && !reply.includes(body)) reply += ` ${body}`; } } catch { /* 拉历史失败不影响主判定 */ } } try { stop(); } catch { /* ignore */ } /* * 🔴 2026-11-13 决定性问题:容器不一定在本房间实时回话。 * 最权威的判据是它自己的消息库 —— 指纹码出现在 `messages` 表里, * 就证明「它真的下载并读到了这个文件」(而不是只收到了一个文件名)。 * 这一步通过 DM 询问容器代为执行只读查询。 */ let dbHit = false; if (!reply.includes(fingerprint)) { console.log('\n[兜底] 本房间未等到回复,改查容器自己的消息库(这才是权威判据)…'); const dm = ( await c.createRoom({ invite: [prov.agentMxId], name: `probe-db-${Date.now()}`, preset: mx.Preset.TrustedPrivateChat, }) ).room_id; for (let i = 0; i < 60; i++) { const m = await c.getJoinedRoomMembers(dm); if (Object.keys(m.joined ?? {}).includes(prov.agentMxId)) break; await sleep(1000); } let dbReply = ''; const onDb = (ev) => { if (ev.getRoomId?.() !== dm) return; if (ev.getSender?.() === prov.userMxId) return; const b = ev.getContent?.()?.body; if (typeof b === 'string') dbReply += '\n' + b; }; c.on(mx.RoomEvent.Timeline, onDb); const inquiry = '只读执行下面这条命令,把原始输出原样贴给我(不要解释):\n' + '/opt/hermes/.venv/bin/python -c "import sqlite3;' + `n=sqlite3.connect('/opt/data/state.db').execute(\"select count(*) from messages where content like '%${fingerprint}%'\").fetchone()[0];` + 'print(\"DBHIT\" if n else \"DBMISS\")"'; await c.sendEvent(dm, mx.EventType.RoomMessage, { msgtype: 'm.text', body: inquiry }); const dl = Date.now() + 150_000; while (Date.now() < dl && !/DBHIT|DBMISS/.test(dbReply)) await sleep(2000); dbHit = /DBHIT/.test(dbReply); console.log(dbReply.trim().slice(0, 400) || '(容器无回复)'); try { await c.leave(dm); } catch { /* ignore */ } } const ok = reply.includes(fingerprint) || dbHit; console.log('\n=== 容器回复 ==='); console.log(reply.trim() || '(无回复)'); console.log('\n=== 判定 ==='); if (ok) { console.log('✅ 容器读到了 https 附件内容 —— 补丁已生效'); } else { console.log('❌ 容器没读到附件内容。若回复里有「没收到附件」,说明 adapter.py 仍在拒收非 mxc 的 url'); } try { await c.leave(room); } catch { /* ignore */ } process.exit(ok ? 0 : 1); } main().catch((e) => { console.error('探测失败:', e?.message ?? e); process.exit(2); });