probe-url-attachment.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. /**
  2. * 探测:容器能否收到「https 云盘地址」的附件(2026-11-13)
  3. *
  4. * ## 它验证什么
  5. *
  6. * App 已改为「文件只上个人存储 → 发 `m.file`,`content.url = https://...`」,
  7. * 不再把文件传进 Matrix 媒体库。容器侧 `adapter.py` 在补丁落地前会**静默丢弃**
  8. * 这种事件(`Rejecting inbound media ... with non-MXC URL`)。
  9. *
  10. * 本脚本模拟一段完整的真实链路,不依赖浏览器:
  11. * ① 走云函数 `AlP56LCKFm` 拿预签名 → PUT 一个真实文件到个人存储
  12. * ② 与容器建 DM 房间,发一条 `m.file`,url 填 ① 得到的 https 地址
  13. * ③ 听容器回复,判定它是否**读到文件内容**
  14. *
  15. * ## 用法
  16. *
  17. * node scripts/probe-url-attachment.mjs
  18. * node scripts/probe-url-attachment.mjs --agent agent-node-lj-test --wait 180
  19. *
  20. * 退出码:0 = 容器读到了文件;1 = 没读到(补丁未落地 / 容器离线);2 = 用法错误
  21. *
  22. * 🔴 只读探测:不改容器任何东西,会在结束后退出房间。
  23. */
  24. import { readFileSync } from 'node:fs';
  25. import { homedir } from 'node:os';
  26. import { join } from 'node:path';
  27. const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions';
  28. const FN_PROVISION = 'RP5Ht55NeX';
  29. const FN_UPLOAD_URL = 'AlP56LCKFm';
  30. const arg = (name, fallback) => {
  31. const i = process.argv.indexOf(`--${name}`);
  32. return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
  33. };
  34. const AGENT_ID = arg('agent', 'agent-node-lj-test');
  35. const WAIT_S = Number(arg('wait', '180'));
  36. function loadToken() {
  37. const p = join(homedir(), '.fmode', 'config', 'user.json');
  38. const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
  39. if (typeof t !== 'string' || !t) throw new Error(`读不到 sessionToken:${p}`);
  40. return t;
  41. }
  42. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  43. /** 发一个云函数请求 */
  44. async function fn(token, id, params) {
  45. const r = await fetch(FUNCTIONS_URL, {
  46. method: 'POST',
  47. headers: { 'Content-Type': 'application/json' },
  48. body: JSON.stringify({ id, token, params }),
  49. });
  50. const j = await r.json();
  51. return j.result ?? j;
  52. }
  53. /** ① 传一个真实文件到个人存储,拿回 https 地址 */
  54. async function uploadProbeFile(token) {
  55. const filename = `probe-${Date.now()}.txt`;
  56. const content = `Fmode 附件直读探测 ${new Date().toISOString()}\n指纹码:FMODE-PROBE-${Date.now()}\n`;
  57. const body = new TextEncoder().encode(content);
  58. const ticket = await fn(token, FN_UPLOAD_URL, {
  59. filename,
  60. mimeType: 'text/plain',
  61. size: body.length,
  62. });
  63. if (ticket?.code !== 200 || !ticket.uploadUrl) {
  64. throw new Error(`取预签名失败:${JSON.stringify(ticket).slice(0, 200)}`);
  65. }
  66. const put = await fetch(ticket.uploadUrl, {
  67. method: 'PUT',
  68. headers: ticket.headers ?? { 'Content-Type': 'text/plain', 'x-obs-acl': 'public-read' },
  69. body,
  70. });
  71. if (!put.ok) throw new Error(`直传 OBS 失败:HTTP ${put.status}`);
  72. const publicUrl = ticket.publicUrl ?? `https://s3.fmode.cn/${ticket.key}`;
  73. const fingerprint = content.match(/FMODE-PROBE-\d+/)[0];
  74. return { publicUrl, fingerprint, filename };
  75. }
  76. async function main() {
  77. const token = loadToken();
  78. const mx = await import('matrix-js-sdk');
  79. console.log(`目标容器: ${AGENT_ID}`);
  80. /* ① 先传文件:补丁没落地前也该先确认「上传这一半」是通的 */
  81. const { publicUrl, fingerprint, filename } = await uploadProbeFile(token);
  82. console.log(`已上传: ${publicUrl}`);
  83. /* ② 建 DM 房间并等容器进来 */
  84. const prov = await fn(token, FN_PROVISION, { agentId: AGENT_ID });
  85. if (!prov?.accessToken) throw new Error('provision 失败(容器未接入?)');
  86. const c = mx.createClient({
  87. baseUrl: prov.homeserver,
  88. accessToken: prov.accessToken,
  89. userId: prov.userMxId,
  90. });
  91. const room = (
  92. await c.createRoom({
  93. invite: [prov.agentMxId],
  94. name: `probe-url-${Date.now()}`,
  95. preset: mx.Preset.TrustedPrivateChat,
  96. })
  97. ).room_id;
  98. console.log(`房间: ${room}`);
  99. let joined = false;
  100. for (let i = 0; i < 60 && !joined; i++) {
  101. const members = await c.getJoinedRoomMembers(room);
  102. joined = Object.keys(members.joined ?? {}).includes(prov.agentMxId);
  103. if (!joined) await sleep(1000);
  104. }
  105. if (!joined) throw new Error('容器没有进房,探测无法继续');
  106. /* ③ 发一条「只看云盘地址」的附件事件:这正是 App 现在发的东西 */
  107. let reply = '';
  108. const stop = c.on(mx.RoomEvent.Timeline, (ev) => {
  109. if (ev.getRoomId?.() !== room) return;
  110. if (ev.getType?.() !== 'm.room.message') return;
  111. if (ev.getSender?.() === prov.userMxId) return;
  112. const body = String(ev.getContent?.()?.body ?? '');
  113. if (body) reply += `\n${body}`;
  114. });
  115. const sent = await c.sendEvent(room, mx.EventType.RoomMessage, {
  116. msgtype: 'm.file',
  117. body: filename,
  118. url: publicUrl,
  119. info: { mimetype: 'text/plain', size: fingerprint.length },
  120. });
  121. console.log(`已发送 m.file(url=https): ${sent.event_id}`);
  122. const ask = await c.sendEvent(room, mx.EventType.RoomMessage, {
  123. msgtype: 'm.text',
  124. body: `我刚发了一个附件 ${filename}。请打开它,把里面的「指纹码」原样告诉我;如果没收到附件就回答「没收到附件」。`,
  125. });
  126. console.log(`已提问: ${ask.event_id}`);
  127. /* ④ 等回复 */
  128. const deadline = Date.now() + WAIT_S * 1000;
  129. while (Date.now() < deadline && !reply.includes(fingerprint)) {
  130. await sleep(2000);
  131. }
  132. /*
  133. * 🔴 2026-11-13 修复:容器把回复发到哪个房间/线程并不总是本房间的实时事件。
  134. * 仅靠 Timeline 监听会**漏判**(实测附件其实已下载成功,脚本却报 ❌)。
  135. * 这里在超时后再拉一次房间历史兜底。
  136. */
  137. if (!reply.includes(fingerprint)) {
  138. try {
  139. const { chunk } = await c.scrollback(room, 50);
  140. for (const ev of chunk) {
  141. if (ev.getType?.() !== 'm.room.message') continue;
  142. if (ev.getSender?.() === prov.userMxId) continue;
  143. const body = String(ev.getContent?.()?.body ?? '');
  144. if (body && !reply.includes(body)) reply += `
  145. ${body}`;
  146. }
  147. } catch {
  148. /* 拉历史失败不影响主判定 */
  149. }
  150. }
  151. try {
  152. stop();
  153. } catch {
  154. /* ignore */
  155. }
  156. /*
  157. * 🔴 2026-11-13 决定性问题:容器不一定在本房间实时回话。
  158. * 最权威的判据是它自己的消息库 —— 指纹码出现在 `messages` 表里,
  159. * 就证明「它真的下载并读到了这个文件」(而不是只收到了一个文件名)。
  160. * 这一步通过 DM 询问容器代为执行只读查询。
  161. */
  162. let dbHit = false;
  163. if (!reply.includes(fingerprint)) {
  164. console.log('\n[兜底] 本房间未等到回复,改查容器自己的消息库(这才是权威判据)…');
  165. const dm = (
  166. await c.createRoom({
  167. invite: [prov.agentMxId],
  168. name: `probe-db-${Date.now()}`,
  169. preset: mx.Preset.TrustedPrivateChat,
  170. })
  171. ).room_id;
  172. for (let i = 0; i < 60; i++) {
  173. const m = await c.getJoinedRoomMembers(dm);
  174. if (Object.keys(m.joined ?? {}).includes(prov.agentMxId)) break;
  175. await sleep(1000);
  176. }
  177. let dbReply = '';
  178. const onDb = (ev) => {
  179. if (ev.getRoomId?.() !== dm) return;
  180. if (ev.getSender?.() === prov.userMxId) return;
  181. const b = ev.getContent?.()?.body;
  182. if (typeof b === 'string') dbReply += '\n' + b;
  183. };
  184. c.on(mx.RoomEvent.Timeline, onDb);
  185. const inquiry =
  186. '只读执行下面这条命令,把原始输出原样贴给我(不要解释):\n' +
  187. '/opt/hermes/.venv/bin/python -c "import sqlite3;' +
  188. `n=sqlite3.connect('/opt/data/state.db').execute(\"select count(*) from messages where content like '%${fingerprint}%'\").fetchone()[0];` +
  189. 'print(\"DBHIT\" if n else \"DBMISS\")"';
  190. await c.sendEvent(dm, mx.EventType.RoomMessage, { msgtype: 'm.text', body: inquiry });
  191. const dl = Date.now() + 150_000;
  192. while (Date.now() < dl && !/DBHIT|DBMISS/.test(dbReply)) await sleep(2000);
  193. dbHit = /DBHIT/.test(dbReply);
  194. console.log(dbReply.trim().slice(0, 400) || '(容器无回复)');
  195. try {
  196. await c.leave(dm);
  197. } catch {
  198. /* ignore */
  199. }
  200. }
  201. const ok = reply.includes(fingerprint) || dbHit;
  202. console.log('\n=== 容器回复 ===');
  203. console.log(reply.trim() || '(无回复)');
  204. console.log('\n=== 判定 ===');
  205. if (ok) {
  206. console.log('✅ 容器读到了 https 附件内容 —— 补丁已生效');
  207. } else {
  208. console.log('❌ 容器没读到附件内容。若回复里有「没收到附件」,说明 adapter.py 仍在拒收非 mxc 的 url');
  209. }
  210. try {
  211. await c.leave(room);
  212. } catch {
  213. /* ignore */
  214. }
  215. process.exit(ok ? 0 : 1);
  216. }
  217. main().catch((e) => {
  218. console.error('探测失败:', e?.message ?? e);
  219. process.exit(2);
  220. });