probe-group-room.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. #!/usr/bin/env node
  2. /**
  3. * 方案 B 可行性实测:容器在「群房间」里会不会对**不带 @ 的消息**自动应答
  4. *
  5. * 背景:方案 B 想给每个伪群聊建一个真实 Matrix 房间,用户消息也往群里发一份
  6. * (只作记录,前台按房间渲染)。风险是容器如果对房间里任何消息都作答,
  7. * 就会「抢答」—— 一条群消息会触发 N 份后台任务。
  8. *
  9. * 用法(需真实账号凭据):
  10. * node scripts/probe-group-room.mjs 建临时房、静默发言、观察应答
  11. * node scripts/probe-group-room.mjs --keep 测完不删房(便于人工看)
  12. *
  13. * 凭据:读 `~/.fmode/config/user.json` 的 sessionToken(与 fmode-admin 同源)
  14. *
  15. * 判定:
  16. * · 静默期(NO_MENTION)内两个容器都没回 → 容器按 mention 过滤,方案 B 可行
  17. * · 任一容器回了 → 会抢答,方案 B 需要容器侧调整或退回方案 A
  18. */
  19. import { readFileSync } from 'node:fs';
  20. import { homedir } from 'node:os';
  21. import { join } from 'node:path';
  22. const FUNCTIONS = 'https://server.fmode.cn/api/functions';
  23. const APP_ID = 'ncloudmaster';
  24. const FN_PROVISION = 'RP5Ht55NeX'; // provision 云函数
  25. const MEMBERS = ['agent-lj', 'agent-node-app-lj-test-v1'];
  26. const NO_MENTION_WAIT_MS = 90_000;
  27. function token() {
  28. const p = join(homedir(), '.fmode', 'config', 'user.json');
  29. try {
  30. const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
  31. if (typeof t === 'string' && t.startsWith('r:')) return t;
  32. } catch {
  33. /* 落到下面的报错 */
  34. }
  35. throw new Error(`读不到 fmode 凭据:${p}`);
  36. }
  37. async function provision(t, agentId) {
  38. const r = await fetch(FUNCTIONS, {
  39. method: 'POST',
  40. headers: {
  41. 'Content-Type': 'application/json',
  42. 'X-Parse-Application-Id': APP_ID,
  43. 'X-Parse-Session-Token': t,
  44. },
  45. body: JSON.stringify({ id: FN_PROVISION, token: t, params: { agentId } }),
  46. });
  47. const j = await r.json();
  48. if (j.code !== 200 || !j.accessToken || !j.userMxId) {
  49. throw new Error(`provision 失败(${agentId}):${j.mess ?? j.msg ?? j.code}`);
  50. }
  51. return j;
  52. }
  53. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  54. async function main() {
  55. const keep = process.argv.includes('--keep');
  56. const t = token();
  57. // ① 取自己的 Matrix 凭据(任一容器 provision 都会返回用户自己的身份)
  58. const prov = await provision(t, MEMBERS[0]);
  59. const mx = await import('matrix-js-sdk');
  60. /*
  61. * 🔴 不调 startClient(Node 下 SDK 的 sync filter 会拼成 `$userId` 占位符导致 M_BAD_JSON)。
  62. * 只用 client 做「建房 / 开房 / 拉时间线」这类一次性请求,不常驻同步。
  63. */
  64. const client = mx.createClient({ baseUrl: prov.homeserver, accessToken: prov.accessToken, userId: prov.userMxId });
  65. console.log('用户身份', prov.userMxId);
  66. // ② 建临时群房间,把两个容器拉进来
  67. const agentMxIds = [];
  68. for (const id of MEMBERS) {
  69. const p = await provision(t, id);
  70. agentMxIds.push({ id, mxId: p.agentMxId, roomId: p.roomId });
  71. }
  72. const created = await client.createRoom({
  73. invite: agentMxIds.map((a) => a.mxId),
  74. name: `probe-no-mention-${Date.now()}`,
  75. preset: mx.Preset.TrustedPrivateChat,
  76. });
  77. const roomId = created.room_id;
  78. console.log('临时房间', roomId);
  79. console.log('成员身份', agentMxIds.map((a) => `${a.id}=${a.mxId}`).join(', '));
  80. // 等容器进房:直接查房间成员状态(不依赖 sync)
  81. const hs = prov.homeserver.replace(/\/$/, '');
  82. const auth = { Authorization: `Bearer ${prov.accessToken}` };
  83. const membersOf = async (rid) => {
  84. const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/joined_members`, { headers: auth });
  85. return (await r.json()).joined ?? {};
  86. };
  87. for (let i = 0; i < 20; i++) {
  88. await sleep(3000);
  89. const joined = await membersOf(roomId).catch(() => ({}));
  90. if (agentMxIds.every((a) => joined[a.mxId])) break;
  91. }
  92. {
  93. const joined = await membersOf(roomId).catch(() => ({}));
  94. console.log(
  95. '进房情况',
  96. agentMxIds.map((a) => `${a.id}:${joined[a.mxId] ? 'join' : '未进'}`).join(', '),
  97. );
  98. }
  99. // ③ 不带 @ 发一句话,观察是否有人应答
  100. const sent = await client.sendEvent(roomId, 'm.room.message', {
  101. msgtype: 'm.text',
  102. body: `[方案B探测 ${new Date().toISOString().slice(11, 19)}] 这是一条不带 @ 的测试消息,请不要回复。`,
  103. });
  104. const sentAt = Date.now();
  105. console.log('已发送(无 @)', sent.event_id);
  106. console.log(`观察 ${NO_MENTION_WAIT_MS / 1000}s 内是否有容器应答…`);
  107. /*
  108. * 轮询房间消息端点来「听」回复 —— 不用 sync,避免 SDK 的 filter 问题。
  109. * 每 5 秒拉一次最新事件,挑出别人发的 m.room.message。
  110. */
  111. const replies = [];
  112. const seen = new Set();
  113. const deadline = Date.now() + NO_MENTION_WAIT_MS;
  114. while (Date.now() < deadline) {
  115. await sleep(5000);
  116. const r = await fetch(
  117. `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=30`,
  118. { headers: auth },
  119. );
  120. const j = await r.json().catch(() => ({}));
  121. for (const ev of j.chunk ?? []) {
  122. if (ev.type !== 'm.room.message') continue;
  123. if (ev.event_id === sent.event_id) continue;
  124. if (ev.sender === prov.userMxId) continue;
  125. if (seen.has(ev.event_id)) continue;
  126. seen.add(ev.event_id);
  127. replies.push({ sender: ev.sender, ts: Date.now() - sentAt });
  128. console.log(` ⚠️ ${Math.round((Date.now() - sentAt) / 1000)}s 收到应答 from ${ev.sender}: ${String(ev.content?.body ?? '').slice(0, 60)}`);
  129. }
  130. }
  131. console.log('\n===== 结论 =====');
  132. if (replies.length === 0) {
  133. console.log('✅ 静默期内无应答 → 容器按 mention 过滤,方案 B 不会抢答');
  134. } else {
  135. console.log(`❌ 收到 ${replies.length} 条应答 → 容器会抢答,方案 B 有风险`);
  136. for (const r of replies) console.log(` - ${Math.round(r.ts / 1000)}s / ${r.sender} / ${r.body}`);
  137. }
  138. if (!keep) {
  139. await client.leave(roomId).catch(() => {});
  140. console.log('已离开临时房间(容器侧由平台回收成员关系)');
  141. } else {
  142. console.log('保留房间以便人工查看:', roomId);
  143. }
  144. client.stopClient();
  145. process.exit(0);
  146. }
  147. main().catch((e) => {
  148. console.error('探测失败:', e);
  149. process.exit(1);
  150. });