| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590 |
- #!/usr/bin/env node
- /**
- * 容器 → Fmode App 绑定工具
- *
- * 解决的问题:一个在企微里已经能聊天的容器(或刚建好的容器),如何在本 App 里也能收发消息。
- *
- * 子命令:
- * node scripts/bind-container.mjs list
- * 列出当前账号名下全部容器及其「App 可聊天」状态
- *
- * node scripts/bind-container.mjs status <agentId>
- * 单个容器体检:缺哪一步、下一步该做什么
- *
- * node scripts/bind-container.mjs bind <agentId> [--name 显示名]
- * 🔴 有写副作用:确保 FmodeAgent 记录存在 → provision 建 Matrix 身份/房间/绑定
- *
- * node scripts/bind-container.mjs prompt <agentId>
- * 打印第 4 步「发给容器」的提示词(填好真实值,可直接复制粘贴)
- *
- * node scripts/bind-container.mjs unbind --binding <bindingId>
- * 解除 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 <agentId>');
- }
- // ---------- 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 <agentId> [--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 <agentId>', 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/<pid>/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=<connected|failed>
- 【铁律】
- - 不打印 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 <agentId> 单容器体检(只读)',
- ' bind <agentId> 补录记录 + provision + 校验入房(有写副作用)',
- ' prompt <agentId> 打印发给容器的提示词',
- '',
- '详见 docs/03-interface/新容器接入Matrix-端到端流程-20261103.md',
- ].join('\n'),
- );
- break;
- default:
- fail(`未知子命令:${cmd}(用 help 查看用法)`, 2);
- }
|