openvibers/openvibe.chat Commits

Chat: route linked accounts through agent bindings (#411)

OP OpenVibers committed 6 days ago · a4e5c35d22 · 3 files, +34 −7

* Chat: send linked account identity to Actor for channel routing

* Chat: clear channel binding when an account is unlinked

* Chat: guide newly linked accounts without an agent

Changes

ddiff --git a/server/chat-apps/actor.js b/server/chat-apps/actor.js
iindex 6b35fc5..dd10b53 100644
---- a/server/chat-apps/actor.js
++++ b/server/chat-apps/actor.js
@@@ -14,8 +14,14 @@ function createActorClient({ http = null } = {}) {
const call = (subject, opts) => (http || client()).json({ service: 'actor', baseUrl: config.actor.internalUrl, audience: 'openvibe.actor',
headers: { 'X-OV-Subject': subject }, retries: 0, ...opts });
return {
-- async start({ subject, text, policy, conversation, deliveryId }) {
-- return call(subject, { method: 'POST', path: '/api/v1/chat-apps/tasks', json: { text, policy, conversation, delivery_id: deliveryId } });
++ async start({ subject, text, policy, conversation, deliveryId, account }) {
++ return call(subject, { method: 'POST', path: '/api/v1/chat-apps/tasks', json: { text, policy, conversation, delivery_id: deliveryId, account } });
++ },
++ async registerAccount({ subject, account }) {
++ return call(subject, { method: 'POST', path: '/api/v1/chat-apps/accounts', json: { account } });
++ },
++ async unregisterAccount({ subject, account }) {
++ return call(subject, { method: 'POST', path: '/api/v1/chat-apps/accounts/unlink', json: { account } });
},
async task(subject, id) { return call(subject, { method: 'GET', path: `/api/v1/tasks/${encodeURIComponent(id)}` }); },
async approvals(subject, conversation) { const out = await call(subject, { method: 'GET', path: '/api/v1/chat-apps/approvals', query: { conversation } }); return out.items || []; },
ddiff --git a/server/chat-apps/handler.js b/server/chat-apps/handler.js
iindex 88367b9..c3b831c 100644
---- a/server/chat-apps/handler.js
++++ b/server/chat-apps/handler.js
@@@ -35,14 +35,24 @@ function createHandler({ config, store, providers, network, actor, now = () => D
const code = privateChat ? (/^\d{6}$/.test(String(m.command ? m.args : m.text || '').trim()) ? String(m.command ? m.args : m.text).trim() : null) : null;
if (code) {
const out = await network.finish({ app: m.app, serviceUserId: ident, code }).catch(() => null);
-- if (out?.ok) return send(m, 'You are linked. Ask me anything — I am your OpenVibe agent here.');
++ if (out?.ok) {
++ const linkedSubject = out.subject || await resolve({ app: m.app, serviceUserId: ident });
++ const binding = linkedSubject ? await actor.registerAccount({ subject: linkedSubject,
++ account: { kind: m.app, id: ident, label: m.userName || `${m.app} account` } }).catch(() => null) : null;
++ if (binding && !binding.bound) return send(m, 'You are linked. Bind this account to an agent on openvibe.actor before asking here.');
++ return send(m, 'You are linked. Ask me anything — I am your OpenVibe agent here.');
++ }
if (out?.error === 'code') return send(m, out.attemptsLeft ? `That code is not right. ${out.attemptsLeft} ${out.attemptsLeft === 1 ? 'try' : 'tries'} left; after that, send /link again.` : 'That code is not right. Send /link to start again.');
if (out?.error === 'expired') return send(m, 'That code expired. Send /link to start again.');
if (out?.error === 'conflict') return send(m, 'This chat account is already linked to another OpenVibe account, or that account already has a chat account of this service.');
}
return send(m, 'Link your account first: send /link in a direct message.');
}
-- if (m.command === 'unlink') return unlinkCommand({ message: { ...m, userId: ident }, subject, provider: p, network, forget: resolve.forget });
++ if (m.command === 'unlink') {
++ const out = await unlinkCommand({ message: { ...m, userId: ident }, subject, provider: p, network, forget: resolve.forget });
++ if (out.ok) await actor.unregisterAccount({ subject, account: { kind: m.app, id: ident } }).catch(() => null);
++ return out;
++ }
if (m.command === 'settings') {
if (privateChat || !await p.isAdmin({ message: m })) return send(m, 'Only a group or workspace administrator can change these settings.');
const args = m.args.replace(/mode=|rate=/g, '').trim().split(/\s+/);
@@@ -64,7 +74,14 @@ function createHandler({ config, store, providers, network, actor, now = () => D
if (!privateChat && !await store.rate(`${m.app}:group:${group(m)}`, settings.rate_per_hour)) return;
const text = (m.command === 'ask' ? m.args.replace(/^text=/, '') : m.text).trim();
if (!text) return;
-- const started = await actor.start({ subject, text, policy: privateChat ? 'dm' : settings.mode, conversation: conversation(m), deliveryId });
++ let started;
++ try {
++ started = await actor.start({ subject, text, policy: privateChat ? 'dm' : settings.mode, conversation: conversation(m), deliveryId,
++ account: { kind: m.app, id: ident, label: m.userName || `${m.app} account` } });
++ } catch (err) {
++ if (err && err.status === 409) return send(m, 'No agent is listening on this account. Bind it on your agent page at openvibe.actor.');
++ throw err;
++ }
const shown = new Set();
const until = now() + 11 * 60_000;
while (now() < until) {
ddiff --git a/test/chat-apps.test.js b/test/chat-apps.test.js
iindex 4fc9ca0..ea24f45 100644
---- a/test/chat-apps.test.js
++++ b/test/chat-apps.test.js
@@@ -16,7 +16,7 @@ const { signButton } = require('openvibe-sdk/chat-apps');
discord: { botToken: 'fixture', applicationId: '222222222222222222', publicKey: keys.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32).toString('hex') },
slack: { botToken: 'fixture', signingSecret: 'fixture-slack', teamId: 'T123' } } };
const store = createStore({ db });
-- const sent = [], starts = [], decisions = [], links = [];
++ const sent = [], starts = [], decisions = [], links = [], registrations = [], unregistrations = [];
let linked = false, admin = false, pendingCode = null, pendingUser = null;
const subject = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1';
const providers = Object.fromEntries(['telegram','discord','slack'].map(app => [app, { on: true, botUserId: '999', botUsername: 'our_bot', sendText: async b => sent.push(b), sendButtons: async b => sent.push(b), isAdmin: async () => admin }]));
@@@ -32,7 +32,7 @@ const { signButton } = require('openvibe-sdk/chat-apps');
},
unlink: async () => { linked = false; return true; },
};
-- const actor = { start: async b => { starts.push(b); return { id: 'task1' }; }, task: async () => ({ state: 'succeeded', result: { answer: 'The answer.', artifacts: [{ name: 'Report', url: 'https://openvibe.actor/file/1' }] } }), approvals: async () => [], decide: async b => { decisions.push(b); return {}; } };
++ const actor = { start: async b => { starts.push(b); return { id: 'task1' }; }, registerAccount: async b => { registrations.push(b); return { bound: true }; }, unregisterAccount: async b => { unregistrations.push(b); return { ok: true }; }, task: async () => ({ state: 'succeeded', result: { answer: 'The answer.', artifacts: [{ name: 'Report', url: 'https://openvibe.actor/file/1' }] } }), approvals: async () => [], decide: async b => { decisions.push(b); return {}; } };
const handler = createHandler({ config: cfg, store, providers, network, actor });
for (const app of ['telegram','discord','slack']) {
const fixture = require(`./fixtures/chat-apps/${app}.json`);
@@@ -53,7 +53,10 @@ const { signButton } = require('openvibe-sdk/chat-apps');
// The chat account that started /link finishes with the code.
await handler.handle({ ...m, text: '123456', command: null, kind: 'message' }, `${app}-code`);
assert.equal(linked, true); assert.match(sent.at(-1).text, /You are linked/);
++ assert.equal(registrations.at(-1).account.kind, app);
++ assert.equal(registrations.at(-1).account.id, identity(m));
await handler.handle(m, `${app}-ask`); assert.equal(starts.at(-1).policy, 'dm');
++ assert.equal(starts.at(-1).account.id, identity(m));
assert.match(sent.at(-1).text, /Report: https/);
starts.length = 0;
const g = { ...m, text: 'hello', command: null, args: '', kind: 'message', chatType: 'group', chatId: `g-${app}`, mention: false, replyToBot: false };
@@@ -72,6 +75,7 @@ const { signButton } = require('openvibe-sdk/chat-apps');
const expired = await store.openCallback({ app, chatId: group(m), userId: identity(m), subject, action: 'approve', approvalId: 'old', expiresAt: Date.now() - 1 });
await handler.handle({ ...button, button: { id: signButton({ secret: cfg.chatApps.callbackSecret, id: expired }) } }, 'expired'); assert.equal(decisions.length, 0);
await handler.handle({ ...m, command: 'unlink' }, 'unlink'); assert.equal(linked, false);
++ assert.equal(unregistrations.at(-1).account.id, identity(m));
}
assert.equal(parse('slack', { ...require('./fixtures/chat-apps/slack.json'), team_id: 'TOTHER' }, providers.slack, cfg.chatApps), null);
// Signed raw payloads cross the real Express boundary. Work never runs before verification.