|
|
@@ -0,0 +1,554 @@
|
|
|
+// fmodeagent-matrix-provision (TASK-matrix-v2 ①): 用户侧 Matrix 账号自动开通
|
|
|
+// 调用: POST https://server.fmode.cn/api/functions {token, id:<Function.objectId>, params:{agentId?}}
|
|
|
+//
|
|
|
+// 流程 (聊天页进入时调用, 取代"该数字生命的飞马(Matrix)身份正在开通或未接入"笼统态):
|
|
|
+// 1. 校验调用者是注册用户 (sessionToken → Parse _User);
|
|
|
+// 2. 为该用户确保 Matrix 账号存在: mxid=@<用户objectId小写>:fmode.cn,
|
|
|
+// 密码=sha256("fmode-mx:"+sessionToken) 前 24 位 (与 fmode-app matrix-client.ts 派生一致,
|
|
|
+// 使 App 端无需云函数也能确定性登录);
|
|
|
+// 不存在 → admin API 注册 (Continuwuity: admin 账号登录 → admin room "!admin users create");
|
|
|
+// 已存在但密码不符 → reset-password 对齐派生密码 (幂等开通);
|
|
|
+// 3. 确认 Agent 账号存在 (params.agentId → FmodeAgent 记录; mxid=@<agentId去agent-node-前缀>:fmode.cn,
|
|
|
+// 如 @xinting:fmode.cn——TASK-matrix-v2 §4 账号保留, 消息接发全交官方 Hermes channel;
|
|
|
+// Agent 密码不在此重置——官方 channel 正在使用该账号; 账号缺失报具体错误);
|
|
|
+// 4. 返回 {userMxId, agentMxId, homeserver, accessToken, deviceId, roomId?}
|
|
|
+// accessToken 用 "!admin users issue-token" 签发 (绑定 device, 长期有效),
|
|
|
+// 并 ensureRoom: 已有共同房间复用, 否则 createRoom(invite agent, is_direct)。
|
|
|
+//
|
|
|
+// 失败返回具体环节: {code:4xx/5xx, mess:"账号注册失败|房间创建失败|SDK登录失败|..."}——
|
|
|
+// App 端按 mess 显示, 不再笼统"开通中"。
|
|
|
+//
|
|
|
+// 凭据策略: admin 密码放本文件常量 (Function 表 code 仅 masterKey 可读, 与 config.nova.json 同级保护);
|
|
|
+// 用户密码为派生值不落库; issue-token 结果只在响应里出现一次。
|
|
|
+// 版本 v0.3.0 · 2026-09-09 · TASK-matrix-v42 P3 (Agent 账号改 objectId 唯一体系)
|
|
|
+// ===== v0.3.0 (TASK-matrix-v42 P3) 变更说明 =====
|
|
|
+// Agent Matrix 账号由语义化拼音名 (@xinting:fmode.cn) 改为 FMODE_AGENT.objectId:
|
|
|
+// @<objectId小写>:fmode.cn — objectId 全局唯一, 语义名不唯一且不利于批量接入。
|
|
|
+// 1. mxid 计算: agentLocal = agentRec.id 小写 (不再去 agent-node- 前缀)
|
|
|
+// 2. 账号缺失不再 502 拒绝 → _ensureAccount 自动注册 (密码 = sha256("fmode-agent-mx:"+objectId) 前 24,
|
|
|
+// 固定盐派生幂等可重置 — Agent 无 sessionToken, 不与用户派生式混用)
|
|
|
+// 3. DM 房间重建: 新 agentMxId → 绑定表 agentMxId/roomId 更新 (旧 roomId 不复用——旧账号房间);
|
|
|
+// 旧语义化账号归档不删 (Hermes channel 迁移完成前并存)
|
|
|
+// 4. 批量接入: scripts/provision-all-agents.sh 遍历 FMODE_AGENT 全表逐个 provision
|
|
|
+// 版本 v0.2.1 · 2026-09-08 · TASK-matrix-v41 (修复 _ensureAccount 形参 mxAdmin→mx ReferenceError)
|
|
|
+//
|
|
|
+// ===== v0.2.1 (TASK-matrix-v41) 变更说明 =====
|
|
|
+// _ensureAccount 形参名为 mxAdmin 而函数体引用 `mx` (第 224/228 行 _adminCmd(mx, …)) —
|
|
|
+// 新开绑定 (无 FmodeMatrixBinding 记录路径, agent-node-tuye/agent-node 首开即命中) 必然
|
|
|
+// ReferenceError "mx is not defined" → HTTP 500 "SDK登录失败: mx is not defined"。
|
|
|
+// 修复: 形参更名 mxAdmin→mx (与函数体一致); 线上 Function 表同改 (2026-09-08 实测三 agent 全通)。
|
|
|
+//
|
|
|
+// ===== v0.2.0 (TASK-matrix-v3 P1) 变更说明 (v0.1.5 流程保留为建绑定路径) =====
|
|
|
+// 根因: 每次进入聊天页都重新 provision (issue-token 新 device/新 token), 第二次进入
|
|
|
+// 慢 + 历史/通话随新 device/新房间丢失。修复: 绑定表 FmodeMatrixBinding 为唯一真相:
|
|
|
+// 1. 查表: {userPointer, agentId} 唯一, status=active → 直接返回表中凭据 (不注册/不签发/不建房),
|
|
|
+// 幂等 (连开两次聊天页第二次秒进); roomId 固定 → SDK 进入同房间自然拉到全部时间线 (P3)。
|
|
|
+// 2. 建绑定: 无记录才走 v0.1.5 注册/签发/ensureRoom 流程并写表 (bindingId=表 objectId)。
|
|
|
+// 3. 过期自愈: App 端 whoami 报 M_UNKNOWN_TOKEN / 本函数按 ?validate=1 探活失败 →
|
|
|
+// 走 admin reset-password+issue-token 刷新凭据并更新表 (status 保持 active)。
|
|
|
+// 4. 响应新增 {bindingId, cached:true|false, status:'active'}; 语义: cached=true 即绑定表命中。
|
|
|
+var CLASS_NAME = 'FmodeAgent';
|
|
|
+var BINDING_CLASS = 'FmodeMatrixBinding'; // TASK-matrix-v3 P1: Channel 绑定表 (user×agent 唯一)
|
|
|
+var MATRIX_HS = 'https://matrix.fmode.cn';
|
|
|
+var MATRIX_DOMAIN = 'fmode.cn';
|
|
|
+var ADMIN_USER = 'admin';
|
|
|
+var ADMIN_PASS = 'Fmode@2026';
|
|
|
+var SERVICE_BOT = '@conduit:fmode.cn';
|
|
|
+var ADMIN_ROOM = '!UcLbdbuAWs9FZkrFIjPWBAe4jfNM2r_sj0-A0vJQgOQ'; // admin+conduit 专用, 严禁加人
|
|
|
+
|
|
|
+function _fail(code, mess) { response.status(code).json({ code: code, mess: mess }); }
|
|
|
+function _ok(data) { response.status(200).json(Object.assign({ code: 200 }, data)); }
|
|
|
+
|
|
|
+/**
|
|
|
+ * 容器归属人与调用者是否属于**同一家公司**(2026-11-12 新增)。
|
|
|
+ *
|
|
|
+ * 用户裁决:「同公司的人,所有容器大家都可以一起使用」。
|
|
|
+ *
|
|
|
+ * ## 判定链路
|
|
|
+ *
|
|
|
+ * ```
|
|
|
+ * FmodeAgent.user → _User.company → Company.objectId
|
|
|
+ * 调用者 _User.company → Company.objectId
|
|
|
+ * 两者相等 = 同公司
|
|
|
+ * ```
|
|
|
+ *
|
|
|
+ * ## 安全边界(必须保留)
|
|
|
+ *
|
|
|
+ * 跨公司一律返回 false → 上层走 403。这是多租户隔离的底线。
|
|
|
+ *
|
|
|
+ * ## 保守策略
|
|
|
+ *
|
|
|
+ * 任何一环取不到 company(字段缺失 / 指针失效 / 查询异常)都返回 **false**。
|
|
|
+ * 宁可拒绝,也不因为数据不全而误放行 —— 越权开通比拒绝开通严重得多。
|
|
|
+ *
|
|
|
+ * @param {Parse.Object|null} owner FmodeAgent.user 的 Pointer(可能为空)
|
|
|
+ * @param {Parse.Object} user 当前调用者
|
|
|
+ * @returns {Promise<boolean>}
|
|
|
+ */
|
|
|
+async function _isSameCompany(owner, user) {
|
|
|
+ try {
|
|
|
+ if (!owner || !owner.id || !user || !user.id) return false;
|
|
|
+ var userCompany = user.get ? user.get('company') : null;
|
|
|
+ var userCompanyId = userCompany && (userCompany.id || userCompany.objectId);
|
|
|
+ if (!userCompanyId) return false;
|
|
|
+ var ownerCompany = owner.get ? owner.get('company') : null;
|
|
|
+ if (!ownerCompany) {
|
|
|
+ /* Pointer 上没带 company → 按 id 再查一次(带 masterKey) */
|
|
|
+ var ownerFull = await new Parse.Query(Parse.User).get(owner.id, { useMasterKey: true });
|
|
|
+ ownerCompany = ownerFull ? ownerFull.get('company') : null;
|
|
|
+ }
|
|
|
+ var ownerCompanyId = ownerCompany && (ownerCompany.id || ownerCompany.objectId);
|
|
|
+ if (!ownerCompanyId) return false;
|
|
|
+ return String(ownerCompanyId) === String(userCompanyId);
|
|
|
+ } catch (e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+function _sessionToken() {
|
|
|
+ /*
|
|
|
+ * 🔴 2026-10-31 修复:平台不再把 token 塞进 request.body,只保留在
|
|
|
+ * X-Parse-Session-Token 请求头里。原实现读 params.sessionToken 与
|
|
|
+ * request.body.token 都拿不到 → 恒返回 null → 所有请求 401
|
|
|
+ * (实测:同一 token 在 /parse/users/me 返回 200,在本函数返回 401)。
|
|
|
+ * 这里加上请求头来源(只增不改,旧调用方式保持有效)。
|
|
|
+ */
|
|
|
+ var h = request && request.headers ? request.headers : {};
|
|
|
+ if (h['x-parse-session-token']) return h['x-parse-session-token'];
|
|
|
+ if (params && params.sessionToken) return params.sessionToken;
|
|
|
+ if (request && request.body && request.body.token) return request.body.token;
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+async function _getUser(t) {
|
|
|
+ if (!t) return null;
|
|
|
+ if (request.user && request.user.id && !(params && params.sessionToken)) return request.user;
|
|
|
+ try {
|
|
|
+ var q = new Parse.Query(Parse.Session);
|
|
|
+ q.equalTo('sessionToken', t);
|
|
|
+ var s = await q.first({ useMasterKey: true });
|
|
|
+ return s ? s.get('user') : null;
|
|
|
+ } catch (e) { return null; }
|
|
|
+}
|
|
|
+
|
|
|
+function _sha256Hex(s) {
|
|
|
+ // 纯 JS SHA-256 (executor 作用域无 crypto/node modules; 输入为 ASCII sessionToken)
|
|
|
+ function rr(x, n) { return (x >>> n) | (x << (32 - n)); }
|
|
|
+ var K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
|
|
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
|
|
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
|
|
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
|
|
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
|
|
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
|
|
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
|
|
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
|
|
+ var H = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
|
|
|
+ var bytes = [];
|
|
|
+ for (var i = 0; i < s.length; i++) {
|
|
|
+ var c = s.charCodeAt(i);
|
|
|
+ if (c < 128) bytes.push(c);
|
|
|
+ else if (c < 2048) { bytes.push(0xc0 | (c >> 6), 0x80 | (c & 63)); }
|
|
|
+ else { bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); }
|
|
|
+ }
|
|
|
+ var bitLen = bytes.length * 8;
|
|
|
+ bytes.push(0x80);
|
|
|
+ while (bytes.length % 64 !== 56) bytes.push(0);
|
|
|
+ for (var j = 7; j >= 0; j--) bytes.push(Math.floor(bitLen / Math.pow(2, j * 8)) & 0xff);
|
|
|
+ for (var b = 0; b < bytes.length; b += 64) {
|
|
|
+ var w = new Array(64);
|
|
|
+ for (var t = 0; t < 16; t++) {
|
|
|
+ w[t] = (bytes[b + t*4] << 24) | (bytes[b + t*4 + 1] << 16) | (bytes[b + t*4 + 2] << 8) | bytes[b + t*4 + 3];
|
|
|
+ }
|
|
|
+ for (var t2 = 16; t2 < 64; t2++) {
|
|
|
+ var s0 = rr(w[t2-15], 7) ^ rr(w[t2-15], 18) ^ (w[t2-15] >>> 3);
|
|
|
+ var s1 = rr(w[t2-2], 17) ^ rr(w[t2-2], 19) ^ (w[t2-2] >>> 10);
|
|
|
+ w[t2] = (w[t2-16] + s0 + w[t2-7] + s1) | 0;
|
|
|
+ }
|
|
|
+ var a = H[0], bb = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
|
|
|
+ for (var t3 = 0; t3 < 64; t3++) {
|
|
|
+ var S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25);
|
|
|
+ var ch = (e & f) ^ (~e & g);
|
|
|
+ var temp1 = (h + S1 + ch + K[t3] + w[t3]) | 0;
|
|
|
+ var S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22);
|
|
|
+ var maj = (a & bb) ^ (a & c) ^ (bb & c);
|
|
|
+ var temp2 = (S0 + maj) | 0;
|
|
|
+ h = g; g = f; f = e; e = (d + temp1) | 0;
|
|
|
+ d = c; c = bb; bb = a; a = (temp1 + temp2) | 0;
|
|
|
+ }
|
|
|
+ H[0] = (H[0] + a) | 0; H[1] = (H[1] + bb) | 0; H[2] = (H[2] + c) | 0; H[3] = (H[3] + d) | 0;
|
|
|
+ H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
|
|
|
+ }
|
|
|
+ var out = '';
|
|
|
+ for (var k = 0; k < 8; k++) out += ('00000000' + (H[k] >>> 0).toString(16)).slice(-8);
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+function _mxPassword(sessionToken) {
|
|
|
+ return _sha256Hex('fmode-mx:' + sessionToken).slice(0, 24);
|
|
|
+}
|
|
|
+
|
|
|
+function _httpJson(url, opts) {
|
|
|
+ // Deno/Node 双兼容 fetch; 10s 超时
|
|
|
+ return new Promise(function (resolve) {
|
|
|
+ try {
|
|
|
+ if (typeof fetch !== 'function') { resolve({ ok: false, err: 'no fetch' }); return; }
|
|
|
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
|
+ var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 10000) : null;
|
|
|
+ var init = {
|
|
|
+ method: (opts && opts.method) || 'GET',
|
|
|
+ headers: (opts && opts.headers) || {},
|
|
|
+ signal: ctrl ? ctrl.signal : undefined,
|
|
|
+ };
|
|
|
+ if (opts && opts.body !== undefined) init.body = JSON.stringify(opts.body);
|
|
|
+ fetch(url, init)
|
|
|
+ .then(function (res) {
|
|
|
+ return res.text().then(function (txt) {
|
|
|
+ if (t) clearTimeout(t);
|
|
|
+ var data = null;
|
|
|
+ try { data = JSON.parse(txt); } catch (e) { data = { raw: txt }; }
|
|
|
+ resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, data: data });
|
|
|
+ });
|
|
|
+ })
|
|
|
+ .catch(function (e) { if (t) clearTimeout(t); resolve({ ok: false, err: String(e) }); });
|
|
|
+ } catch (e) { resolve({ ok: false, err: String(e) }); }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ---- Matrix 客户端 (access_token 方式,Continuwuity v1.18) ----
|
|
|
+function _mx(token) {
|
|
|
+ function call(path, method, body) {
|
|
|
+ var url = MATRIX_HS + path;
|
|
|
+ if (token) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(token);
|
|
|
+ return _httpJson(url, { method: method || 'GET', body: body,
|
|
|
+ headers: body !== undefined ? { 'Content-Type': 'application/json' } : {} });
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ login: function (user, password) {
|
|
|
+ return call('/_matrix/client/v3/login', 'POST', {
|
|
|
+ type: 'm.login.password', identifier: { type: 'm.id.user', user: user }, password: password });
|
|
|
+ },
|
|
|
+ whoami: function () { return call('/_matrix/client/v3/account/whoami'); },
|
|
|
+ joinedRooms: function () { return call('/_matrix/client/v3/joined_rooms'); },
|
|
|
+ joinedMembers: function (roomId) {
|
|
|
+ return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/joined_members');
|
|
|
+ },
|
|
|
+ sendAdminCmd: function (roomId, cmd) {
|
|
|
+ return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) +
|
|
|
+ '/send/m.room.message/prov' + Date.now() + Math.floor(Math.random() * 900 + 100), 'PUT',
|
|
|
+ { msgtype: 'm.text', body: cmd });
|
|
|
+ },
|
|
|
+ lastBotMsg: function (roomId) {
|
|
|
+ return call('/_matrix/client/v3/rooms/' + encodeURIComponent(roomId) + '/messages?dir=b&limit=3');
|
|
|
+ },
|
|
|
+ createRoom: function (inviteMxid, name) {
|
|
|
+ return call('/_matrix/client/v3/createRoom', 'POST', {
|
|
|
+ invite: [inviteMxid], is_direct: true, preset: 'trusted_private_chat', name: name || undefined });
|
|
|
+ },
|
|
|
+ joinRoom: function (roomId) {
|
|
|
+ return call('/_matrix/client/v3/join/' + encodeURIComponent(roomId), 'POST', {});
|
|
|
+ },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function _sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
|
|
+
|
|
|
+/** admin room 命令: 发送后轮询最新 bot 回复 (机器人回复可能滞后一条) */
|
|
|
+async function _adminCmd(mx, token, cmd) {
|
|
|
+ var before = null;
|
|
|
+ var d0 = await mx.lastBotMsg(ADMIN_ROOM);
|
|
|
+ if (d0.ok && d0.data && d0.data.chunk) {
|
|
|
+ for (var i = 0; i < d0.data.chunk.length; i++) {
|
|
|
+ var ev = d0.data.chunk[i];
|
|
|
+ if (ev.sender === SERVICE_BOT && ev.type === 'm.room.message') { before = ev.content.body; break; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ var sent = await mx.sendAdminCmd(ADMIN_ROOM, cmd);
|
|
|
+ if (!sent.ok) return { ok: false, err: 'admin cmd 发送失败: ' + JSON.stringify(sent).slice(0, 120) };
|
|
|
+ var last = before;
|
|
|
+ for (var a = 0; a < 5; a++) {
|
|
|
+ await _sleep(1500);
|
|
|
+ var d = await mx.lastBotMsg(ADMIN_ROOM);
|
|
|
+ if (d.ok && d.data && d.data.chunk) {
|
|
|
+ for (var j = 0; j < d.data.chunk.length; j++) {
|
|
|
+ var ev2 = d.data.chunk[j];
|
|
|
+ if (ev2.sender === SERVICE_BOT && ev2.type === 'm.room.message') {
|
|
|
+ if (ev2.content.body !== before) last = ev2.content.body;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (last && last !== before && (last.indexOf('```') >= 0 || last.indexOf('|') >= 0)) break;
|
|
|
+ }
|
|
|
+ return { ok: true, reply: last || '' };
|
|
|
+}
|
|
|
+
|
|
|
+/** 确保本地 Matrix 账号存在并返回 access_token。幂等: 已存在则重置为派生密码再签 token。
|
|
|
+ * v4.1 修复: 参数名 mxAdmin→mx — 原函数体引用 `mx` 而 `_ensureAccount(mxAdmin, …)` 形参叫
|
|
|
+ * mxAdmin → 新开绑定 (无缓存路径) 必然 ReferenceError "mx is not defined" (HTTP 500 SDK登录失败)。 */
|
|
|
+async function _ensureAccount(mx, adminTok, local, password) {
|
|
|
+ var mx0 = _mx(''); // login 端点不能带他人 access_token, 探测/登录一律用无凭据客户端
|
|
|
+ var mxid = '@' + local + ':' + MATRIX_DOMAIN;
|
|
|
+ // 先探测: 用派生密码登录 (账号已存在且密码一致 → 直接成功)
|
|
|
+ var lg = await mx0.login(local, password);
|
|
|
+ if (lg.ok && lg.data && lg.data.access_token) {
|
|
|
+ return { mxid: mxid, accessToken: lg.data.access_token, deviceId: lg.data.device_id || '', created: false };
|
|
|
+ }
|
|
|
+ // 不存在/密码不符 → admin room create (已存在报错则 reset-password 对齐派生密码)
|
|
|
+ var cr = await _adminCmd(mx, adminTok, '!admin users create ' + local + ' ' + password);
|
|
|
+ var reply = cr.reply || '';
|
|
|
+ if (reply.indexOf('Created') < 0) {
|
|
|
+ // 已存在/不可用/任意报错 → reset-password 对齐派生密码后重试登录
|
|
|
+ var rs = await _adminCmd(mx, adminTok, '!admin users reset-password ' + local + ' ' + password);
|
|
|
+ reply = rs.reply || reply;
|
|
|
+ }
|
|
|
+ var lg2 = await mx0.login(local, password);
|
|
|
+ if (!lg2.ok || !lg2.data || !lg2.data.access_token) {
|
|
|
+ return null; // 账号注册失败
|
|
|
+ }
|
|
|
+ return { mxid: mxid, accessToken: lg2.data.access_token, deviceId: lg2.data.device_id || '', created: true };
|
|
|
+}
|
|
|
+
|
|
|
+// ---- TASK-matrix-v3 P1: FmodeMatrixBinding 绑定表 (user×agent 唯一, roomId 固定) ----
|
|
|
+
|
|
|
+/** 查绑定: {userPointer, agentId 唯一} → 记录或 null (agentId=null 时按 userPointer+agentMxId 空) */
|
|
|
+async function _findBinding(userPointer, agentIdStr) {
|
|
|
+ try {
|
|
|
+ var q = new Parse.Query(BINDING_CLASS);
|
|
|
+ q.equalTo('userPointer', userPointer);
|
|
|
+ q.equalTo('agentId', agentIdStr || '');
|
|
|
+ q.descending('updatedAt');
|
|
|
+ var rec = await q.first({ useMasterKey: true });
|
|
|
+ if (!rec) return null;
|
|
|
+ // 历史遗留多条 (v0.1.x 每次新建/极端并发): 只认最新一条, 其余标 revoked
|
|
|
+ var others = await q.find({ useMasterKey: true });
|
|
|
+ for (var i = 0; i < others.length; i++) {
|
|
|
+ if (others[i].id !== rec.id && others[i].get('status') !== 'revoked') {
|
|
|
+ others[i].set('status', 'revoked');
|
|
|
+ try { await others[i].save(null, { useMasterKey: true }); } catch (e) { /* 幂等尽力 */ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return rec;
|
|
|
+ } catch (e) { return null; } // 表不存在/查询失败 → 按无绑定走新建
|
|
|
+}
|
|
|
+
|
|
|
+/** 绑定记录 → 响应凭据 */
|
|
|
+function _bindingPayload(rec) {
|
|
|
+ return {
|
|
|
+ userMxId: rec.get('userMxId'),
|
|
|
+ agentMxId: rec.get('agentMxId') || null,
|
|
|
+ homeserver: rec.get('homeserver') || MATRIX_HS,
|
|
|
+ accessToken: rec.get('accessToken'),
|
|
|
+ deviceId: rec.get('deviceId') || '',
|
|
|
+ roomId: rec.get('roomId') || null,
|
|
|
+ bindingId: rec.id,
|
|
|
+ status: rec.get('status') || 'active',
|
|
|
+ updatedAt: rec.get('updatedAt'),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/** 读/建 FmodeMatrixBinding 表 (schema 缺失时无法显式建表, Parse 无 schema 约束时首条 save 即建) */
|
|
|
+async function _upsertBinding(user, agentIdStr, cred) {
|
|
|
+ var B = Parse.Object.extend(BINDING_CLASS);
|
|
|
+ var rec = await _findBinding(user, agentIdStr);
|
|
|
+ if (!rec) rec = new B();
|
|
|
+ rec.set('userPointer', user);
|
|
|
+ rec.set('agentId', agentIdStr || '');
|
|
|
+ rec.set('userMxId', cred.userMxId);
|
|
|
+ if (cred.agentMxId) rec.set('agentMxId', cred.agentMxId);
|
|
|
+ rec.set('homeserver', cred.homeserver || MATRIX_HS);
|
|
|
+ if (cred.accessToken) rec.set('accessToken', cred.accessToken);
|
|
|
+ if (cred.deviceId) rec.set('deviceId', cred.deviceId);
|
|
|
+ if (cred.roomId) rec.set('roomId', cred.roomId);
|
|
|
+ rec.set('status', cred.status || 'active');
|
|
|
+ rec.set('updatedAt', new Date());
|
|
|
+ await rec.save(null, { useMasterKey: true });
|
|
|
+ return rec;
|
|
|
+}
|
|
|
+
|
|
|
+async function handler(request, response) {
|
|
|
+ try {
|
|
|
+ var user = await _getUser(_sessionToken());
|
|
|
+ if (!user) return _fail(401, '账号注册失败: sessionToken 无效或已过期, 请重新登录 App');
|
|
|
+ var sessionToken = _sessionToken();
|
|
|
+
|
|
|
+ // ---- Agent 定位 (agentId 可传 objectId 或 agentId; 缺省只开通用户身份) ----
|
|
|
+ var agentRec = null;
|
|
|
+ var agentIdParam = params && params.agentId;
|
|
|
+ var agentIdStr = '';
|
|
|
+ if (agentIdParam) {
|
|
|
+ var rec = await (function () {
|
|
|
+ var q1 = new Parse.Query(CLASS_NAME); q1.equalTo('agentId', agentIdParam);
|
|
|
+ return q1.first({ useMasterKey: true }).then(function (r1) {
|
|
|
+ return r1 || new Parse.Query(CLASS_NAME).get(agentIdParam, { useMasterKey: true });
|
|
|
+ });
|
|
|
+ })();
|
|
|
+ if (!rec) return _fail(404, '房间创建失败: Agent 不存在(' + agentIdParam + ')');
|
|
|
+ // ACL 校验: 记录归属人或超管才可开通 (与 fmodeagent-status 同规则, 防越权开通)
|
|
|
+ var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
|
|
|
+ var isSuper = false;
|
|
|
+ try {
|
|
|
+ var rq = new Parse.Query(Parse.Role); rq.equalTo('name', SUPER_ROLE);
|
|
|
+ var role = await rq.first({ useMasterKey: true });
|
|
|
+ if (role) {
|
|
|
+ var us = await role.getUsers().query().find({ useMasterKey: true });
|
|
|
+ for (var ui = 0; ui < us.length; ui++) { if (us[ui].id === user.id) { isSuper = true; break; } }
|
|
|
+ }
|
|
|
+ } catch (e) { /* 角色查询失败按非超管处理 */ }
|
|
|
+ /*
|
|
|
+ * ACL 校验(2026-11-12 变更:同公司成员可共用)。
|
|
|
+ *
|
|
|
+ * ## 变更背景
|
|
|
+ *
|
|
|
+ * 用户裁决:「只要是从公司的人,所有的容器大家是可以一起使用的,
|
|
|
+ * 没有说容器归属个人这种东西。」
|
|
|
+ *
|
|
|
+ * 原规则只允许「记录归属人」或「超管」开通 —— 同公司同事点开容器直接 403,
|
|
|
+ * 而 `xlosi3m46q` 的 `scope:'company'` 又能查到它(看得见、进不去)。
|
|
|
+ *
|
|
|
+ * ## 新规则
|
|
|
+ *
|
|
|
+ * | 调用者 | 结果 |
|
|
|
+ * |---|---|
|
|
|
+ * | 超管 | ✅ 允许 |
|
|
|
+ * | 记录归属人 | ✅ 允许 |
|
|
|
+ * | **同 company 的其他成员** | ✅ **允许(本次放开)** |
|
|
|
+ * | 其他 company 的人 | ❌ 403(多租户隔离,必须保留) |
|
|
|
+ *
|
|
|
+ * 🔴 跨公司隔离是安全底线:容器归属公司取自 `agent.user.company`,
|
|
|
+ * 与调用者自己的 `user.company` 比对,不一致一律拒绝。
|
|
|
+ */
|
|
|
+ var owner = rec.get('user');
|
|
|
+ var isOwner = !!owner && owner.id === user.id;
|
|
|
+ var sameCompany = isOwner ? true : await _isSameCompany(owner, user);
|
|
|
+ if (!isSuper && !isOwner && !sameCompany) {
|
|
|
+ return _fail(403, '房间创建失败: 该 Agent 不属于你所在的公司, 无权开通会话');
|
|
|
+ }
|
|
|
+ agentRec = rec;
|
|
|
+ agentIdStr = String(rec.get('agentId') || rec.id);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- P1a 幂等: 查绑定表 → active 且 (validate 时探活通过) → 直接返回, 不重新生成 ----
|
|
|
+ var validate = !!(params && params.validate); // App 缓存校验/过期自愈探测时传 validate:1
|
|
|
+ var binding = await _findBinding(user, agentIdStr);
|
|
|
+ // v4.2 P3: 绑定里的 agentMxId 若还是旧语义化账号 (≠ @<objectId小写>) → 不命中缓存,
|
|
|
+ // 走下方重建流程 (新 objectId 账号 + 新 DM 房间 + 绑定表更新)
|
|
|
+ var bindingMxStale = false;
|
|
|
+ if (binding && agentRec) {
|
|
|
+ var expectedAgentMx = '@' + String(agentRec.id).toLowerCase() + ':' + MATRIX_DOMAIN;
|
|
|
+ bindingMxStale = binding.get('agentMxId') !== expectedAgentMx;
|
|
|
+ }
|
|
|
+ if (binding && !bindingMxStale && binding.get('status') === 'active' && binding.get('accessToken')) {
|
|
|
+ var bp = _bindingPayload(binding);
|
|
|
+ if (!validate) {
|
|
|
+ // 非校验请求: 直接命中绑定表返回 (秒进, roomId 固定 → 历史完整)
|
|
|
+ return _ok(Object.assign({ cached: true }, bp));
|
|
|
+ }
|
|
|
+ // validate: 用绑定 token 探活 whoami → 200 即凭据仍有效 (幂等返回); 失效走下方刷新
|
|
|
+ var mxProbe = _mx(bp.accessToken);
|
|
|
+ var who = await mxProbe.whoami();
|
|
|
+ if (who.ok && who.data && who.data.user_id === bp.userMxId) {
|
|
|
+ return _ok(Object.assign({ cached: true }, bp));
|
|
|
+ }
|
|
|
+ // M_UNKNOWN_TOKEN/任何失效 → 刷新凭据并更新表 (不换 device/roomId: admin 对齐派生密码后重签)
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- Matrix: admin 登录 → 确保用户/Agent 账号 ----
|
|
|
+ var mx0 = _mx('');
|
|
|
+ var adm = await mx0.login(ADMIN_USER, ADMIN_PASS);
|
|
|
+ if (!adm.ok || !adm.data || !adm.data.access_token) {
|
|
|
+ return _fail(502, '账号注册失败: Matrix homeserver 管理登录失败(' +
|
|
|
+ ((adm.data && (adm.data.errcode || adm.data.error)) || adm.err || '未知') + ')');
|
|
|
+ }
|
|
|
+ var adminTok = adm.data.access_token;
|
|
|
+ var mx = _mx(adminTok);
|
|
|
+
|
|
|
+ var password = _mxPassword(sessionToken);
|
|
|
+ var userLocal = String(user.id).toLowerCase();
|
|
|
+ var ua = await _ensureAccount(mx, adminTok, userLocal, password);
|
|
|
+ if (!ua) return _fail(502, '账号注册失败: 用户 Matrix 账号(' + userLocal + ')创建/登录失败');
|
|
|
+
|
|
|
+ var agentMxId = null;
|
|
|
+ if (agentRec) {
|
|
|
+ // v4.2 P3 (TASK-matrix-v42 2026-09-09): Agent Matrix 账号一律用 FMODE_AGENT.objectId
|
|
|
+ // —— @<objectId小写>:fmode.cn (objectId 全局唯一, 语义化拼音名不唯一且不利于批量)。
|
|
|
+ // displayname 仍用中文名 (由 Hermes channel 侧/房间名承载, 不改账号 localpart)。
|
|
|
+ // 兼容: FmodeAgent.channels.matrixConfig.userId 若已是 objectId 账号则直接用;
|
|
|
+ // 旧语义化账号 (@xinting 等) 由 provision-all-agents 批量迁移 (新建+绑定表更新+旧号归档)。
|
|
|
+ var agentLocal = String(agentRec.id).toLowerCase();
|
|
|
+ // Agent 账号存在性: admin room users list 探测; 缺失时走 _ensureAccount 自动注册
|
|
|
+ // (Agent 密码 = 与 App 用户同款派生式不适用——Agent 无 sessionToken,
|
|
|
+ // 用 objectId 固定盐派生: sha256("fmode-agent-mx:"+objectId) 前 24 位, 幂等可重置)
|
|
|
+ var ul = await _adminCmd(mx, adminTok, '!admin users list');
|
|
|
+ var found = ((ul.reply || '').indexOf('@' + agentLocal + ':') >= 0);
|
|
|
+ if (!found) {
|
|
|
+ // v4.2: 不再 502 拒绝 — 直接注册 objectId 账号 (通用插件自接入铺路)
|
|
|
+ var agentPass = _sha256Hex('fmode-agent-mx:' + agentRec.id).slice(0, 24);
|
|
|
+ var ag = await _ensureAccount(mx, adminTok, agentLocal, agentPass);
|
|
|
+ if (!ag) {
|
|
|
+ return _fail(502, '账号注册失败: Agent Matrix 账号(@' + agentLocal + ')创建失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ agentMxId = '@' + agentLocal + ':' + MATRIX_DOMAIN;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 用户侧长效 token (issue-token, 绑定 device; App 端也可用派生密码自行登录) ----
|
|
|
+ var accessToken = ua.accessToken;
|
|
|
+ var deviceId = ua.deviceId;
|
|
|
+ var it = await _adminCmd(mx, adminTok, '!admin users issue-token ' + userLocal + ' ' + password);
|
|
|
+ var m = (it.reply || '').match(/device `([^`]+)` with access token `([A-Za-z0-9_\-\.]+)`/);
|
|
|
+ if (m) { deviceId = m[1]; accessToken = m[2]; }
|
|
|
+
|
|
|
+ // ---- DM 房间: 绑定表 roomId 优先 (P1b 历史恢复: 房间唯一不重建) → 已有共同房间探测 → createRoom ----
|
|
|
+ var roomId = null;
|
|
|
+ if (agentMxId) {
|
|
|
+ if (binding && binding.get('roomId')
|
|
|
+ // v4.2 P3: 旧绑定房间属于旧语义化账号 — agentMxId 变更后旧 roomId 不复用 (房间重建)
|
|
|
+ && (!binding.get('agentMxId') || binding.get('agentMxId') === agentMxId)) {
|
|
|
+ roomId = binding.get('roomId'); // 绑定表既定房间, 不新建
|
|
|
+ } else {
|
|
|
+ var myTok = accessToken;
|
|
|
+ var mxMe = _mx(myTok);
|
|
|
+ var jr = await mxMe.joinedRooms();
|
|
|
+ if (jr.ok && jr.data && jr.data.joined_rooms) {
|
|
|
+ for (var ri = 0; ri < jr.data.joined_rooms.length; ri++) {
|
|
|
+ var rid = jr.data.joined_rooms[ri];
|
|
|
+ var jm = await mxMe.joinedMembers(rid);
|
|
|
+ if (jm.ok && jm.data && jm.data.joined && jm.data.joined[agentMxId]) { roomId = rid; break; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!roomId) {
|
|
|
+ var crr = await mxMe.createRoom(agentMxId, (agentRec && agentRec.get('name')) || undefined);
|
|
|
+ if (crr.ok && crr.data && crr.data.room_id) {
|
|
|
+ roomId = crr.data.room_id;
|
|
|
+ // Agent 侧由官方 Hermes channel 自动 accept 邀请 (MATRIX_REQUIRE_MENTION=false DM)
|
|
|
+ } else {
|
|
|
+ return _fail(502, '房间创建失败: createRoom(' + agentMxId + ') ' +
|
|
|
+ ((crr.data && (crr.data.errcode || crr.data.error)) || crr.err || '未知'));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- P1a 写表/更新表 (首次建绑定 或 token 过期刷新) ----
|
|
|
+ var cred = {
|
|
|
+ userMxId: ua.mxid, agentMxId: agentMxId, homeserver: MATRIX_HS,
|
|
|
+ accessToken: accessToken, deviceId: deviceId, roomId: roomId, status: 'active',
|
|
|
+ };
|
|
|
+ var saved = await _upsertBinding(user, agentIdStr, cred);
|
|
|
+ return _ok({
|
|
|
+ cached: false,
|
|
|
+ userMxId: ua.mxid,
|
|
|
+ agentMxId: agentMxId,
|
|
|
+ homeserver: MATRIX_HS,
|
|
|
+ accessToken: accessToken,
|
|
|
+ deviceId: deviceId,
|
|
|
+ roomId: roomId,
|
|
|
+ bindingId: saved.id,
|
|
|
+ status: 'active',
|
|
|
+ userCreated: ua.created,
|
|
|
+ });
|
|
|
+ } catch (e) {
|
|
|
+ return _fail(500, 'SDK登录失败: ' + ((e && e.message) || String(e)));
|
|
|
+ }
|
|
|
+}
|