bind-container.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. #!/usr/bin/env node
  2. /**
  3. * 容器 → Fmode App 绑定工具
  4. *
  5. * 解决的问题:一个在企微里已经能聊天的容器(或刚建好的容器),如何在本 App 里也能收发消息。
  6. *
  7. * 子命令:
  8. * node scripts/bind-container.mjs list
  9. * 列出当前账号名下全部容器及其「App 可聊天」状态
  10. *
  11. * node scripts/bind-container.mjs status <agentId>
  12. * 单个容器体检:缺哪一步、下一步该做什么
  13. *
  14. * node scripts/bind-container.mjs bind <agentId> [--name 显示名]
  15. * 🔴 有写副作用:确保 FmodeAgent 记录存在 → provision 建 Matrix 身份/房间/绑定
  16. *
  17. * node scripts/bind-container.mjs prompt <agentId>
  18. * 打印第 4 步「发给容器」的提示词(填好真实值,可直接复制粘贴)
  19. *
  20. * node scripts/bind-container.mjs unbind --binding <bindingId>
  21. * 解除 Matrix 绑定(不删 FmodeAgent 记录,不动容器)
  22. *
  23. * 凭据:读 `~/.fmode/config/user.json` 的 sessionToken,全程不打印。
  24. *
  25. * 依据:《docs/03-interface/新容器接入Matrix-端到端流程-20261103.md》
  26. */
  27. import { readFileSync } from 'node:fs';
  28. import { createHash } from 'node:crypto';
  29. import { homedir } from 'node:os';
  30. import { join } from 'node:path';
  31. const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions';
  32. const PARSE_URL = 'https://server.fmode.cn/parse';
  33. const MATRIX_HS = 'https://matrix.fmode.cn';
  34. const APP_ID = 'ncloudmaster';
  35. const FN_STATUS = 'xlosi3m46q';
  36. const FN_PROVISION = 'RP5Ht55NeX';
  37. // ---------- 凭据 ----------
  38. function loadToken() {
  39. const p = join(homedir(), '.fmode', 'config', 'user.json');
  40. try {
  41. const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
  42. if (typeof t === 'string' && t.startsWith('r:')) return t;
  43. } catch {
  44. /* fallthrough */
  45. }
  46. fail(`读不到平台 sessionToken(期望 ${p} 的 sessionToken 且以 "r:" 开头)`, 2);
  47. }
  48. const TOKEN = loadToken();
  49. function fail(msg, code = 1) {
  50. console.error(`❌ ${msg}`);
  51. process.exit(code);
  52. }
  53. // ---------- 平台调用 ----------
  54. async function callFn(id, params) {
  55. const res = await fetch(FUNCTIONS_URL, {
  56. method: 'POST',
  57. headers: { 'Content-Type': 'application/json' },
  58. body: JSON.stringify({ id, token: TOKEN, params }),
  59. });
  60. const text = await res.text();
  61. try {
  62. return JSON.parse(text);
  63. } catch {
  64. return { code: res.status, mess: text.slice(0, 200) };
  65. }
  66. }
  67. async function parseReq(method, path, body) {
  68. const res = await fetch(`${PARSE_URL}${path}`, {
  69. method,
  70. headers: {
  71. 'X-Parse-Application-Id': APP_ID,
  72. 'X-Parse-Session-Token': TOKEN,
  73. 'Content-Type': 'application/json',
  74. },
  75. body: body ? JSON.stringify(body) : undefined,
  76. });
  77. const text = await res.text();
  78. try {
  79. return { ...JSON.parse(text), _status: res.status };
  80. } catch {
  81. return { _status: res.status, error: text.slice(0, 200) };
  82. }
  83. }
  84. /** 当前登录用户(含 company 指针) */
  85. async function me() {
  86. const u = await parseReq('GET', '/users/me');
  87. if (u.error) fail(`取登录用户失败:${u.error}`);
  88. return u;
  89. }
  90. function parseChannels(raw) {
  91. if (!raw) return {};
  92. if (typeof raw === 'object') return raw;
  93. try {
  94. return JSON.parse(raw) ?? {};
  95. } catch {
  96. return {};
  97. }
  98. }
  99. const ok = (s) => `✅ ${s}`;
  100. const bad = (s) => `❌ ${s}`;
  101. const warn = (s) => `⚠️ ${s}`;
  102. // ---------- Matrix 身份 ----------
  103. /** sha256 前 24 位十六进制(小写) */
  104. async function derive(s) {
  105. return createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 24);
  106. }
  107. /** 用指定密码尝试登录,成功返回 true */
  108. async function canLogin(username, password) {
  109. try {
  110. const r = await fetch(`${MATRIX_HS}/_matrix/client/v3/login`, {
  111. method: 'POST',
  112. headers: { 'Content-Type': 'application/json' },
  113. body: JSON.stringify({
  114. type: 'm.login.password',
  115. identifier: { type: 'm.id.user', user: username },
  116. password,
  117. }),
  118. });
  119. const d = await r.json();
  120. return !!d.accessToken || !!d.access_token;
  121. } catch {
  122. return false;
  123. }
  124. }
  125. /**
  126. * 只读地取该容器的 Matrix 绑定(**绝不调 provision**)。
  127. *
  128. * 🔴 为什么必须这样(2026-11-03 踩坑):provision 对「无绑定」的 agentId
  129. * **会直接新建一条绑定**,不是纯查询。用 provision 做「只读展示」=
  130. * 每调一次就给该容器多造一条绑定,正是文档《容器初始化流程优化建议》
  131. * 问题 5 的「同一 Agent 两条绑定、房间割裂」。
  132. * 所以只读命令一律走 Parse 直查 FmodeMatrixBinding。
  133. */
  134. async function readBinding(agentId) {
  135. const where = JSON.stringify({ agentId, status: 'active' });
  136. const r = await parseReq(
  137. 'GET',
  138. `/classes/FmodeMatrixBinding?where=${encodeURIComponent(where)}&limit=20`,
  139. );
  140. const list = r?.results ?? [];
  141. if (!list.length) return null;
  142. // 多条 = 已经存在「房间割裂」隐患,如实报出来
  143. const latest = list.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0];
  144. return { list, latest };
  145. }
  146. // ---------- list ----------
  147. async function cmdList() {
  148. const d = await callFn(FN_STATUS, { op: 'query', scope: 'mine' });
  149. if (d.code !== 200) fail(`查询容器失败:${d.code} ${d.mess ?? ''}`);
  150. const agents = d.agents ?? [];
  151. console.log(`当前账号下 ${agents.length} 个容器\n`);
  152. console.log('agentId'.padEnd(26), 'App可聊天 matrix.userId'.padEnd(30), '企微');
  153. console.log('-'.repeat(84));
  154. for (const a of agents) {
  155. const ch = parseChannels(a.channels);
  156. const uid = ch?.matrixConfig?.userId ?? '';
  157. /*
  158. * 🔴 `channels` 可能是 null / 数组(复刻版上报),也可能是对象。
  159. * `ch.wecom === true` 才算真开了企微;只看键存在会把 null 的容器误判成已接。
  160. */
  161. const wecom = ch?.wecom === true ? ch?.wecomBotId || '已开' : '—';
  162. console.log(
  163. String(a.agentId ?? '').padEnd(26),
  164. (uid ? '✅ 是' : '❌ 否').padEnd(10),
  165. String(uid || '—').padEnd(26),
  166. wecom === '—' ? '—' : '✅',
  167. );
  168. }
  169. console.log('\n下一步:node scripts/bind-container.mjs status <agentId>');
  170. }
  171. // ---------- status ----------
  172. async function cmdStatus(agentId) {
  173. console.log(`\n=== 容器体检:${agentId} ===\n`);
  174. const rows = [];
  175. // 检查 1:FmodeAgent 记录
  176. //
  177. // 🔴 必须用 scope:'all'(2026-11-03 修正):用 'mine' 查别人的容器会 404,
  178. // 被误报成「记录不存在」,会误导使用者去 bind —— 而 bind 又会因归属校验拒绝。
  179. // 属主信息在下面单独展示,不改变「能不能聊」的判定。
  180. const q = await callFn(FN_STATUS, { op: 'query', scope: 'all', agentId });
  181. const agent = q.code === 200 ? q.agent : null;
  182. if (!agent) {
  183. console.log(bad(`检查1 FmodeAgent 记录:不存在(${q.code})`));
  184. console.log(' → 直接跑:node scripts/bind-container.mjs bind ' + agentId);
  185. rows.push(['FmodeAgent 记录', false]);
  186. return summarize(rows);
  187. }
  188. const objectId = agent.objectId ?? '';
  189. const expectedMxid = objectId ? `@${objectId.toLowerCase()}:fmode.cn` : '';
  190. const ch = parseChannels(agent.channels);
  191. const uid = ch?.matrixConfig?.userId ?? '';
  192. console.log(ok(`检查1 FmodeAgent 记录存在 objectId=${objectId}`));
  193. // 属主:本命令可能查别人的容器(代开通场景),必须让人一眼看出归属
  194. const self = await me();
  195. const ownerUid = agent.user?.objectId ?? '';
  196. const ownerLabel = `${agent.user?.nickname || ownerUid || '未知'}(${agent.ownerMobile || '未知'})`;
  197. if (ownerUid && ownerUid !== self.objectId) {
  198. console.log(warn(` 属主:${ownerLabel} —— 不是当前账号(当前 ${self.objectId})`));
  199. } else {
  200. console.log(` 属主:${ownerLabel} —— 当前账号`);
  201. }
  202. rows.push(['FmodeAgent 记录', true]);
  203. // 检查 2:App 可见性
  204. if (uid) {
  205. console.log(ok(`检查2 App 可见性:userId=${uid}`));
  206. if (expectedMxid && uid !== expectedMxid) {
  207. console.log(warn(` 与 objectId 派生的 ${expectedMxid} 不一致(可能手工注册过)`));
  208. }
  209. rows.push(['App 可见性', true]);
  210. } else {
  211. console.log(bad('检查2 App 可见性:matrixConfig.userId 为空 → App 里点不进聊天'));
  212. /*
  213. * 只读查询绑定表,**不调 provision**(provision 会给无绑定的容器新建绑定)。
  214. */
  215. const b = await readBinding(agentId);
  216. if (b) {
  217. console.log(` 平台侧已有绑定(bindingId=${b.latest.objectId})。`);
  218. if (b.list.length > 1) {
  219. console.log(` ⚠️ 该容器有 ${b.list.length} 条 active 绑定 —— 会造成「房间割裂」,建议清理到 1 条。`);
  220. }
  221. console.log(' → 缺的是容器侧配置:node scripts/bind-container.mjs prompt ' + agentId);
  222. } else {
  223. console.log(' → 平台侧尚未开通,跑:node scripts/bind-container.mjs bind ' + agentId);
  224. }
  225. rows.push(['App 可见性', false]);
  226. }
  227. // 检查 3:容器侧上报
  228. const state = ch?.matrixConfig?.channelState ?? '';
  229. const listenerOk = ch?.matrixConfig?.listenerOk;
  230. if (state === 'connected' && listenerOk !== false) {
  231. console.log(ok(`检查3 容器侧上报:matrixState=${state} listenerOk=${listenerOk}`));
  232. console.log(' 局限:这是容器自报,不等于「已入房」。权威判据见流程 §第3步。');
  233. rows.push(['容器侧上报', true]);
  234. } else {
  235. console.log(warn(`检查3 容器侧上报:matrixState=${state || '(无)'} listenerOk=${listenerOk ?? '(无)'}`));
  236. console.log(' → 需要把配置发进容器:node scripts/bind-container.mjs prompt ' + agentId);
  237. rows.push(['容器侧上报', false]);
  238. }
  239. console.log(warn('检查4 收发闭环:需人工实测(脚本不代发消息)'));
  240. rows.push(['收发闭环(人工)', null]);
  241. summarize(rows);
  242. }
  243. function summarize(rows) {
  244. console.log('\n--- 结论 ---');
  245. for (const [name, v] of rows) {
  246. console.log(` ${v === true ? '✅' : v === false ? '❌' : '⚠️ '} ${name}`);
  247. }
  248. const failed = rows.filter(([, v]) => v === false).length;
  249. const pending = rows.filter(([, v]) => v === null).length;
  250. if (failed === 0 && pending === 0) console.log('\n自动可判项全部通过。');
  251. else if (failed === 0) console.log(`\n无失败项,但有 ${pending} 项需人工实测。`);
  252. else console.log(`\n${failed} 项未通过,按上面 → 的提示处理。`);
  253. process.exit(failed === 0 ? 0 : 1);
  254. }
  255. // ---------- bind ----------
  256. async function cmdBind(agentId, opts) {
  257. if (!agentId) fail('用法:bind <agentId> [--name 显示名]', 2);
  258. console.log(`\n=== 绑定 ${agentId} → 当前账号 ===\n`);
  259. const user = await me();
  260. const userId = user.objectId;
  261. const companyId = user.company?.objectId;
  262. if (!companyId) fail('当前用户没有 company 指针,无法做多租户隔离');
  263. console.log(`登录账号:${user.nickname ?? user.username}(${user.mobile})`);
  264. console.log(` user = ${userId}`);
  265. console.log(` company = ${companyId}\n`);
  266. // ① 确保 FmodeAgent 记录存在
  267. //
  268. // 🔴 必须用 scope:'all' 查,不能用 'mine'(2026-11-03 修正):
  269. // 别人的容器在 'mine' 视角下返回 404 → 会被误判成「没有记录」→ 进入补录分支
  270. // 把自己的 user 指针写上去 → 等于**抢绑别人的容器**。用 'all' 才能先看清真实归属。
  271. const q = await callFn(FN_STATUS, { op: 'query', scope: 'all', agentId });
  272. let agent = q.code === 200 ? q.agent : null;
  273. if (agent) {
  274. console.log(ok(`FmodeAgent 记录已存在 objectId=${agent.objectId}`));
  275. // 归属校验:防止把别人的容器绑到自己名下
  276. const ownerUid = agent.user?.objectId ?? '';
  277. const owned =
  278. ownerUid === userId ||
  279. (!ownerUid && !!agent.ownerMobile && agent.ownerMobile === user.mobile);
  280. if (!owned) {
  281. fail(
  282. `该容器归属 ${agent.user?.nickname || ownerUid || '未知'}(${agent.ownerMobile || '未知'}),不是当前账号。\n` +
  283. ' 拒绝绑定:跨账号绑定会造成「同一 Agent 两条绑定、房间割裂」(见《容器初始化流程优化建议》问题 5)。\n' +
  284. ' 如确需处理,请先确认归属,或由该账号本人执行。',
  285. );
  286. }
  287. console.log(ok('归属校验通过:该容器属于当前账号'));
  288. } else {
  289. console.log(`FmodeAgent 无记录,开始补录……`);
  290. const body = {
  291. agentId,
  292. name: opts.name ?? agentId,
  293. user: { __type: 'Pointer', className: '_User', objectId: userId },
  294. company: { __type: 'Pointer', className: 'Company', objectId: companyId },
  295. ownerMobile: user.mobile,
  296. ownerName: user.nickname ?? '',
  297. status: 'online',
  298. gender: 'unknown',
  299. containerRef: agentId,
  300. // 🔴 JSON 字符串,不是对象(契约要求)
  301. host: JSON.stringify({ cpuPercent: 0, diskPercent: 0, memPercent: 0 }),
  302. balance: JSON.stringify({ remainCny: null, source: 'none' }),
  303. channels: JSON.stringify({ homeserver: '', matrix: false, matrixConfig: {}, wecom: false }),
  304. extra: JSON.stringify({ agentId }),
  305. ACL: {
  306. [userId]: { read: true, write: true },
  307. 'role:FMODE_AGENT_SUPERADMIN': { read: true, write: true },
  308. [`role:${companyId}_Admin`]: { read: true, write: true },
  309. [`role:${companyId}_Public`]: { read: true },
  310. },
  311. };
  312. const created = await parseReq('POST', '/classes/FmodeAgent', body);
  313. if (created.error || !created.objectId) fail(`补录失败:${JSON.stringify(created).slice(0, 300)}`);
  314. console.log(ok(`补录成功 objectId=${created.objectId}`));
  315. const re = await callFn(FN_STATUS, { op: 'query', scope: 'mine', agentId });
  316. agent = re.code === 200 ? re.agent : null;
  317. if (!agent) fail('补录后仍查不到记录 —— 可能是 scope 可见性问题,请检查 ACL');
  318. }
  319. const objectId = agent.objectId;
  320. console.log(`\n预期 Matrix 身份:@${objectId.toLowerCase()}:fmode.cn`);
  321. // ② provision
  322. //
  323. // 🔴 先只读查绑定表:已有 active 绑定的容器**不再调 provision**。
  324. // provision 对已有绑定的处理不可预期(历史实测会出现第二条绑定),
  325. // 而「一容器一绑定」是硬约束。需要重绑请显式加 --rebind。
  326. const existing = await readBinding(agentId);
  327. if (existing && !opts.rebind) {
  328. const mine = existing.list.filter(
  329. (x) => (x.userPointer?.objectId ?? x.userPointer) === userId,
  330. );
  331. if (!mine.length) {
  332. fail(
  333. `该容器已有绑定,但归属不是当前账号(${existing.list.length} 条 active)。\n` +
  334. ' 拒绝重复绑定。确认后可用 --rebind 强制,或先清理旧绑定。',
  335. );
  336. }
  337. console.log(
  338. ok(`已有本账号绑定(bindingId=${mine.map((x) => x.objectId).join(', ')}),跳过 provision`),
  339. );
  340. if (existing.list.length > 1) {
  341. const others = existing.list.filter((x) => !mine.includes(x));
  342. console.log(
  343. warn(
  344. `该容器共 ${existing.list.length} 条 active 绑定,其中 ${others.length} 条属于其他账号 ` +
  345. `(${others.map((x) => x.userPointer?.objectId ?? '?').join(', ')})—— 存在「房间割裂」隐患`,
  346. ),
  347. );
  348. }
  349. return finishBind(agentId);
  350. }
  351. console.log('\n调用 provision(幂等,已有绑定会秒回)……');
  352. const prov = await callFn(FN_PROVISION, { agentId });
  353. if (prov.code !== 200 || !prov.accessToken || !prov.userMxId) {
  354. fail(`provision 失败:${prov.code} ${prov.mess ?? prov.message ?? ''}`);
  355. }
  356. console.log(ok(`provision 成功${prov.cached ? '(绑定表命中)' : '(新建)'}`));
  357. console.log(` agentMxId = ${prov.agentMxId}`);
  358. console.log(` userMxId = ${prov.userMxId}`);
  359. console.log(` roomId = ${prov.roomId}`);
  360. console.log(` bindingId = ${prov.bindingId}`);
  361. // ③ 校验 Agent 真的在房间里(最常见的坑)
  362. console.log('\n校验 Agent 是否在房间……');
  363. const joined = await fetch(
  364. `${MATRIX_HS}/_matrix/client/v3/rooms/${encodeURIComponent(prov.roomId)}/joined_members`,
  365. { headers: { Authorization: `Bearer ${prov.accessToken}` } },
  366. )
  367. .then((r) => r.json())
  368. .catch(() => null);
  369. if (!joined?.joined) {
  370. console.log(warn('无法读取房间成员(token 或房间可能未就绪)'));
  371. console.log(' → 请在 App 里打开该容器会话,或手动核验 joined_members');
  372. } else {
  373. const members = Object.keys(joined.joined);
  374. const inRoom = members.includes(prov.agentMxId);
  375. if (inRoom) {
  376. console.log(ok(`Agent 已在房间(共 ${members.length} 名成员)`));
  377. } else {
  378. console.log(bad(`Agent 不在房间!当前成员:${members.join(', ')}`));
  379. console.log(' → 这是「房间零消息、发不出去话」的头号原因。');
  380. console.log(' → 用 prompt 子命令让容器自己 join(它会用派生密码登录自己)。');
  381. }
  382. }
  383. finishBind(agentId);
  384. }
  385. /** 绑定收尾:统一输出下一步 */
  386. function finishBind(agentId) {
  387. console.log('\n--- 绑定完成 ---');
  388. console.log('平台侧已就绪。还差最后一步:让容器知道自己的 Matrix 身份。\n');
  389. console.log(` node scripts/bind-container.mjs prompt ${agentId}\n`);
  390. console.log('把输出的提示词发进容器(或让容器自己执行),之后在 App 里打开:');
  391. console.log(` 桌面端 /desktop/chat/${agentId}`);
  392. console.log(` 移动端 /mobile/chat/${agentId}\n`);
  393. process.exit(0);
  394. }
  395. // ---------- prompt ----------
  396. async function cmdPrompt(agentId) {
  397. if (!agentId) fail('用法:prompt <agentId>', 2);
  398. // 用 scope:'all' —— prompt 需要给别人的容器生成提示词(如代同事开通),'mine' 会误判为不存在
  399. const q = await callFn(FN_STATUS, { op: 'query', scope: 'all', agentId });
  400. const agent = q.code === 200 ? q.agent : null;
  401. if (!agent) {
  402. fail(`FmodeAgent 里查不到 ${agentId}。先跑:node scripts/bind-container.mjs bind ${agentId}`);
  403. }
  404. const objectId = agent.objectId;
  405. const lower = objectId.toLowerCase();
  406. /*
  407. * 🔴 只读查绑定表,**不调 provision**:provision 会给无绑定的容器新建一条绑定。
  408. */
  409. const b = await readBinding(agentId);
  410. if (!b) {
  411. fail(
  412. `该容器还没有 Matrix 绑定。先跑:node scripts/bind-container.mjs bind ${agentId}\n` +
  413. ' (prompt 只负责生成提示词,不代你开通)',
  414. );
  415. }
  416. const roomId = b.latest.roomId ?? '';
  417. if (!roomId) fail('绑定记录里没有 roomId,请重新跑 bind');
  418. const user = await me();
  419. const masterMxid = `@${user.objectId.toLowerCase()}:fmode.cn`;
  420. const roomEncoded = encodeURIComponent(roomId);
  421. const name = agent.name && agent.name !== agentId ? agent.name : agentId;
  422. /*
  423. * 🔴 派生密码有两套规则并存(2026-11-03 实测):
  424. * · provision 新建的账号 → sha256(prefix + **原始 objectId**)[:24] (agent-lj 验证通过)
  425. * · 手工注册的历史账号 → sha256(prefix + **小写 objectId**)[:24] (lj-test 验证通过)
  426. * 旧文档只写了「小写」这一条,对 provision 新建的账号是**错的**,必须两种都试。
  427. * 这里在脚本里直接试一次,把结果告诉用户,避免容器里反复失败。
  428. */
  429. const mxidUser = lower;
  430. const cands = [
  431. { label: '原始 objectId', pw: await derive(`fmode-agent-mx:${objectId}`) },
  432. { label: '小写 objectId', pw: await derive(`fmode-agent-mx:${lower}`) },
  433. ];
  434. let good = null;
  435. for (const c of cands) {
  436. if (await canLogin(mxidUser, c.pw)) {
  437. good = c;
  438. break;
  439. }
  440. }
  441. const ruleLine = good
  442. ? ` ✅ 实测可用的派生规则:${good.label} → sha256("fmode-agent-mx:" + ${good.label === '原始 objectId' ? objectId : lower})[:24]`
  443. : ` ⚠️ 两种派生规则都登录失败 —— 该账号可能用了自定义密码(手工注册)。\n 请在容器里用它**现有的** Matrix token,跳过第 2 步直接做第 3、4 步。`;
  444. console.log(`\n===== 发进容器 ${agentId} 的提示词(直接复制)=====\n`);
  445. console.log(`${ruleLine}
  446. 【任务】接入 FMODE Matrix,并自检回报
  447. == 你的身份 ==
  448. agentId = ${agentId}
  449. objectId = ${objectId}
  450. 你的 mxId = @${lower}:fmode.cn
  451. 主人 mxId = ${masterMxid}
  452. 房间 roomId = ${roomId}
  453. homeserver = ${MATRIX_HS}
  454. 显示名 = ${name}
  455. 【第 1 步:先自检,不要改任何东西】
  456. 打印 hostname。
  457. 打印 /opt/data/.env 里所有 MATRIX_* 键的**当前生效值**(只报键名和「是否为空」,不要打印 token 全文)。
  458. ⚠️ 不要用 /proc/<pid>/environ 查 —— .env 由 Python 层 load_hermes_dotenv() 加载,那里永远为空(假阴性)。
  459. 【第 2 步:登录你自己(把下面 PWD_MX 换成实测可用的那条规则算出的值)】
  460. # 规则:sha256("fmode-agent-mx:" + <${good?.label ?? '原始 objectId 或 小写 objectId,二选一'}>) 的前 24 位
  461. PWD_MX=<用上面的规则算出的 24 位十六进制>
  462. MY_TOKEN=$(curl -s -X POST ${MATRIX_HS}/_matrix/client/v3/login \\
  463. -H 'Content-Type: application/json' \\
  464. -d "{\\"type\\":\\"m.login.password\\",\\"identifier\\":{\\"type\\":\\"m.id.user\\",\\"user\\":\\"${mxidUser}\\"},\\"password\\":\\"$PWD_MX\\"}" \\
  465. | sed -n 's/.*"access_token":"\\([^"]*\\)".*/\\1/p')
  466. [ -n "$MY_TOKEN" ] || { echo "MX-FAIL 登录失败"; exit 1; }
  467. curl -s -H "Authorization: Bearer $MY_TOKEN" ${MATRIX_HS}/_matrix/client/v3/account/whoami
  468. # 必须输出 @${lower}:fmode.cn 才算登对
  469. 【第 3 步:确保你在房间里(最关键,漏了就是「房间零消息」)】
  470. curl -s -H "Authorization: Bearer $MY_TOKEN" ${MATRIX_HS}/_matrix/client/v3/joined_rooms
  471. 若 ${roomId} 不在返回里:
  472. curl -s -X POST -H "Authorization: Bearer $MY_TOKEN" ${MATRIX_HS}/_matrix/client/v3/join/${roomEncoded}
  473. 返回 403 = 没有邀请 → 停下来报告「需 admin 先 invite」,不要反复重试。
  474. 【第 4 步:改 /opt/data/.env(先备份为 .env.bak-mxenv-<时间戳>,保留其它键)】
  475. MATRIX_HOMESERVER=${MATRIX_HS}
  476. MATRIX_USER_ID=@${lower}:fmode.cn
  477. MATRIX_ACCESS_TOKEN=$MY_TOKEN
  478. MATRIX_HOME_ROOM=${roomId}
  479. MATRIX_ALLOWED_USERS=${masterMxid}
  480. 保留现有的 MATRIX_REQUIRE_MENTION / MATRIX_IGNORE_USER_PATTERNS 原值,不要改。
  481. chmod 600 /opt/data/.env
  482. 【第 5 步:重启 gateway】
  483. s6 服务 gateway-default 会自动拉起,确认新进程起来了。
  484. 【第 6 步:自检回报(只报事实)】
  485. - whoami 的 user_id 是否 == @${lower}:fmode.cn
  486. - joined_rooms 里是否含 ${roomId}
  487. - 在房间发一条自检消息,回读 /messages 确认落库
  488. 最后输出一行:MX-DONE mxid=<...> room=<...> gateway=<connected|failed>
  489. 【铁律】
  490. - 不打印 token 全文,不写进任何仓库、日志或聊天。
  491. - 任一步失败如实报「卡在哪 + 需要谁做什么」,不伪造成功。
  492. `);
  493. console.log(`===== 结束 =====\n`);
  494. console.log('发完后在 App 里打开会话,发一句消息,看是否回复。');
  495. }
  496. // ---------- main ----------
  497. const [cmd, arg, ...rest] = process.argv.slice(2);
  498. function opt(name) {
  499. const i = rest.indexOf(`--${name}`);
  500. return i >= 0 ? rest[i + 1] : undefined;
  501. }
  502. switch (cmd) {
  503. case 'list':
  504. await cmdList();
  505. break;
  506. case 'status':
  507. await cmdStatus(arg);
  508. break;
  509. case 'bind':
  510. await cmdBind(arg, { name: opt("name"), rebind: rest.includes("--rebind") });
  511. break;
  512. case 'prompt':
  513. await cmdPrompt(arg);
  514. break;
  515. case 'help':
  516. case undefined:
  517. console.log(
  518. [
  519. '容器 → Fmode App 绑定工具',
  520. '',
  521. ' list 列出全部容器及 App 可聊天状态',
  522. ' status <agentId> 单容器体检(只读)',
  523. ' bind <agentId> 补录记录 + provision + 校验入房(有写副作用)',
  524. ' prompt <agentId> 打印发给容器的提示词',
  525. '',
  526. '详见 docs/03-interface/新容器接入Matrix-端到端流程-20261103.md',
  527. ].join('\n'),
  528. );
  529. break;
  530. default:
  531. fail(`未知子命令:${cmd}(用 help 查看用法)`, 2);
  532. }