| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- #!/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);
- });
|