openvibers/openvibe.chat Commits
Chat: route linked accounts through agent bindings (#411)
Changes
@@@ -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 || []; },@@@ -35,14 +35,24 @@ function createHandler({ config, store, providers, network, actor, now = () => Dconst 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 = () => Dif (!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) {@@@ -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.
@@ -14,8 +14,14 @@ function createActorClient({ http = null } = {}) {
@@ -14,8 +14,14 @@ function createActorClient({ http = null } = {}) {
const call = (subject, opts) => (http || client()).json({ service: 'actor', baseUrl: config.actor.internalUrl, audience: 'openvibe.actor',
const call = (subject, opts) => (http || client()).json({ service: 'actor', baseUrl: config.actor.internalUrl, audience: 'openvibe.actor',
headers: { 'X-OV-Subject': subject }, retries: 0, ...opts });
headers: { 'X-OV-Subject': subject }, retries: 0, ...opts });
return {
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 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 || []; },
async approvals(subject, conversation) { const out = await call(subject, { method: 'GET', path: '/api/v1/chat-apps/approvals', query: { conversation } }); return out.items || []; },
@@ -35,14 +35,24 @@ function createHandler({ config, store, providers, network, actor, now = () => D
@@ -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;
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) {
if (code) {
const out = await network.finish({ app: m.app, serviceUserId: ident, code }).catch(() => null);
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 === '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 === '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.');
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.');
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 (m.command === 'settings') {
if (privateChat || !await p.isAdmin({ message: m })) return send(m, 'Only a group or workspace administrator can change these 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+/);
const args = m.args.replace(/mode=|rate=/g, '').trim().split(/\s+/);
@@ -64,7 +74,14 @@ function createHandler({ config, store, providers, network, actor, now = () => D
@@ -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;
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();
const text = (m.command === 'ask' ? m.args.replace(/^text=/, '') : m.text).trim();
if (!text) return;
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 shown = new Set();
const until = now() + 11 * 60_000;
const until = now() + 11 * 60_000;
while (now() < until) {
while (now() < until) {
@@ -16,7 +16,7 @@ const { signButton } = require('openvibe-sdk/chat-apps');
@@ -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') },
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' } } };
slack: { botToken: 'fixture', signingSecret: 'fixture-slack', teamId: 'T123' } } };
const store = createStore({ db });
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;
let linked = false, admin = false, pendingCode = null, pendingUser = null;
const subject = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1';
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 }]));
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');
@@ -32,7 +32,7 @@ const { signButton } = require('openvibe-sdk/chat-apps');
},
},
unlink: async () => { linked = false; return true; },
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 });
const handler = createHandler({ config: cfg, store, providers, network, actor });
for (const app of ['telegram','discord','slack']) {
for (const app of ['telegram','discord','slack']) {
const fixture = require(`./fixtures/chat-apps/${app}.json`);
const fixture = require(`./fixtures/chat-apps/${app}.json`);
@@ -53,7 +53,10 @@ const { signButton } = require('openvibe-sdk/chat-apps');
@@ -53,7 +53,10 @@ const { signButton } = require('openvibe-sdk/chat-apps');
// The chat account that started /link finishes with the code.
// The chat account that started /link finishes with the code.
await handler.handle({ ...m, text: '123456', command: null, kind: 'message' }, `${app}-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(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');
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/);
assert.match(sent.at(-1).text, /Report: https/);
starts.length = 0;
starts.length = 0;
const g = { ...m, text: 'hello', command: null, args: '', kind: 'message', chatType: 'group', chatId: `g-${app}`, mention: false, replyToBot: false };
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');
@@ -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 });
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({ ...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);
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);
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.
// Signed raw payloads cross the real Express boundary. Work never runs before verification.