#!/usr/bin/env node /** * 方案 B 可行性实测:容器在「群房间」里会不会对**不带 @ 的消息**自动应答 * * 背景:方案 B 想给每个伪群聊建一个真实 Matrix 房间,用户消息也往群里发一份 * (只作记录,前台按房间渲染)。风险是容器如果对房间里任何消息都作答, * 就会「抢答」—— 一条群消息会触发 N 份后台任务。 * * 用法(需真实账号凭据): * node scripts/probe-group-room.mjs 建临时房、静默发言、观察应答 * node scripts/probe-group-room.mjs --keep 测完不删房(便于人工看) * * 凭据:读 `~/.fmode/config/user.json` 的 sessionToken(与 fmode-admin 同源) * * 判定: * · 静默期(NO_MENTION)内两个容器都没回 → 容器按 mention 过滤,方案 B 可行 * · 任一容器回了 → 会抢答,方案 B 需要容器侧调整或退回方案 A */ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; const FUNCTIONS = 'https://server.fmode.cn/api/functions'; const APP_ID = 'ncloudmaster'; const FN_PROVISION = 'RP5Ht55NeX'; // provision 云函数 const MEMBERS = ['agent-lj', 'agent-node-app-lj-test-v1']; const NO_MENTION_WAIT_MS = 90_000; function token() { const p = join(homedir(), '.fmode', 'config', 'user.json'); try { const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken; if (typeof t === 'string' && t.startsWith('r:')) return t; } catch { /* 落到下面的报错 */ } throw new Error(`读不到 fmode 凭据:${p}`); } async function provision(t, agentId) { const r = await fetch(FUNCTIONS, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': t, }, body: JSON.stringify({ id: FN_PROVISION, token: t, params: { agentId } }), }); const j = await r.json(); if (j.code !== 200 || !j.accessToken || !j.userMxId) { throw new Error(`provision 失败(${agentId}):${j.mess ?? j.msg ?? j.code}`); } return j; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function main() { const keep = process.argv.includes('--keep'); const t = token(); // ① 取自己的 Matrix 凭据(任一容器 provision 都会返回用户自己的身份) const prov = await provision(t, MEMBERS[0]); const mx = await import('matrix-js-sdk'); /* * 🔴 不调 startClient(Node 下 SDK 的 sync filter 会拼成 `$userId` 占位符导致 M_BAD_JSON)。 * 只用 client 做「建房 / 开房 / 拉时间线」这类一次性请求,不常驻同步。 */ const client = mx.createClient({ baseUrl: prov.homeserver, accessToken: prov.accessToken, userId: prov.userMxId }); console.log('用户身份', prov.userMxId); // ② 建临时群房间,把两个容器拉进来 const agentMxIds = []; for (const id of MEMBERS) { const p = await provision(t, id); agentMxIds.push({ id, mxId: p.agentMxId, roomId: p.roomId }); } const created = await client.createRoom({ invite: agentMxIds.map((a) => a.mxId), name: `probe-no-mention-${Date.now()}`, preset: mx.Preset.TrustedPrivateChat, }); const roomId = created.room_id; console.log('临时房间', roomId); console.log('成员身份', agentMxIds.map((a) => `${a.id}=${a.mxId}`).join(', ')); // 等容器进房:直接查房间成员状态(不依赖 sync) const hs = prov.homeserver.replace(/\/$/, ''); const auth = { Authorization: `Bearer ${prov.accessToken}` }; const membersOf = async (rid) => { const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/joined_members`, { headers: auth }); return (await r.json()).joined ?? {}; }; for (let i = 0; i < 20; i++) { await sleep(3000); const joined = await membersOf(roomId).catch(() => ({})); if (agentMxIds.every((a) => joined[a.mxId])) break; } { const joined = await membersOf(roomId).catch(() => ({})); console.log( '进房情况', agentMxIds.map((a) => `${a.id}:${joined[a.mxId] ? 'join' : '未进'}`).join(', '), ); } // ③ 不带 @ 发一句话,观察是否有人应答 const sent = await client.sendEvent(roomId, 'm.room.message', { msgtype: 'm.text', body: `[方案B探测 ${new Date().toISOString().slice(11, 19)}] 这是一条不带 @ 的测试消息,请不要回复。`, }); const sentAt = Date.now(); console.log('已发送(无 @)', sent.event_id); console.log(`观察 ${NO_MENTION_WAIT_MS / 1000}s 内是否有容器应答…`); /* * 轮询房间消息端点来「听」回复 —— 不用 sync,避免 SDK 的 filter 问题。 * 每 5 秒拉一次最新事件,挑出别人发的 m.room.message。 */ const replies = []; const seen = new Set(); const deadline = Date.now() + NO_MENTION_WAIT_MS; while (Date.now() < deadline) { await sleep(5000); const r = await fetch( `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=30`, { headers: auth }, ); const j = await r.json().catch(() => ({})); for (const ev of j.chunk ?? []) { if (ev.type !== 'm.room.message') continue; if (ev.event_id === sent.event_id) continue; if (ev.sender === prov.userMxId) continue; if (seen.has(ev.event_id)) continue; seen.add(ev.event_id); replies.push({ sender: ev.sender, ts: Date.now() - sentAt }); console.log(` ⚠️ ${Math.round((Date.now() - sentAt) / 1000)}s 收到应答 from ${ev.sender}: ${String(ev.content?.body ?? '').slice(0, 60)}`); } } console.log('\n===== 结论 ====='); if (replies.length === 0) { console.log('✅ 静默期内无应答 → 容器按 mention 过滤,方案 B 不会抢答'); } else { console.log(`❌ 收到 ${replies.length} 条应答 → 容器会抢答,方案 B 有风险`); for (const r of replies) console.log(` - ${Math.round(r.ts / 1000)}s / ${r.sender} / ${r.body}`); } if (!keep) { await client.leave(roomId).catch(() => {}); console.log('已离开临时房间(容器侧由平台回收成员关系)'); } else { console.log('保留房间以便人工查看:', roomId); } client.stopClient(); process.exit(0); } main().catch((e) => { console.error('探测失败:', e); process.exit(1); });