| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- /**
- * 探测:容器能否收到「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);
- });
|