openvibers/openvibe.chat Commits
Chat: your agent in Telegram, Discord and Slack (A4) (#358)
Changes
@@@ -100,5 +100,25 @@ SOUNDS_PATH=./data/sounds# Where a room's Community attachments link (room settings page).#OV_COMMUNITY_URL=https://openvibe.community++# Your agent in Telegram, Discord and Slack (agent plan A4; docs/chat-apps.md). Each app is OFF++# until its own bot token and its own secret are here. Buttons are signed with++# CHAT_APPS_CALLBACK_SECRET: a long random string of its own, never an app token.++#CHAT_APPS_CALLBACK_SECRET=++#CHAT_TELEGRAM_BOT_TOKEN=++#CHAT_TELEGRAM_SECRET_TOKEN=++#CHAT_TELEGRAM_USERNAME=++#CHAT_DISCORD_BOT_TOKEN=++#CHAT_DISCORD_PUBLIC_KEY=++#CHAT_DISCORD_APPLICATION_ID=++#CHAT_SLACK_BOT_TOKEN=++#CHAT_SLACK_TEAM_ID=++#CHAT_SLACK_SIGNING_SECRET=++# Where a person confirms /link while signed in to OpenVibe.Network++#CHAT_LINK_CONFIRM_URL=https://openvibe.network/chat-apps/link++#CHAT_LINK_TTL_MS=600000++#CHAT_CALLBACK_TTL_MS=1800000++#CHAT_APPS_RATE_PER_HOUR=20++#CHAT_APPS_PER_USER_RATE_PER_HOUR=10++# Optional diagnostics#DEBUG_DM_DELIVERY=1nnew file mode 100644@@@ -0,0 +1,135 @@++# Your agent in Telegram, Discord and Slack (A4)++++Chat answers the three chat apps. A message to the bot goes to the person's OpenVibe agent in++OpenVibe.Actor (their memory, tools and budget), and the answer comes back in the same chat.++OpenVibe.Network owns the account links; Chat keeps only its own rows (the one-time link token,++a group's rules and the buttons an approval made).++++Every app is **off until the owner puts its token and its own secret in the settings**++(`/etc/openvibe/chat.env`): with one missing, the app answers 503, sets no webhook and starts no++gateway. Nothing here creates a bot account.++++## What the owner creates, in two lines per app++++- **Telegram:** a bot from [@BotFather](https://t.me/BotFather) (`/newbot`), then a webhook secret++ (any long random string). No other account.++- **Discord:** an application and its bot user (Developer Portal), the application's public key,++ the bot token, and the **Message Content** intent; invite it with `bot` + `applications.commands`.++- **Slack:** an app in the workspace with a bot user; the signing secret, the bot token and the++ workspace (team) id; scopes `chat:write`, `commands`, `users:read`, `team:read`.++++## Settings++++| Variable | What it is |++|---|---|++| `CHAT_APPS_CALLBACK_SECRET` | signs every approval button; a long random string of its own |++| `CHAT_TELEGRAM_BOT_TOKEN` | BotFather's token |++| `CHAT_TELEGRAM_SECRET_TOKEN` | the webhook secret (Telegram sends it in `X-Telegram-Bot-Api-Secret-Token`) |++| `CHAT_TELEGRAM_USERNAME` | the bot's @name, so `/link@another_bot` is not ours (learned from `getMe` when unset) |++| `CHAT_DISCORD_BOT_TOKEN` | the bot token |++| `CHAT_DISCORD_PUBLIC_KEY` | 64 hex characters, the application's public key (interactions are signed with it) |++| `CHAT_DISCORD_APPLICATION_ID` | the application id (commands are registered on it) |++| `CHAT_SLACK_BOT_TOKEN` | `xoxb-…` |++| `CHAT_SLACK_TEAM_ID` | the workspace id (`T…`); a delivery from another workspace is refused |++| `CHAT_SLACK_SIGNING_SECRET` | the app's signing secret (v0 over `v0:<timestamp>:<body>`) |++| `CHAT_LINK_CONFIRM_URL` | Network's confirm page, default `https://openvibe.network/chat-apps/link` |++| `CHAT_LINK_TTL_MS` | how long a `/link` token lives (default 10 min) |++| `CHAT_CALLBACK_TTL_MS` | how long an approval button lives (default 30 min) |++| `CHAT_APPS_RATE_PER_HOUR` | answers an hour a group gets (default 20) |++| `CHAT_APPS_PER_USER_RATE_PER_HOUR` | messages an hour one person gets (default 10) |++++Secrets are read from the environment only, used only against the provider's fixed origin and++never printed or put into an error.++++## Endpoints and what to set where++++The base is Chat's own origin (`CHAT_WEB_URL`, production `https://openvibe.chat`).++++- Telegram: `https://openvibe.chat/hooks/chat-apps/telegram`++- Discord: `https://openvibe.chat/hooks/chat-apps/discord`++- Slack: `https://openvibe.chat/hooks/chat-apps/slack`++++Register what a provider lets us register:++++```bash++sudo node --env-file=/etc/openvibe/chat.env scripts/chat-apps.js # what is on, and as which bot++sudo node --env-file=/etc/openvibe/chat.env scripts/chat-apps.js --register # Telegram webhook + commands, Discord commands++```++++- **Telegram:** the script calls `setWebhook` (with `secret_token`, `allowed_updates`++ `message` and `callback_query`) and `setMyCommands` (`/link`, `/unlink`, `/help`, `/settings`).++- **Discord:** the script PUTs the global commands (`/link`, `/unlink`, `/help`, `/settings`).++ Set the **Interactions Endpoint URL** to the URL above in the app's General Information page;++ Discord checks it with a signed PING. Enable **Message Content** so an ordinary message in a++ group reaches the agent (and `Guilds`, `GuildMessages`, `DirectMessages`).++- **Slack:** the dashboard sets these: **Event Subscriptions** Request URL = the URL above,++ bot events `app_mention` and `message.im`; **Interactivity** Request URL = the same; a slash++ command per command (`/link`, `/unlink`, `/help`, `/settings`) with the same Request URL.++++nginx serves `openvibe.chat` through `location /` to Chat, so no new location is needed.++++## How it behaves++++- **Linking is two-way.** `/link` in a direct message answers a one-time URL. The person opens it++ signed in to Network, sees that this chat account will talk to their agent and approve its++ steps, and confirms. The page then shows a **6-digit code** (10 minutes, kept only as a hash):++ the person sends that code to the bot **from the same chat** to finish. Only then is the account++ link written; a confirm URL forwarded to somebody else links nothing, because only the chat++ account that started `/link` can finish it. Three wrong codes cancel it and the code expires.++ The code is accepted only in a direct message (never in a group) and only from an unlinked++ account. `/unlink` removes the link. Until linking finishes only `/link` and `/help` answer.++ A link is used once; the token is stored only as a SHA-256 hash. A second person confirming the++ same chat account is refused; so is replacing a different linked account with the same service.++- **A message.** Group answers only when the bot is mentioned or the message replies to it.++ A direct message is a private policy: the agent may use the person's tools and memory. A group++ is `answer_only` (public tools, no acting) or `act_with_approvals`; a group never gets the++ person's private memory, computers, robots or code, and never creates memory, waits or files.++ Answers are split the app's way (Telegram 4096, Discord 2000, Slack 3000) and the agent's files++ come back as links. Chat's moderation rules (`lineOk`) apply to everything the bot says; a++ refused line becomes "I cannot share that answer under the chat rules."++- **Approvals.** An acting tool (send, post, pay, delete, deploy) never runs on its own: Actor++ writes an approval row with the exact tool and arguments, and Chat shows it as buttons++ (Telegram inline keyboard, Discord components, Slack Block Kit). A button is an id signed with++ `CHAT_APPS_CALLBACK_SECRET`; the row behind it says who may press it (the linked person in the++ same chat, as the same subject), what it does, that it is open, and when it expires. The row is++ consumed in one statement, so two presses (or a press by anyone else) decide nothing. Approving++ lets that one exact call run; the agent cannot change the arguments after consent. Arguments++ over 1200 characters are refused rather than shown truncated.++- **Groups.** `/settings off|answer_only|act_with_approvals [answers per hour]` is answered only++ to a group or workspace administrator (Telegram `getChatMember`, Discord `Manage Guild` /++ `Administrator`, Slack `is_admin`/`is_owner` in the workspace). `off` answers nothing but++ `/settings`. The hourly rate is per group; a person also has a per-hour message limit.++- **Durability.** A delivery is stored (`chat_app_deliveries`, deduped by the provider's event++ id) and acknowledged before any work: Telegram and Slack expect a fast 200, Discord a reply++ within 3 seconds. A fixed number of workers answers messages and buttons in separate lanes, so++ a slow task does not hold a button. On start, a delivery left `running` is marked failed, not++ replayed after an external effect. Buttons, tokens and deliveries are pruned.++- **Untrusted text.** In a group the text is wrapped as a stranger's suggestion, never the++ owner's words, and the tool allow-list is the group policy's. In a direct message it is the++ person's own message.++++## Security notes++++- Telegram's secret token, Slack's v0 signing and Discord's Ed25519 signature are checked before++ the body is parsed, in constant time, against the raw bytes (the SDK's `openvibe-sdk/chat-apps`,++ the code Watch has used since 2026-11-03). A provider the module does not know is never++ verified.++- A provider access token, an interaction token and the raw event are not stored: a delivery keeps++ only what the answer needs.++- The link POST on Network checks same-origin, the person must be signed in, and the confirm page++ shows the app and the chat name before asking. Confirming only mints a code; the link exists++ only after the same chat account sends that code to the bot. A forwarded confirm URL therefore++ cannot attach somebody else's agent to the sender's chat.++- Account deletion removes the person's link tokens, approval buttons and queued deliveries; the++ export includes their chat-app links and approvals.++++## Tests++++`node test/run.js chat-apps` covers the three signed payloads across the real HTTP boundary++(401 without the signature, 503 when an app is off, one delivery stored per event id), linking,++the 6-digit code finishing from the starter's chat (and a wrong chat account and a wrong code++refused), an unlinked sender refused, a wrong person's button refused, two presses deciding once,++an expired button, group mention rules, group admin settings, the moderation refusal, the rates++and the provider calls. Network's `node test/run.js chat-app-links` covers the code's hash at++rest, its 10-minute expiry, three wrong codes cancelling the link, and the real service-token++gate. `node test/run.js account-data` covers export and deletion.nnew file mode 100644@@@ -0,0 +1,64 @@++-- phase: expand++-- The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of++-- 2026-10-30, stage A4). Three kinds of rows: the one-time link token a person gets from /link, the++-- rules of a group (what the bot may do there and how fast), and the buttons a person may press.++--++-- The link token holds only the token's hash: the token itself is in the person's link and in the++-- Network's page. It is used once (state 'used') when the person confirms it while signed in.++CREATE TABLE IF NOT EXISTS chat_app_link_tokens (++ token_hash text COLLATE "C" PRIMARY KEY,++ app text COLLATE "C" NOT NULL CHECK (app IN ('telegram', 'discord', 'slack')),++ service_user_id text COLLATE "C" NOT NULL, -- the person in the app (their id there)++ service_username text COLLATE "C", -- their name there, for the page that asks++ state text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'used', 'expired')),++ subject text COLLATE "C", -- their usr_… once the link is made++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ expires_at text COLLATE "C" NOT NULL,++ used_at text COLLATE "C"++);++CREATE INDEX IF NOT EXISTS chat_app_link_tokens_by_person ON chat_app_link_tokens (app, service_user_id, state);++CREATE INDEX IF NOT EXISTS chat_app_link_tokens_expiry ON chat_app_link_tokens (expires_at);++++-- What the bot may do in a group or a channel: off, answer only (it answers when it is mentioned and++-- never acts), or act with approvals (it may act, and every risky step waits for the linked person).++-- The rate is how many answers an hour that chat gets.++CREATE TABLE IF NOT EXISTS chat_app_settings (++ app text COLLATE "C" NOT NULL CHECK (app IN ('telegram', 'discord', 'slack')),++ chat_id text COLLATE "C" NOT NULL,++ mode text COLLATE "C" NOT NULL DEFAULT 'answer_only' CHECK (mode IN ('off', 'answer_only', 'act_with_approvals')),++ rate_per_hour integer NOT NULL DEFAULT 20 CHECK (rate_per_hour > 0),++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (app, chat_id)++);++++-- A button ("Needs you" from the agent). It is used once (state 'used'), it expires, and only the++-- person the button was made for may press it.++CREATE TABLE IF NOT EXISTS chat_app_callbacks (++ id text COLLATE "C" PRIMARY KEY,++ app text COLLATE "C" NOT NULL CHECK (app IN ('telegram', 'discord', 'slack')),++ chat_id text COLLATE "C" NOT NULL,++ user_id text COLLATE "C" NOT NULL, -- the linked person the button belongs to++ subject text COLLATE "C", -- their usr_…++ action text COLLATE "C" NOT NULL CHECK (action IN ('approve', 'decline')),++ approval_id text COLLATE "C", -- the approval in OpenVibe.Actor++ state text COLLATE "C" NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'used')),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ expires_at text COLLATE "C" NOT NULL,++ used_at text COLLATE "C"++);++CREATE INDEX IF NOT EXISTS chat_app_callbacks_by_chat ON chat_app_callbacks (app, chat_id, state);++CREATE INDEX IF NOT EXISTS chat_app_callbacks_expiry ON chat_app_callbacks (expires_at);++++CREATE TABLE chat_app_deliveries (++ app text NOT NULL,++ id text NOT NULL,++ message jsonb NOT NULL,++ state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'running', 'done', 'failed')),++ created_at text NOT NULL DEFAULT ov_now(),++ PRIMARY KEY(app, id)++);++CREATE TABLE chat_app_rates (++ key text PRIMARY KEY,++ hour_bucket bigint NOT NULL,++ count integer NOT NULL++);@@@ -11,13 +11,14 @@"dependencies": {"cookie-parser": "^1.4.7","cors": "^2.8.5",++ "discord.js": "14.26.3","dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.92.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1","pg": "^8.23.0","ws": "^8.18.0"@@@ -36,6 +37,155 @@"integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==","license": "MIT"},++ "node_modules/@discordjs/builders": {++ "version": "1.15.0",++ "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.15.0.tgz",++ "integrity": "sha512-AifaDWTw6WUDhKBn8E6mwwoyjDAcfsYq+ZE0nKSu1dMH1NLR22D3hGkNx+ctEv8NqHEHdAZnP5REteHDmn1lkA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@discordjs/formatters": "^0.6.2",++ "@discordjs/util": "^1.2.0",++ "@sapphire/shapeshift": "^4.0.0",++ "discord-api-types": "^0.38.50",++ "fast-deep-equal": "^3.1.3",++ "ts-mixer": "^6.0.4",++ "tslib": "^2.6.3"++ },++ "engines": {++ "node": ">=16.11.0"++ },++ "funding": {++ "url": "https://github.com/discordjs/discord.js?sponsor"++ }++ },++ "node_modules/@discordjs/collection": {++ "version": "1.5.3",++ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",++ "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=16.11.0"++ }++ },++ "node_modules/@discordjs/formatters": {++ "version": "0.6.2",++ "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",++ "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "discord-api-types": "^0.38.33"++ },++ "engines": {++ "node": ">=16.11.0"++ },++ "funding": {++ "url": "https://github.com/discordjs/discord.js?sponsor"++ }++ },++ "node_modules/@discordjs/rest": {++ "version": "2.6.3",++ "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz",++ "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==",++ "license": "Apache-2.0",++ "dependencies": {++ "@discordjs/collection": "^2.1.1",++ "@discordjs/util": "^1.2.0",++ "@sapphire/async-queue": "^1.5.3",++ "@sapphire/snowflake": "^3.5.5",++ "@vladfrangu/async_event_emitter": "^2.4.6",++ "discord-api-types": "^0.38.50",++ "magic-bytes.js": "^1.13.0",++ "tslib": "^2.6.3",++ "undici": "^6.27.0"++ },++ "engines": {++ "node": ">=18"++ },++ "funding": {++ "url": "https://github.com/discordjs/discord.js?sponsor"++ }++ },++ "node_modules/@discordjs/rest/node_modules/@discordjs/collection": {++ "version": "2.1.1",++ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",++ "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=18"++ },++ "funding": {++ "url": "https://github.com/discordjs/discord.js?sponsor"++ }++ },++ "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": {++ "version": "3.5.5",++ "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",++ "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",++ "license": "MIT",++ "engines": {++ "node": ">=v14.0.0",++ "npm": ">=7.0.0"++ }++ },++ "node_modules/@discordjs/rest/node_modules/undici": {++ "version": "6.29.1",++ "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",++ "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",++ "license": "MIT",++ "engines": {++ "node": ">=18.17"++ }++ },++ "node_modules/@discordjs/util": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",++ "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",++ "license": "Apache-2.0",++ "dependencies": {++ "discord-api-types": "^0.38.33"++ },++ "engines": {++ "node": ">=18"++ },++ "funding": {++ "url": "https://github.com/discordjs/discord.js?sponsor"++ }++ },++ "node_modules/@discordjs/ws": {++ "version": "1.2.3",++ "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",++ "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@discordjs/collection": "^2.1.0",++ "@discordjs/rest": "^2.5.1",++ "@discordjs/util": "^1.1.0",++ "@sapphire/async-queue": "^1.5.2",++ "@types/ws": "^8.5.10",++ "@vladfrangu/async_event_emitter": "^2.2.4",++ "discord-api-types": "^0.38.1",++ "tslib": "^2.6.2",++ "ws": "^8.17.0"++ },++ "engines": {++ "node": ">=16.11.0"++ },++ "funding": {++ "url": "https://github.com/discordjs/discord.js?sponsor"++ }++ },++ "node_modules/@discordjs/ws/node_modules/@discordjs/collection": {++ "version": "2.1.1",++ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",++ "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=18"++ },++ "funding": {++ "url": "https://github.com/discordjs/discord.js?sponsor"++ }++ },"node_modules/@electric-sql/pglite": {"version": "0.5.8","resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",@@@ -146,6 +296,67 @@"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==","license": "MIT"},++ "node_modules/@sapphire/async-queue": {++ "version": "1.5.5",++ "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",++ "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",++ "license": "MIT",++ "engines": {++ "node": ">=v14.0.0",++ "npm": ">=7.0.0"++ }++ },++ "node_modules/@sapphire/shapeshift": {++ "version": "4.0.0",++ "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",++ "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "lodash": "^4.17.21"++ },++ "engines": {++ "node": ">=v16"++ }++ },++ "node_modules/@sapphire/snowflake": {++ "version": "3.5.3",++ "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",++ "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",++ "license": "MIT",++ "engines": {++ "node": ">=v14.0.0",++ "npm": ">=7.0.0"++ }++ },++ "node_modules/@types/node": {++ "version": "26.6.6",++ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",++ "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",++ "license": "MIT",++ "dependencies": {++ "undici-types": "~8.9.0"++ }++ },++ "node_modules/@types/ws": {++ "version": "8.18.2",++ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",++ "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",++ "license": "MIT",++ "dependencies": {++ "@types/node": "*"++ }++ },++ "node_modules/@vladfrangu/async_event_emitter": {++ "version": "2.4.7",++ "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",++ "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",++ "license": "MIT",++ "engines": {++ "node": ">=v14.0.0",++ "npm": ">=7.0.0"++ }++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -441,6 +652,39 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/discord-api-types": {++ "version": "0.38.57",++ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.57.tgz",++ "integrity": "sha512-R1CTSSt4RshqCq23j2Xq2ZscvzB4LzG9OB1CYQdKUGdw8Ti/oF/72EEtK2mNpagG/Vj9NcBT7dw9lKLf+Dacfg==",++ "license": "MIT"++ },++ "node_modules/discord.js": {++ "version": "14.26.3",++ "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz",++ "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==",++ "license": "Apache-2.0",++ "dependencies": {++ "@discordjs/builders": "^1.14.1",++ "@discordjs/collection": "1.5.3",++ "@discordjs/formatters": "^0.6.2",++ "@discordjs/rest": "^2.6.1",++ "@discordjs/util": "^1.2.0",++ "@discordjs/ws": "^1.2.3",++ "@sapphire/snowflake": "3.5.3",++ "discord-api-types": "^0.38.40",++ "fast-deep-equal": "3.1.3",++ "lodash.snakecase": "4.1.1",++ "magic-bytes.js": "^1.13.0",++ "tslib": "^2.6.3",++ "undici": "6.24.1"++ },++ "engines": {++ "node": ">=18"++ },++ "funding": {++ "url": "https://github.com/discordjs/discord.js?sponsor"++ }++ },"node_modules/dom-serializer": {"version": "3.1.1","resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",@@@ -1089,6 +1333,12 @@}}},++ "node_modules/lodash": {++ "version": "4.18.1",++ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",++ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",++ "license": "MIT"++ },"node_modules/lodash.defaults": {"version": "4.2.0","resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",@@@ -1143,6 +1393,18 @@"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==","license": "MIT"},++ "node_modules/lodash.snakecase": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",++ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",++ "license": "MIT"++ },++ "node_modules/magic-bytes.js": {++ "version": "1.13.1",++ "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz",++ "integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==",++ "license": "MIT"++ },"node_modules/math-intrinsics": {"version": "1.1.0","resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",@@@ -1311,9 +1573,8 @@}},"node_modules/openvibe-sdk": {-- "version": "0.91.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",-- "integrity": "sha512-LUWT2g30FUCTgaj9hVUqSfvT4FNMG4LujctcO8tqtXK81vpOOWDbWvaln/zfqFVup9KfCme7fctd46DXrq4Zbw==",++ "version": "0.92.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.92.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1783,6 +2044,18 @@"node": ">=0.6"}},++ "node_modules/ts-mixer": {++ "version": "6.0.4",++ "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",++ "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",++ "license": "MIT"++ },++ "node_modules/tslib": {++ "version": "2.9.1",++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz",++ "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==",++ "license": "0BSD"++ },"node_modules/type-is": {"version": "1.6.18","resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",@@@ -1802,6 +2075,21 @@"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==","license": "ISC"},++ "node_modules/undici": {++ "version": "6.24.1",++ "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",++ "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",++ "license": "MIT",++ "engines": {++ "node": ">=18.17"++ }++ },++ "node_modules/undici-types": {++ "version": "8.9.0",++ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",++ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",++ "license": "MIT"++ },"node_modules/unpipe": {"version": "1.0.0","resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",@@@ -23,13 +23,14 @@"dependencies": {"cookie-parser": "^1.4.7","cors": "^2.8.5",++ "discord.js": "14.26.3","dotenv": "^16.4.7","express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0","multer": "^2.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.92.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1","pg": "^8.23.0","ws": "^8.18.0"nnew file mode 100644@@@ -0,0 +1,102 @@++#!/usr/bin/env node++'use strict';++/**++ * The chat apps' setup helper (agent plan A4): show what is configured, and register what the++ * providers let a service register for itself.++ *++ * node --env-file=/etc/openvibe/chat.env scripts/chat-apps.js++ * Which apps are on (a token and its own secret are set), and the bot each one is.++ * node --env-file=/etc/openvibe/chat.env scripts/chat-apps.js --register++ * Telegram: setWebhook (our /hooks/chat-apps/telegram, the secret token, messages and++ * callbacks) and setMyCommands. Discord: the global application commands. Slack: the++ * slash commands and the Request URL are set in the app's dashboard, so this only checks++ * the token and prints what to paste there.++ *++ * No value of a token or a secret is ever printed. The URLs are read from CHAT_WEB_URL (Chat's++ * own origin), so a staging run registers a staging URL.++ */++const config = require('../server/config');++const { createProviders } = require('../server/chat-apps/providers');++++const base = config.web.baseUrl.replace(/\/$/, '');++const url = (app) => `${base}/hooks/chat-apps/${app}`;++++const COMMANDS = {++ telegram: [++ { command: 'link', description: 'Link this chat to your OpenVibe account' },++ { command: 'unlink', description: 'Disconnect this chat from your OpenVibe account' },++ { command: 'help', description: 'What this agent can do here' },++ { command: 'settings', description: 'Group admins: off, answer only, or act with approvals' },++ ],++ discord: [++ { name: 'link', description: 'Link this chat to your OpenVibe account', type: 1 },++ { name: 'unlink', description: 'Disconnect this chat from your OpenVibe account', type: 1 },++ { name: 'help', description: 'What this agent can do here', type: 1 },++ {++ name: 'settings', description: 'Server admins: what the agent may do here', type: 1,++ options: [++ { name: 'mode', description: 'off, answer_only or act_with_approvals', type: 3, required: true,++ choices: ['off', 'answer_only', 'act_with_approvals'].map((v) => ({ name: v, value: v })) },++ { name: 'rate', description: 'Answers per hour in this server (1-500)', type: 4, required: false, min_value: 1, max_value: 500 },++ ],++ },++ ],++};++++async function api(app, method, body, verb = 'POST') {++ const c = config.chatApps[app];++ const origin = app === 'telegram' ? `https://api.telegram.org/bot${c.botToken}/` : app === 'discord' ? 'https://discord.com/api/v10/' : 'https://slack.com/api/';++ const headers = { 'Content-Type': 'application/json', ...(app === 'telegram' ? {} : { Authorization: `${app === 'discord' ? 'Bot' : 'Bearer'} ${c.botToken}` }) };++ const res = await fetch(origin + method, { method: verb, headers, ...(verb === 'GET' ? {} : { body: JSON.stringify(body || {}) }), signal: AbortSignal.timeout(15_000) });++ const out = await res.json().catch(() => null);++ if (!res.ok || !out || out.ok === false) throw new Error(`${app}: ${(out && (out.description || out.message || out.error)) || `HTTP ${res.status}`}`);++ return app === 'telegram' ? out.result : out;++}++++async function check(providers) {++ for (const app of ['telegram', 'discord', 'slack']) {++ const p = providers[app];++ if (!p.on) { console.log(`${app}: off (no token, or no secret of its own)`); continue; }++ try {++ await p.whoami();++ console.log(`${app}: on, as ${p.botUsername || p.botUserId || 'the configured bot'}`);++ } catch (err) {++ console.log(`${app}: configured, but ${err.message}`);++ }++ }++}++++async function register(app) {++ const c = config.chatApps[app];++ if (!c.botToken) throw new Error(`${app}: no bot token is set`);++ if (app === 'telegram') {++ if (!c.secretToken) throw new Error('telegram: set CHAT_TELEGRAM_SECRET_TOKEN first (it is the webhook secret the deliveries are checked with)');++ await api('telegram', 'setWebhook', { url: url('telegram'), secret_token: c.secretToken, allowed_updates: ['message', 'callback_query'] });++ await api('telegram', 'setMyCommands', { commands: COMMANDS.telegram });++ console.log(`telegram: webhook ${url('telegram')} set, ${COMMANDS.telegram.length} commands registered`);++ return;++ }++ if (app === 'discord') {++ if (!c.applicationId) throw new Error('discord: set CHAT_DISCORD_APPLICATION_ID first');++ await api('discord', `applications/${encodeURIComponent(c.applicationId)}/commands`, COMMANDS.discord, 'PUT');++ console.log(`discord: ${COMMANDS.discord.length} global commands registered; the interactions endpoint is ${url('discord')} (set it in the app's General Information page, with the public key already in CHAT_DISCORD_PUBLIC_KEY)`);++ console.log('discord: enable the Message Content intent (Bot page) so a message in a group reaches the agent outside a slash command');++ return;++ }++ await api('slack', 'auth.test', {});++ console.log(`slack: token ok; set the Request URL ${url('slack')} under Event Subscriptions, and the slash command Request URL ${url('slack')}`);++ console.log('slack: subscribe to app_mention and message.im; enable Interactivity (same Request URL); the bot needs chat:write, users:read, team:read and commands');++}++++(async () => {++ const { providers } = createProviders({ config });++ if (process.argv.includes('--register')) {++ for (const app of ['telegram', 'discord', 'slack']) {++ if (!providers[app].on) { console.log(`${app}: off, nothing registered`); continue; }++ try { await register(app); } catch (err) { console.log(`${app}: ${err.message}`); process.exitCode = 1; }++ }++ return;++ }++ await check(providers);++ console.log(`\nEndpoints: telegram ${url('telegram')} · discord ${url('discord')} · slack ${url('slack')}`);++})().catch((err) => { console.error(err.message); process.exitCode = 1; });@@@ -84,6 +84,9 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {}res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });});++ const chatApps = require('./chat-apps').createChatApps();++ app.locals.chatApps = chatApps;++ app.use('/hooks/chat-apps', chatApps.router);app.disable('x-powered-by');// Cloudflare → nginx → Node: TRUST_PROXY hops, but a hop past nginx only when it is a// Cloudflare address (DNS-only hosts reach nginx directly with a client-written X-Forwarded-For).nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++const config = require('../config');++let cached;++function client() {++ if (!cached) {++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.actor', scope: 'actor.agent.manage actor.task.read' });++ cached = require('openvibe-sdk').createClient({ tokenProvider: tokens, timeoutMs: 20_000 });++ }++ return cached;++}++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 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 decide({ subject, approvalId, decision }) {++ return call(subject, { method: 'POST', path: `/api/v1/chat-apps/approvals/${encodeURIComponent(approvalId)}/decide`, json: { decision } });++ },++ };++}++module.exports = { createActorClient };nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++// discord.js owns heartbeat, reconnect/resume and identify rate limits. HTTP interactions remain signed ingress.++const { parseDiscordMessage } = require('openvibe-sdk/chat-apps');++async function start({ config, provider, store, wake, log }) {++ const { Client, GatewayIntentBits, Partials, PermissionFlagsBits } = require('discord.js');++ const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent], partials: [Partials.Channel] });++ client.on('error', () => log.warn('[chat-apps] Discord gateway unavailable'));++ client.on('messageCreate', async msg => {++ try {++ const m = parseDiscordMessage({ id: msg.id, channel_id: msg.channelId, guild_id: msg.guildId,++ author: { id: msg.author.id, username: msg.author.username, bot: msg.author.bot }, webhook_id: msg.webhookId,++ content: msg.content, mentions: [...msg.mentions.users.values()].map(u => ({ id: u.id })),++ attachments: [...msg.attachments.values()].map(f => ({ url: f.url, filename: f.name })) }, { botUserId: client.user.id });++ if (!m) return;++ if (msg.reference?.messageId) {++ const parent = await msg.fetchReference().catch(() => null);++ m.replyToBot = parent?.author?.id === client.user.id;++ }++ m.guildId = msg.guildId || ''; m.gateway = true; m.raw = {};++ m.admin = m.command === 'settings' && msg.guild ? Boolean((await msg.guild.members.fetch(msg.author.id)).permissions.has(PermissionFlagsBits.ManageGuild)) : false;++ if (await store.enqueue('discord', msg.id, m)) wake();++ } catch { log.warn('[chat-apps] Discord delivery refused'); }++ });++ try { await client.login(config.chatApps.discord.botToken); }++ catch { client.destroy(); throw new Error('Discord unavailable'); }++ return client;++}++module.exports = { start };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++const { setTimeout: delay } = require('node:timers/promises');++const { splitText, signButton, verifyButton } = require('openvibe-sdk/chat-apps');++const { createResolver, linkCommand, unlinkCommand } = require('./links');++const { lineOk } = require('../chat/global-standard');++const identity = m => m.app === 'slack' ? `${m.teamId}:${m.userId}` : m.userId;++const group = m => `${m.teamId || m.guildId || ''}:${m.chatId}`;++const conversation = m => `${m.app}:${group(m)}`;++function createHandler({ config, store, providers, network, actor, now = () => Date.now(), sleep = delay }) {++ const resolve = createResolver({ network });++ const send = async (m, text) => {++ const safe = lineOk(text) ? text : 'I cannot share that answer under the chat rules.';++ for (const part of splitText(m.app, safe)) await providers[m.app].sendText({ chatId: m.chatId, text: part, threadTs: m.threadTs });++ };++ async function handle(m, deliveryId, signal) {++ if (!m || !m.userId || !m.chatId || !providers[m.app]?.on) return;++ const p = providers[m.app];++ const privateChat = m.chatType === 'private';++ const settings = await store.settings(m.app, group(m));++ if (!privateChat && m.kind !== 'button' && !m.mention && !m.replyToBot) return;++ if (!privateChat && settings.mode === 'off' && m.command !== 'settings') return;++ const ident = identity(m);++ // Rate-limit link/help as well, including an unlinked sender. Callbacks have their own bounded budget.++ if (!await store.rate(`${m.app}:user:${ident}:${m.kind === 'button' ? 'buttons' : 'messages'}`, m.kind === 'button' ? 60 : config.chatApps.perUserRatePerHour)) return;++ if (m.command === 'help') return send(m, 'Send /link in a direct message to connect your OpenVibe agent. /unlink disconnects it. In a group, mention or reply to me. Admins: /settings off|answer_only|act_with_approvals [answers per hour].');++ if (m.command === 'link') {++ if (!privateChat) return send(m, 'Send /link to me in a direct message.');++ return linkCommand({ message: { ...m, userId: ident }, provider: p, store, network, config });++ }++ const subject = await resolve({ app: m.app, serviceUserId: ident });++ if (!subject) {++ // The confirm page sends the person back here with a 6-digit code. Only the chat++ // account that started /link can finish it (Network checks the identity), and only in++ // a direct message: a code in a group would be read by everyone there.++ 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?.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 === '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+/);++ if (!['off', 'answer_only', 'act_with_approvals'].includes(args[0]) || (args[1] && !/^\d{1,3}$/.test(args[1]))) return send(m, 'Use /settings off|answer_only|act_with_approvals [1–500 answers per hour].');++ const out = await store.saveSettings(m.app, group(m), { mode: args[0], rate_per_hour: Number(args[1] || settings.rate_per_hour) });++ return send(m, `Group setting: ${out.mode}, up to ${out.rate_per_hour} answers per hour.`);++ }++ if (m.kind === 'button') {++ // Telegram keeps a spinner on the button until its callback is answered.++ if (m.app === 'telegram' && m.callbackQueryId) await p.answerButton({ buttonId: m.callbackQueryId }).catch(() => {});++ if (!privateChat && settings.mode !== 'act_with_approvals') return send(m, 'Acting is disabled in this group.');++ const id = verifyButton({ secret: config.chatApps.callbackSecret, token: m.button?.id });++ const taken = id && await store.takeCallback(id, { app: m.app, chatId: group(m), userId: ident, subject });++ if (!taken?.ok) return send(m, 'This button is unavailable, expired, or belongs to someone else.');++ await actor.decide({ subject, approvalId: taken.row.approval_id, decision: taken.row.action });++ return send(m, taken.row.action === 'approve' ? 'Approved.' : 'Declined.');++ }++ if (m.command && m.command !== 'ask') return send(m, 'Use /help to see the commands.');++ 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 });++ const shown = new Set();++ const until = now() + 11 * 60_000;++ while (now() < until) {++ signal?.throwIfAborted();++ // Revocation or a change of the group policy stops delivery of private results/approval cards.++ if (await resolve({ app: m.app, serviceUserId: ident }) !== subject) return;++ if (!privateChat && (await store.settings(m.app, group(m))).mode !== settings.mode) return;++ const task = await actor.task(subject, started.id);++ for (const a of await actor.approvals(subject, conversation(m))) {++ if (shown.has(a.id)) continue;++ const buttons = [];++ for (const action of ['approve', 'decline']) {++ const id = await store.openCallback({ app: m.app, chatId: group(m), userId: ident, subject, action, approvalId: a.id,++ expiresAt: Math.min(Date.parse(a.expires_at), now() + config.chatApps.callbackTtlMs) });++ buttons.push({ label: action === 'approve' ? 'Approve' : 'Decline', id: signButton({ secret: config.chatApps.callbackSecret, id }) });++ }++ const summary = lineOk(a.summary) ? a.summary : 'Review this request on OpenVibe.Actor.';++ await p.sendButtons({ chatId: m.chatId, threadTs: m.threadTs, text: `Needs you: ${summary}`.slice(0, 1800), buttons });++ shown.add(a.id);++ }++ if (['succeeded', 'failed', 'cancelled'].includes(task.state)) {++ if (task.state !== 'succeeded') return send(m, 'Your agent could not finish this request. Check your task on openvibe.actor.');++ const result = task.result || {};++ const files = (result.artifacts || []).filter(a => /^https:\/\//.test(a.url || '')).map(a => `${a.name || 'File'}: ${a.url}`);++ return send(m, [result.answer || result.text || 'Done.', ...files].join('\n\n'));++ }++ await sleep(1500, undefined, { signal });++ }++ await send(m, 'Your agent is still working. You can follow the task on openvibe.actor.');++ }++ return { handle, send };++}++module.exports = { createHandler, identity, group, conversation };nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++const config = require('../config');++const db = require('../db/database');++const { createProviders } = require('./providers');++const { createStore } = require('./store');++const { createNetworkClient } = require('./network');++const { createActorClient } = require('./actor');++const { createHandler } = require('./handler');++function createChatApps({ log = console } = {}) {++ const { providers, whoami } = createProviders({ config });++ const store = createStore({ db });++ const handler = createHandler({ config, store, providers, network: createNetworkClient(), actor: createActorClient() });++ const abort = new AbortController();++ let timer, gateway, starting = false, stopped = false;++ const running = [0, 0];++ async function worker(button) {++ const lane = button ? 1 : 0;++ if (stopped || running[lane] >= (button ? 2 : 4)) return;++ running[lane]++;++ try {++ for (;;) {++ if (stopped) return;++ const row = await store.next(button);++ if (!row) return;++ const m = typeof row.message === 'string' ? JSON.parse(row.message) : row.message;++ let ok = true;++ try { await handler.handle(m, row.id, abort.signal); }++ catch {++ ok = false;++ if (!stopped) await handler.send(m, 'I could not complete that request. Please try again.').catch(() => {});++ log.warn('[chat-apps] delivery failed');++ }++ await store.finish(row.app, row.id, ok);++ }++ } catch { log.warn('[chat-apps] queue unavailable'); }++ finally { running[lane]--; }++ }++ const wake = () => { if (!starting && !stopped) { void worker(false); void worker(true); } };++ const router = require('./ingress').router({ config, providers, store, wake, onError: () => log.warn('[chat-apps] ingress unavailable') });++ async function start() {++ if (!Object.values(providers).some(p => p.on)) return;++ starting = true;++ try {++ await whoami();++ // A delivery interrupted after an external effect is not blindly repeated on restart.++ await db.run("UPDATE chat_app_deliveries SET state = 'failed', message = '{}'::jsonb WHERE state = 'running'");++ await store.prune();++ if (providers.discord.on) gateway = await require('./discord-gateway').start({ config, provider: providers.discord, store, wake, log });++ timer = setInterval(wake, 1000); timer.unref();++ } catch { log.warn('[chat-apps] startup unavailable'); }++ finally { starting = false; wake(); }++ }++ return { router, start, stop() { stopped = true; abort.abort(); clearInterval(timer); gateway?.destroy(); } };++}++module.exports = { createChatApps };nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++const express = require('express');++const crypto = require('crypto');++const sdk = require('openvibe-sdk/chat-apps');++function parse(app, body, provider, cfg) {++ let m;++ if (app === 'telegram') m = sdk.parseTelegramUpdate(body, { botUsername: provider.botUsername, botUserId: provider.botUserId });++ if (app === 'discord') {++ m = sdk.parseDiscordInteraction(body);++ if (m) { m.guildId = body.guild_id || ''; m.args = (body.data?.options || []).map(o => String(o.value)).join(' '); }++ }++ if (app === 'slack') {++ const teamId = typeof body.team === 'object' ? body.team.id : body.team_id;++ if (teamId !== cfg.slack.teamId) return null;++ if (body.type === 'block_actions') m = sdk.parseSlackInteraction(body);++ else if (body.command) m = { app, kind: 'command', chatId: body.channel_id, chatType: /^D/.test(body.channel_id || '') ? 'private' : 'group',++ userId: body.user_id, userName: body.user_name, command: body.command.replace(/^\//, ''), args: body.text || '', text: body.text || '', mention: true };++ else m = sdk.parseSlackEvent(body.event, { botUserId: provider.botUserId });++ if (m) m.teamId = teamId;++ }++ if (!m || ['unknown', 'ping'].includes(m.kind)) return null;++ // Never persist provider access/interaction tokens or arbitrary raw event content.++ const permissions = m.raw?.member?.permissions;++ m.raw = permissions ? { member: { permissions } } : {};++ if (app === 'telegram' && body.callback_query) m.callbackQueryId = body.callback_query.id;++ return m;++}++function router({ config, providers, store, wake, onError = () => {} }) {++ const r = express.Router();++ r.post('/:app', express.raw({ type: '*/*', limit: '256kb' }), async (req, res) => {++ const app = req.params.app;++ const c = config.chatApps[app];++ if (!providers[app]?.on) return res.status(503).json({ error: 'App is off' });++ const raw = req.body;++ if (!Buffer.isBuffer(raw) || !sdk.verifyDelivery({ provider: app, secret: c.secretToken || c.signingSecret, publicKey: c.publicKey, raw, headers: req.headers })) return res.sendStatus(401);++ let body;++ try {++ if (req.is('application/x-www-form-urlencoded')) {++ const form = Object.fromEntries(new URLSearchParams(raw.toString()));++ body = form.payload ? JSON.parse(form.payload) : form;++ } else body = JSON.parse(raw.toString());++ } catch { return res.sendStatus(400); }++ if (app === 'discord' && body.type === 1) return res.json({ type: 1 });++ if (app === 'slack' && body.type === 'url_verification') return res.json({ challenge: body.challenge });++ try {++ const m = parse(app, body, providers[app], config.chatApps);++ if (m) {++ const id = String(body.update_id ?? body.event_id ?? body.id ?? crypto.createHash('sha256').update(raw).digest('hex'));++ if (await store.enqueue(app, id, m)) wake();++ }++ // Final acknowledgement immediately; all work/replies use the bot API outside the HTTP request.++ if (app === 'discord') return res.json({ type: 4, data: { content: 'Received. I will reply in this chat.', flags: 64, allowed_mentions: { parse: [] } } });++ res.sendStatus(200);++ } catch { onError(); res.sendStatus(503); }++ });++ return r;++}++module.exports = { router, parse };nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * Linking: /link and /unlink.++ *++ * /link The bot answers a one-time link to openvibe.network. The person opens it while signed++ * in and confirms; the Network writes the account link (its linked_accounts, the link++ * every service reads) and Chat hears that it was made. The token is good for ten++ * minutes, is registered with the Network (so its page can show what is being linked)++ * and is used once.++ * /unlink The account link is removed and the bot forgets the person in that app.++ *++ * Nothing else works for an account that is not linked: without a subject there is no person to++ * act as, so only /link and /help are answered.++ */++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** Resolve afresh: unlink must revoke approval authority immediately. Fail closed on outage. */++function createResolver({ network }) {++ const resolver = async ({ app, serviceUserId }) => {++ const subject = await network.resolve({ app, serviceUserId });++ return SUBJECT_RE.test(String(subject || '')) ? subject : null;++ };++ resolver.forget = () => {};++ return resolver;++}++++/**++ * The answer to /link: the one-time link the person opens. The link is registered with the Network++ * first, so the page there can show what is being linked (the app and the name) before it asks.++ */++async function linkCommand({ message, provider, store, network, config, log = console }) {++ const token = store.newToken();++ const expiresAt = Date.now() + config.chatApps.linkTtlMs;++ await store.saveToken({++ token, app: message.app, serviceUserId: message.userId,++ serviceUsername: message.userName, expiresAt,++ });++ try {++ const registered = await network.registerPending({++ token, app: message.app, serviceUserId: message.userId,++ serviceUsername: message.userName, botUsername: provider.botUsername, expiresAt,++ });++ if (!registered) throw new Error('Link registration failed');++ } catch (err) {++ throw new Error('Link service unavailable');++ }++ const url = `${config.chatApps.linkUrl}?t=${encodeURIComponent(token)}`;++ await provider.sendText({++ chatId: message.chatId,++ text: `Link your ${message.app} account to OpenVibe: open this link while you are signed in to openvibe.network and confirm it. The page shows a 6-digit code — send that code to me here, from this chat, to finish.\n\n${url}\n\nThe link is good for ${Math.round(config.chatApps.linkTtlMs / 60000)} minutes and works once.`,++ });++++ return { token, url };++}++++/** The answer to /unlink: the account link is removed and the person is forgotten in that app. */++async function unlinkCommand({ message, subject, provider, network, forget, log = console }) {++ if (!subject) {++ await provider.sendText({ chatId: message.chatId, text: 'You are not linked here. Send /link to link your account.' });++ return { ok: false, reason: 'not_linked' };++ }++ let removed = false;++ try { removed = await network.unlink({ app: message.app, serviceUserId: message.userId, subject }); } catch (err) {++ log.warn(`[chat-apps] ${message.app}: the Network did not take the unlink: ${err && err.message}`);++ }++ if (removed && forget) forget({ app: message.app, serviceUserId: message.userId });++ await provider.sendText({++ chatId: message.chatId,++ text: removed ? 'You are not linked here any more. Send /link to link again.' : 'I could not remove the link just now. Try again in a while.',++ });++ return { ok: removed };++}++++module.exports = { createResolver, linkCommand, unlinkCommand, SUBJECT_RE };nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * Chat → the Network: the account links and the person behind a chat identity.++ *++ * The Network owns the linked accounts (its linked_accounts, the account link every service reads);++ * Chat asks it for three things:++ *++ * registerPending({ token, app, serviceUserId, serviceUsername, expiresAt })++ * The one-time token from /link is registered with the Network, so the page that asks the++ * person (openvibe.network/chat-apps/link, signed in there) can show what is being linked and++ * write the account link when they say yes.++ * pending(token) → { state: 'pending' | 'confirmed' | 'unknown', subject }++ * Whether the person confirmed the link, and the subject it was confirmed for.++ * resolve({ app, serviceUserId }) → subject | null++ * The OpenVibe subject of a person in a chat app. A person who is not linked has no subject here.++ * unlink({ app, serviceUserId, subject }) → true++ * The account link is removed: the bot forgets the person in that app.++ *++ * The calls go to the Network's own routes with Chat's service token (client `chat`, audience++ * openvibe.network, the capability identity.subject.resolve).++ */++const config = require('../config');++++let _client = null;++++function client() {++ if (_client) return _client;++ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');++ const { createClient } = require('openvibe-sdk');++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const tokens = createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network',++ scope: 'identity.subject.resolve',++ });++ _client = createClient({ tokenProvider: tokens, timeoutMs: 20000 });++ return _client;++}++++/** The Network's answer, or null when it cannot be reached or does not know the person. */++function createNetworkClient({ http = null } = {}) {++ const call = async (opts) => {++ const c = http || client();++ try {++ return await c.json({ service: 'network', baseUrl: config.networkInternalUrl, audience: 'openvibe.network', retries: 1, ...opts });++ } catch (err) {++ if (err && (err.status === 404 || err.status === 400)) return null;++ throw err;++ }++ };++++ return {++ /** Register the one-time link token with the Network (its page asks the person). */++ async registerPending({ token, app, serviceUserId, serviceUsername, botUsername, expiresAt }) {++ const out = await call({++ method: 'POST', path: '/internal/chat-app-links', idempotent: true,++ json: {++ token, app, service_user_id: serviceUserId, service_username: serviceUsername || null,++ bot_handle: botUsername || null, expires_at: new Date(expiresAt).toISOString(),++ },++ });++ return !!(out && out.ok !== false);++ },++++ /** Whether the person confirmed the link, and for which subject. */++ async pending(token) {++ const out = await call({ method: 'GET', path: '/internal/chat-app-links', query: { t: token } });++ if (!out) return { state: 'unknown' };++ return { state: out.state || 'pending', subject: out.subject || null, app: out.app || null, service_username: out.service_username || null };++ },++++ /**++ * Finish a link: the chat account that started /link sends the 6-digit code from the++ * confirm page. Network checks the code (three wrong codes cancel it) and only then++ * writes the account link; a person who is already linked here never needs this.++ */++ async finish({ app, serviceUserId, code }) {++ try {++ const out = await call({++ method: 'POST', path: '/internal/chat-app-links/finish', idempotent: false,++ json: { app, service_user_id: serviceUserId, code },++ });++ return out && typeof out === 'object' ? out : { ok: false, error: 'unavailable' };++ } catch (err) {++ return { ok: false, error: err && err.status === 409 ? 'conflict' : 'unavailable' };++ }++ },++++ /** The OpenVibe subject of a person in a chat app, or null when they are not linked. */++ async resolve({ app, serviceUserId }) {++ const out = await call({ method: 'GET', path: '/internal/chat-app-links/resolve', query: { app, id: serviceUserId } });++ const subject = out && (out.subject_id || (out.subject && out.subject.id));++ return subject ? String(subject) : null;++ },++++ /** The account link is removed. */++ async unlink({ app, serviceUserId, subject }) {++ const out = await call({++ method: 'POST', path: '/internal/chat-app-links/unlink', idempotent: true,++ json: { app, service_user_id: serviceUserId, subject: subject || null },++ });++ return !!(out && out.ok !== false);++ },++ };++}++++module.exports = { createNetworkClient };nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++// Secrets are used only for fixed provider origins and are never included in errors or logs.++function createProviders({ config, fetchImpl = globalThis.fetch }) {++ const cfg = config.chatApps;++ const known = {};++ const api = async (app, method, body, verb = 'POST') => {++ const c = cfg[app];++ const origin = app === 'telegram' ? `https://api.telegram.org/bot${c.botToken}/` : app === 'discord' ? 'https://discord.com/api/v10/' : 'https://slack.com/api/';++ const headers = { 'Content-Type': 'application/json', ...(app === 'telegram' ? {} : { Authorization: `${app === 'discord' ? 'Bot' : 'Bearer'} ${c.botToken}` }) };++ let r;++ try { r = await fetchImpl(origin + method, { method: verb, headers, ...(verb === 'GET' ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000) }); }++ catch { throw new Error(`${app} unavailable`); }++ const out = await r.json().catch(() => null);++ if (!r.ok || !out || out.ok === false) throw new Error(`${app} request refused`);++ return app === 'telegram' ? out.result : out;++ };++ const providers = {};++ for (const app of ['telegram', 'discord', 'slack']) {++ const c = cfg[app];++ providers[app] = {++ app,++ get on() { return Boolean(cfg.callbackSecret && c.botToken && (app === 'telegram' ? c.secretToken : app === 'discord' ? c.publicKey && c.applicationId : c.signingSecret && c.teamId)); },++ get botUserId() { return known[app]?.id || known[app]?.user_id || null; },++ get botUsername() { return c.username || known[app]?.username || known[app]?.user || null; },++ async whoami() {++ if (!this.on) return;++ known[app] = await api(app, app === 'telegram' ? 'getMe' : app === 'discord' ? 'users/@me' : 'auth.test', {}, app === 'discord' ? 'GET' : 'POST');++ },++ async sendText({ chatId, text, threadTs }) {++ if (!this.on) throw new Error('App is off');++ if (app === 'telegram') return api(app, 'sendMessage', { chat_id: chatId, text, link_preview_options: { is_disabled: true } });++ if (app === 'discord') return api(app, `channels/${encodeURIComponent(chatId)}/messages`, { content: text, allowed_mentions: { parse: [] } });++ return api(app, 'chat.postMessage', { channel: chatId, text, mrkdwn: false, unfurl_links: false, unfurl_media: false, ...(threadTs ? { thread_ts: threadTs } : {}) });++ },++ async sendButtons({ chatId, text, buttons, threadTs }) {++ if (!this.on) throw new Error('App is off');++ if (buttons.some(b => Buffer.byteLength(b.id) > 64)) throw new Error('Button is too long');++ if (app === 'telegram') return api(app, 'sendMessage', { chat_id: chatId, text, reply_markup: { inline_keyboard: [buttons.map(b => ({ text: b.label, callback_data: b.id }))] } });++ if (app === 'discord') return api(app, `channels/${encodeURIComponent(chatId)}/messages`, { content: text, allowed_mentions: { parse: [] }, components: [{ type: 1, components: buttons.map(b => ({ type: 2, style: 2, label: b.label, custom_id: b.id })) }] });++ return api(app, 'chat.postMessage', { channel: chatId, text, ...(threadTs ? { thread_ts: threadTs } : {}), blocks: [++ { type: 'section', text: { type: 'plain_text', text } },++ { type: 'actions', elements: buttons.map(b => ({ type: 'button', text: { type: 'plain_text', text: b.label }, action_id: b.id, value: b.id })) },++ ] });++ },++ async answerButton({ buttonId, text }) {++ if (app === 'telegram') await api(app, 'answerCallbackQuery', { callback_query_id: buttonId, text });++ },++ async isAdmin({ message }) {++ if (app === 'telegram') { const u = await api(app, 'getChatMember', { chat_id: message.chatId, user_id: message.userId }); return ['creator', 'administrator'].includes(u.status); }++ if (app === 'discord') {++ if (message.gateway) return message.admin === true;++ const permissions = String(message.raw?.member?.permissions || '');++ return /^\d+$/.test(permissions) && (BigInt(permissions) & (8n | 32n)) !== 0n;++ }++ const u = (await api(app, `users.info?user=${encodeURIComponent(message.userId)}`, null, 'GET')).user;++ return Boolean(u && u.team_id === c.teamId && (u.is_admin || u.is_owner));++ },++ };++ }++ return { providers, async whoami() { await Promise.all(Object.values(providers).filter(p => p.on).map(p => p.whoami())); } };++}++module.exports = { createProviders };nnew file mode 100644@@@ -0,0 +1,156 @@++'use strict';++/**++ * The chat apps' own rows (migrations/0037_chat_apps.sql): the one-time link tokens a person gets++ * from /link, the rules of a group and the buttons a person may press.++ *++ * The link token One row per /link: the token's hash (the token itself is only in the person's++ * link and in the Network's page), the app, the person in the app and when it++ * expires. It is used once: the person confirms it on the Network's page (signed++ * in there) and the same token binds the app's account to their OpenVibe subject.++ * The settings One row per chat (a group, a channel, a server): what the bot may do there++ * (off, answer only, act with approvals) and how many answers an hour it may give.++ * The callback One row per button: who may press it, what it does and until when. A button is++ * used once: the row is marked used the first time it is pressed, so the same++ * button cannot approve twice.++ *++ * The account link itself is the Network's (linked_accounts, the account link every service reads);++ * these rows are the chat apps' own bookkeeping.++ */++const crypto = require('crypto');++++const MODES = ['off', 'answer_only', 'act_with_approvals'];++const ACTIONS = ['approve', 'decline'];++++const iso = (ms) => new Date(ms).toISOString();++const hash = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');++++/** A new one-time token: 32 random bytes, base64url. Only its hash is kept. */++function newToken() {++ return crypto.randomBytes(32).toString('base64url');++}++++/** A new button's id: 16 characters of base64url (96 bits), unguessable and short enough for Telegram. */++function newId() {++ return crypto.randomBytes(12).toString('base64url');++}++++function createStore({ db, now = () => Date.now() }) {++ /** The pending token of a person in an app (the newest one that has not expired), if any. */++ async function pendingToken(app, serviceUserId) {++ return await db.get(++ `SELECT * FROM chat_app_link_tokens++ WHERE app = ? AND service_user_id = ? AND state = 'pending' AND expires_at > ?++ ORDER BY created_at DESC LIMIT 1`,++ [app, serviceUserId, iso(now())]);++ }++++ /** A token's row by its hash (the person carries the token itself). */++ async function tokenRow(token) {++ if (!token) return null;++ return await db.get('SELECT * FROM chat_app_link_tokens WHERE token_hash = ?', [hash(token)]);++ }++++ /** Keep the token: one row per token, and the newest pending one for that person. */++ async function saveToken({ token, app, serviceUserId, serviceUsername, expiresAt }) {++ const h = hash(token);++ await db.run(++ `INSERT INTO chat_app_link_tokens (token_hash, app, service_user_id, service_username, state, created_at, expires_at)++ VALUES (?, ?, ?, ?, 'pending', ?, ?)++ ON CONFLICT (token_hash) DO UPDATE SET app = excluded.app, service_user_id = excluded.service_user_id,++ service_username = excluded.service_username, state = 'pending', created_at = excluded.created_at, expires_at = excluded.expires_at`,++ [h, app, serviceUserId, serviceUsername || null, iso(now()), iso(expiresAt)]);++ return h;++ }++++ /** The token is used: it is bound to the subject and cannot be used again. */++ async function useToken(token, subject) {++ const h = hash(token);++ const r = await db.run(++ `UPDATE chat_app_link_tokens SET state = 'used', subject = ?, used_at = ?++ WHERE token_hash = ? AND state = 'pending' AND expires_at > ?`,++ [subject, iso(now()), h, iso(now())]);++ return r.changes > 0;++ }++++ /** The rules of a chat. The default is the safe one: answer when mentioned, never act. */++ async function settings(app, chatId) {++ const row = await db.get('SELECT * FROM chat_app_settings WHERE app = ? AND chat_id = ?', [app, chatId]);++ return {++ mode: row && MODES.includes(row.mode) ? row.mode : 'answer_only',++ rate_per_hour: row ? Number(row.rate_per_hour) || 20 : 20,++ };++ }++++ /** Change the rules of a chat. */++ async function saveSettings(app, chatId, { mode, rate_per_hour }) {++ if (!MODES.includes(mode)) throw new Error(`mode must be one of ${MODES.join(', ')}`);++ const rate = Math.max(1, Math.min(500, Number(rate_per_hour) || 20));++ await db.run(++ `INSERT INTO chat_app_settings (app, chat_id, mode, rate_per_hour, updated_at) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT (app, chat_id) DO UPDATE SET mode = excluded.mode, rate_per_hour = excluded.rate_per_hour, updated_at = excluded.updated_at`,++ [app, chatId, mode, rate, iso(now())]);++ return { mode, rate_per_hour: rate };++ }++++ /** A button: one row, open until it is pressed or it expires. */++ async function openCallback({ app, chatId, userId, subject, action, approvalId, expiresAt }) {++ if (!ACTIONS.includes(action)) throw new Error(`action must be one of ${ACTIONS.join(', ')}`);++ const id = newId();++ await db.run(++ `INSERT INTO chat_app_callbacks (id, app, chat_id, user_id, subject, action, approval_id, state, created_at, expires_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,++ [id, app, chatId, userId, subject || null, action, approvalId || null, iso(now()), iso(expiresAt)]);++ return id;++ }++++ /**++ * Take a button: it is used once. The row is marked used only when it was open and had not++ * expired, so a second press (or a press after the expiry) changes nothing.++ */++ async function takeCallback(id, { app, chatId, userId, subject }) {++ const row = await db.get(`UPDATE chat_app_callbacks SET state = 'used', used_at = ?++ WHERE id = ? AND app = ? AND chat_id = ? AND user_id = ? AND subject = ?++ AND state = 'open' AND expires_at > ? RETURNING *`,++ [iso(now()), id, app, chatId, userId, subject, iso(now())]);++ return row ? { ok: true, row } : { ok: false, reason: 'unavailable' };++ }++ async function rate(key, limit) {++ const hour_bucket = Math.floor(now() / 3600000);++ const row = await db.get(`INSERT INTO chat_app_rates (key, hour_bucket, count) VALUES (?, ?, 1)++ ON CONFLICT (key) DO UPDATE SET hour_bucket = excluded.hour_bucket,++ count = CASE WHEN chat_app_rates.hour_bucket = excluded.hour_bucket THEN chat_app_rates.count + 1 ELSE 1 END++ WHERE chat_app_rates.hour_bucket != excluded.hour_bucket OR chat_app_rates.count < ? RETURNING count`, [key, hour_bucket, limit]);++ return Boolean(row);++ }++ async function enqueue(app, id, message) {++ return Boolean(await db.get(`INSERT INTO chat_app_deliveries (app, id, message) VALUES (?, ?, ?::jsonb)++ ON CONFLICT DO NOTHING RETURNING id`, [app, id, JSON.stringify(message)]));++ }++ async function next(button = false) {++ return db.get(`UPDATE chat_app_deliveries SET state = 'running' WHERE (app, id) =++ (SELECT app, id FROM chat_app_deliveries WHERE state = 'pending' AND ((message->>'kind' = 'button') = ?::boolean) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED)++ AND state = 'pending' RETURNING *`, [button]);++ }++ async function finish(app, id, ok) {++ await db.run("UPDATE chat_app_deliveries SET state = ?, message = '{}'::jsonb WHERE app = ? AND id = ?", [ok ? 'done' : 'failed', app, id]);++ }++++ /** The buttons of a chat that are still open (for the tests and for a restart). */++ async function openCallbacks(app, chatId) {++ return await db.all(++ `SELECT * FROM chat_app_callbacks WHERE app = ? AND chat_id = ? AND state = 'open' AND expires_at > ?`,++ [app, chatId, iso(now())]);++ }++++ /** Old rows are dropped: a token that expired and a button that was pressed are of no use. */++ async function prune() {++ await db.run('DELETE FROM chat_app_deliveries WHERE created_at < ?', [iso(now() - 86400000)]);++ await db.run('DELETE FROM chat_app_rates WHERE hour_bucket < ?', [Math.floor(now() / 3600000) - 1]);++ await db.run('DELETE FROM chat_app_link_tokens WHERE expires_at < ?', [iso(now() - 24 * 3600 * 1000)]);++ await db.run('DELETE FROM chat_app_callbacks WHERE expires_at < ?', [iso(now() - 24 * 3600 * 1000)]);++ }++++ return { rate, enqueue, next, finish, pendingToken, tokenRow, saveToken, useToken, settings, saveSettings, openCallback, takeCallback, openCallbacks, prune, newToken, newId, MODES, ACTIONS };++}++++module.exports = { createStore, MODES, ACTIONS, newToken, newId };@@@ -81,6 +81,10 @@ const EXPORTS = [// Feeds (server/feeds/feeds.js): the feeds, without their key's hash, and what was published to them.['feeds.json', `SELECT id, kind, title, room_id, managed_stream_id, settings, key_hint, legacy_source, created_at, last_event_at FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],['feed_events.json', `SELECT e.feed_id, e.session_key, e.event_id, e.type, e.summary, e.payload, e.created_at FROM feed_events e JOIN feeds f ON f.id = e.feed_id WHERE f.owner_subject = ANY(@s) OR f.owner_id = ANY(@ids) ORDER BY e.id DESC LIMIT ${LIMIT}`],++ // Their chat-app accounts (migrations/0037_chat_apps.sql): the one-time links they made and the++ // buttons their agent's approvals put in a chat. The group settings are the group's, not theirs.++ ['chat_app_links.json', `SELECT app, service_user_id, service_username, state, created_at, expires_at, used_at FROM chat_app_link_tokens WHERE subject = ANY(@s) ORDER BY created_at DESC LIMIT ${LIMIT}`],++ ['chat_app_approvals.json', `SELECT app, chat_id, action, approval_id, state, created_at, expires_at, used_at FROM chat_app_callbacks WHERE subject = ANY(@s) ORDER BY created_at DESC LIMIT ${LIMIT}`],];async function exportPart(d, subject) {@@@ -153,6 +157,11 @@ const ERASE_AFTER_ROOMS = [[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.[null, "UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL WHERE engine_subject = ANY(@s)"],++ // Their chat-app deliveries, links and approval buttons go. A delivery's text is kept only until it++ // is answered or pruned (24 h); these are the ones the linked account still had queued.++ [null, "DELETE FROM chat_app_deliveries WHERE EXISTS (SELECT 1 FROM chat_app_link_tokens t WHERE t.subject = ANY(@s) AND t.app = chat_app_deliveries.app AND t.service_user_id = chat_app_deliveries.message->>'userId')"],++ ['chat_app_links', 'DELETE FROM chat_app_link_tokens WHERE subject = ANY(@s)'],++ ['chat_app_approvals', 'DELETE FROM chat_app_callbacks WHERE subject = ANY(@s)'],];// Their channel (only when they have Live ids): emotes, sounds, moderators, stats, audio requests, the Live mirrors.const ERASE_CHANNEL = [@@@ -219,6 +219,43 @@ module.exports = {vibeRepliesPerDay: Math.max(1, int(process.env.CHAT_VIBE_REPLIES_PER_DAY, 100)),},++ // The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of++ // 2026-10-30, stage A4). Each app is OFF until its own token and its own secret are here: the owner's++ // settings (the same way AI's provider keys and Watch's secrets are configured). An app without its++ // token answers nothing and its webhook is never set.++ chatApps: {++ telegram: {++ botToken: process.env.CHAT_TELEGRAM_BOT_TOKEN || '',++ // The secret token setWebhook's secret_token made (Telegram sends it in++ // X-Telegram-Bot-Api-Secret-Token; it is checked before a delivery is read).++ secretToken: process.env.CHAT_TELEGRAM_SECRET_TOKEN || '',++ username: process.env.CHAT_TELEGRAM_USERNAME || '',++ },++ discord: {++ botToken: process.env.CHAT_DISCORD_BOT_TOKEN || '',++ // The application's public key (64 hex characters), the one Discord signs its interactions with.++ publicKey: process.env.CHAT_DISCORD_PUBLIC_KEY || '',++ applicationId: process.env.CHAT_DISCORD_APPLICATION_ID || '',++ },++ slack: {++ botToken: process.env.CHAT_SLACK_BOT_TOKEN || '',++ teamId: process.env.CHAT_SLACK_TEAM_ID || '',++ // The app's signing secret (Slack signs each delivery with it).++ signingSecret: process.env.CHAT_SLACK_SIGNING_SECRET || '',++ },++ // The secret the buttons are signed with (the app's own, never the app's token).++ callbackSecret: process.env.CHAT_APPS_CALLBACK_SECRET || '',++ // Where a person confirms a link (the Network's page; the person is signed in there).++ linkUrl: strip(process.env.CHAT_LINK_CONFIRM_URL || 'https://openvibe.network/chat-apps/link'),++ // How long a link token and a button live.++ linkTtlMs: Math.min(600_000, Math.max(60_000, int(process.env.CHAT_LINK_TTL_MS, 10 * 60 * 1000))),++ callbackTtlMs: Math.max(60_000, int(process.env.CHAT_CALLBACK_TTL_MS, 30 * 60 * 1000)),++ // A group's own rate: how many answers an hour its members get.++ ratePerHour: Math.max(1, int(process.env.CHAT_APPS_RATE_PER_HOUR, 20)),++ // A group's own rate: how many answers an hour one member gets.++ perUserRatePerHour: Math.max(1, int(process.env.CHAT_APPS_PER_USER_RATE_PER_HOUR, 10)),++ },++// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows// hold absolute paths into it, and Live's donation alerts read the alert sounds from there.sounds: {@@@ -57,6 +57,7 @@ async function start() {const relay = serviceOutbox.configure({ config });const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer });++ void app.locals.chatApps.start();server.on('request', app);server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });relay.start();@@@ -105,6 +106,7 @@ async function start() {() => require('./rooms/workspace').stop(),() => subs.stop(),() => { if (valkey) return valkey.close(); },++ () => app.locals.chatApps.stop(),() => chatServer.close(),() => callServer.close(),],@@@ -33,7 +33,7 @@ t('export, then erase and confirm', async () => {const d = require('../server/db/database').getDb();{const missing = await uncovered(d, {-- taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_messages', 'chat_server_invites', 'chat_server_members',++ taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members','ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_participants', 'emotes', 'inbox_actions','inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages','user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage'],@@@ -53,6 +53,11 @@ t('export, then erase and confirm', async () => {await d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(dana.subject_id, otto.subject_id, otto.subject_id, dana.subject_id);await d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id, actor_subject_id) VALUES ('timeout', ?, ?, ?)").run(otto.id, dana.id, otto.subject_id);await d.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (?, 'veteran')").run(dana.id);++ // Chat-app rows (migrations/0037_chat_apps.sql): her linked chat account, a button and a queued delivery.++ const future = new Date(Date.now() + 600000).toISOString();++ await d.prepare("INSERT INTO chat_app_link_tokens (token_hash, app, service_user_id, service_username, state, subject, expires_at) VALUES ('h-dana', 'telegram', '123', 'dana_tg', 'used', ?, ?)").run(dana.subject_id, future);++ await d.prepare("INSERT INTO chat_app_callbacks (id, app, chat_id, user_id, subject, action, approval_id, state, expires_at) VALUES ('cb-dana', 'telegram', '123', '123', ?, 'approve', 'apr1', 'used', ?)").run(dana.subject_id, future);++ await d.prepare("INSERT INTO chat_app_deliveries (app, id, message, state) VALUES ('telegram', 'd-dana', ?::jsonb, 'done')").run(JSON.stringify({ app: 'telegram', userId: '123', text: 'her queued text' }));const sent = [];let failNext = false;@@@ -68,6 +73,8 @@ t('export, then erase and confirm', async () => {assert.deepStrictEqual(files['chat_messages.json'].map((m) => m.message), ['hi otto']);assert.deepStrictEqual(files['direct_messages.json'].map((m) => m.message), ['psst']);assert.ok(files['moderation_on_you.json'][0] && !('actor_user_id' in files['moderation_on_you.json'][0]), "moderation on them, without who acted");++ assert.deepStrictEqual(files['chat_app_links.json'].map((l) => l.service_user_id), ['123'], 'her linked chat account');++ assert.deepStrictEqual(files['chat_app_approvals.json'].map((c) => c.approval_id), ['apr1'], 'the button made for her');assert.strictEqual(await accountData.apply(exp, { send }), 'unchanged');assert.strictEqual(sent.length, 1);@@@ -82,6 +89,9 @@ t('export, then erase and confirm', async () => {assert.strictEqual((await q('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');assert.strictEqual((await q('SELECT * FROM moderation_actions')).length, 1, 'moderation history stays');assert.strictEqual((await q('SELECT * FROM user_tags')).length, 0, 'the tags table is Chat’s and goes');++ assert.strictEqual((await q('SELECT * FROM chat_app_link_tokens')).length, 0, 'her chat-app link goes');++ assert.strictEqual((await q('SELECT * FROM chat_app_callbacks')).length, 0, 'her approval buttons go');++ assert.strictEqual((await q('SELECT * FROM chat_app_deliveries')).length, 0, 'her queued chat text goes');assert.strictEqual((await d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id)).username, `deleted-${dana.id}`);assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');const conf = sent[1];nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++const assert = require('node:assert/strict');++const crypto = require('node:crypto');++const express = require('express');++const db = require('../server/db/database');++const { createStore } = require('../server/chat-apps/store');++const { createHandler, group, identity } = require('../server/chat-apps/handler');++const { router, parse } = require('../server/chat-apps/ingress');++const { createProviders } = require('../server/chat-apps/providers');++const { signButton } = require('openvibe-sdk/chat-apps');++(async () => {++ const keys = crypto.generateKeyPairSync('ed25519');++ const cfg = { chatApps: { callbackSecret: 'fixture-callback-only', linkTtlMs: 600000, callbackTtlMs: 600000, perUserRatePerHour: 100,++ linkUrl: 'https://openvibe.network/chat-apps/link',++ telegram: { botToken: 'fixture', secretToken: 'fixture-telegram' },++ 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 = [];++ 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 }]));++ const network = {++ resolve: async () => linked ? subject : null,++ registerPending: async b => { links.push(b); return true; },++ // Network only finishes a link for the chat account that started it and only with its code.++ finish: async ({ serviceUserId, code }) => {++ if (serviceUserId !== pendingUser) return { ok: false, error: 'no_link' };++ if (code !== pendingCode) return { ok: false, error: 'code', attemptsLeft: 2 };++ linked = true; pendingCode = null;++ return { ok: true, subject };++ },++ 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 handler = createHandler({ config: cfg, store, providers, network, actor });++ for (const app of ['telegram','discord','slack']) {++ const fixture = require(`./fixtures/chat-apps/${app}.json`);++ const m = parse(app, fixture, providers[app], cfg.chatApps);++ assert.ok(m); assert.equal(m.chatType, 'private');++ linked = false; pendingCode = null; pendingUser = null;++ await handler.handle(m, `${app}-unlinked`); assert.equal(starts.length, 0);++ await handler.handle({ ...m, command: 'link', kind: 'command' }, `${app}-link`);++ assert.ok(links.at(-1).token.length === 43, 'a one-time link was registered');++ assert.equal(links.at(-1).botUsername, 'our_bot');++ pendingCode = '123456'; pendingUser = identity(m);++ // Another chat account sending the code finishes nothing.++ await handler.handle({ ...m, userId: 'stranger', text: '123456', command: null, kind: 'message' }, `${app}-code-wrong-person`);++ assert.equal(linked, false);++ // A wrong code does not link.++ await handler.handle({ ...m, text: '000000', command: null, kind: 'message' }, `${app}-code-wrong`);++ assert.equal(linked, false); assert.match(sent.at(-1).text, /not right/);++ // 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/);++ await handler.handle(m, `${app}-ask`); assert.equal(starts.at(-1).policy, 'dm');++ 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 };++ await handler.handle(g, 'unmentioned'); assert.equal(starts.length, 0);++ await handler.handle({ ...g, mention: true }, 'mentioned'); assert.equal(starts.at(-1).policy, 'answer_only'); starts.length = 0;++ admin = false;++ await handler.handle({ ...g, mention: true, command: 'settings', args: 'act_with_approvals 10' }, 'badadmin');++ assert.equal((await store.settings(app, group(g))).mode, 'answer_only');++ admin = true;++ await handler.handle({ ...g, mention: true, command: 'settings', args: 'act_with_approvals 10' }, 'admin');++ assert.equal((await store.settings(app, group(g))).mode, 'act_with_approvals');++ const id = await store.openCallback({ app, chatId: group(m), userId: identity(m), subject, action: 'approve', approvalId: 'approval1', expiresAt: Date.now() + 60000 });++ const button = { ...m, command: null, kind: 'button', button: { id: signButton({ secret: cfg.chatApps.callbackSecret, id }) } };++ await handler.handle({ ...button, userId: 'stranger' }, 'wrong'); assert.equal(decisions.length, 0);++ await Promise.all([handler.handle(button, 'right'), handler.handle(button, 'duplicate')]); assert.equal(decisions.length, 1); decisions.length = 0;++ 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(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.++ const web = express(); web.use('/hooks', router({ config: cfg, providers, store, wake() {} }));++ const server = web.listen(0, '127.0.0.1'); await new Promise(r => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/hooks`;++ try {++ for (const app of ['telegram','discord','slack']) {++ const raw = JSON.stringify(require(`./fixtures/chat-apps/${app}.json`));++ const ts = String(Math.floor(Date.now()/1000));++ const headers = { 'content-type': 'application/json' };++ if (app === 'telegram') headers['x-telegram-bot-api-secret-token'] = cfg.chatApps.telegram.secretToken;++ if (app === 'slack') { headers['x-slack-request-timestamp'] = ts; headers['x-slack-signature'] = 'v0=' + crypto.createHmac('sha256', cfg.chatApps.slack.signingSecret).update(`v0:${ts}:${raw}`).digest('hex'); }++ if (app === 'discord') { headers['x-signature-timestamp'] = ts; headers['x-signature-ed25519'] = crypto.sign(null, Buffer.from(ts + raw), keys.privateKey).toString('hex'); }++ assert.equal((await fetch(`${base}/${app}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: raw })).status, 401);++ const at = Date.now();++ assert.equal((await fetch(`${base}/${app}`, { method: 'POST', headers, body: raw })).status, 200); assert.ok(Date.now() - at < 3000);++ assert.equal((await fetch(`${base}/${app}`, { method: 'POST', headers, body: raw })).status, 200);++ assert.equal(Number((await db.get('SELECT count(*) AS n FROM chat_app_deliveries WHERE app = ?', [app])).n), 1);++ providers[app].on = false;++ assert.equal((await fetch(`${base}/${app}`, { method: 'POST', headers, body: raw })).status, 503);++ }++ } finally { await new Promise(r => server.close(r)); }++ const real = createProviders({ config: cfg, fetchImpl: async () => ({ ok: true, json: async () => ({ ok: true, user: { team_id: 'T123', is_admin: true } }) }) }).providers;++ assert.equal(await real.discord.isAdmin({ message: { raw: { member: { permissions: '8' } } } }), true);++ assert.equal(await real.discord.isAdmin({ message: { raw: { member: { permissions: '0' } } } }), false);++ assert.equal(await real.slack.isAdmin({ message: { userId: 'U123' } }), true);++ const rates = await Promise.all(Array.from({ length: 10 }, () => store.rate('race', 2)));++ assert.equal(rates.filter(Boolean).length, 2);++ console.log('chat apps: three payloads, signatures, linking, approvals, identity, expiry, group policy, durable dedupe and rates passed');++})().catch(e => { console.error(e); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1 @@++{"id":"111111111111111111","application_id":"222222222222222222","type":2,"channel_id":"333333333333333333","user":{"id":"123","username":"fixture"},"data":{"name":"ask","options":[{"name":"text","type":3,"value":"hello"}]}}nnew file mode 100644@@@ -0,0 +1 @@++{"type":"event_callback","team_id":"T123","event_id":"Ev123","event":{"type":"message","user":"U123","channel":"D123","channel_type":"im","text":"hello","ts":"1800000000.000001"}}nnew file mode 100644@@@ -0,0 +1 @@++{"update_id":1001,"message":{"message_id":22,"from":{"id":123,"is_bot":false,"username":"fixture"},"chat":{"id":123,"type":"private"},"date":1800000000,"text":"hello"}}@@@ -29,11 +29,13 @@ const TABLES = ['chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases','chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log','network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',++ 'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',];const INDEXES = ['idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup','idx_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent','idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',++ 'chat_app_link_tokens_by_person', 'chat_app_link_tokens_expiry', 'chat_app_callbacks_by_chat', 'chat_app_callbacks_expiry',];const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides','channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions'];
@@ -100,5 +100,25 @@ SOUNDS_PATH=./data/sounds
@@ -100,5 +100,25 @@ SOUNDS_PATH=./data/sounds
# Where a room's Community attachments link (room settings page).
# Where a room's Community attachments link (room settings page).
#OV_COMMUNITY_URL=https://openvibe.community
#OV_COMMUNITY_URL=https://openvibe.community
+# Your agent in Telegram, Discord and Slack (agent plan A4; docs/chat-apps.md). Each app is OFF
+# until its own bot token and its own secret are here. Buttons are signed with
+# CHAT_APPS_CALLBACK_SECRET: a long random string of its own, never an app token.
+#CHAT_APPS_CALLBACK_SECRET=
+#CHAT_TELEGRAM_BOT_TOKEN=
+#CHAT_TELEGRAM_SECRET_TOKEN=
+#CHAT_TELEGRAM_USERNAME=
+#CHAT_DISCORD_BOT_TOKEN=
+#CHAT_DISCORD_PUBLIC_KEY=
+#CHAT_DISCORD_APPLICATION_ID=
+#CHAT_SLACK_BOT_TOKEN=
+#CHAT_SLACK_TEAM_ID=
+#CHAT_SLACK_SIGNING_SECRET=
+# Where a person confirms /link while signed in to OpenVibe.Network
+#CHAT_LINK_CONFIRM_URL=https://openvibe.network/chat-apps/link
+#CHAT_LINK_TTL_MS=600000
+#CHAT_CALLBACK_TTL_MS=1800000
+#CHAT_APPS_RATE_PER_HOUR=20
+#CHAT_APPS_PER_USER_RATE_PER_HOUR=10
+
# Optional diagnostics
# Optional diagnostics
#DEBUG_DM_DELIVERY=1
#DEBUG_DM_DELIVERY=1
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+# Your agent in Telegram, Discord and Slack (A4)
+
+Chat answers the three chat apps. A message to the bot goes to the person's OpenVibe agent in
+OpenVibe.Actor (their memory, tools and budget), and the answer comes back in the same chat.
+OpenVibe.Network owns the account links; Chat keeps only its own rows (the one-time link token,
+a group's rules and the buttons an approval made).
+
+Every app is **off until the owner puts its token and its own secret in the settings**
+(`/etc/openvibe/chat.env`): with one missing, the app answers 503, sets no webhook and starts no
+gateway. Nothing here creates a bot account.
+
+## What the owner creates, in two lines per app
+
+- **Telegram:** a bot from [@BotFather](https://t.me/BotFather) (`/newbot`), then a webhook secret
+ (any long random string). No other account.
+- **Discord:** an application and its bot user (Developer Portal), the application's public key,
+ the bot token, and the **Message Content** intent; invite it with `bot` + `applications.commands`.
+- **Slack:** an app in the workspace with a bot user; the signing secret, the bot token and the
+ workspace (team) id; scopes `chat:write`, `commands`, `users:read`, `team:read`.
+
+## Settings
+
+| Variable | What it is |
+|---|---|
+| `CHAT_APPS_CALLBACK_SECRET` | signs every approval button; a long random string of its own |
+| `CHAT_TELEGRAM_BOT_TOKEN` | BotFather's token |
+| `CHAT_TELEGRAM_SECRET_TOKEN` | the webhook secret (Telegram sends it in `X-Telegram-Bot-Api-Secret-Token`) |
+| `CHAT_TELEGRAM_USERNAME` | the bot's @name, so `/link@another_bot` is not ours (learned from `getMe` when unset) |
+| `CHAT_DISCORD_BOT_TOKEN` | the bot token |
+| `CHAT_DISCORD_PUBLIC_KEY` | 64 hex characters, the application's public key (interactions are signed with it) |
+| `CHAT_DISCORD_APPLICATION_ID` | the application id (commands are registered on it) |
+| `CHAT_SLACK_BOT_TOKEN` | `xoxb-…` |
+| `CHAT_SLACK_TEAM_ID` | the workspace id (`T…`); a delivery from another workspace is refused |
+| `CHAT_SLACK_SIGNING_SECRET` | the app's signing secret (v0 over `v0:<timestamp>:<body>`) |
+| `CHAT_LINK_CONFIRM_URL` | Network's confirm page, default `https://openvibe.network/chat-apps/link` |
+| `CHAT_LINK_TTL_MS` | how long a `/link` token lives (default 10 min) |
+| `CHAT_CALLBACK_TTL_MS` | how long an approval button lives (default 30 min) |
+| `CHAT_APPS_RATE_PER_HOUR` | answers an hour a group gets (default 20) |
+| `CHAT_APPS_PER_USER_RATE_PER_HOUR` | messages an hour one person gets (default 10) |
+
+Secrets are read from the environment only, used only against the provider's fixed origin and
+never printed or put into an error.
+
+## Endpoints and what to set where
+
+The base is Chat's own origin (`CHAT_WEB_URL`, production `https://openvibe.chat`).
+
+- Telegram: `https://openvibe.chat/hooks/chat-apps/telegram`
+- Discord: `https://openvibe.chat/hooks/chat-apps/discord`
+- Slack: `https://openvibe.chat/hooks/chat-apps/slack`
+
+Register what a provider lets us register:
+
+```bash
+sudo node --env-file=/etc/openvibe/chat.env scripts/chat-apps.js # what is on, and as which bot
+sudo node --env-file=/etc/openvibe/chat.env scripts/chat-apps.js --register # Telegram webhook + commands, Discord commands
+```
+
+- **Telegram:** the script calls `setWebhook` (with `secret_token`, `allowed_updates`
+ `message` and `callback_query`) and `setMyCommands` (`/link`, `/unlink`, `/help`, `/settings`).
+- **Discord:** the script PUTs the global commands (`/link`, `/unlink`, `/help`, `/settings`).
+ Set the **Interactions Endpoint URL** to the URL above in the app's General Information page;
+ Discord checks it with a signed PING. Enable **Message Content** so an ordinary message in a
+ group reaches the agent (and `Guilds`, `GuildMessages`, `DirectMessages`).
+- **Slack:** the dashboard sets these: **Event Subscriptions** Request URL = the URL above,
+ bot events `app_mention` and `message.im`; **Interactivity** Request URL = the same; a slash
+ command per command (`/link`, `/unlink`, `/help`, `/settings`) with the same Request URL.
+
+nginx serves `openvibe.chat` through `location /` to Chat, so no new location is needed.
+
+## How it behaves
+
+- **Linking is two-way.** `/link` in a direct message answers a one-time URL. The person opens it
+ signed in to Network, sees that this chat account will talk to their agent and approve its
+ steps, and confirms. The page then shows a **6-digit code** (10 minutes, kept only as a hash):
+ the person sends that code to the bot **from the same chat** to finish. Only then is the account
+ link written; a confirm URL forwarded to somebody else links nothing, because only the chat
+ account that started `/link` can finish it. Three wrong codes cancel it and the code expires.
+ The code is accepted only in a direct message (never in a group) and only from an unlinked
+ account. `/unlink` removes the link. Until linking finishes only `/link` and `/help` answer.
+ A link is used once; the token is stored only as a SHA-256 hash. A second person confirming the
+ same chat account is refused; so is replacing a different linked account with the same service.
+- **A message.** Group answers only when the bot is mentioned or the message replies to it.
+ A direct message is a private policy: the agent may use the person's tools and memory. A group
+ is `answer_only` (public tools, no acting) or `act_with_approvals`; a group never gets the
+ person's private memory, computers, robots or code, and never creates memory, waits or files.
+ Answers are split the app's way (Telegram 4096, Discord 2000, Slack 3000) and the agent's files
+ come back as links. Chat's moderation rules (`lineOk`) apply to everything the bot says; a
+ refused line becomes "I cannot share that answer under the chat rules."
+- **Approvals.** An acting tool (send, post, pay, delete, deploy) never runs on its own: Actor
+ writes an approval row with the exact tool and arguments, and Chat shows it as buttons
+ (Telegram inline keyboard, Discord components, Slack Block Kit). A button is an id signed with
+ `CHAT_APPS_CALLBACK_SECRET`; the row behind it says who may press it (the linked person in the
+ same chat, as the same subject), what it does, that it is open, and when it expires. The row is
+ consumed in one statement, so two presses (or a press by anyone else) decide nothing. Approving
+ lets that one exact call run; the agent cannot change the arguments after consent. Arguments
+ over 1200 characters are refused rather than shown truncated.
+- **Groups.** `/settings off|answer_only|act_with_approvals [answers per hour]` is answered only
+ to a group or workspace administrator (Telegram `getChatMember`, Discord `Manage Guild` /
+ `Administrator`, Slack `is_admin`/`is_owner` in the workspace). `off` answers nothing but
+ `/settings`. The hourly rate is per group; a person also has a per-hour message limit.
+- **Durability.** A delivery is stored (`chat_app_deliveries`, deduped by the provider's event
+ id) and acknowledged before any work: Telegram and Slack expect a fast 200, Discord a reply
+ within 3 seconds. A fixed number of workers answers messages and buttons in separate lanes, so
+ a slow task does not hold a button. On start, a delivery left `running` is marked failed, not
+ replayed after an external effect. Buttons, tokens and deliveries are pruned.
+- **Untrusted text.** In a group the text is wrapped as a stranger's suggestion, never the
+ owner's words, and the tool allow-list is the group policy's. In a direct message it is the
+ person's own message.
+
+## Security notes
+
+- Telegram's secret token, Slack's v0 signing and Discord's Ed25519 signature are checked before
+ the body is parsed, in constant time, against the raw bytes (the SDK's `openvibe-sdk/chat-apps`,
+ the code Watch has used since 2026-11-03). A provider the module does not know is never
+ verified.
+- A provider access token, an interaction token and the raw event are not stored: a delivery keeps
+ only what the answer needs.
+- The link POST on Network checks same-origin, the person must be signed in, and the confirm page
+ shows the app and the chat name before asking. Confirming only mints a code; the link exists
+ only after the same chat account sends that code to the bot. A forwarded confirm URL therefore
+ cannot attach somebody else's agent to the sender's chat.
+- Account deletion removes the person's link tokens, approval buttons and queued deliveries; the
+ export includes their chat-app links and approvals.
+
+## Tests
+
+`node test/run.js chat-apps` covers the three signed payloads across the real HTTP boundary
+(401 without the signature, 503 when an app is off, one delivery stored per event id), linking,
+the 6-digit code finishing from the starter's chat (and a wrong chat account and a wrong code
+refused), an unlinked sender refused, a wrong person's button refused, two presses deciding once,
+an expired button, group mention rules, group admin settings, the moderation refusal, the rates
+and the provider calls. Network's `node test/run.js chat-app-links` covers the code's hash at
+rest, its 10-minute expiry, three wrong codes cancelling the link, and the real service-token
+gate. `node test/run.js account-data` covers export and deletion.
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+-- phase: expand
+-- The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of
+-- 2026-10-30, stage A4). Three kinds of rows: the one-time link token a person gets from /link, the
+-- rules of a group (what the bot may do there and how fast), and the buttons a person may press.
+--
+-- The link token holds only the token's hash: the token itself is in the person's link and in the
+-- Network's page. It is used once (state 'used') when the person confirms it while signed in.
+CREATE TABLE IF NOT EXISTS chat_app_link_tokens (
+ token_hash text COLLATE "C" PRIMARY KEY,
+ app text COLLATE "C" NOT NULL CHECK (app IN ('telegram', 'discord', 'slack')),
+ service_user_id text COLLATE "C" NOT NULL, -- the person in the app (their id there)
+ service_username text COLLATE "C", -- their name there, for the page that asks
+ state text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'used', 'expired')),
+ subject text COLLATE "C", -- their usr_… once the link is made
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ expires_at text COLLATE "C" NOT NULL,
+ used_at text COLLATE "C"
+);
+CREATE INDEX IF NOT EXISTS chat_app_link_tokens_by_person ON chat_app_link_tokens (app, service_user_id, state);
+CREATE INDEX IF NOT EXISTS chat_app_link_tokens_expiry ON chat_app_link_tokens (expires_at);
+
+-- What the bot may do in a group or a channel: off, answer only (it answers when it is mentioned and
+-- never acts), or act with approvals (it may act, and every risky step waits for the linked person).
+-- The rate is how many answers an hour that chat gets.
+CREATE TABLE IF NOT EXISTS chat_app_settings (
+ app text COLLATE "C" NOT NULL CHECK (app IN ('telegram', 'discord', 'slack')),
+ chat_id text COLLATE "C" NOT NULL,
+ mode text COLLATE "C" NOT NULL DEFAULT 'answer_only' CHECK (mode IN ('off', 'answer_only', 'act_with_approvals')),
+ rate_per_hour integer NOT NULL DEFAULT 20 CHECK (rate_per_hour > 0),
+ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (app, chat_id)
+);
+
+-- A button ("Needs you" from the agent). It is used once (state 'used'), it expires, and only the
+-- person the button was made for may press it.
+CREATE TABLE IF NOT EXISTS chat_app_callbacks (
+ id text COLLATE "C" PRIMARY KEY,
+ app text COLLATE "C" NOT NULL CHECK (app IN ('telegram', 'discord', 'slack')),
+ chat_id text COLLATE "C" NOT NULL,
+ user_id text COLLATE "C" NOT NULL, -- the linked person the button belongs to
+ subject text COLLATE "C", -- their usr_…
+ action text COLLATE "C" NOT NULL CHECK (action IN ('approve', 'decline')),
+ approval_id text COLLATE "C", -- the approval in OpenVibe.Actor
+ state text COLLATE "C" NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'used')),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ expires_at text COLLATE "C" NOT NULL,
+ used_at text COLLATE "C"
+);
+CREATE INDEX IF NOT EXISTS chat_app_callbacks_by_chat ON chat_app_callbacks (app, chat_id, state);
+CREATE INDEX IF NOT EXISTS chat_app_callbacks_expiry ON chat_app_callbacks (expires_at);
+
+CREATE TABLE chat_app_deliveries (
+ app text NOT NULL,
+ id text NOT NULL,
+ message jsonb NOT NULL,
+ state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'running', 'done', 'failed')),
+ created_at text NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY(app, id)
+);
+CREATE TABLE chat_app_rates (
+ key text PRIMARY KEY,
+ hour_bucket bigint NOT NULL,
+ count integer NOT NULL
+);
@@ -11,13 +11,14 @@
@@ -11,13 +11,14 @@
"dependencies": {
"dependencies": {
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
+ "discord.js": "14.26.3",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.92.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -36,6 +37,155 @@
@@ -36,6 +37,155 @@
"integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
"integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/@discordjs/builders": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.15.0.tgz",
+ "integrity": "sha512-AifaDWTw6WUDhKBn8E6mwwoyjDAcfsYq+ZE0nKSu1dMH1NLR22D3hGkNx+ctEv8NqHEHdAZnP5REteHDmn1lkA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@discordjs/formatters": "^0.6.2",
+ "@discordjs/util": "^1.2.0",
+ "@sapphire/shapeshift": "^4.0.0",
+ "discord-api-types": "^0.38.50",
+ "fast-deep-equal": "^3.1.3",
+ "ts-mixer": "^6.0.4",
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=16.11.0"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@discordjs/collection": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
+ "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.11.0"
+ }
+ },
+ "node_modules/@discordjs/formatters": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
+ "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "discord-api-types": "^0.38.33"
+ },
+ "engines": {
+ "node": ">=16.11.0"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@discordjs/rest": {
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz",
+ "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@discordjs/collection": "^2.1.1",
+ "@discordjs/util": "^1.2.0",
+ "@sapphire/async-queue": "^1.5.3",
+ "@sapphire/snowflake": "^3.5.5",
+ "@vladfrangu/async_event_emitter": "^2.4.6",
+ "discord-api-types": "^0.38.50",
+ "magic-bytes.js": "^1.13.0",
+ "tslib": "^2.6.3",
+ "undici": "^6.27.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
+ "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": {
+ "version": "3.5.5",
+ "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
+ "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=v14.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/@discordjs/rest/node_modules/undici": {
+ "version": "6.29.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
+ "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/@discordjs/util": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
+ "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "discord-api-types": "^0.38.33"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@discordjs/ws": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
+ "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@discordjs/collection": "^2.1.0",
+ "@discordjs/rest": "^2.5.1",
+ "@discordjs/util": "^1.1.0",
+ "@sapphire/async-queue": "^1.5.2",
+ "@types/ws": "^8.5.10",
+ "@vladfrangu/async_event_emitter": "^2.2.4",
+ "discord-api-types": "^0.38.1",
+ "tslib": "^2.6.2",
+ "ws": "^8.17.0"
+ },
+ "engines": {
+ "node": ">=16.11.0"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
+ "node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
+ "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@ -146,6 +296,67 @@
@@ -146,6 +296,67 @@
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/@sapphire/async-queue": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
+ "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=v14.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/@sapphire/shapeshift": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
+ "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "lodash": "^4.17.21"
+ },
+ "engines": {
+ "node": ">=v16"
+ }
+ },
+ "node_modules/@sapphire/snowflake": {
+ "version": "3.5.3",
+ "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
+ "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=v14.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.6",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
+ "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@vladfrangu/async_event_emitter": {
+ "version": "2.4.7",
+ "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
+ "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=v14.0.0",
+ "npm": ">=7.0.0"
+ }
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -441,6 +652,39 @@
@@ -441,6 +652,39 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/discord-api-types": {
+ "version": "0.38.57",
+ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.57.tgz",
+ "integrity": "sha512-R1CTSSt4RshqCq23j2Xq2ZscvzB4LzG9OB1CYQdKUGdw8Ti/oF/72EEtK2mNpagG/Vj9NcBT7dw9lKLf+Dacfg==",
+ "license": "MIT"
+ },
+ "node_modules/discord.js": {
+ "version": "14.26.3",
+ "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz",
+ "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@discordjs/builders": "^1.14.1",
+ "@discordjs/collection": "1.5.3",
+ "@discordjs/formatters": "^0.6.2",
+ "@discordjs/rest": "^2.6.1",
+ "@discordjs/util": "^1.2.0",
+ "@discordjs/ws": "^1.2.3",
+ "@sapphire/snowflake": "3.5.3",
+ "discord-api-types": "^0.38.40",
+ "fast-deep-equal": "3.1.3",
+ "lodash.snakecase": "4.1.1",
+ "magic-bytes.js": "^1.13.0",
+ "tslib": "^2.6.3",
+ "undici": "6.24.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/discordjs/discord.js?sponsor"
+ }
+ },
"node_modules/dom-serializer": {
"node_modules/dom-serializer": {
"version": "3.1.1",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
@@ -1089,6 +1333,12 @@
@@ -1089,6 +1333,12 @@
}
}
}
}
},
},
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
"node_modules/lodash.defaults": {
"node_modules/lodash.defaults": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -1143,6 +1393,18 @@
@@ -1143,6 +1393,18 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/lodash.snakecase": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
+ "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
+ "license": "MIT"
+ },
+ "node_modules/magic-bytes.js": {
+ "version": "1.13.1",
+ "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz",
+ "integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==",
+ "license": "MIT"
+ },
"node_modules/math-intrinsics": {
"node_modules/math-intrinsics": {
"version": "1.1.0",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1311,9 +1573,8 @@
@@ -1311,9 +1573,8 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.91.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
- "integrity": "sha512-LUWT2g30FUCTgaj9hVUqSfvT4FNMG4LujctcO8tqtXK81vpOOWDbWvaln/zfqFVup9KfCme7fctd46DXrq4Zbw==",
+ "version": "0.92.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.92.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1783,6 +2044,18 @@
@@ -1783,6 +2044,18 @@
"node": ">=0.6"
"node": ">=0.6"
}
}
},
},
+ "node_modules/ts-mixer": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
+ "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz",
+ "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==",
+ "license": "0BSD"
+ },
"node_modules/type-is": {
"node_modules/type-is": {
"version": "1.6.18",
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1802,6 +2075,21 @@
@@ -1802,6 +2075,21 @@
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"license": "ISC"
"license": "ISC"
},
},
+ "node_modules/undici": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
+ "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "license": "MIT"
+ },
"node_modules/unpipe": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -23,13 +23,14 @@
@@ -23,13 +23,14 @@
"dependencies": {
"dependencies": {
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cors": "^2.8.5",
+ "discord.js": "14.26.3",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.92.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * The chat apps' setup helper (agent plan A4): show what is configured, and register what the
+ * providers let a service register for itself.
+ *
+ * node --env-file=/etc/openvibe/chat.env scripts/chat-apps.js
+ * Which apps are on (a token and its own secret are set), and the bot each one is.
+ * node --env-file=/etc/openvibe/chat.env scripts/chat-apps.js --register
+ * Telegram: setWebhook (our /hooks/chat-apps/telegram, the secret token, messages and
+ * callbacks) and setMyCommands. Discord: the global application commands. Slack: the
+ * slash commands and the Request URL are set in the app's dashboard, so this only checks
+ * the token and prints what to paste there.
+ *
+ * No value of a token or a secret is ever printed. The URLs are read from CHAT_WEB_URL (Chat's
+ * own origin), so a staging run registers a staging URL.
+ */
+const config = require('../server/config');
+const { createProviders } = require('../server/chat-apps/providers');
+
+const base = config.web.baseUrl.replace(/\/$/, '');
+const url = (app) => `${base}/hooks/chat-apps/${app}`;
+
+const COMMANDS = {
+ telegram: [
+ { command: 'link', description: 'Link this chat to your OpenVibe account' },
+ { command: 'unlink', description: 'Disconnect this chat from your OpenVibe account' },
+ { command: 'help', description: 'What this agent can do here' },
+ { command: 'settings', description: 'Group admins: off, answer only, or act with approvals' },
+ ],
+ discord: [
+ { name: 'link', description: 'Link this chat to your OpenVibe account', type: 1 },
+ { name: 'unlink', description: 'Disconnect this chat from your OpenVibe account', type: 1 },
+ { name: 'help', description: 'What this agent can do here', type: 1 },
+ {
+ name: 'settings', description: 'Server admins: what the agent may do here', type: 1,
+ options: [
+ { name: 'mode', description: 'off, answer_only or act_with_approvals', type: 3, required: true,
+ choices: ['off', 'answer_only', 'act_with_approvals'].map((v) => ({ name: v, value: v })) },
+ { name: 'rate', description: 'Answers per hour in this server (1-500)', type: 4, required: false, min_value: 1, max_value: 500 },
+ ],
+ },
+ ],
+};
+
+async function api(app, method, body, verb = 'POST') {
+ const c = config.chatApps[app];
+ const origin = app === 'telegram' ? `https://api.telegram.org/bot${c.botToken}/` : app === 'discord' ? 'https://discord.com/api/v10/' : 'https://slack.com/api/';
+ const headers = { 'Content-Type': 'application/json', ...(app === 'telegram' ? {} : { Authorization: `${app === 'discord' ? 'Bot' : 'Bearer'} ${c.botToken}` }) };
+ const res = await fetch(origin + method, { method: verb, headers, ...(verb === 'GET' ? {} : { body: JSON.stringify(body || {}) }), signal: AbortSignal.timeout(15_000) });
+ const out = await res.json().catch(() => null);
+ if (!res.ok || !out || out.ok === false) throw new Error(`${app}: ${(out && (out.description || out.message || out.error)) || `HTTP ${res.status}`}`);
+ return app === 'telegram' ? out.result : out;
+}
+
+async function check(providers) {
+ for (const app of ['telegram', 'discord', 'slack']) {
+ const p = providers[app];
+ if (!p.on) { console.log(`${app}: off (no token, or no secret of its own)`); continue; }
+ try {
+ await p.whoami();
+ console.log(`${app}: on, as ${p.botUsername || p.botUserId || 'the configured bot'}`);
+ } catch (err) {
+ console.log(`${app}: configured, but ${err.message}`);
+ }
+ }
+}
+
+async function register(app) {
+ const c = config.chatApps[app];
+ if (!c.botToken) throw new Error(`${app}: no bot token is set`);
+ if (app === 'telegram') {
+ if (!c.secretToken) throw new Error('telegram: set CHAT_TELEGRAM_SECRET_TOKEN first (it is the webhook secret the deliveries are checked with)');
+ await api('telegram', 'setWebhook', { url: url('telegram'), secret_token: c.secretToken, allowed_updates: ['message', 'callback_query'] });
+ await api('telegram', 'setMyCommands', { commands: COMMANDS.telegram });
+ console.log(`telegram: webhook ${url('telegram')} set, ${COMMANDS.telegram.length} commands registered`);
+ return;
+ }
+ if (app === 'discord') {
+ if (!c.applicationId) throw new Error('discord: set CHAT_DISCORD_APPLICATION_ID first');
+ await api('discord', `applications/${encodeURIComponent(c.applicationId)}/commands`, COMMANDS.discord, 'PUT');
+ console.log(`discord: ${COMMANDS.discord.length} global commands registered; the interactions endpoint is ${url('discord')} (set it in the app's General Information page, with the public key already in CHAT_DISCORD_PUBLIC_KEY)`);
+ console.log('discord: enable the Message Content intent (Bot page) so a message in a group reaches the agent outside a slash command');
+ return;
+ }
+ await api('slack', 'auth.test', {});
+ console.log(`slack: token ok; set the Request URL ${url('slack')} under Event Subscriptions, and the slash command Request URL ${url('slack')}`);
+ console.log('slack: subscribe to app_mention and message.im; enable Interactivity (same Request URL); the bot needs chat:write, users:read, team:read and commands');
+}
+
+(async () => {
+ const { providers } = createProviders({ config });
+ if (process.argv.includes('--register')) {
+ for (const app of ['telegram', 'discord', 'slack']) {
+ if (!providers[app].on) { console.log(`${app}: off, nothing registered`); continue; }
+ try { await register(app); } catch (err) { console.log(`${app}: ${err.message}`); process.exitCode = 1; }
+ }
+ return;
+ }
+ await check(providers);
+ console.log(`\nEndpoints: telegram ${url('telegram')} · discord ${url('discord')} · slack ${url('slack')}`);
+})().catch((err) => { console.error(err.message); process.exitCode = 1; });
@@ -84,6 +84,9 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
@@ -84,6 +84,9 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
}
}
res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });
res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });
});
});
+ const chatApps = require('./chat-apps').createChatApps();
+ app.locals.chatApps = chatApps;
+ app.use('/hooks/chat-apps', chatApps.router);
app.disable('x-powered-by');
app.disable('x-powered-by');
// Cloudflare → nginx → Node: TRUST_PROXY hops, but a hop past nginx only when it is a
// Cloudflare → nginx → Node: TRUST_PROXY hops, but a hop past nginx only when it is a
// Cloudflare address (DNS-only hosts reach nginx directly with a client-written X-Forwarded-For).
// Cloudflare address (DNS-only hosts reach nginx directly with a client-written X-Forwarded-For).
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+const config = require('../config');
+let cached;
+function client() {
+ if (!cached) {
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret, audience: 'openvibe.actor', scope: 'actor.agent.manage actor.task.read' });
+ cached = require('openvibe-sdk').createClient({ tokenProvider: tokens, timeoutMs: 20_000 });
+ }
+ return cached;
+}
+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 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 decide({ subject, approvalId, decision }) {
+ return call(subject, { method: 'POST', path: `/api/v1/chat-apps/approvals/${encodeURIComponent(approvalId)}/decide`, json: { decision } });
+ },
+ };
+}
+module.exports = { createActorClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+// discord.js owns heartbeat, reconnect/resume and identify rate limits. HTTP interactions remain signed ingress.
+const { parseDiscordMessage } = require('openvibe-sdk/chat-apps');
+async function start({ config, provider, store, wake, log }) {
+ const { Client, GatewayIntentBits, Partials, PermissionFlagsBits } = require('discord.js');
+ const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, GatewayIntentBits.MessageContent], partials: [Partials.Channel] });
+ client.on('error', () => log.warn('[chat-apps] Discord gateway unavailable'));
+ client.on('messageCreate', async msg => {
+ try {
+ const m = parseDiscordMessage({ id: msg.id, channel_id: msg.channelId, guild_id: msg.guildId,
+ author: { id: msg.author.id, username: msg.author.username, bot: msg.author.bot }, webhook_id: msg.webhookId,
+ content: msg.content, mentions: [...msg.mentions.users.values()].map(u => ({ id: u.id })),
+ attachments: [...msg.attachments.values()].map(f => ({ url: f.url, filename: f.name })) }, { botUserId: client.user.id });
+ if (!m) return;
+ if (msg.reference?.messageId) {
+ const parent = await msg.fetchReference().catch(() => null);
+ m.replyToBot = parent?.author?.id === client.user.id;
+ }
+ m.guildId = msg.guildId || ''; m.gateway = true; m.raw = {};
+ m.admin = m.command === 'settings' && msg.guild ? Boolean((await msg.guild.members.fetch(msg.author.id)).permissions.has(PermissionFlagsBits.ManageGuild)) : false;
+ if (await store.enqueue('discord', msg.id, m)) wake();
+ } catch { log.warn('[chat-apps] Discord delivery refused'); }
+ });
+ try { await client.login(config.chatApps.discord.botToken); }
+ catch { client.destroy(); throw new Error('Discord unavailable'); }
+ return client;
+}
+module.exports = { start };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+const { setTimeout: delay } = require('node:timers/promises');
+const { splitText, signButton, verifyButton } = require('openvibe-sdk/chat-apps');
+const { createResolver, linkCommand, unlinkCommand } = require('./links');
+const { lineOk } = require('../chat/global-standard');
+const identity = m => m.app === 'slack' ? `${m.teamId}:${m.userId}` : m.userId;
+const group = m => `${m.teamId || m.guildId || ''}:${m.chatId}`;
+const conversation = m => `${m.app}:${group(m)}`;
+function createHandler({ config, store, providers, network, actor, now = () => Date.now(), sleep = delay }) {
+ const resolve = createResolver({ network });
+ const send = async (m, text) => {
+ const safe = lineOk(text) ? text : 'I cannot share that answer under the chat rules.';
+ for (const part of splitText(m.app, safe)) await providers[m.app].sendText({ chatId: m.chatId, text: part, threadTs: m.threadTs });
+ };
+ async function handle(m, deliveryId, signal) {
+ if (!m || !m.userId || !m.chatId || !providers[m.app]?.on) return;
+ const p = providers[m.app];
+ const privateChat = m.chatType === 'private';
+ const settings = await store.settings(m.app, group(m));
+ if (!privateChat && m.kind !== 'button' && !m.mention && !m.replyToBot) return;
+ if (!privateChat && settings.mode === 'off' && m.command !== 'settings') return;
+ const ident = identity(m);
+ // Rate-limit link/help as well, including an unlinked sender. Callbacks have their own bounded budget.
+ if (!await store.rate(`${m.app}:user:${ident}:${m.kind === 'button' ? 'buttons' : 'messages'}`, m.kind === 'button' ? 60 : config.chatApps.perUserRatePerHour)) return;
+ if (m.command === 'help') return send(m, 'Send /link in a direct message to connect your OpenVibe agent. /unlink disconnects it. In a group, mention or reply to me. Admins: /settings off|answer_only|act_with_approvals [answers per hour].');
+ if (m.command === 'link') {
+ if (!privateChat) return send(m, 'Send /link to me in a direct message.');
+ return linkCommand({ message: { ...m, userId: ident }, provider: p, store, network, config });
+ }
+ const subject = await resolve({ app: m.app, serviceUserId: ident });
+ if (!subject) {
+ // The confirm page sends the person back here with a 6-digit code. Only the chat
+ // account that started /link can finish it (Network checks the identity), and only in
+ // a direct message: a code in a group would be read by everyone there.
+ 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?.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 === '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+/);
+ if (!['off', 'answer_only', 'act_with_approvals'].includes(args[0]) || (args[1] && !/^\d{1,3}$/.test(args[1]))) return send(m, 'Use /settings off|answer_only|act_with_approvals [1–500 answers per hour].');
+ const out = await store.saveSettings(m.app, group(m), { mode: args[0], rate_per_hour: Number(args[1] || settings.rate_per_hour) });
+ return send(m, `Group setting: ${out.mode}, up to ${out.rate_per_hour} answers per hour.`);
+ }
+ if (m.kind === 'button') {
+ // Telegram keeps a spinner on the button until its callback is answered.
+ if (m.app === 'telegram' && m.callbackQueryId) await p.answerButton({ buttonId: m.callbackQueryId }).catch(() => {});
+ if (!privateChat && settings.mode !== 'act_with_approvals') return send(m, 'Acting is disabled in this group.');
+ const id = verifyButton({ secret: config.chatApps.callbackSecret, token: m.button?.id });
+ const taken = id && await store.takeCallback(id, { app: m.app, chatId: group(m), userId: ident, subject });
+ if (!taken?.ok) return send(m, 'This button is unavailable, expired, or belongs to someone else.');
+ await actor.decide({ subject, approvalId: taken.row.approval_id, decision: taken.row.action });
+ return send(m, taken.row.action === 'approve' ? 'Approved.' : 'Declined.');
+ }
+ if (m.command && m.command !== 'ask') return send(m, 'Use /help to see the commands.');
+ 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 });
+ const shown = new Set();
+ const until = now() + 11 * 60_000;
+ while (now() < until) {
+ signal?.throwIfAborted();
+ // Revocation or a change of the group policy stops delivery of private results/approval cards.
+ if (await resolve({ app: m.app, serviceUserId: ident }) !== subject) return;
+ if (!privateChat && (await store.settings(m.app, group(m))).mode !== settings.mode) return;
+ const task = await actor.task(subject, started.id);
+ for (const a of await actor.approvals(subject, conversation(m))) {
+ if (shown.has(a.id)) continue;
+ const buttons = [];
+ for (const action of ['approve', 'decline']) {
+ const id = await store.openCallback({ app: m.app, chatId: group(m), userId: ident, subject, action, approvalId: a.id,
+ expiresAt: Math.min(Date.parse(a.expires_at), now() + config.chatApps.callbackTtlMs) });
+ buttons.push({ label: action === 'approve' ? 'Approve' : 'Decline', id: signButton({ secret: config.chatApps.callbackSecret, id }) });
+ }
+ const summary = lineOk(a.summary) ? a.summary : 'Review this request on OpenVibe.Actor.';
+ await p.sendButtons({ chatId: m.chatId, threadTs: m.threadTs, text: `Needs you: ${summary}`.slice(0, 1800), buttons });
+ shown.add(a.id);
+ }
+ if (['succeeded', 'failed', 'cancelled'].includes(task.state)) {
+ if (task.state !== 'succeeded') return send(m, 'Your agent could not finish this request. Check your task on openvibe.actor.');
+ const result = task.result || {};
+ const files = (result.artifacts || []).filter(a => /^https:\/\//.test(a.url || '')).map(a => `${a.name || 'File'}: ${a.url}`);
+ return send(m, [result.answer || result.text || 'Done.', ...files].join('\n\n'));
+ }
+ await sleep(1500, undefined, { signal });
+ }
+ await send(m, 'Your agent is still working. You can follow the task on openvibe.actor.');
+ }
+ return { handle, send };
+}
+module.exports = { createHandler, identity, group, conversation };
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+const config = require('../config');
+const db = require('../db/database');
+const { createProviders } = require('./providers');
+const { createStore } = require('./store');
+const { createNetworkClient } = require('./network');
+const { createActorClient } = require('./actor');
+const { createHandler } = require('./handler');
+function createChatApps({ log = console } = {}) {
+ const { providers, whoami } = createProviders({ config });
+ const store = createStore({ db });
+ const handler = createHandler({ config, store, providers, network: createNetworkClient(), actor: createActorClient() });
+ const abort = new AbortController();
+ let timer, gateway, starting = false, stopped = false;
+ const running = [0, 0];
+ async function worker(button) {
+ const lane = button ? 1 : 0;
+ if (stopped || running[lane] >= (button ? 2 : 4)) return;
+ running[lane]++;
+ try {
+ for (;;) {
+ if (stopped) return;
+ const row = await store.next(button);
+ if (!row) return;
+ const m = typeof row.message === 'string' ? JSON.parse(row.message) : row.message;
+ let ok = true;
+ try { await handler.handle(m, row.id, abort.signal); }
+ catch {
+ ok = false;
+ if (!stopped) await handler.send(m, 'I could not complete that request. Please try again.').catch(() => {});
+ log.warn('[chat-apps] delivery failed');
+ }
+ await store.finish(row.app, row.id, ok);
+ }
+ } catch { log.warn('[chat-apps] queue unavailable'); }
+ finally { running[lane]--; }
+ }
+ const wake = () => { if (!starting && !stopped) { void worker(false); void worker(true); } };
+ const router = require('./ingress').router({ config, providers, store, wake, onError: () => log.warn('[chat-apps] ingress unavailable') });
+ async function start() {
+ if (!Object.values(providers).some(p => p.on)) return;
+ starting = true;
+ try {
+ await whoami();
+ // A delivery interrupted after an external effect is not blindly repeated on restart.
+ await db.run("UPDATE chat_app_deliveries SET state = 'failed', message = '{}'::jsonb WHERE state = 'running'");
+ await store.prune();
+ if (providers.discord.on) gateway = await require('./discord-gateway').start({ config, provider: providers.discord, store, wake, log });
+ timer = setInterval(wake, 1000); timer.unref();
+ } catch { log.warn('[chat-apps] startup unavailable'); }
+ finally { starting = false; wake(); }
+ }
+ return { router, start, stop() { stopped = true; abort.abort(); clearInterval(timer); gateway?.destroy(); } };
+}
+module.exports = { createChatApps };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+const express = require('express');
+const crypto = require('crypto');
+const sdk = require('openvibe-sdk/chat-apps');
+function parse(app, body, provider, cfg) {
+ let m;
+ if (app === 'telegram') m = sdk.parseTelegramUpdate(body, { botUsername: provider.botUsername, botUserId: provider.botUserId });
+ if (app === 'discord') {
+ m = sdk.parseDiscordInteraction(body);
+ if (m) { m.guildId = body.guild_id || ''; m.args = (body.data?.options || []).map(o => String(o.value)).join(' '); }
+ }
+ if (app === 'slack') {
+ const teamId = typeof body.team === 'object' ? body.team.id : body.team_id;
+ if (teamId !== cfg.slack.teamId) return null;
+ if (body.type === 'block_actions') m = sdk.parseSlackInteraction(body);
+ else if (body.command) m = { app, kind: 'command', chatId: body.channel_id, chatType: /^D/.test(body.channel_id || '') ? 'private' : 'group',
+ userId: body.user_id, userName: body.user_name, command: body.command.replace(/^\//, ''), args: body.text || '', text: body.text || '', mention: true };
+ else m = sdk.parseSlackEvent(body.event, { botUserId: provider.botUserId });
+ if (m) m.teamId = teamId;
+ }
+ if (!m || ['unknown', 'ping'].includes(m.kind)) return null;
+ // Never persist provider access/interaction tokens or arbitrary raw event content.
+ const permissions = m.raw?.member?.permissions;
+ m.raw = permissions ? { member: { permissions } } : {};
+ if (app === 'telegram' && body.callback_query) m.callbackQueryId = body.callback_query.id;
+ return m;
+}
+function router({ config, providers, store, wake, onError = () => {} }) {
+ const r = express.Router();
+ r.post('/:app', express.raw({ type: '*/*', limit: '256kb' }), async (req, res) => {
+ const app = req.params.app;
+ const c = config.chatApps[app];
+ if (!providers[app]?.on) return res.status(503).json({ error: 'App is off' });
+ const raw = req.body;
+ if (!Buffer.isBuffer(raw) || !sdk.verifyDelivery({ provider: app, secret: c.secretToken || c.signingSecret, publicKey: c.publicKey, raw, headers: req.headers })) return res.sendStatus(401);
+ let body;
+ try {
+ if (req.is('application/x-www-form-urlencoded')) {
+ const form = Object.fromEntries(new URLSearchParams(raw.toString()));
+ body = form.payload ? JSON.parse(form.payload) : form;
+ } else body = JSON.parse(raw.toString());
+ } catch { return res.sendStatus(400); }
+ if (app === 'discord' && body.type === 1) return res.json({ type: 1 });
+ if (app === 'slack' && body.type === 'url_verification') return res.json({ challenge: body.challenge });
+ try {
+ const m = parse(app, body, providers[app], config.chatApps);
+ if (m) {
+ const id = String(body.update_id ?? body.event_id ?? body.id ?? crypto.createHash('sha256').update(raw).digest('hex'));
+ if (await store.enqueue(app, id, m)) wake();
+ }
+ // Final acknowledgement immediately; all work/replies use the bot API outside the HTTP request.
+ if (app === 'discord') return res.json({ type: 4, data: { content: 'Received. I will reply in this chat.', flags: 64, allowed_mentions: { parse: [] } } });
+ res.sendStatus(200);
+ } catch { onError(); res.sendStatus(503); }
+ });
+ return r;
+}
+module.exports = { router, parse };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * Linking: /link and /unlink.
+ *
+ * /link The bot answers a one-time link to openvibe.network. The person opens it while signed
+ * in and confirms; the Network writes the account link (its linked_accounts, the link
+ * every service reads) and Chat hears that it was made. The token is good for ten
+ * minutes, is registered with the Network (so its page can show what is being linked)
+ * and is used once.
+ * /unlink The account link is removed and the bot forgets the person in that app.
+ *
+ * Nothing else works for an account that is not linked: without a subject there is no person to
+ * act as, so only /link and /help are answered.
+ */
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** Resolve afresh: unlink must revoke approval authority immediately. Fail closed on outage. */
+function createResolver({ network }) {
+ const resolver = async ({ app, serviceUserId }) => {
+ const subject = await network.resolve({ app, serviceUserId });
+ return SUBJECT_RE.test(String(subject || '')) ? subject : null;
+ };
+ resolver.forget = () => {};
+ return resolver;
+}
+
+/**
+ * The answer to /link: the one-time link the person opens. The link is registered with the Network
+ * first, so the page there can show what is being linked (the app and the name) before it asks.
+ */
+async function linkCommand({ message, provider, store, network, config, log = console }) {
+ const token = store.newToken();
+ const expiresAt = Date.now() + config.chatApps.linkTtlMs;
+ await store.saveToken({
+ token, app: message.app, serviceUserId: message.userId,
+ serviceUsername: message.userName, expiresAt,
+ });
+ try {
+ const registered = await network.registerPending({
+ token, app: message.app, serviceUserId: message.userId,
+ serviceUsername: message.userName, botUsername: provider.botUsername, expiresAt,
+ });
+ if (!registered) throw new Error('Link registration failed');
+ } catch (err) {
+ throw new Error('Link service unavailable');
+ }
+ const url = `${config.chatApps.linkUrl}?t=${encodeURIComponent(token)}`;
+ await provider.sendText({
+ chatId: message.chatId,
+ text: `Link your ${message.app} account to OpenVibe: open this link while you are signed in to openvibe.network and confirm it. The page shows a 6-digit code — send that code to me here, from this chat, to finish.\n\n${url}\n\nThe link is good for ${Math.round(config.chatApps.linkTtlMs / 60000)} minutes and works once.`,
+ });
+
+ return { token, url };
+}
+
+/** The answer to /unlink: the account link is removed and the person is forgotten in that app. */
+async function unlinkCommand({ message, subject, provider, network, forget, log = console }) {
+ if (!subject) {
+ await provider.sendText({ chatId: message.chatId, text: 'You are not linked here. Send /link to link your account.' });
+ return { ok: false, reason: 'not_linked' };
+ }
+ let removed = false;
+ try { removed = await network.unlink({ app: message.app, serviceUserId: message.userId, subject }); } catch (err) {
+ log.warn(`[chat-apps] ${message.app}: the Network did not take the unlink: ${err && err.message}`);
+ }
+ if (removed && forget) forget({ app: message.app, serviceUserId: message.userId });
+ await provider.sendText({
+ chatId: message.chatId,
+ text: removed ? 'You are not linked here any more. Send /link to link again.' : 'I could not remove the link just now. Try again in a while.',
+ });
+ return { ok: removed };
+}
+
+module.exports = { createResolver, linkCommand, unlinkCommand, SUBJECT_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * Chat → the Network: the account links and the person behind a chat identity.
+ *
+ * The Network owns the linked accounts (its linked_accounts, the account link every service reads);
+ * Chat asks it for three things:
+ *
+ * registerPending({ token, app, serviceUserId, serviceUsername, expiresAt })
+ * The one-time token from /link is registered with the Network, so the page that asks the
+ * person (openvibe.network/chat-apps/link, signed in there) can show what is being linked and
+ * write the account link when they say yes.
+ * pending(token) → { state: 'pending' | 'confirmed' | 'unknown', subject }
+ * Whether the person confirmed the link, and the subject it was confirmed for.
+ * resolve({ app, serviceUserId }) → subject | null
+ * The OpenVibe subject of a person in a chat app. A person who is not linked has no subject here.
+ * unlink({ app, serviceUserId, subject }) → true
+ * The account link is removed: the bot forgets the person in that app.
+ *
+ * The calls go to the Network's own routes with Chat's service token (client `chat`, audience
+ * openvibe.network, the capability identity.subject.resolve).
+ */
+const config = require('../config');
+
+let _client = null;
+
+function client() {
+ if (_client) return _client;
+ if (!config.oauth.clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
+ const { createClient } = require('openvibe-sdk');
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const tokens = createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network',
+ scope: 'identity.subject.resolve',
+ });
+ _client = createClient({ tokenProvider: tokens, timeoutMs: 20000 });
+ return _client;
+}
+
+/** The Network's answer, or null when it cannot be reached or does not know the person. */
+function createNetworkClient({ http = null } = {}) {
+ const call = async (opts) => {
+ const c = http || client();
+ try {
+ return await c.json({ service: 'network', baseUrl: config.networkInternalUrl, audience: 'openvibe.network', retries: 1, ...opts });
+ } catch (err) {
+ if (err && (err.status === 404 || err.status === 400)) return null;
+ throw err;
+ }
+ };
+
+ return {
+ /** Register the one-time link token with the Network (its page asks the person). */
+ async registerPending({ token, app, serviceUserId, serviceUsername, botUsername, expiresAt }) {
+ const out = await call({
+ method: 'POST', path: '/internal/chat-app-links', idempotent: true,
+ json: {
+ token, app, service_user_id: serviceUserId, service_username: serviceUsername || null,
+ bot_handle: botUsername || null, expires_at: new Date(expiresAt).toISOString(),
+ },
+ });
+ return !!(out && out.ok !== false);
+ },
+
+ /** Whether the person confirmed the link, and for which subject. */
+ async pending(token) {
+ const out = await call({ method: 'GET', path: '/internal/chat-app-links', query: { t: token } });
+ if (!out) return { state: 'unknown' };
+ return { state: out.state || 'pending', subject: out.subject || null, app: out.app || null, service_username: out.service_username || null };
+ },
+
+ /**
+ * Finish a link: the chat account that started /link sends the 6-digit code from the
+ * confirm page. Network checks the code (three wrong codes cancel it) and only then
+ * writes the account link; a person who is already linked here never needs this.
+ */
+ async finish({ app, serviceUserId, code }) {
+ try {
+ const out = await call({
+ method: 'POST', path: '/internal/chat-app-links/finish', idempotent: false,
+ json: { app, service_user_id: serviceUserId, code },
+ });
+ return out && typeof out === 'object' ? out : { ok: false, error: 'unavailable' };
+ } catch (err) {
+ return { ok: false, error: err && err.status === 409 ? 'conflict' : 'unavailable' };
+ }
+ },
+
+ /** The OpenVibe subject of a person in a chat app, or null when they are not linked. */
+ async resolve({ app, serviceUserId }) {
+ const out = await call({ method: 'GET', path: '/internal/chat-app-links/resolve', query: { app, id: serviceUserId } });
+ const subject = out && (out.subject_id || (out.subject && out.subject.id));
+ return subject ? String(subject) : null;
+ },
+
+ /** The account link is removed. */
+ async unlink({ app, serviceUserId, subject }) {
+ const out = await call({
+ method: 'POST', path: '/internal/chat-app-links/unlink', idempotent: true,
+ json: { app, service_user_id: serviceUserId, subject: subject || null },
+ });
+ return !!(out && out.ok !== false);
+ },
+ };
+}
+
+module.exports = { createNetworkClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+// Secrets are used only for fixed provider origins and are never included in errors or logs.
+function createProviders({ config, fetchImpl = globalThis.fetch }) {
+ const cfg = config.chatApps;
+ const known = {};
+ const api = async (app, method, body, verb = 'POST') => {
+ const c = cfg[app];
+ const origin = app === 'telegram' ? `https://api.telegram.org/bot${c.botToken}/` : app === 'discord' ? 'https://discord.com/api/v10/' : 'https://slack.com/api/';
+ const headers = { 'Content-Type': 'application/json', ...(app === 'telegram' ? {} : { Authorization: `${app === 'discord' ? 'Bot' : 'Bearer'} ${c.botToken}` }) };
+ let r;
+ try { r = await fetchImpl(origin + method, { method: verb, headers, ...(verb === 'GET' ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000) }); }
+ catch { throw new Error(`${app} unavailable`); }
+ const out = await r.json().catch(() => null);
+ if (!r.ok || !out || out.ok === false) throw new Error(`${app} request refused`);
+ return app === 'telegram' ? out.result : out;
+ };
+ const providers = {};
+ for (const app of ['telegram', 'discord', 'slack']) {
+ const c = cfg[app];
+ providers[app] = {
+ app,
+ get on() { return Boolean(cfg.callbackSecret && c.botToken && (app === 'telegram' ? c.secretToken : app === 'discord' ? c.publicKey && c.applicationId : c.signingSecret && c.teamId)); },
+ get botUserId() { return known[app]?.id || known[app]?.user_id || null; },
+ get botUsername() { return c.username || known[app]?.username || known[app]?.user || null; },
+ async whoami() {
+ if (!this.on) return;
+ known[app] = await api(app, app === 'telegram' ? 'getMe' : app === 'discord' ? 'users/@me' : 'auth.test', {}, app === 'discord' ? 'GET' : 'POST');
+ },
+ async sendText({ chatId, text, threadTs }) {
+ if (!this.on) throw new Error('App is off');
+ if (app === 'telegram') return api(app, 'sendMessage', { chat_id: chatId, text, link_preview_options: { is_disabled: true } });
+ if (app === 'discord') return api(app, `channels/${encodeURIComponent(chatId)}/messages`, { content: text, allowed_mentions: { parse: [] } });
+ return api(app, 'chat.postMessage', { channel: chatId, text, mrkdwn: false, unfurl_links: false, unfurl_media: false, ...(threadTs ? { thread_ts: threadTs } : {}) });
+ },
+ async sendButtons({ chatId, text, buttons, threadTs }) {
+ if (!this.on) throw new Error('App is off');
+ if (buttons.some(b => Buffer.byteLength(b.id) > 64)) throw new Error('Button is too long');
+ if (app === 'telegram') return api(app, 'sendMessage', { chat_id: chatId, text, reply_markup: { inline_keyboard: [buttons.map(b => ({ text: b.label, callback_data: b.id }))] } });
+ if (app === 'discord') return api(app, `channels/${encodeURIComponent(chatId)}/messages`, { content: text, allowed_mentions: { parse: [] }, components: [{ type: 1, components: buttons.map(b => ({ type: 2, style: 2, label: b.label, custom_id: b.id })) }] });
+ return api(app, 'chat.postMessage', { channel: chatId, text, ...(threadTs ? { thread_ts: threadTs } : {}), blocks: [
+ { type: 'section', text: { type: 'plain_text', text } },
+ { type: 'actions', elements: buttons.map(b => ({ type: 'button', text: { type: 'plain_text', text: b.label }, action_id: b.id, value: b.id })) },
+ ] });
+ },
+ async answerButton({ buttonId, text }) {
+ if (app === 'telegram') await api(app, 'answerCallbackQuery', { callback_query_id: buttonId, text });
+ },
+ async isAdmin({ message }) {
+ if (app === 'telegram') { const u = await api(app, 'getChatMember', { chat_id: message.chatId, user_id: message.userId }); return ['creator', 'administrator'].includes(u.status); }
+ if (app === 'discord') {
+ if (message.gateway) return message.admin === true;
+ const permissions = String(message.raw?.member?.permissions || '');
+ return /^\d+$/.test(permissions) && (BigInt(permissions) & (8n | 32n)) !== 0n;
+ }
+ const u = (await api(app, `users.info?user=${encodeURIComponent(message.userId)}`, null, 'GET')).user;
+ return Boolean(u && u.team_id === c.teamId && (u.is_admin || u.is_owner));
+ },
+ };
+ }
+ return { providers, async whoami() { await Promise.all(Object.values(providers).filter(p => p.on).map(p => p.whoami())); } };
+}
+module.exports = { createProviders };
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+'use strict';
+/**
+ * The chat apps' own rows (migrations/0037_chat_apps.sql): the one-time link tokens a person gets
+ * from /link, the rules of a group and the buttons a person may press.
+ *
+ * The link token One row per /link: the token's hash (the token itself is only in the person's
+ * link and in the Network's page), the app, the person in the app and when it
+ * expires. It is used once: the person confirms it on the Network's page (signed
+ * in there) and the same token binds the app's account to their OpenVibe subject.
+ * The settings One row per chat (a group, a channel, a server): what the bot may do there
+ * (off, answer only, act with approvals) and how many answers an hour it may give.
+ * The callback One row per button: who may press it, what it does and until when. A button is
+ * used once: the row is marked used the first time it is pressed, so the same
+ * button cannot approve twice.
+ *
+ * The account link itself is the Network's (linked_accounts, the account link every service reads);
+ * these rows are the chat apps' own bookkeeping.
+ */
+const crypto = require('crypto');
+
+const MODES = ['off', 'answer_only', 'act_with_approvals'];
+const ACTIONS = ['approve', 'decline'];
+
+const iso = (ms) => new Date(ms).toISOString();
+const hash = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
+
+/** A new one-time token: 32 random bytes, base64url. Only its hash is kept. */
+function newToken() {
+ return crypto.randomBytes(32).toString('base64url');
+}
+
+/** A new button's id: 16 characters of base64url (96 bits), unguessable and short enough for Telegram. */
+function newId() {
+ return crypto.randomBytes(12).toString('base64url');
+}
+
+function createStore({ db, now = () => Date.now() }) {
+ /** The pending token of a person in an app (the newest one that has not expired), if any. */
+ async function pendingToken(app, serviceUserId) {
+ return await db.get(
+ `SELECT * FROM chat_app_link_tokens
+ WHERE app = ? AND service_user_id = ? AND state = 'pending' AND expires_at > ?
+ ORDER BY created_at DESC LIMIT 1`,
+ [app, serviceUserId, iso(now())]);
+ }
+
+ /** A token's row by its hash (the person carries the token itself). */
+ async function tokenRow(token) {
+ if (!token) return null;
+ return await db.get('SELECT * FROM chat_app_link_tokens WHERE token_hash = ?', [hash(token)]);
+ }
+
+ /** Keep the token: one row per token, and the newest pending one for that person. */
+ async function saveToken({ token, app, serviceUserId, serviceUsername, expiresAt }) {
+ const h = hash(token);
+ await db.run(
+ `INSERT INTO chat_app_link_tokens (token_hash, app, service_user_id, service_username, state, created_at, expires_at)
+ VALUES (?, ?, ?, ?, 'pending', ?, ?)
+ ON CONFLICT (token_hash) DO UPDATE SET app = excluded.app, service_user_id = excluded.service_user_id,
+ service_username = excluded.service_username, state = 'pending', created_at = excluded.created_at, expires_at = excluded.expires_at`,
+ [h, app, serviceUserId, serviceUsername || null, iso(now()), iso(expiresAt)]);
+ return h;
+ }
+
+ /** The token is used: it is bound to the subject and cannot be used again. */
+ async function useToken(token, subject) {
+ const h = hash(token);
+ const r = await db.run(
+ `UPDATE chat_app_link_tokens SET state = 'used', subject = ?, used_at = ?
+ WHERE token_hash = ? AND state = 'pending' AND expires_at > ?`,
+ [subject, iso(now()), h, iso(now())]);
+ return r.changes > 0;
+ }
+
+ /** The rules of a chat. The default is the safe one: answer when mentioned, never act. */
+ async function settings(app, chatId) {
+ const row = await db.get('SELECT * FROM chat_app_settings WHERE app = ? AND chat_id = ?', [app, chatId]);
+ return {
+ mode: row && MODES.includes(row.mode) ? row.mode : 'answer_only',
+ rate_per_hour: row ? Number(row.rate_per_hour) || 20 : 20,
+ };
+ }
+
+ /** Change the rules of a chat. */
+ async function saveSettings(app, chatId, { mode, rate_per_hour }) {
+ if (!MODES.includes(mode)) throw new Error(`mode must be one of ${MODES.join(', ')}`);
+ const rate = Math.max(1, Math.min(500, Number(rate_per_hour) || 20));
+ await db.run(
+ `INSERT INTO chat_app_settings (app, chat_id, mode, rate_per_hour, updated_at) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (app, chat_id) DO UPDATE SET mode = excluded.mode, rate_per_hour = excluded.rate_per_hour, updated_at = excluded.updated_at`,
+ [app, chatId, mode, rate, iso(now())]);
+ return { mode, rate_per_hour: rate };
+ }
+
+ /** A button: one row, open until it is pressed or it expires. */
+ async function openCallback({ app, chatId, userId, subject, action, approvalId, expiresAt }) {
+ if (!ACTIONS.includes(action)) throw new Error(`action must be one of ${ACTIONS.join(', ')}`);
+ const id = newId();
+ await db.run(
+ `INSERT INTO chat_app_callbacks (id, app, chat_id, user_id, subject, action, approval_id, state, created_at, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
+ [id, app, chatId, userId, subject || null, action, approvalId || null, iso(now()), iso(expiresAt)]);
+ return id;
+ }
+
+ /**
+ * Take a button: it is used once. The row is marked used only when it was open and had not
+ * expired, so a second press (or a press after the expiry) changes nothing.
+ */
+ async function takeCallback(id, { app, chatId, userId, subject }) {
+ const row = await db.get(`UPDATE chat_app_callbacks SET state = 'used', used_at = ?
+ WHERE id = ? AND app = ? AND chat_id = ? AND user_id = ? AND subject = ?
+ AND state = 'open' AND expires_at > ? RETURNING *`,
+ [iso(now()), id, app, chatId, userId, subject, iso(now())]);
+ return row ? { ok: true, row } : { ok: false, reason: 'unavailable' };
+ }
+ async function rate(key, limit) {
+ const hour_bucket = Math.floor(now() / 3600000);
+ const row = await db.get(`INSERT INTO chat_app_rates (key, hour_bucket, count) VALUES (?, ?, 1)
+ ON CONFLICT (key) DO UPDATE SET hour_bucket = excluded.hour_bucket,
+ count = CASE WHEN chat_app_rates.hour_bucket = excluded.hour_bucket THEN chat_app_rates.count + 1 ELSE 1 END
+ WHERE chat_app_rates.hour_bucket != excluded.hour_bucket OR chat_app_rates.count < ? RETURNING count`, [key, hour_bucket, limit]);
+ return Boolean(row);
+ }
+ async function enqueue(app, id, message) {
+ return Boolean(await db.get(`INSERT INTO chat_app_deliveries (app, id, message) VALUES (?, ?, ?::jsonb)
+ ON CONFLICT DO NOTHING RETURNING id`, [app, id, JSON.stringify(message)]));
+ }
+ async function next(button = false) {
+ return db.get(`UPDATE chat_app_deliveries SET state = 'running' WHERE (app, id) =
+ (SELECT app, id FROM chat_app_deliveries WHERE state = 'pending' AND ((message->>'kind' = 'button') = ?::boolean) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED)
+ AND state = 'pending' RETURNING *`, [button]);
+ }
+ async function finish(app, id, ok) {
+ await db.run("UPDATE chat_app_deliveries SET state = ?, message = '{}'::jsonb WHERE app = ? AND id = ?", [ok ? 'done' : 'failed', app, id]);
+ }
+
+ /** The buttons of a chat that are still open (for the tests and for a restart). */
+ async function openCallbacks(app, chatId) {
+ return await db.all(
+ `SELECT * FROM chat_app_callbacks WHERE app = ? AND chat_id = ? AND state = 'open' AND expires_at > ?`,
+ [app, chatId, iso(now())]);
+ }
+
+ /** Old rows are dropped: a token that expired and a button that was pressed are of no use. */
+ async function prune() {
+ await db.run('DELETE FROM chat_app_deliveries WHERE created_at < ?', [iso(now() - 86400000)]);
+ await db.run('DELETE FROM chat_app_rates WHERE hour_bucket < ?', [Math.floor(now() / 3600000) - 1]);
+ await db.run('DELETE FROM chat_app_link_tokens WHERE expires_at < ?', [iso(now() - 24 * 3600 * 1000)]);
+ await db.run('DELETE FROM chat_app_callbacks WHERE expires_at < ?', [iso(now() - 24 * 3600 * 1000)]);
+ }
+
+ return { rate, enqueue, next, finish, pendingToken, tokenRow, saveToken, useToken, settings, saveSettings, openCallback, takeCallback, openCallbacks, prune, newToken, newId, MODES, ACTIONS };
+}
+
+module.exports = { createStore, MODES, ACTIONS, newToken, newId };
@@ -81,6 +81,10 @@ const EXPORTS = [
@@ -81,6 +81,10 @@ const EXPORTS = [
// Feeds (server/feeds/feeds.js): the feeds, without their key's hash, and what was published to them.
// Feeds (server/feeds/feeds.js): the feeds, without their key's hash, and what was published to them.
['feeds.json', `SELECT id, kind, title, room_id, managed_stream_id, settings, key_hint, legacy_source, created_at, last_event_at FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['feeds.json', `SELECT id, kind, title, room_id, managed_stream_id, settings, key_hint, legacy_source, created_at, last_event_at FROM feeds WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['feed_events.json', `SELECT e.feed_id, e.session_key, e.event_id, e.type, e.summary, e.payload, e.created_at FROM feed_events e JOIN feeds f ON f.id = e.feed_id WHERE f.owner_subject = ANY(@s) OR f.owner_id = ANY(@ids) ORDER BY e.id DESC LIMIT ${LIMIT}`],
['feed_events.json', `SELECT e.feed_id, e.session_key, e.event_id, e.type, e.summary, e.payload, e.created_at FROM feed_events e JOIN feeds f ON f.id = e.feed_id WHERE f.owner_subject = ANY(@s) OR f.owner_id = ANY(@ids) ORDER BY e.id DESC LIMIT ${LIMIT}`],
+ // Their chat-app accounts (migrations/0037_chat_apps.sql): the one-time links they made and the
+ // buttons their agent's approvals put in a chat. The group settings are the group's, not theirs.
+ ['chat_app_links.json', `SELECT app, service_user_id, service_username, state, created_at, expires_at, used_at FROM chat_app_link_tokens WHERE subject = ANY(@s) ORDER BY created_at DESC LIMIT ${LIMIT}`],
+ ['chat_app_approvals.json', `SELECT app, chat_id, action, approval_id, state, created_at, expires_at, used_at FROM chat_app_callbacks WHERE subject = ANY(@s) ORDER BY created_at DESC LIMIT ${LIMIT}`],
];
];
async function exportPart(d, subject) {
async function exportPart(d, subject) {
@@ -153,6 +157,11 @@ const ERASE_AFTER_ROOMS = [
@@ -153,6 +157,11 @@ const ERASE_AFTER_ROOMS = [
[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],
[null, "UPDATE room_tasks SET runner = 'a former member' WHERE runner ~ '^user:[0-9]+$' AND substr(runner, 6)::bigint = ANY(@ids)"],
// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.
// Their own AI key is gone with them (OpenVibe.AI erases it): members that thought with it go back to OpenVibe's model.
[null, "UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL WHERE engine_subject = ANY(@s)"],
[null, "UPDATE room_ai_members SET engine = 'openvibe', engine_subject = NULL, engine_label = NULL WHERE engine_subject = ANY(@s)"],
+ // Their chat-app deliveries, links and approval buttons go. A delivery's text is kept only until it
+ // is answered or pruned (24 h); these are the ones the linked account still had queued.
+ [null, "DELETE FROM chat_app_deliveries WHERE EXISTS (SELECT 1 FROM chat_app_link_tokens t WHERE t.subject = ANY(@s) AND t.app = chat_app_deliveries.app AND t.service_user_id = chat_app_deliveries.message->>'userId')"],
+ ['chat_app_links', 'DELETE FROM chat_app_link_tokens WHERE subject = ANY(@s)'],
+ ['chat_app_approvals', 'DELETE FROM chat_app_callbacks WHERE subject = ANY(@s)'],
];
];
// Their channel (only when they have Live ids): emotes, sounds, moderators, stats, audio requests, the Live mirrors.
// Their channel (only when they have Live ids): emotes, sounds, moderators, stats, audio requests, the Live mirrors.
const ERASE_CHANNEL = [
const ERASE_CHANNEL = [
@@ -219,6 +219,43 @@ module.exports = {
@@ -219,6 +219,43 @@ module.exports = {
vibeRepliesPerDay: Math.max(1, int(process.env.CHAT_VIBE_REPLIES_PER_DAY, 100)),
vibeRepliesPerDay: Math.max(1, int(process.env.CHAT_VIBE_REPLIES_PER_DAY, 100)),
},
},
+ // The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of
+ // 2026-10-30, stage A4). Each app is OFF until its own token and its own secret are here: the owner's
+ // settings (the same way AI's provider keys and Watch's secrets are configured). An app without its
+ // token answers nothing and its webhook is never set.
+ chatApps: {
+ telegram: {
+ botToken: process.env.CHAT_TELEGRAM_BOT_TOKEN || '',
+ // The secret token setWebhook's secret_token made (Telegram sends it in
+ // X-Telegram-Bot-Api-Secret-Token; it is checked before a delivery is read).
+ secretToken: process.env.CHAT_TELEGRAM_SECRET_TOKEN || '',
+ username: process.env.CHAT_TELEGRAM_USERNAME || '',
+ },
+ discord: {
+ botToken: process.env.CHAT_DISCORD_BOT_TOKEN || '',
+ // The application's public key (64 hex characters), the one Discord signs its interactions with.
+ publicKey: process.env.CHAT_DISCORD_PUBLIC_KEY || '',
+ applicationId: process.env.CHAT_DISCORD_APPLICATION_ID || '',
+ },
+ slack: {
+ botToken: process.env.CHAT_SLACK_BOT_TOKEN || '',
+ teamId: process.env.CHAT_SLACK_TEAM_ID || '',
+ // The app's signing secret (Slack signs each delivery with it).
+ signingSecret: process.env.CHAT_SLACK_SIGNING_SECRET || '',
+ },
+ // The secret the buttons are signed with (the app's own, never the app's token).
+ callbackSecret: process.env.CHAT_APPS_CALLBACK_SECRET || '',
+ // Where a person confirms a link (the Network's page; the person is signed in there).
+ linkUrl: strip(process.env.CHAT_LINK_CONFIRM_URL || 'https://openvibe.network/chat-apps/link'),
+ // How long a link token and a button live.
+ linkTtlMs: Math.min(600_000, Math.max(60_000, int(process.env.CHAT_LINK_TTL_MS, 10 * 60 * 1000))),
+ callbackTtlMs: Math.max(60_000, int(process.env.CHAT_CALLBACK_TTL_MS, 30 * 60 * 1000)),
+ // A group's own rate: how many answers an hour its members get.
+ ratePerHour: Math.max(1, int(process.env.CHAT_APPS_RATE_PER_HOUR, 20)),
+ // A group's own rate: how many answers an hour one member gets.
+ perUserRatePerHour: Math.max(1, int(process.env.CHAT_APPS_PER_USER_RATE_PER_HOUR, 10)),
+ },
+
// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows
// Channel sound clips live on disk. At cutover this is Live's sounds directory: stored rows
// hold absolute paths into it, and Live's donation alerts read the alert sounds from there.
// hold absolute paths into it, and Live's donation alerts read the alert sounds from there.
sounds: {
sounds: {
@@ -57,6 +57,7 @@ async function start() {
@@ -57,6 +57,7 @@ async function start() {
const relay = serviceOutbox.configure({ config });
const relay = serviceOutbox.configure({ config });
const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });
const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });
const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer });
const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer });
+ void app.locals.chatApps.start();
server.on('request', app);
server.on('request', app);
server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });
server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });
relay.start();
relay.start();
@@ -105,6 +106,7 @@ async function start() {
@@ -105,6 +106,7 @@ async function start() {
() => require('./rooms/workspace').stop(),
() => require('./rooms/workspace').stop(),
() => subs.stop(),
() => subs.stop(),
() => { if (valkey) return valkey.close(); },
() => { if (valkey) return valkey.close(); },
+ () => app.locals.chatApps.stop(),
() => chatServer.close(),
() => chatServer.close(),
() => callServer.close(),
() => callServer.close(),
],
],
@@ -33,7 +33,7 @@ t('export, then erase and confirm', async () => {
@@ -33,7 +33,7 @@ t('export, then erase and confirm', async () => {
const d = require('../server/db/database').getDb();
const d = require('../server/db/database').getDb();
{
{
const missing = await uncovered(d, {
const missing = await uncovered(d, {
- taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_messages', 'chat_server_invites', 'chat_server_members',
+ taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_participants', 'emotes', 'inbox_actions',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_participants', 'emotes', 'inbox_actions',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages',
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage'],
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage'],
@@ -53,6 +53,11 @@ t('export, then erase and confirm', async () => {
@@ -53,6 +53,11 @@ t('export, then erase and confirm', async () => {
await d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(dana.subject_id, otto.subject_id, otto.subject_id, dana.subject_id);
await d.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(dana.subject_id, otto.subject_id, otto.subject_id, dana.subject_id);
await d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id, actor_subject_id) VALUES ('timeout', ?, ?, ?)").run(otto.id, dana.id, otto.subject_id);
await d.prepare("INSERT INTO moderation_actions (action_type, actor_user_id, target_user_id, actor_subject_id) VALUES ('timeout', ?, ?, ?)").run(otto.id, dana.id, otto.subject_id);
await d.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (?, 'veteran')").run(dana.id);
await d.prepare("INSERT INTO user_tags (user_id, tag_id) VALUES (?, 'veteran')").run(dana.id);
+ // Chat-app rows (migrations/0037_chat_apps.sql): her linked chat account, a button and a queued delivery.
+ const future = new Date(Date.now() + 600000).toISOString();
+ await d.prepare("INSERT INTO chat_app_link_tokens (token_hash, app, service_user_id, service_username, state, subject, expires_at) VALUES ('h-dana', 'telegram', '123', 'dana_tg', 'used', ?, ?)").run(dana.subject_id, future);
+ await d.prepare("INSERT INTO chat_app_callbacks (id, app, chat_id, user_id, subject, action, approval_id, state, expires_at) VALUES ('cb-dana', 'telegram', '123', '123', ?, 'approve', 'apr1', 'used', ?)").run(dana.subject_id, future);
+ await d.prepare("INSERT INTO chat_app_deliveries (app, id, message, state) VALUES ('telegram', 'd-dana', ?::jsonb, 'done')").run(JSON.stringify({ app: 'telegram', userId: '123', text: 'her queued text' }));
const sent = [];
const sent = [];
let failNext = false;
let failNext = false;
@@ -68,6 +73,8 @@ t('export, then erase and confirm', async () => {
@@ -68,6 +73,8 @@ t('export, then erase and confirm', async () => {
assert.deepStrictEqual(files['chat_messages.json'].map((m) => m.message), ['hi otto']);
assert.deepStrictEqual(files['chat_messages.json'].map((m) => m.message), ['hi otto']);
assert.deepStrictEqual(files['direct_messages.json'].map((m) => m.message), ['psst']);
assert.deepStrictEqual(files['direct_messages.json'].map((m) => m.message), ['psst']);
assert.ok(files['moderation_on_you.json'][0] && !('actor_user_id' in files['moderation_on_you.json'][0]), "moderation on them, without who acted");
assert.ok(files['moderation_on_you.json'][0] && !('actor_user_id' in files['moderation_on_you.json'][0]), "moderation on them, without who acted");
+ assert.deepStrictEqual(files['chat_app_links.json'].map((l) => l.service_user_id), ['123'], 'her linked chat account');
+ assert.deepStrictEqual(files['chat_app_approvals.json'].map((c) => c.approval_id), ['apr1'], 'the button made for her');
assert.strictEqual(await accountData.apply(exp, { send }), 'unchanged');
assert.strictEqual(await accountData.apply(exp, { send }), 'unchanged');
assert.strictEqual(sent.length, 1);
assert.strictEqual(sent.length, 1);
@@ -82,6 +89,9 @@ t('export, then erase and confirm', async () => {
@@ -82,6 +89,9 @@ t('export, then erase and confirm', async () => {
assert.strictEqual((await q('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');
assert.strictEqual((await q('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');
assert.strictEqual((await q('SELECT * FROM moderation_actions')).length, 1, 'moderation history stays');
assert.strictEqual((await q('SELECT * FROM moderation_actions')).length, 1, 'moderation history stays');
assert.strictEqual((await q('SELECT * FROM user_tags')).length, 0, 'the tags table is Chat’s and goes');
assert.strictEqual((await q('SELECT * FROM user_tags')).length, 0, 'the tags table is Chat’s and goes');
+ assert.strictEqual((await q('SELECT * FROM chat_app_link_tokens')).length, 0, 'her chat-app link goes');
+ assert.strictEqual((await q('SELECT * FROM chat_app_callbacks')).length, 0, 'her approval buttons go');
+ assert.strictEqual((await q('SELECT * FROM chat_app_deliveries')).length, 0, 'her queued chat text goes');
assert.strictEqual((await d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id)).username, `deleted-${dana.id}`);
assert.strictEqual((await d.prepare('SELECT username FROM ctx_users WHERE id = ?').get(dana.id)).username, `deleted-${dana.id}`);
assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');
assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');
const conf = sent[1];
const conf = sent[1];
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+const express = require('express');
+const db = require('../server/db/database');
+const { createStore } = require('../server/chat-apps/store');
+const { createHandler, group, identity } = require('../server/chat-apps/handler');
+const { router, parse } = require('../server/chat-apps/ingress');
+const { createProviders } = require('../server/chat-apps/providers');
+const { signButton } = require('openvibe-sdk/chat-apps');
+(async () => {
+ const keys = crypto.generateKeyPairSync('ed25519');
+ const cfg = { chatApps: { callbackSecret: 'fixture-callback-only', linkTtlMs: 600000, callbackTtlMs: 600000, perUserRatePerHour: 100,
+ linkUrl: 'https://openvibe.network/chat-apps/link',
+ telegram: { botToken: 'fixture', secretToken: 'fixture-telegram' },
+ 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 = [];
+ 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 }]));
+ const network = {
+ resolve: async () => linked ? subject : null,
+ registerPending: async b => { links.push(b); return true; },
+ // Network only finishes a link for the chat account that started it and only with its code.
+ finish: async ({ serviceUserId, code }) => {
+ if (serviceUserId !== pendingUser) return { ok: false, error: 'no_link' };
+ if (code !== pendingCode) return { ok: false, error: 'code', attemptsLeft: 2 };
+ linked = true; pendingCode = null;
+ return { ok: true, subject };
+ },
+ 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 handler = createHandler({ config: cfg, store, providers, network, actor });
+ for (const app of ['telegram','discord','slack']) {
+ const fixture = require(`./fixtures/chat-apps/${app}.json`);
+ const m = parse(app, fixture, providers[app], cfg.chatApps);
+ assert.ok(m); assert.equal(m.chatType, 'private');
+ linked = false; pendingCode = null; pendingUser = null;
+ await handler.handle(m, `${app}-unlinked`); assert.equal(starts.length, 0);
+ await handler.handle({ ...m, command: 'link', kind: 'command' }, `${app}-link`);
+ assert.ok(links.at(-1).token.length === 43, 'a one-time link was registered');
+ assert.equal(links.at(-1).botUsername, 'our_bot');
+ pendingCode = '123456'; pendingUser = identity(m);
+ // Another chat account sending the code finishes nothing.
+ await handler.handle({ ...m, userId: 'stranger', text: '123456', command: null, kind: 'message' }, `${app}-code-wrong-person`);
+ assert.equal(linked, false);
+ // A wrong code does not link.
+ await handler.handle({ ...m, text: '000000', command: null, kind: 'message' }, `${app}-code-wrong`);
+ assert.equal(linked, false); assert.match(sent.at(-1).text, /not right/);
+ // 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/);
+ await handler.handle(m, `${app}-ask`); assert.equal(starts.at(-1).policy, 'dm');
+ 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 };
+ await handler.handle(g, 'unmentioned'); assert.equal(starts.length, 0);
+ await handler.handle({ ...g, mention: true }, 'mentioned'); assert.equal(starts.at(-1).policy, 'answer_only'); starts.length = 0;
+ admin = false;
+ await handler.handle({ ...g, mention: true, command: 'settings', args: 'act_with_approvals 10' }, 'badadmin');
+ assert.equal((await store.settings(app, group(g))).mode, 'answer_only');
+ admin = true;
+ await handler.handle({ ...g, mention: true, command: 'settings', args: 'act_with_approvals 10' }, 'admin');
+ assert.equal((await store.settings(app, group(g))).mode, 'act_with_approvals');
+ const id = await store.openCallback({ app, chatId: group(m), userId: identity(m), subject, action: 'approve', approvalId: 'approval1', expiresAt: Date.now() + 60000 });
+ const button = { ...m, command: null, kind: 'button', button: { id: signButton({ secret: cfg.chatApps.callbackSecret, id }) } };
+ await handler.handle({ ...button, userId: 'stranger' }, 'wrong'); assert.equal(decisions.length, 0);
+ await Promise.all([handler.handle(button, 'right'), handler.handle(button, 'duplicate')]); assert.equal(decisions.length, 1); decisions.length = 0;
+ 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(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.
+ const web = express(); web.use('/hooks', router({ config: cfg, providers, store, wake() {} }));
+ const server = web.listen(0, '127.0.0.1'); await new Promise(r => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/hooks`;
+ try {
+ for (const app of ['telegram','discord','slack']) {
+ const raw = JSON.stringify(require(`./fixtures/chat-apps/${app}.json`));
+ const ts = String(Math.floor(Date.now()/1000));
+ const headers = { 'content-type': 'application/json' };
+ if (app === 'telegram') headers['x-telegram-bot-api-secret-token'] = cfg.chatApps.telegram.secretToken;
+ if (app === 'slack') { headers['x-slack-request-timestamp'] = ts; headers['x-slack-signature'] = 'v0=' + crypto.createHmac('sha256', cfg.chatApps.slack.signingSecret).update(`v0:${ts}:${raw}`).digest('hex'); }
+ if (app === 'discord') { headers['x-signature-timestamp'] = ts; headers['x-signature-ed25519'] = crypto.sign(null, Buffer.from(ts + raw), keys.privateKey).toString('hex'); }
+ assert.equal((await fetch(`${base}/${app}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: raw })).status, 401);
+ const at = Date.now();
+ assert.equal((await fetch(`${base}/${app}`, { method: 'POST', headers, body: raw })).status, 200); assert.ok(Date.now() - at < 3000);
+ assert.equal((await fetch(`${base}/${app}`, { method: 'POST', headers, body: raw })).status, 200);
+ assert.equal(Number((await db.get('SELECT count(*) AS n FROM chat_app_deliveries WHERE app = ?', [app])).n), 1);
+ providers[app].on = false;
+ assert.equal((await fetch(`${base}/${app}`, { method: 'POST', headers, body: raw })).status, 503);
+ }
+ } finally { await new Promise(r => server.close(r)); }
+ const real = createProviders({ config: cfg, fetchImpl: async () => ({ ok: true, json: async () => ({ ok: true, user: { team_id: 'T123', is_admin: true } }) }) }).providers;
+ assert.equal(await real.discord.isAdmin({ message: { raw: { member: { permissions: '8' } } } }), true);
+ assert.equal(await real.discord.isAdmin({ message: { raw: { member: { permissions: '0' } } } }), false);
+ assert.equal(await real.slack.isAdmin({ message: { userId: 'U123' } }), true);
+ const rates = await Promise.all(Array.from({ length: 10 }, () => store.rate('race', 2)));
+ assert.equal(rates.filter(Boolean).length, 2);
+ console.log('chat apps: three payloads, signatures, linking, approvals, identity, expiry, group policy, durable dedupe and rates passed');
+})().catch(e => { console.error(e); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"id":"111111111111111111","application_id":"222222222222222222","type":2,"channel_id":"333333333333333333","user":{"id":"123","username":"fixture"},"data":{"name":"ask","options":[{"name":"text","type":3,"value":"hello"}]}}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"type":"event_callback","team_id":"T123","event_id":"Ev123","event":{"type":"message","user":"U123","channel":"D123","channel_type":"im","text":"hello","ts":"1800000000.000001"}}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"update_id":1001,"message":{"message_id":22,"from":{"id":123,"is_bot":false,"username":"fixture"},"chat":{"id":123,"type":"private"},"date":1800000000,"text":"hello"}}
@@ -29,11 +29,13 @@ const TABLES = [
@@ -29,11 +29,13 @@ const TABLES = [
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'room_tasks', 'room_hooks', 'vibe_usage', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_asks', 'vibe_deliveries', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_actions', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
+ 'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
];
];
const INDEXES = [
const INDEXES = [
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
'idx_chat_ts_deleted', 'idx_chat_page_live', 'idx_chat_channel_user_ts', 'idx_chat_stream_ts', 'idx_emotes_channel_code', 'idx_chat_tl_dedup',
'idx_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent',
'idx_calls_open', 'idx_ctx_users_subject', 'idx_ctx_users_username', 'idx_audio_requests_room_state', 'idx_events_outbox_unsent', 'service_outbox_due', 'service_outbox_sent',
'idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',
'idx_room_messages_room', 'idx_room_members_user', 'idx_network_blocks_blocked', 'idx_mod_actions_created',
+ 'chat_app_link_tokens_by_person', 'chat_app_link_tokens_expiry', 'chat_app_callbacks_by_chat', 'chat_app_callbacks_expiry',
];
];
const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
const MIRRORED = ['chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions'];
'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions'];