#!/usr/bin/env node /** * 容器 → Fmode App 绑定工具 * * 解决的问题:一个在企微里已经能聊天的容器(或刚建好的容器),如何在本 App 里也能收发消息。 * * 子命令: * node scripts/bind-container.mjs list * 列出当前账号名下全部容器及其「App 可聊天」状态 * * node scripts/bind-container.mjs status * 单个容器体检:缺哪一步、下一步该做什么 * * node scripts/bind-container.mjs bind [--name 显示名] * 🔴 有写副作用:确保 FmodeAgent 记录存在 → provision 建 Matrix 身份/房间/绑定 * * node scripts/bind-container.mjs prompt * 打印第 4 步「发给容器」的提示词(填好真实值,可直接复制粘贴) * * node scripts/bind-container.mjs unbind --binding * 解除 Matrix 绑定(不删 FmodeAgent 记录,不动容器) * * 凭据:读 `~/.fmode/config/user.json` 的 sessionToken,全程不打印。 * * 依据:《docs/03-interface/新容器接入Matrix-端到端流程-20261103.md》 */ import { readFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions'; const PARSE_URL = 'https://server.fmode.cn/parse'; const MATRIX_HS = 'https://matrix.fmode.cn'; const APP_ID = 'ncloudmaster'; const FN_STATUS = 'xlosi3m46q'; const FN_PROVISION = 'RP5Ht55NeX'; // ---------- 凭据 ---------- function loadToken() { 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 { /* fallthrough */ } fail(`读不到平台 sessionToken(期望 ${p} 的 sessionToken 且以 "r:" 开头)`, 2); } const TOKEN = loadToken(); function fail(msg, code = 1) { console.error(`❌ ${msg}`); process.exit(code); } // ---------- 平台调用 ---------- async function callFn(id, params) { const res = await fetch(FUNCTIONS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, token: TOKEN, params }), }); const text = await res.text(); try { return JSON.parse(text); } catch { return { code: res.status, mess: text.slice(0, 200) }; } } async function parseReq(method, path, body) { const res = await fetch(`${PARSE_URL}${path}`, { method, headers: { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': TOKEN, 'Content-Type': 'application/json', }, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text(); try { return { ...JSON.parse(text), _status: res.status }; } catch { return { _status: res.status, error: text.slice(0, 200) }; } } /** 当前登录用户(含 company 指针) */ async function me() { const u = await parseReq('GET', '/users/me'); if (u.error) fail(`取登录用户失败:${u.error}`); return u; } function parseChannels(raw) { if (!raw) return {}; if (typeof raw === 'object') return raw; try { return JSON.parse(raw) ?? {}; } catch { return {}; } } const ok = (s) => `✅ ${s}`; const bad = (s) => `❌ ${s}`; const warn = (s) => `⚠️ ${s}`; // ---------- Matrix 身份 ---------- /** sha256 前 24 位十六进制(小写) */ async function derive(s) { return createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 24); } /** 用指定密码尝试登录,成功返回 true */ async function canLogin(username, password) { try { const r = await fetch(`${MATRIX_HS}/_matrix/client/v3/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'm.login.password', identifier: { type: 'm.id.user', user: username }, password, }), }); const d = await r.json(); return !!d.accessToken || !!d.access_token; } catch { return false; } } /** * 只读地取该容器的 Matrix 绑定(**绝不调 provision**)。 * * 🔴 为什么必须这样(2026-11-03 踩坑):provision 对「无绑定」的 agentId * **会直接新建一条绑定**,不是纯查询。用 provision 做「只读展示」= * 每调一次就给该容器多造一条绑定,正是文档《容器初始化流程优化建议》 * 问题 5 的「同一 Agent 两条绑定、房间割裂」。 * 所以只读命令一律走 Parse 直查 FmodeMatrixBinding。 */ async function readBinding(agentId) { const where = JSON.stringify({ agentId, status: 'active' }); const r = await parseReq( 'GET', `/classes/FmodeMatrixBinding?where=${encodeURIComponent(where)}&limit=20`, ); const list = r?.results ?? []; if (!list.length) return null; // 多条 = 已经存在「房间割裂」隐患,如实报出来 const latest = list.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]; return { list, latest }; } // ---------- list ---------- async function cmdList() { const d = await callFn(FN_STATUS, { op: 'query', scope: 'mine' }); if (d.code !== 200) fail(`查询容器失败:${d.code} ${d.mess ?? ''}`); const agents = d.agents ?? []; console.log(`当前账号下 ${agents.length} 个容器\n`); console.log('agentId'.padEnd(26), 'App可聊天 matrix.userId'.padEnd(30), '企微'); console.log('-'.repeat(84)); for (const a of agents) { const ch = parseChannels(a.channels); const uid = ch?.matrixConfig?.userId ?? ''; /* * 🔴 `channels` 可能是 null / 数组(复刻版上报),也可能是对象。 * `ch.wecom === true` 才算真开了企微;只看键存在会把 null 的容器误判成已接。 */ const wecom = ch?.wecom === true ? ch?.wecomBotId || '已开' : '—'; console.log( String(a.agentId ?? '').padEnd(26), (uid ? '✅ 是' : '❌ 否').padEnd(10), String(uid || '—').padEnd(26), wecom === '—' ? '—' : '✅', ); } console.log('\n下一步:node scripts/bind-container.mjs status '); } // ---------- status ---------- async function cmdStatus(agentId) { console.log(`\n=== 容器体检:${agentId} ===\n`); const rows = []; // 检查 1:FmodeAgent 记录 // // 🔴 必须用 scope:'all'(2026-11-03 修正):用 'mine' 查别人的容器会 404, // 被误报成「记录不存在」,会误导使用者去 bind —— 而 bind 又会因归属校验拒绝。 // 属主信息在下面单独展示,不改变「能不能聊」的判定。 const q = await callFn(FN_STATUS, { op: 'query', scope: 'all', agentId }); const agent = q.code === 200 ? q.agent : null; if (!agent) { console.log(bad(`检查1 FmodeAgent 记录:不存在(${q.code})`)); console.log(' → 直接跑:node scripts/bind-container.mjs bind ' + agentId); rows.push(['FmodeAgent 记录', false]); return summarize(rows); } const objectId = agent.objectId ?? ''; const expectedMxid = objectId ? `@${objectId.toLowerCase()}:fmode.cn` : ''; const ch = parseChannels(agent.channels); const uid = ch?.matrixConfig?.userId ?? ''; console.log(ok(`检查1 FmodeAgent 记录存在 objectId=${objectId}`)); // 属主:本命令可能查别人的容器(代开通场景),必须让人一眼看出归属 const self = await me(); const ownerUid = agent.user?.objectId ?? ''; const ownerLabel = `${agent.user?.nickname || ownerUid || '未知'}(${agent.ownerMobile || '未知'})`; if (ownerUid && ownerUid !== self.objectId) { console.log(warn(` 属主:${ownerLabel} —— 不是当前账号(当前 ${self.objectId})`)); } else { console.log(` 属主:${ownerLabel} —— 当前账号`); } rows.push(['FmodeAgent 记录', true]); // 检查 2:App 可见性 if (uid) { console.log(ok(`检查2 App 可见性:userId=${uid}`)); if (expectedMxid && uid !== expectedMxid) { console.log(warn(` 与 objectId 派生的 ${expectedMxid} 不一致(可能手工注册过)`)); } rows.push(['App 可见性', true]); } else { console.log(bad('检查2 App 可见性:matrixConfig.userId 为空 → App 里点不进聊天')); /* * 只读查询绑定表,**不调 provision**(provision 会给无绑定的容器新建绑定)。 */ const b = await readBinding(agentId); if (b) { console.log(` 平台侧已有绑定(bindingId=${b.latest.objectId})。`); if (b.list.length > 1) { console.log(` ⚠️ 该容器有 ${b.list.length} 条 active 绑定 —— 会造成「房间割裂」,建议清理到 1 条。`); } console.log(' → 缺的是容器侧配置:node scripts/bind-container.mjs prompt ' + agentId); } else { console.log(' → 平台侧尚未开通,跑:node scripts/bind-container.mjs bind ' + agentId); } rows.push(['App 可见性', false]); } // 检查 3:容器侧上报 const state = ch?.matrixConfig?.channelState ?? ''; const listenerOk = ch?.matrixConfig?.listenerOk; if (state === 'connected' && listenerOk !== false) { console.log(ok(`检查3 容器侧上报:matrixState=${state} listenerOk=${listenerOk}`)); console.log(' 局限:这是容器自报,不等于「已入房」。权威判据见流程 §第3步。'); rows.push(['容器侧上报', true]); } else { console.log(warn(`检查3 容器侧上报:matrixState=${state || '(无)'} listenerOk=${listenerOk ?? '(无)'}`)); console.log(' → 需要把配置发进容器:node scripts/bind-container.mjs prompt ' + agentId); rows.push(['容器侧上报', false]); } console.log(warn('检查4 收发闭环:需人工实测(脚本不代发消息)')); rows.push(['收发闭环(人工)', null]); summarize(rows); } function summarize(rows) { console.log('\n--- 结论 ---'); for (const [name, v] of rows) { console.log(` ${v === true ? '✅' : v === false ? '❌' : '⚠️ '} ${name}`); } const failed = rows.filter(([, v]) => v === false).length; const pending = rows.filter(([, v]) => v === null).length; if (failed === 0 && pending === 0) console.log('\n自动可判项全部通过。'); else if (failed === 0) console.log(`\n无失败项,但有 ${pending} 项需人工实测。`); else console.log(`\n${failed} 项未通过,按上面 → 的提示处理。`); process.exit(failed === 0 ? 0 : 1); } // ---------- bind ---------- async function cmdBind(agentId, opts) { if (!agentId) fail('用法:bind [--name 显示名]', 2); console.log(`\n=== 绑定 ${agentId} → 当前账号 ===\n`); const user = await me(); const userId = user.objectId; const companyId = user.company?.objectId; if (!companyId) fail('当前用户没有 company 指针,无法做多租户隔离'); console.log(`登录账号:${user.nickname ?? user.username}(${user.mobile})`); console.log(` user = ${userId}`); console.log(` company = ${companyId}\n`); // ① 确保 FmodeAgent 记录存在 // // 🔴 必须用 scope:'all' 查,不能用 'mine'(2026-11-03 修正): // 别人的容器在 'mine' 视角下返回 404 → 会被误判成「没有记录」→ 进入补录分支 // 把自己的 user 指针写上去 → 等于**抢绑别人的容器**。用 'all' 才能先看清真实归属。 const q = await callFn(FN_STATUS, { op: 'query', scope: 'all', agentId }); let agent = q.code === 200 ? q.agent : null; if (agent) { console.log(ok(`FmodeAgent 记录已存在 objectId=${agent.objectId}`)); // 归属校验:防止把别人的容器绑到自己名下 const ownerUid = agent.user?.objectId ?? ''; const owned = ownerUid === userId || (!ownerUid && !!agent.ownerMobile && agent.ownerMobile === user.mobile); if (!owned) { fail( `该容器归属 ${agent.user?.nickname || ownerUid || '未知'}(${agent.ownerMobile || '未知'}),不是当前账号。\n` + ' 拒绝绑定:跨账号绑定会造成「同一 Agent 两条绑定、房间割裂」(见《容器初始化流程优化建议》问题 5)。\n' + ' 如确需处理,请先确认归属,或由该账号本人执行。', ); } console.log(ok('归属校验通过:该容器属于当前账号')); } else { console.log(`FmodeAgent 无记录,开始补录……`); const body = { agentId, name: opts.name ?? agentId, user: { __type: 'Pointer', className: '_User', objectId: userId }, company: { __type: 'Pointer', className: 'Company', objectId: companyId }, ownerMobile: user.mobile, ownerName: user.nickname ?? '', status: 'online', gender: 'unknown', containerRef: agentId, // 🔴 JSON 字符串,不是对象(契约要求) host: JSON.stringify({ cpuPercent: 0, diskPercent: 0, memPercent: 0 }), balance: JSON.stringify({ remainCny: null, source: 'none' }), channels: JSON.stringify({ homeserver: '', matrix: false, matrixConfig: {}, wecom: false }), extra: JSON.stringify({ agentId }), ACL: { [userId]: { read: true, write: true }, 'role:FMODE_AGENT_SUPERADMIN': { read: true, write: true }, [`role:${companyId}_Admin`]: { read: true, write: true }, [`role:${companyId}_Public`]: { read: true }, }, }; const created = await parseReq('POST', '/classes/FmodeAgent', body); if (created.error || !created.objectId) fail(`补录失败:${JSON.stringify(created).slice(0, 300)}`); console.log(ok(`补录成功 objectId=${created.objectId}`)); const re = await callFn(FN_STATUS, { op: 'query', scope: 'mine', agentId }); agent = re.code === 200 ? re.agent : null; if (!agent) fail('补录后仍查不到记录 —— 可能是 scope 可见性问题,请检查 ACL'); } const objectId = agent.objectId; console.log(`\n预期 Matrix 身份:@${objectId.toLowerCase()}:fmode.cn`); // ② provision // // 🔴 先只读查绑定表:已有 active 绑定的容器**不再调 provision**。 // provision 对已有绑定的处理不可预期(历史实测会出现第二条绑定), // 而「一容器一绑定」是硬约束。需要重绑请显式加 --rebind。 const existing = await readBinding(agentId); if (existing && !opts.rebind) { const mine = existing.list.filter( (x) => (x.userPointer?.objectId ?? x.userPointer) === userId, ); if (!mine.length) { fail( `该容器已有绑定,但归属不是当前账号(${existing.list.length} 条 active)。\n` + ' 拒绝重复绑定。确认后可用 --rebind 强制,或先清理旧绑定。', ); } console.log( ok(`已有本账号绑定(bindingId=${mine.map((x) => x.objectId).join(', ')}),跳过 provision`), ); if (existing.list.length > 1) { const others = existing.list.filter((x) => !mine.includes(x)); console.log( warn( `该容器共 ${existing.list.length} 条 active 绑定,其中 ${others.length} 条属于其他账号 ` + `(${others.map((x) => x.userPointer?.objectId ?? '?').join(', ')})—— 存在「房间割裂」隐患`, ), ); } return finishBind(agentId); } console.log('\n调用 provision(幂等,已有绑定会秒回)……'); const prov = await callFn(FN_PROVISION, { agentId }); if (prov.code !== 200 || !prov.accessToken || !prov.userMxId) { fail(`provision 失败:${prov.code} ${prov.mess ?? prov.message ?? ''}`); } console.log(ok(`provision 成功${prov.cached ? '(绑定表命中)' : '(新建)'}`)); console.log(` agentMxId = ${prov.agentMxId}`); console.log(` userMxId = ${prov.userMxId}`); console.log(` roomId = ${prov.roomId}`); console.log(` bindingId = ${prov.bindingId}`); // ③ 校验 Agent 真的在房间里(最常见的坑) console.log('\n校验 Agent 是否在房间……'); const joined = await fetch( `${MATRIX_HS}/_matrix/client/v3/rooms/${encodeURIComponent(prov.roomId)}/joined_members`, { headers: { Authorization: `Bearer ${prov.accessToken}` } }, ) .then((r) => r.json()) .catch(() => null); if (!joined?.joined) { console.log(warn('无法读取房间成员(token 或房间可能未就绪)')); console.log(' → 请在 App 里打开该容器会话,或手动核验 joined_members'); } else { const members = Object.keys(joined.joined); const inRoom = members.includes(prov.agentMxId); if (inRoom) { console.log(ok(`Agent 已在房间(共 ${members.length} 名成员)`)); } else { console.log(bad(`Agent 不在房间!当前成员:${members.join(', ')}`)); console.log(' → 这是「房间零消息、发不出去话」的头号原因。'); console.log(' → 用 prompt 子命令让容器自己 join(它会用派生密码登录自己)。'); } } finishBind(agentId); } /** 绑定收尾:统一输出下一步 */ function finishBind(agentId) { console.log('\n--- 绑定完成 ---'); console.log('平台侧已就绪。还差最后一步:让容器知道自己的 Matrix 身份。\n'); console.log(` node scripts/bind-container.mjs prompt ${agentId}\n`); console.log('把输出的提示词发进容器(或让容器自己执行),之后在 App 里打开:'); console.log(` 桌面端 /desktop/chat/${agentId}`); console.log(` 移动端 /mobile/chat/${agentId}\n`); process.exit(0); } // ---------- prompt ---------- async function cmdPrompt(agentId) { if (!agentId) fail('用法:prompt ', 2); // 用 scope:'all' —— prompt 需要给别人的容器生成提示词(如代同事开通),'mine' 会误判为不存在 const q = await callFn(FN_STATUS, { op: 'query', scope: 'all', agentId }); const agent = q.code === 200 ? q.agent : null; if (!agent) { fail(`FmodeAgent 里查不到 ${agentId}。先跑:node scripts/bind-container.mjs bind ${agentId}`); } const objectId = agent.objectId; const lower = objectId.toLowerCase(); /* * 🔴 只读查绑定表,**不调 provision**:provision 会给无绑定的容器新建一条绑定。 */ const b = await readBinding(agentId); if (!b) { fail( `该容器还没有 Matrix 绑定。先跑:node scripts/bind-container.mjs bind ${agentId}\n` + ' (prompt 只负责生成提示词,不代你开通)', ); } const roomId = b.latest.roomId ?? ''; if (!roomId) fail('绑定记录里没有 roomId,请重新跑 bind'); const user = await me(); const masterMxid = `@${user.objectId.toLowerCase()}:fmode.cn`; const roomEncoded = encodeURIComponent(roomId); const name = agent.name && agent.name !== agentId ? agent.name : agentId; /* * 🔴 派生密码有两套规则并存(2026-11-03 实测): * · provision 新建的账号 → sha256(prefix + **原始 objectId**)[:24] (agent-lj 验证通过) * · 手工注册的历史账号 → sha256(prefix + **小写 objectId**)[:24] (lj-test 验证通过) * 旧文档只写了「小写」这一条,对 provision 新建的账号是**错的**,必须两种都试。 * 这里在脚本里直接试一次,把结果告诉用户,避免容器里反复失败。 */ const mxidUser = lower; const cands = [ { label: '原始 objectId', pw: await derive(`fmode-agent-mx:${objectId}`) }, { label: '小写 objectId', pw: await derive(`fmode-agent-mx:${lower}`) }, ]; let good = null; for (const c of cands) { if (await canLogin(mxidUser, c.pw)) { good = c; break; } } const ruleLine = good ? ` ✅ 实测可用的派生规则:${good.label} → sha256("fmode-agent-mx:" + ${good.label === '原始 objectId' ? objectId : lower})[:24]` : ` ⚠️ 两种派生规则都登录失败 —— 该账号可能用了自定义密码(手工注册)。\n 请在容器里用它**现有的** Matrix token,跳过第 2 步直接做第 3、4 步。`; console.log(`\n===== 发进容器 ${agentId} 的提示词(直接复制)=====\n`); console.log(`${ruleLine} 【任务】接入 FMODE Matrix,并自检回报 == 你的身份 == agentId = ${agentId} objectId = ${objectId} 你的 mxId = @${lower}:fmode.cn 主人 mxId = ${masterMxid} 房间 roomId = ${roomId} homeserver = ${MATRIX_HS} 显示名 = ${name} 【第 1 步:先自检,不要改任何东西】 打印 hostname。 打印 /opt/data/.env 里所有 MATRIX_* 键的**当前生效值**(只报键名和「是否为空」,不要打印 token 全文)。 ⚠️ 不要用 /proc//environ 查 —— .env 由 Python 层 load_hermes_dotenv() 加载,那里永远为空(假阴性)。 【第 2 步:登录你自己(把下面 PWD_MX 换成实测可用的那条规则算出的值)】 # 规则:sha256("fmode-agent-mx:" + <${good?.label ?? '原始 objectId 或 小写 objectId,二选一'}>) 的前 24 位 PWD_MX=<用上面的规则算出的 24 位十六进制> MY_TOKEN=$(curl -s -X POST ${MATRIX_HS}/_matrix/client/v3/login \\ -H 'Content-Type: application/json' \\ -d "{\\"type\\":\\"m.login.password\\",\\"identifier\\":{\\"type\\":\\"m.id.user\\",\\"user\\":\\"${mxidUser}\\"},\\"password\\":\\"$PWD_MX\\"}" \\ | sed -n 's/.*"access_token":"\\([^"]*\\)".*/\\1/p') [ -n "$MY_TOKEN" ] || { echo "MX-FAIL 登录失败"; exit 1; } curl -s -H "Authorization: Bearer $MY_TOKEN" ${MATRIX_HS}/_matrix/client/v3/account/whoami # 必须输出 @${lower}:fmode.cn 才算登对 【第 3 步:确保你在房间里(最关键,漏了就是「房间零消息」)】 curl -s -H "Authorization: Bearer $MY_TOKEN" ${MATRIX_HS}/_matrix/client/v3/joined_rooms 若 ${roomId} 不在返回里: curl -s -X POST -H "Authorization: Bearer $MY_TOKEN" ${MATRIX_HS}/_matrix/client/v3/join/${roomEncoded} 返回 403 = 没有邀请 → 停下来报告「需 admin 先 invite」,不要反复重试。 【第 4 步:改 /opt/data/.env(先备份为 .env.bak-mxenv-<时间戳>,保留其它键)】 MATRIX_HOMESERVER=${MATRIX_HS} MATRIX_USER_ID=@${lower}:fmode.cn MATRIX_ACCESS_TOKEN=$MY_TOKEN MATRIX_HOME_ROOM=${roomId} MATRIX_ALLOWED_USERS=${masterMxid} 保留现有的 MATRIX_REQUIRE_MENTION / MATRIX_IGNORE_USER_PATTERNS 原值,不要改。 chmod 600 /opt/data/.env 【第 5 步:重启 gateway】 s6 服务 gateway-default 会自动拉起,确认新进程起来了。 【第 6 步:自检回报(只报事实)】 - whoami 的 user_id 是否 == @${lower}:fmode.cn - joined_rooms 里是否含 ${roomId} - 在房间发一条自检消息,回读 /messages 确认落库 最后输出一行:MX-DONE mxid=<...> room=<...> gateway= 【铁律】 - 不打印 token 全文,不写进任何仓库、日志或聊天。 - 任一步失败如实报「卡在哪 + 需要谁做什么」,不伪造成功。 `); console.log(`===== 结束 =====\n`); console.log('发完后在 App 里打开会话,发一句消息,看是否回复。'); } // ---------- main ---------- const [cmd, arg, ...rest] = process.argv.slice(2); function opt(name) { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : undefined; } switch (cmd) { case 'list': await cmdList(); break; case 'status': await cmdStatus(arg); break; case 'bind': await cmdBind(arg, { name: opt("name"), rebind: rest.includes("--rebind") }); break; case 'prompt': await cmdPrompt(arg); break; case 'help': case undefined: console.log( [ '容器 → Fmode App 绑定工具', '', ' list 列出全部容器及 App 可聊天状态', ' status 单容器体检(只读)', ' bind 补录记录 + provision + 校验入房(有写副作用)', ' prompt 打印发给容器的提示词', '', '详见 docs/03-interface/新容器接入Matrix-端到端流程-20261103.md', ].join('\n'), ); break; default: fail(`未知子命令:${cmd}(用 help 查看用法)`, 2); }