openvibers/openvibe.chat Commits

Chat: your agent in Telegram, Discord and Slack (A4) (#358)

OP OpenVibers committed 7 days ago · 9130dc4287 · 25 files, +1457 −6

* Chat: the agent in Telegram, Discord and Slack (A4)

Chat owns the chat-app bots (the agent plan of 2026-10-30, stage A4). Every app is off until
its own bot token and its own secret are in /etc/openvibe/chat.env, and Chat starts nothing
for an app that is off.

- Ingress per app at /hooks/chat-apps/:app: the delivery's own proof is checked before the
  body is parsed (Telegram's secret token, Slack's v0 signature, Discord's Ed25519), the event
  id dedupes, and the provider gets its acknowledgement before any work (200, or Discord's
  type 4 within 3 seconds). Durable lanes answer messages and buttons separately.
- Linking: /link answers a one-time Network link that the person confirms signed in; /unlink
  removes it. Until then only /link and /help answer. The token is kept as a SHA-256 hash and
  used once. Group and workspace rules are in chat_app_settings; buttons in chat_app_callbacks,
  bound to the linked person, the app, the chat and the exact approval, consumed once.
- A message becomes the person's own task in Actor (their agent, memory, tools and budget) for
  a DM, or a stranger's suggestion for a group, with the group's policy. Answers are split the
  app's way, files come back as links, and Chat's moderation rules (lineOk) apply to everything
  the bot says.
- An approval arrives as Telegram/Discord/Slack buttons; Approve and Decline post back to Actor
  only for the linked person in that chat.
- Discord: a discord.js gateway for messages (the signed HTTP endpoint still answers
  interactions). Telegram callbacks are acknowledged so no spinner is left.
- Account data: chat-app links, buttons and queued deliveries are exported and erased.
- docs/chat-apps.md and scripts/chat-apps.js: what the owner creates per app, the endpoint,
  scopes, the registration helper (Telegram webhook and commands, Discord commands) and the
  off-until-configured behaviour.
- Pins openvibe-sdk v0.92.0 (SDK #124), and discord.js 14.26.3.

Tests: node test/run.js chat-apps and account-data pass; the full suite is run before the PR.

* Chat: the chat-app tables and indexes in the schema test (A4)

migrations/0037_chat_apps.sql adds five tables (the link tokens, the group settings, the
buttons, the delivery queue and its rates) and four indexes. The schema test pins the exact
table count, so it names them; node test/run.js pg-schema passes.

* Chat: pin openvibe-sdk v0.92.0 after the fleet moved to 0.91.0

Merges main (the SDK 0.91.0 pin, Chat #357) and keeps this branch on v0.92.0, which carries
openvibe-sdk/chat-apps (SDK #124). No other change.

* Chat: finishing a link takes the 6-digit code from that chat (A4 review)

Network now confirms a link in two steps: the signed-in page mints a 6-digit code, and the link
exists only when the chat account that started /link sends that code to the bot.

- /link tells the person the page will show a code to send back here; the registration carries
  the bot's @name so the page can name it.
- An unlinked person's 6 digits in a direct message go to Network's finish; success answers
  "You are linked", a wrong code says how many tries are left, an expired code asks for /link
  again. A code in a group is never redeemed.
- Slack's bot handle falls back to the name auth.test returns.

Tests: per app, another chat account's code links nothing, a wrong code links nothing, and the
starter's code links. node test/run.js chat-apps account-data pass.

* Chat: a finished link that conflicts says why (A4 review)

If Network refuses the finish because the chat account or the person already has a link of that
service, the bot now says so instead of asking for /link again. node test/run.js chat-apps
passes.

Changes

ddiff --git a/.env.example b/.env.example
iindex f72ede0..d2cbb92 100644
---- a/.env.example
++++ b/.env.example
@@@ -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=1
ddiff --git a/docs/chat-apps.md b/docs/chat-apps.md
nnew file mode 100644
iindex 0000000..bb92a4c
---- /dev/null
++++ b/docs/chat-apps.md
@@@ -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.
ddiff --git a/migrations/0037_chat_apps.sql b/migrations/0037_chat_apps.sql
nnew file mode 100644
iindex 0000000..e9fa1ab
---- /dev/null
++++ b/migrations/0037_chat_apps.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 144da2a..d12d1f0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex aee6002..1878c55 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/scripts/chat-apps.js b/scripts/chat-apps.js
nnew file mode 100644
iindex 0000000..2c559a1
---- /dev/null
++++ b/scripts/chat-apps.js
@@@ -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; });
ddiff --git a/server/app.js b/server/app.js
iindex d7a06ae..8f1cd82 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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).
ddiff --git a/server/chat-apps/actor.js b/server/chat-apps/actor.js
nnew file mode 100644
iindex 0000000..6b35fc5
---- /dev/null
++++ b/server/chat-apps/actor.js
@@@ -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 };
ddiff --git a/server/chat-apps/discord-gateway.js b/server/chat-apps/discord-gateway.js
nnew file mode 100644
iindex 0000000..74be55b
---- /dev/null
++++ b/server/chat-apps/discord-gateway.js
@@@ -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 };
ddiff --git a/server/chat-apps/handler.js b/server/chat-apps/handler.js
nnew file mode 100644
iindex 0000000..88367b9
---- /dev/null
++++ b/server/chat-apps/handler.js
@@@ -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 };
ddiff --git a/server/chat-apps/index.js b/server/chat-apps/index.js
nnew file mode 100644
iindex 0000000..e9e96e0
---- /dev/null
++++ b/server/chat-apps/index.js
@@@ -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 };
ddiff --git a/server/chat-apps/ingress.js b/server/chat-apps/ingress.js
nnew file mode 100644
iindex 0000000..c544247
---- /dev/null
++++ b/server/chat-apps/ingress.js
@@@ -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 };
ddiff --git a/server/chat-apps/links.js b/server/chat-apps/links.js
nnew file mode 100644
iindex 0000000..cd4e268
---- /dev/null
++++ b/server/chat-apps/links.js
@@@ -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 };
ddiff --git a/server/chat-apps/network.js b/server/chat-apps/network.js
nnew file mode 100644
iindex 0000000..e933151
---- /dev/null
++++ b/server/chat-apps/network.js
@@@ -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 };
ddiff --git a/server/chat-apps/providers.js b/server/chat-apps/providers.js
nnew file mode 100644
iindex 0000000..a9a3cc1
---- /dev/null
++++ b/server/chat-apps/providers.js
@@@ -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 };
ddiff --git a/server/chat-apps/store.js b/server/chat-apps/store.js
nnew file mode 100644
iindex 0000000..c7a9403
---- /dev/null
++++ b/server/chat-apps/store.js
@@@ -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 };
ddiff --git a/server/chat/account-data.js b/server/chat/account-data.js
iindex 0f16513..d9acbea 100644
---- a/server/chat/account-data.js
++++ b/server/chat/account-data.js
@@@ -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 = [
ddiff --git a/server/config.js b/server/config.js
iindex da53a56..c2e27af 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/index.js b/server/index.js
iindex 0247c37..5e1e6e4 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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(),
],
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex fb83c93..0dc5903 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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];
ddiff --git a/test/chat-apps.test.js b/test/chat-apps.test.js
nnew file mode 100644
iindex 0000000..4fc9ca0
---- /dev/null
++++ b/test/chat-apps.test.js
@@@ -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; });
ddiff --git a/test/fixtures/chat-apps/discord.json b/test/fixtures/chat-apps/discord.json
nnew file mode 100644
iindex 0000000..a596e3e
---- /dev/null
++++ b/test/fixtures/chat-apps/discord.json
@@@ -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"}]}}
ddiff --git a/test/fixtures/chat-apps/slack.json b/test/fixtures/chat-apps/slack.json
nnew file mode 100644
iindex 0000000..36a08ac
---- /dev/null
++++ b/test/fixtures/chat-apps/slack.json
@@@ -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"}}
ddiff --git a/test/fixtures/chat-apps/telegram.json b/test/fixtures/chat-apps/telegram.json
nnew file mode 100644
iindex 0000000..d3a7fe9
---- /dev/null
++++ b/test/fixtures/chat-apps/telegram.json
@@@ -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"}}
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex 708038c..f112b85 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -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'];