openvibers/openvibe.sdk Commits

openvibe-sdk/chat-apps: the three chat apps (Telegram, Discord, Slack) in one place (0.92.0) (#124)

OP OpenVibers committed 7 days ago · a910a9277e · 18 files, +674 −19

* openvibe-sdk/chat-apps: the three chat apps in one place (agent plan A4)

Telegram, Discord and Slack are answered by one service, and each of them checks
its deliveries its own way. That code lived in OpenVibe.Watch (Watch #194 and
#195) and is now here, so a service that answers a chat app reads it the same
way instead of copying it.

- verifyTelegram, verifySlack, verifyDiscord and verifyDelivery: the secret
  token, the v0 signature over "v0:<timestamp>:<body>" and the Ed25519
  signature over "<timestamp><body>", compared in constant time. A provider
  the module does not know is never verified.
- signCallback and verifyCallback: a button callback is "v1.<payload>.<hmac>"
  over the app's own secret, with its expiry inside the payload. A changed,
  replayed or expired token is refused.
- parseTelegramUpdate, parseDiscordInteraction, parseSlackEvent and
  parseSlackInteraction: each app's payload as the same shape (who said it,
  where, whether it was said to the bot), and splitText for a long answer.

* openvibe-sdk/chat-apps: the three chat apps in one place (0.92.0)

Merges main (0.90.0 subject assertions) and releases the chat-apps module as
0.92.0; 0.91.0 is held by the open Agent Streams PR #125.

- Compact signed buttons: signButton/verifyButton carry a 16-character row id
  in 44 bytes, inside Telegram's 64-byte callback_data. The caller checks the
  person, the action, the expiry and one use against its own row.
- Telegram: only a new message is read (an edit is not answered twice), a
  message from a bot is ignored, /command@another_bot is not ours, and a
  document arrives as an attachment.
- Discord: a gateway message parses too (parseDiscordMessage), a message from
  a bot or a webhook is ignored, and an interaction without a channel object
  takes its channel_id.
- Slack: a shared file is a message; an edit, a join and the bot's own message
  are not; a mention is the bot's id in the text.
- verifyCallback refuses a payload whose iat is missing, in the future or too
  old when it carries no exp.

Tests: the full SDK suite passes (83 passed, 6 skipped for the absent
PostgreSQL/Valkey containers); pack 84 subpaths, surface, types and
browser-entries agree.

* openvibe-sdk/chat-apps: 0.92.0 after the streams release

Merges main (0.91.0, openvibe-sdk/streams) and keeps 0.92.0 for chat-apps, the next free
version after the v0.91.0 tag.

Resolution: the CHANGELOG keeps both entries (0.92.0 chat-apps, 0.91.0 streams); package.json,
the lockfile, src/core and README/STATUS are 0.92.0; the browser bundle is regenerated (streams
is server-only and is not in it); pack counts 85 subpaths; the browser-entry blocked list names
chat-apps and streams; docs/api-surface.json includes both.

Full suite: 85 files pass, 6 skipped (the PostgreSQL/Valkey containers are not up). types.test.js
needs the devDependency installed (NODE_ENV was production here); with openvibe-contracts
v0.298.0 installed it passes.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 46fbd66..96a0eae 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,24 @@
# Changelog
++## 0.92.0 (2026-11-06)
++
++- Add `openvibe-sdk/chat-apps`: the three chat apps (Telegram, Discord, Slack) in one place, so a service that answers
++ a chat app reads them the same way (the plan of 2026-10-30, stage A4: "your agent in Telegram, Discord and Slack").
++ - The checks: `verifyTelegram()` (the `X-Telegram-Bot-Api-Secret-Token` header, compared in constant time),
++ `verifySlack()` (v0 over "v0:<timestamp>:<body>", the timestamp within five minutes), `verifyDiscord()` (Ed25519
++ over "<timestamp><body>" with the application's public key) and `verifyDelivery()` (the provider names its own
++ scheme; a provider this does not know is never verified). The code came from OpenVibe.Watch, which has checked
++ these deliveries since 2026-11-03 (Watch #194 and #195).
++ - The buttons: `signCallback()` and `verifyCallback()` carry a whole signed payload with its expiry;
++ `signButton()` and `verifyButton()` carry a 16-character row id in 44 bytes, inside Telegram's 64-byte
++ `callback_data`, and the caller checks who may press it, what it does, that it is used once and that it has
++ not expired against its own row. A token that was changed, replayed or is too old is refused.
++ - The messages: `parseTelegramUpdate()`, `parseDiscordInteraction()`, `parseDiscordMessage()`, `parseSlackEvent()`
++ and `parseSlackInteraction()` give each app's payload the same shape (who said it, where, what they said,
++ whether it was said to the bot, and the button that was pressed), never the app's own JSON and never a
++ message the bot itself sent. `splitText()` splits one answer the way the app accepts it, and `commandOf()`
++ reads a command and its arguments.
++
## 0.91.0 (2026-11-06)
- `openvibe-sdk/streams`: the Agent Streams plan, stage 1 — an agent's own stream on OpenVibe.Live (a stream of kind `agent`, id `ags_…`, no stream key and no slot, published with a ticket instead of a key).
ddiff --git a/README.md b/README.md
iindex 952a657..3164328 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.91.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.92.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.91.0 is tagged:
++After v0.92.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.92.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
@@@ -212,6 +212,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/front-page` | any | What may lead a front page (a latest or most viewed list, a quote strip, a feed): `frontPageOk(text)` is false for a built-in slur category (OpenVibe.Chat's, spaced out or leet-spelled included), strong profanity in English, Portuguese, Spanish, French or German, or a hate topic out of context; `containsCoreSlur(text)` is the slur check alone. Leave such an item off the front page and show the next one; it stays on its own page |
| `openvibe-sdk/news` | server | `createNewsClient(client)`: OpenVibe.News' published stories, sent without a token. `topics()`, `stories.list({topic, limit, offset})` (published and retracted, newest first) and `stories.get(id)` (sources, timeline, perspectives). Writing and the editorial reads stay first-party |
| `openvibe-sdk/food` | server | `createFoodClient(client)`: OpenVibe.Food. Public: `places({kind, q, lat, lon, radius})` (food banks, pantries, free meals from OpenStreetMap), `foods.list/get` (the food list with store prices). The caller's own: `plans.create({people, days, budget, title})/list/get` (deterministic meal plans with a shopping list and costs), `pantry.get/set(items)` (with "what can I make" matches), `recipes.suggest({ask, people})/list` (AI recipe ideas, 10 a day). Plans and recipe ideas are never retried on their own. Audience `openvibe.food` |
++| `openvibe-sdk/chat-apps` | server | The three chat apps (Telegram, Discord, Slack) in one place: `verifyTelegram()`, `verifySlack()`, `verifyDiscord()`, `verifyDelivery()` (the delivery's own proof), `signCallback()` / `verifyCallback()` (a button callback: signed once, used once, expiring), `parseTelegramUpdate()`, `parseDiscordInteraction()`, `parseSlackEvent()`, `parseSlackInteraction()` (each app's payload as the same shape: who said it, where, whether it was said to the bot) and `splitText()` (an answer split the app's way) |
| `openvibe-sdk/actor` | server | `createActorClient(client)`: OpenVibe.Actor. `agents.list()` and `route({task, mode})` (a dry run) are public. `tasks.create({task, mode, budget, agent, idempotency_key, webhooks})` (retried only with an idempotency_key; `webhook_secret` comes back once), `tasks.list/get/cancel`, `tasks.events(id, {lastEventId})` (SSE until the task ends). A person's own agents: `me.agents.list/create/get/update/remove/run/runs` and `me.agents.memory.list/add/update/remove/clear`. Audience `openvibe.actor` |
| `openvibe-sdk/run` | server | `createRunClient(client, {project?})`: OpenVibe.Run, jobs on worker nodes. `jobs.submit({class, artifact, args, limits, …})` (Idempotency-Key, so retried safely) → `{ created, job }`, `jobs.list({state, limit, cursor})`, `jobs.get/cancel`, `jobs.events(id, {lastEventId})` (SSE until `end`), `jobs.streamTicket(id)` (a one-use URL for a browser's EventSource); `forProject(prj_…)` sends X-OV-Project. Audience `openvibe.run` |
| `openvibe-sdk/video` | server | `createVideoClient(client)`: OpenVibe.Video's playlists of recordings and clips. `playlists.list/create/get/update/remove`, `playlists.items.add/reorder/remove` (`{ kind: 'vod' \| 'clip', id }`), `users.playlists(username)` (public). Private playlists are 404 to everyone but their owner. Audience `openvibe.download` (the media hub's) |
ddiff --git a/STATUS.json b/STATUS.json
iindex ba8d00b..4467d37 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,12 +3,13 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.91.0",
++ "version": "0.92.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"contracts": "openvibe-contracts v0.298.0",
"features": [
++ "chat-apps (openvibe-sdk/chat-apps): the three chat apps (Telegram, Discord, Slack) in one place — the delivery checks (Telegram's secret token, Slack's v0 signing, Discord's Ed25519), the button callback (signed once, used once, expiring), each app's payload as the same shape and splitText() for a long answer",
"exchange (openvibe-sdk/exchange): the buyer side (chat, models, offers, presets, usage, receipts) and the seller side (offer, earnings, verification) of OpenVibe.Exchange, with the platform webhook signature check",
"locker (openvibe-sdk/locker, openvibe-sdk/locker/server): the Locker envelope (locker.envelope@1) for end-to-end encryption, WebCrypto only, in Node and the browser: seal/open for text and bytes, sealStream/openStream, the three locks (people, password, link), person key pairs (ECDH P-256, ECDSA P-256), the eight-word fingerprint, wrapFor and rewrap, recoveryWords for the phrase to write down, opening a people lock by key, inspect for what an envelope says about itself, and the header checks a service runs without a key",
"the 1.0 core surface is frozen now, before 1.0 (core, auth, events, db, media): docs/one-point-oh.json gives every export its tier, test/surface.test.js pins the exact names, and removing or renaming one needs a major release; createCodeVerifier and pkceChallenge left in 0.86.0",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 18755a9..71770e2 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.91.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.92.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, modules, realtime, media, community, bot, search, bench, meme, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -48,7 +48,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.91.0';
++const SDK_VERSION = '0.92.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 6567340..9f9eabc 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -64,6 +64,29 @@
"createInboxVisitor"
]
},
++ "chatApps": {
++ "object": [
++ "APP_LIMITS",
++ "PROVIDERS",
++ "TOLERANCE_SEC",
++ "commandOf",
++ "parseDiscordInteraction",
++ "parseDiscordMessage",
++ "parseSlackEvent",
++ "parseSlackInteraction",
++ "parseTelegramUpdate",
++ "safeEqual",
++ "signButton",
++ "signCallback",
++ "splitText",
++ "verifyButton",
++ "verifyCallback",
++ "verifyDelivery",
++ "verifyDiscord",
++ "verifySlack",
++ "verifyTelegram"
++ ]
++ },
"commerce": {
"object": [
"CommerceError",
@@@ -461,6 +484,33 @@
"createFeedPublisher": "function",
"createInboxVisitor": "function"
},
++ "openvibe-sdk/chat-apps": {
++ "APP_LIMITS": {
++ "object": [
++ "discord",
++ "slack",
++ "telegram"
++ ]
++ },
++ "PROVIDERS": "array",
++ "TOLERANCE_SEC": "number",
++ "commandOf": "function",
++ "parseDiscordInteraction": "function",
++ "parseDiscordMessage": "function",
++ "parseSlackEvent": "function",
++ "parseSlackInteraction": "function",
++ "parseTelegramUpdate": "function",
++ "safeEqual": "function",
++ "signButton": "function",
++ "signCallback": "function",
++ "splitText": "function",
++ "verifyButton": "function",
++ "verifyCallback": "function",
++ "verifyDelivery": "function",
++ "verifyDiscord": "function",
++ "verifySlack": "function",
++ "verifyTelegram": "function"
++ },
"openvibe-sdk/commerce": {
"CommerceError": "class",
"createCommerceClient": "function",
ddiff --git a/esm/chat-apps.mjs b/esm/chat-apps.mjs
nnew file mode 100644
iindex 0000000..5b20fe5
---- /dev/null
++++ b/esm/chat-apps.mjs
@@@ -0,0 +1,10 @@
++// Generated by scripts/esm.js from src/chat-apps.js. Do not edit.
++import cjs from '../src/chat-apps.js';
++
++export const {
++ verifyTelegram, verifySlack, verifyDiscord, verifyDelivery, signCallback, verifyCallback,
++ parseTelegramUpdate, parseDiscordInteraction, parseSlackEvent, parseSlackInteraction, splitText, commandOf,
++ safeEqual, signButton, verifyButton, parseDiscordMessage, APP_LIMITS, PROVIDERS,
++ TOLERANCE_SEC,
++} = cjs;
++export default cjs;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 0865ff3..9fb8b38 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -7,12 +7,12 @@ export const {
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, share,
locker, auth, registry, identity, modules, events,
realtime, media, community, bot, search, bench,
-- meme, vision, themes, redact, chat, ai,
-- jobs, tools, projects, resources, vip, quest,
-- live, inventory, tips, blog, reviews, wiki,
-- coupons, deals, trade, space, host, news,
-- food, actor, run, video, work, zone,
-- play, download, openre, geo, usage, commerce,
-- telemetry,
++ meme, vision, themes, redact, chat, chatApps,
++ ai, jobs, tools, projects, resources, vip,
++ quest, live, inventory, tips, blog, reviews,
++ wiki, coupons, deals, trade, space, host,
++ news, food, actor, run, video, work,
++ zone, play, download, openre, geo, usage,
++ commerce, telemetry,
} = cjs;
export default cjs;
ddiff --git a/index.js b/index.js
iindex 9e8ce16..b676439 100644
---- a/index.js
++++ b/index.js
@@@ -26,6 +26,7 @@ module.exports = {
themes: require('./src/themes'),
redact: require('./src/redact'),
chat: require('./src/chat'),
++ chatApps: require('./src/chat-apps'),
ai: require('./src/ai'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
ddiff --git a/package-lock.json b/package-lock.json
iindex 7ad4a7e..af97e6e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.91.0",
++ "version": "0.92.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.91.0",
++ "version": "0.92.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex d5bd3be..ecf672e 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.91.0",
++ "version": "0.92.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -239,6 +239,12 @@
"import": "./esm/chat.mjs",
"default": "./src/chat.js"
},
++ "./chat-apps": {
++ "types": "./types/chat-apps.d.ts",
++ "browser": null,
++ "import": "./esm/chat-apps.mjs",
++ "default": "./src/chat-apps.js"
++ },
"./ai": {
"types": "./types/ai.d.ts",
"import": "./esm/ai.mjs",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 7666986..09ed985 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -58,6 +58,7 @@ const ENTRIES = {
'queue.mjs': '../src/queue.js',
'pubsub.mjs': '../src/pubsub.js',
'chat.mjs': '../src/chat.js',
++ 'chat-apps.mjs': '../src/chat-apps.js',
'ai.mjs': '../src/ai.js',
'jobs.mjs': '../src/jobs.js',
'tools.mjs': '../src/tools.js',
ddiff --git a/src/chat-apps.js b/src/chat-apps.js
nnew file mode 100644
iindex 0000000..c4c4d8e
---- /dev/null
++++ b/src/chat-apps.js
@@@ -0,0 +1,308 @@
++'use strict';
++/**
++ * openvibe-sdk/chat-apps — the three chat apps (Telegram, Discord, Slack) in one place.
++ *
++ * The checks Every delivery is checked before it is read: Telegram's secret token, Slack's signing
++ * secret (v0 over "v0:<timestamp>:<body>", the timestamp within five minutes) and
++ * Discord's Ed25519 signature over "<timestamp><body>" (the application's public key).
++ * The code came from OpenVibe.Watch, which has checked these deliveries since 2026-11-03
++ * (Watch #194 and #195); it lives here so every service that answers a chat app reads it
++ * the same way. The comparison is constant time and a wrong signature is only ever "no".
++ * The buttons An approval arrives in the chat as buttons. What the buttons carry is a callback token:
++ * "v1.<base64url(payload)>.<hex hmac>" over the payload with the app's own secret, with an
++ * expiry inside the payload. It is signed once, used once and expires; a token that was
++ * changed, replayed or is too old is refused.
++ * The messages Each app's payload becomes the same small shape: who said it, where, what they said and
++ * whether they said it to the bot. A service that answers the bot works with that shape and
++ * never with the app's own JSON.
++ *
++ * const { verifyDelivery, parseTelegramUpdate } = require('openvibe-sdk/chat-apps');
++ * if (!verifyDelivery({ provider: 'telegram', secret, headers: req.headers })) return res.sendStatus(401);
++ * const message = parseTelegramUpdate(req.body, { botUsername: 'openvibe_bot' });
++ */
++const crypto = require('crypto');
++
++const TOLERANCE_SEC = 300;
++// The DER prefix of an Ed25519 public key (SubjectPublicKeyInfo), so the 32 raw bytes Discord shows become a key object.
++const ED25519_SPKI = Buffer.from('302a300506032b6570032100', 'hex');
++// How long one message may be in each app: Telegram 4096, Discord 2000, Slack 3000 (a block's text).
++const APP_LIMITS = Object.freeze({ telegram: 4096, discord: 2000, slack: 3000 });
++const PROVIDERS = Object.freeze(['telegram', 'discord', 'slack']);
++
++function safeEqual(a, b) {
++ const x = Buffer.from(String(a == null ? '' : a));
++ const y = Buffer.from(String(b == null ? '' : b));
++ return x.length === y.length && crypto.timingSafeEqual(x, y);
++}
++
++const hmacHex = (secret, data) => crypto.createHmac('sha256', secret).update(data).digest('hex');
++const fresh = (ts, nowMs) => /^\d{1,12}$/.test(String(ts)) && Math.abs(Math.floor(nowMs / 1000) - Number(ts)) <= TOLERANCE_SEC;
++
++/**
++ * Telegram's secret token: the header setWebhook's secret_token carries, compared in constant time.
++ * The token is the app's own (setWebhook), 1–256 characters of A–Z, a–z, 0–9, _ and -.
++ */
++function verifyTelegram({ secret, headers = {} } = {}) {
++ if (!secret) return false;
++ const given = headers['x-telegram-bot-api-secret-token'];
++ if (given == null) return false;
++ return safeEqual(String(given), String(secret));
++}
++
++/**
++ * Slack's request signing: v0 over "v0:<timestamp>:<body>", the timestamp within five minutes.
++ * Slack's retry of a delivery it did not see answered carries X-Slack-Retry-Num; the check is the same.
++ */
++function verifySlack({ secret, raw, headers = {}, now = Date.now() } = {}) {
++ if (!secret) return false;
++ const ts = String(headers['x-slack-request-timestamp'] || '');
++ const sig = String(headers['x-slack-signature'] || '');
++ if (!fresh(ts, now) || !sig.startsWith('v0=')) return false;
++ const body = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw == null ? '' : raw), 'utf8');
++ return safeEqual(sig, `v0=${hmacHex(secret, Buffer.concat([Buffer.from(`v0:${ts}:`), body]))}`);
++}
++
++/**
++ * Discord's interaction signing: Ed25519 over "<timestamp><body>" with the application's public key
++ * (64 hex characters), the timestamp within five minutes.
++ */
++function verifyDiscord({ publicKey, raw, headers = {}, now = Date.now() } = {}) {
++ const ts = String(headers['x-signature-timestamp'] || '');
++ const sig = String(headers['x-signature-ed25519'] || '');
++ if (!publicKey || !fresh(ts, now) || !/^[0-9a-f]{128}$/i.test(sig) || !/^[0-9a-f]{64}$/i.test(String(publicKey))) return false;
++ try {
++ const key = crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI, Buffer.from(String(publicKey), 'hex')]), format: 'der', type: 'spki' });
++ const body = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw == null ? '' : raw), 'utf8');
++ return crypto.verify(null, Buffer.concat([Buffer.from(ts), body]), key, Buffer.from(sig, 'hex'));
++ } catch { return false; }
++}
++
++/**
++ * The delivery carries the app's own proof. The provider names how it is checked: telegram (the secret
++ * token), slack (the signing secret) or discord (the application's public key). A provider this does not
++ * know is never verified.
++ */
++function verifyDelivery({ provider, secret, publicKey, raw, headers = {}, now = Date.now() } = {}) {
++ if (provider === 'telegram') return verifyTelegram({ secret, headers });
++ if (provider === 'slack') return verifySlack({ secret, raw, headers, now });
++ if (provider === 'discord') return verifyDiscord({ publicKey, raw, headers, now });
++ return false;
++}
++
++/**
++ * A callback token: the payload, signed with the app's own secret. The payload is the base64url of its
++ * JSON; the signature is an HMAC-SHA256 over "v1.<payload>" in hex. The payload carries its own expiry
++ * (exp, in milliseconds since the epoch).
++ */
++function signCallback({ secret, payload } = {}) {
++ if (!secret) throw new TypeError('signCallback: the app needs a secret of its own');
++ const body = Buffer.from(JSON.stringify(payload || {}), 'utf8').toString('base64url');
++ return `v1.${body}.${hmacHex(secret, `v1.${body}`)}`;
++}
++
++/**
++ * The payload of a callback token, or null when the token is not ours, was changed, was already used
++ * (the caller marks it used) or has expired. maxAgeMs is the window when the payload has no exp of its own.
++ */
++function verifyCallback({ secret, token, now = Date.now(), maxAgeMs = 15 * 60 * 1000 } = {}) {
++ if (!secret || typeof token !== 'string') return null;
++ const parts = token.split('.');
++ if (parts.length !== 3 || parts[0] !== 'v1') return null;
++ const [, body, sig] = parts;
++ if (!/^[0-9a-f]{64}$/.test(sig)) return null;
++ if (!safeEqual(sig, hmacHex(secret, `v1.${body}`))) return null;
++ let payload;
++ try { payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); } catch { return null; }
++ if (!payload || typeof payload !== 'object') return null;
++ if (typeof payload.exp === 'number' && Number.isFinite(payload.exp)) { if (now >= payload.exp) return null; }
++ else if (typeof payload.iat !== 'number' || !Number.isFinite(payload.iat) || payload.iat > now || now - payload.iat > maxAgeMs) return null;
++ return payload;
++}
++
++const text = (v, max = 4000) => String(v == null ? '' : v).slice(0, max);
++const chatTypeOf = (t) => (t === 'private' || t === 'dm' ? 'private'
++ : t === 'group' || t === 'supergroup' ? 'group'
++ : t === 'channel' ? 'channel' : 'unknown');
++
++/** A command word at the start of a message: "/link now" → command 'link', args 'now'. */
++function commandOf(body) {
++ const m = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:@([A-Za-z0-9_]+))?(?:\s+([\s\S]*))?$/.exec(String(body || '').trim());
++ if (!m) return null;
++ return { command: m[1].toLowerCase(), args: (m[3] || '').trim(), mentionName: m[2] ? m[2].toLowerCase() : null };
++}
++
++/**
++ * A Telegram update (a new message) as the common shape. An edit of a message that was already
++ * answered and a channel post are not answered: only `update.message` is read. A callback query
++ * (a button) is the same shape with kind 'button'. A message from a bot is never read.
++ */
++function parseTelegramUpdate(update, { botUsername = null, botUserId = null } = {}) {
++ if (!update || typeof update !== 'object') return null;
++ if (update.callback_query) {
++ const q = update.callback_query;
++ const from = q.from || {};
++ return {
++ app: 'telegram', kind: 'button', chatId: String((q.message && q.message.chat && q.message.chat.id) || ''),
++ chatType: chatTypeOf(q.message && q.message.chat && q.message.chat.type), userId: String(from.id || ''),
++ userName: text(from.username, 64) || null, text: '', command: null, args: '', mention: true, replyToBot: false,
++ attachments: [], button: { id: text(q.data, 200) }, messageId: String((q.message && q.message.message_id) || ''), raw: q,
++ };
++ }
++ const m = update.message;
++ if (m && m.from && m.from.is_bot) return null;
++ if (!m) return null;
++ const chat = m.chat || {};
++ const from = m.from || {};
++ const body = text(m.text != null ? m.text : m.caption, 4000);
++ const cmd = commandOf(body);
++ if (cmd && cmd.mentionName && (!botUsername || cmd.mentionName !== String(botUsername).toLowerCase())) return null;
++ const replyUser = m.reply_to_message && m.reply_to_message.from;
++ const mentioned = Boolean(botUsername && body.toLowerCase().includes(`@${String(botUsername).toLowerCase()}`))
++ || Boolean(botUserId && replyUser && String(replyUser.id) === String(botUserId));
++ const attachments = [];
++ if (m.document && m.document.file_id) attachments.push({ kind: 'file', id: m.document.file_id, name: text(m.document.file_name, 120) || null, mime: text(m.document.mime_type, 80) || null });
++ return {
++ app: 'telegram', kind: cmd ? 'command' : mentioned ? 'mention' : 'message',
++ chatId: String(chat.id == null ? '' : chat.id),
++ chatType: chatTypeOf(chat.type), userId: String(from.id == null ? '' : from.id),
++ userName: text(from.username, 64) || text(from.first_name, 64) || null,
++ text: body, command: cmd ? cmd.command : null, args: cmd ? cmd.args : '', mention: mentioned,
++ replyToBot: Boolean(replyUser && botUserId && String(replyUser.id) === String(botUserId)),
++ attachments, button: null, messageId: String(m.message_id == null ? '' : m.message_id), raw: m,
++ };
++}
++
++/**
++ * A Discord interaction (a ping, a slash command, a button) as the common shape. The application's own
++ * user id says whether the bot was mentioned; a guild id says it was not a private conversation.
++ */
++function parseDiscordInteraction(interaction, { botUserId = null } = {}) {
++ if (!interaction || typeof interaction !== 'object') return null;
++ const member = interaction.member || {};
++ const user = interaction.user || member.user || {};
++ const channel = interaction.channel || {};
++ const guildId = interaction.guild_id || (interaction.guild && interaction.guild.id) || null;
++ const base = {
++ app: 'discord', chatId: String(channel.id || interaction.channel_id || ''),
++ chatType: guildId ? 'group' : 'private', userId: String(user.id == null ? '' : user.id),
++ userName: text(user.global_name || user.username, 64) || null, text: '', command: null, args: '',
++ mention: false, replyToBot: false, attachments: [], button: null,
++ requestId: text(interaction.id, 64) || null, raw: interaction,
++ };
++ if (interaction.type === 1) return { ...base, kind: 'ping' };
++ if (interaction.type === 3) {
++ const data = interaction.data || {};
++ const values = Array.isArray(data.values) ? data.values : [];
++ return { ...base, kind: 'button', button: { id: text(data.custom_id, 200), value: values.length ? text(values[0], 200) : null } };
++ }
++ if (interaction.type === 2) {
++ const data = interaction.data || {};
++ const options = Array.isArray(data.options) ? data.options : [];
++ const opts = options.map((o) => `${o.name}=${text(o.value, 500)}`).join(' ');
++ return { ...base, kind: 'command', command: text(data.name, 60).toLowerCase() || null, args: opts, mention: true };
++ }
++ if (interaction.type === 4) return { ...base, kind: 'unknown' };
++ return { ...base, kind: 'unknown' };
++}
++
++/**
++ * A Slack event (a message, an app_mention) as the common shape. A message in a channel is a group; a
++ * message to the bot in a direct conversation is private; a thread's parent (thread_ts) means it is a reply.
++ */
++function parseSlackEvent(event, { botUserId = null } = {}) {
++ if (!event || typeof event !== 'object') return null;
++ if (event.bot_id || (botUserId && event.user === botUserId)) return null;
++ if (event.type === 'app_mention') {
++ const user = event.user || '';
++ return {
++ app: 'slack', kind: 'mention', chatId: text(event.channel, 64), chatType: 'group', userId: text(user, 64),
++ userName: null, text: text(event.text, 4000), command: null, args: '', mention: true, replyToBot: true,
++ attachments: [], button: null, threadTs: text(event.thread_ts, 64) || null, raw: event,
++ };
++ }
++ if (event.type !== 'message') return null;
++ // A file a person shares is a message; an edit of one already answered, a join and a bot's own
++ // message are not. Reading an edit would answer twice, and a join is not something said.
++ if (event.subtype && event.subtype !== 'file_share') return null;
++ if (botUserId && event.user === botUserId) return null; // the bot's own message
++ const m = event;
++ const channelType = text(event.channel_type, 20);
++ const cmd = commandOf(m.text);
++ const files = Array.isArray(m.files) ? m.files.map((f) => ({ kind: 'file', id: text(f.id, 80), name: text(f.name, 120) || null, url: text(f.url_private || f.permalink, 300) || null })) : [];
++ return {
++ app: 'slack', kind: cmd ? 'command' : channelType === 'im' ? 'message' : 'message',
++ chatId: text(event.channel, 64), chatType: channelType === 'im' ? 'private' : channelType === 'channel' ? 'channel' : 'group',
++ userId: text(m.user, 64), userName: null, text: text(m.text, 4000), command: cmd ? cmd.command : null,
++ args: cmd ? cmd.args : '', mention: Boolean(botUserId && String(m.text || '').includes(`<@${botUserId}>`)), replyToBot: Boolean(m.thread_ts && botUserId && m.parent_user_id === botUserId),
++ attachments: files, button: null, threadTs: text(m.thread_ts, 64) || null, raw: m,
++ };
++}
++
++/**
++ * A Slack button press: the interactivity payload with its actions (each with a custom id and a value).
++ */
++function parseSlackInteraction(payload) {
++ if (!payload || typeof payload !== 'object') return null;
++ const actions = Array.isArray(payload.actions) ? payload.actions : [];
++ const action = actions[0] || null;
++ const user = payload.user || {};
++ return {
++ app: 'slack', kind: action ? 'button' : 'unknown', chatId: text(payload.channel && payload.channel.id, 64),
++ chatType: payload.channel && payload.channel.is_im ? 'private' : 'group', userId: text(user.id, 64),
++ userName: text(user.name || user.username, 64) || null, text: '', command: null, args: '', mention: false,
++ replyToBot: false, attachments: [], button: action ? { id: text(action.action_id, 200), value: text(action.value, 200) } : null,
++ raw: payload,
++ };
++}
++
++/**
++ * One answer in the app's own shape: the text split into pieces the app accepts, never longer than its
++ * limit, and never cutting a word in half when a line break can do it.
++ */
++function splitText(app, body, limit) {
++ const max = Math.max(1, Math.min(Number(limit) || APP_LIMITS[app] || 2000, 10000));
++ const s = String(body == null ? '' : body);
++ if (!s) return [];
++ const out = [];
++ let rest = s;
++ while (rest.length > max) {
++ let cut = rest.lastIndexOf('\n', max);
++ if (cut < Math.floor(max / 2)) cut = rest.lastIndexOf(' ', max);
++ if (cut < Math.floor(max / 2)) cut = max;
++ out.push(rest.slice(0, cut).replace(/\s+$/, ''));
++ rest = rest.slice(cut).replace(/^[ \t]+/, '');
++ }
++ if (rest) out.push(rest);
++ return out;
++}
++
++/** Compact signed row reference: 44 ASCII bytes, under Telegram's 64-byte limit.
++ * Identity, action, expiry and consumption are checked atomically against the caller's stored row. */
++function signButton({ secret, id }) {
++ if (!secret || !/^[A-Za-z0-9_-]{16}$/.test(id)) throw new TypeError('invalid button');
++ const body = `b1.${id}`;
++ return `${body}.${crypto.createHmac('sha256', secret).update(body).digest().subarray(0, 18).toString('base64url')}`;
++}
++function verifyButton({ secret, token }) {
++ if (!secret || typeof token !== 'string' || !/^b1\.[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{24}$/.test(token)) return null;
++ const id = token.split('.')[1];
++ return safeEqual(token, signButton({ secret, id })) ? id : null;
++}
++function parseDiscordMessage(m, { botUserId } = {}) {
++ if (!m || !m.author || m.author.bot || m.webhook_id) return null;
++ const cmd = commandOf(m.content);
++ return { app: 'discord', kind: cmd ? 'command' : 'message', chatId: String(m.channel_id || ''),
++ chatType: m.guild_id ? 'group' : 'private', userId: String(m.author.id || ''), userName: m.author.username || null,
++ text: text(m.content), command: cmd ? cmd.command : null, args: cmd ? cmd.args : '',
++ mention: Boolean(botUserId && (m.mentions || []).some(u => String(u.id) === String(botUserId))),
++ replyToBot: Boolean(botUserId && m.referenced_message && m.referenced_message.author && String(m.referenced_message.author.id) === String(botUserId)),
++ messageId: String(m.id || ''), attachments: (m.attachments || []).map(f => ({ kind: 'file', url: f.url, name: f.filename })), button: null, raw: m };
++}
++
++module.exports = {
++ verifyTelegram, verifySlack, verifyDiscord, verifyDelivery,
++ signCallback, verifyCallback,
++ parseTelegramUpdate, parseDiscordInteraction, parseSlackEvent, parseSlackInteraction,
++ splitText, commandOf, safeEqual, signButton, verifyButton, parseDiscordMessage,
++ APP_LIMITS, PROVIDERS, TOLERANCE_SEC,
++};
ddiff --git a/src/core/index.js b/src/core/index.js
iindex cb882d7..564250f 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.91.0';
++const SDK_VERSION = '0.92.0';
module.exports = {
createClient,
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 4826e7a..af6860c 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './share', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/chat-apps.test.js b/test/chat-apps.test.js
nnew file mode 100644
iindex 0000000..4873bcf
---- /dev/null
++++ b/test/chat-apps.test.js
@@@ -0,0 +1,197 @@
++'use strict';
++/**
++ * openvibe-sdk/chat-apps — the three chat apps' checks, their button callbacks and their messages.
++ *
++ * The Discord key pair is made here, so the test signs exactly as Discord signs: Ed25519 over
++ * "<timestamp><body>".
++ */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const { run } = require('./helpers');
++const {
++ verifyTelegram, verifySlack, verifyDiscord, verifyDelivery,
++ signCallback, verifyCallback,
++ parseTelegramUpdate, parseDiscordInteraction, parseSlackEvent, parseSlackInteraction,
++ splitText,
++} = require('../src/chat-apps');
++
++const NOW = 1_800_000_000_000;
++const stamp = (ms = NOW) => String(Math.floor(ms / 1000));
++
++function slackSignature(secret, body, ts = stamp()) {
++ return `v0=${crypto.createHmac('sha256', secret).update(`v0:${ts}:`).update(body).digest('hex')}`;
++}
++
++const discordKeys = crypto.generateKeyPairSync('ed25519');
++const discordPublicHex = discordKeys.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('hex');
++function discordSignature(body, ts = stamp(), key = discordKeys.privateKey) {
++ return crypto.sign(null, Buffer.concat([Buffer.from(ts), Buffer.from(body)]), key).toString('hex');
++}
++
++run([
++ ['Telegram: the secret token is compared in constant time', () => {
++ assert.equal(verifyTelegram({ secret: 'tg-secret', headers: { 'x-telegram-bot-api-secret-token': 'tg-secret' } }), true);
++ assert.equal(verifyTelegram({ secret: 'tg-secret', headers: { 'x-telegram-bot-api-secret-token': 'tg-secrex' } }), false);
++ assert.equal(verifyTelegram({ secret: 'tg-secret', headers: {} }), false);
++ assert.equal(verifyTelegram({ secret: 'tg-secret', headers: { 'x-telegram-bot-api-secret-token': 'tg-secret-longer' } }), false);
++ assert.equal(verifyTelegram({ secret: '', headers: { 'x-telegram-bot-api-secret-token': 'tg-secret' } }), false);
++ }],
++
++ ['Slack: v0 over "v0:<timestamp>:<body>", within five minutes', () => {
++ const secret = 'a'.repeat(32);
++ const body = '{"type":"url_verification"}';
++ const ts = stamp();
++ assert.equal(verifySlack({ secret, raw: body, headers: { 'x-slack-request-timestamp': ts, 'x-slack-signature': slackSignature(secret, body, ts) }, now: NOW }), true);
++ // another body with the same signature
++ assert.equal(verifySlack({ secret, raw: '{"type":"event_callback"}', headers: { 'x-slack-request-timestamp': ts, 'x-slack-signature': slackSignature(secret, body, ts) }, now: NOW }), false);
++ // too old
++ const old = stamp(NOW - 301 * 1000);
++ assert.equal(verifySlack({ secret, raw: body, headers: { 'x-slack-request-timestamp': old, 'x-slack-signature': slackSignature(secret, body, old) }, now: NOW }), false);
++ // no timestamp
++ assert.equal(verifySlack({ secret, raw: body, headers: { 'x-slack-signature': slackSignature(secret, body) }, now: NOW }), false);
++ }],
++
++ ['Discord: Ed25519 over "<timestamp><body>"', () => {
++ const body = '{"type":2,"data":{"name":"link"}}';
++ const ts = stamp();
++ assert.equal(verifyDiscord({ publicKey: discordPublicHex, raw: body, headers: { 'x-signature-timestamp': ts, 'x-signature-ed25519': discordSignature(body, ts) }, now: NOW }), true);
++ assert.equal(verifyDiscord({ publicKey: discordPublicHex, raw: '{"type":2}', headers: { 'x-signature-timestamp': ts, 'x-signature-ed25519': discordSignature(body, ts) }, now: NOW }), false);
++ // another key
++ const other = crypto.generateKeyPairSync('ed25519');
++ assert.equal(verifyDiscord({ publicKey: discordPublicHex, raw: body, headers: { 'x-signature-timestamp': ts, 'x-signature-ed25519': discordSignature(body, ts, other.privateKey) }, now: NOW }), false);
++ // too old
++ const old = stamp(NOW - 400 * 1000);
++ assert.equal(verifyDiscord({ publicKey: discordPublicHex, raw: body, headers: { 'x-signature-timestamp': old, 'x-signature-ed25519': discordSignature(body, old) }, now: NOW }), false);
++ }],
++
++ ['the delivery is checked with the provider it names, and an unknown provider is never checked', () => {
++ const body = 'hello';
++ const ts = stamp();
++ assert.equal(verifyDelivery({ provider: 'telegram', secret: 's', headers: { 'x-telegram-bot-api-secret-token': 's' } }), true);
++ assert.equal(verifyDelivery({ provider: 'slack', secret: 's', raw: body, headers: {} }), false);
++ assert.equal(verifyDelivery({ provider: 'whatsapp', secret: 's', headers: { 'x-telegram-bot-api-secret-token': 's' } }), false);
++ assert.equal(verifyDelivery({ provider: 'discord', publicKey: discordPublicHex, raw: body, headers: { 'x-signature-timestamp': ts, 'x-signature-ed25519': discordSignature(body, ts) }, now: NOW }), true);
++ }],
++
++ ['a button callback is signed once, read once and expires', () => {
++ const secret = 'own-secret';
++ const token = signCallback({ secret, payload: { exp: NOW + 60_000, act: 'approve', id: 'apr_1' } });
++ assert.deepEqual(verifyCallback({ secret, token, now: NOW }), { exp: NOW + 60_000, act: 'approve', id: 'apr_1' });
++ // another secret
++ assert.equal(verifyCallback({ secret: 'other', token, now: NOW }), null);
++ // a changed payload
++ const parts = token.split('.');
++ const changed = `v1.${Buffer.from(JSON.stringify({ exp: NOW + 60_000, act: 'approve', id: 'apr_2' })).toString('base64url')}.${parts[2]}`;
++ assert.equal(verifyCallback({ secret, token: changed, now: NOW }), null);
++ // expired
++ assert.equal(verifyCallback({ secret, token: signCallback({ secret, payload: { exp: NOW - 1, act: 'approve' } }), now: NOW }), null);
++ // not a token
++ assert.equal(verifyCallback({ secret, token: 'nope', now: NOW }), null);
++ assert.equal(verifyCallback({ secret, token: 'v1.abc.def', now: NOW }), null);
++ }],
++
++ ['a Telegram message becomes the common shape', () => {
++ const m = parseTelegramUpdate({
++ update_id: 1,
++ message: { message_id: 7, text: 'hello there', chat: { id: 42, type: 'private' }, from: { id: 5, username: 'alex', first_name: 'Alex' } },
++ }, { botUsername: 'openvibe_bot' });
++ assert.equal(m.app, 'telegram');
++ assert.equal(m.kind, 'message');
++ assert.equal(m.chatId, '42');
++ assert.equal(m.chatType, 'private');
++ assert.equal(m.userId, '5');
++ assert.equal(m.userName, 'alex');
++ assert.equal(m.text, 'hello there');
++ assert.equal(m.command, null);
++ }],
++
++ ['a Telegram command and a group mention are told apart', () => {
++ const cmd = parseTelegramUpdate({ message: { message_id: 8, text: '/link now', chat: { id: 42, type: 'private' }, from: { id: 5 } } });
++ assert.equal(cmd.kind, 'command');
++ assert.equal(cmd.command, 'link');
++ assert.equal(cmd.args, 'now');
++ const group = parseTelegramUpdate({ message: { message_id: 9, text: 'hey @openvibe_bot what now', chat: { id: -100, type: 'supergroup' }, from: { id: 6 } } }, { botUsername: 'openvibe_bot' });
++ assert.equal(group.chatType, 'group');
++ assert.equal(group.mention, true);
++ assert.equal(group.kind, 'mention');
++ const notForUs = parseTelegramUpdate({ message: { message_id: 10, text: 'hey @someone_else', chat: { id: -100, type: 'supergroup' }, from: { id: 6 } } }, { botUsername: 'openvibe_bot' });
++ assert.equal(notForUs.mention, false);
++ }],
++
++ ['a Telegram button press carries its data', () => {
++ const b = parseTelegramUpdate({ callback_query: { id: '1', data: 'v1.abc.def', from: { id: 5, username: 'alex' }, message: { message_id: 7, chat: { id: 42, type: 'private' } } } });
++ assert.equal(b.kind, 'button');
++ assert.equal(b.button.id, 'v1.abc.def');
++ assert.equal(b.userId, '5');
++ }],
++
++ ['a Discord ping, a slash command and a button are told apart', () => {
++ const base = { id: 'i1', channel: { id: 'c1' }, user: { id: 'u1', username: 'alex' } };
++ assert.equal(parseDiscordInteraction({ ...base, type: 1 }).kind, 'ping');
++ const cmd = parseDiscordInteraction({ ...base, type: 2, guild_id: 'g1', data: { name: 'link', options: [] } }, { botUserId: 'u9' });
++ assert.equal(cmd.kind, 'command');
++ assert.equal(cmd.command, 'link');
++ assert.equal(cmd.chatType, 'group');
++ const btn = parseDiscordInteraction({ ...base, type: 3, data: { custom_id: 'v1.abc.def', values: ['approve'] } });
++ assert.equal(btn.kind, 'button');
++ assert.equal(btn.button.id, 'v1.abc.def');
++ assert.equal(btn.button.value, 'approve');
++ const dm = parseDiscordInteraction({ ...base, type: 2, data: { name: 'link' } });
++ assert.equal(dm.chatType, 'private');
++ }],
++
++ ['a Slack message, an app mention and a button are told apart', () => {
++ const m = parseSlackEvent({ type: 'message', channel: 'C1', channel_type: 'channel', user: 'U1', text: 'hello', ts: '1.2' }, { botUserId: 'U9' });
++ assert.equal(m.app, 'slack');
++ assert.equal(m.chatType, 'channel'); // a public channel: it is not a private conversation
++ assert.equal(m.userId, 'U1');
++ const dm = parseSlackEvent({ type: 'message', channel: 'D1', channel_type: 'im', user: 'U1', text: 'hello', ts: '1.3' }, { botUserId: 'U9' });
++ assert.equal(dm.chatType, 'private');
++ const mention = parseSlackEvent({ type: 'app_mention', channel: 'C1', user: 'U1', text: '<@U9> hello', ts: '1.4' }, { botUserId: 'U9' });
++ assert.equal(mention.kind, 'mention');
++ assert.equal(mention.mention, true);
++ // the bot's own message is not a message to answer
++ assert.equal(parseSlackEvent({ type: 'message', channel: 'C1', user: 'U9', text: 'mine', bot_id: 'B1' }, { botUserId: 'U9' }), null);
++ const btn = parseSlackInteraction({ type: 'block_actions', user: { id: 'U1', name: 'alex' }, channel: { id: 'C1' }, actions: [{ action_id: 'v1.abc.def', value: 'approve' }] });
++ assert.equal(btn.kind, 'button');
++ assert.equal(btn.button.id, 'v1.abc.def');
++ assert.equal(btn.userId, 'U1');
++ }],
++
++ ['a long answer is split the app\'s way and nothing is lost', () => {
++ const text = Array.from({ length: 300 }, (_, i) => `line ${i} of the answer`).join('\n');
++ for (const app of ['telegram', 'discord', 'slack']) {
++ const parts = splitText(app, text);
++ assert.ok(parts.length > 1, `${app}: a long answer is split`);
++ for (const p of parts) assert.ok(p.length <= (app === 'telegram' ? 4096 : app === 'discord' ? 2000 : 3000), `${app}: a piece fits`);
++ assert.equal(parts.join('\n').replace(/\s+/g, ' '), text.replace(/\s+/g, ' '), `${app}: nothing is lost`);
++ }
++ assert.deepEqual(splitText('telegram', 'short'), ['short']);
++ assert.deepEqual(splitText('telegram', ''), []);
++ }],
++]);
++
++// Provider fixture regressions: unrelated bot commands, attachments, workspace payloads and bounded callbacks.
++{
++ const sdk = require('../src/chat-apps');
++ const id = 'abcdefghijklmnop';
++ const token = sdk.signButton({ secret: 'fixture-only', id });
++ assert.ok(Buffer.byteLength(token) <= 64);
++ assert.equal(sdk.verifyButton({ secret: 'fixture-only', token }), id);
++ assert.equal(sdk.verifyButton({ secret: 'wrong', token }), null);
++ assert.equal(sdk.verifyButton({ secret: 'fixture-only', token: token + 'x' }), null);
++ assert.equal(sdk.parseTelegramUpdate({ message: { text: '/link@another_bot', from: { id: 1 }, chat: { id: 1, type: 'private' } } }, { botUsername: 'our_bot' }), null);
++ assert.equal(sdk.parseTelegramUpdate({ message: { from: { id: 1 }, chat: { id: 1, type: 'private' }, document: { file_id: 'doc', file_name: 'a.pdf' } } }).attachments[0].id, 'doc');
++ assert.equal(sdk.parseDiscordInteraction({ type: 2, channel_id: '123', user: { id: '9' }, data: { name: 'link' } }).chatId, '123');
++ assert.equal(sdk.parseDiscordMessage({ id: '1', channel_id: '2', guild_id: '3', author: { id: '4' }, content: 'hello', mentions: [{ id: '9' }] }, { botUserId: '9' }).mention, true);
++ assert.equal(sdk.parseDiscordMessage({ author: { bot: true } }), null);
++ assert.equal(sdk.verifyCallback({ secret: 's', token: sdk.signCallback({ secret: 's', payload: { iat: 'invalid' } }) }), null);
++ assert.equal(sdk.parseSlackEvent({ type: 'app_mention', bot_id: 'B1', text: 'loop' }), null);
++ assert.equal(sdk.parseTelegramUpdate({ edited_message: { text: 'changed', from: { id: 1 }, chat: { id: 1 } } }), null);
++ assert.equal(sdk.parseTelegramUpdate({ message: { from: { id: 1, is_bot: true }, chat: { id: 1 }, text: 'bot' } }), null);
++ const file = sdk.parseSlackEvent({ type: 'message', subtype: 'file_share', channel: 'C1', channel_type: 'channel', user: 'U1', text: 'the report', files: [{ id: 'F1', name: 'report.pdf', url_private: 'https://files.slack.com/report.pdf' }] }, { botUserId: 'U9' });
++ assert.equal(file.attachments[0].name, 'report.pdf');
++ assert.equal(file.attachments[0].url, 'https://files.slack.com/report.pdf');
++ assert.equal(sdk.parseSlackEvent({ type: 'message', subtype: 'message_changed', channel: 'C1', message: { user: 'U1', text: 'edited' } }, { botUserId: 'U9' }), null);
++ assert.equal(sdk.parseSlackEvent({ type: 'message', subtype: 'channel_join', channel: 'C1', user: 'U1' }, { botUserId: 'U9' }), null);
++}
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 3a99977..23e6ff1 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -68,7 +68,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 84); // 0.90.0 adds openvibe-sdk/assertions and 0.91.0 openvibe-sdk/streams
++ assert.equal(subpaths.length, 85); // 0.90.0 assertions, 0.91.0 streams, 0.92.0 chat-apps
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/types/chat-apps.d.ts b/types/chat-apps.d.ts
nnew file mode 100644
iindex 0000000..a317301
---- /dev/null
++++ b/types/chat-apps.d.ts
@@@ -0,0 +1,60 @@
++/** openvibe-sdk/chat-apps: the three chat apps (Telegram, Discord, Slack) — the checks, the buttons and the messages. */
++export type ChatApp = 'telegram' | 'discord' | 'slack';
++export interface DeliveryCheck {
++ provider: ChatApp | string;
++ secret?: string | null;
++ publicKey?: string | null;
++ raw?: Buffer | string;
++ headers?: Record<string, string | string[] | undefined>;
++ now?: number;
++}
++export interface CallbackPayload {
++ exp?: number;
++ iat?: number;
++ act?: string;
++ id?: string;
++ app?: ChatApp | string;
++ chatId?: string;
++ userId?: string;
++ subject?: string;
++ [k: string]: unknown;
++}
++export interface ChatMessage {
++ app: ChatApp;
++ kind: 'message' | 'command' | 'mention' | 'button' | 'ping' | 'unknown';
++ chatId: string;
++ chatType: 'private' | 'group' | 'channel' | 'unknown';
++ userId: string;
++ userName: string | null;
++ text: string;
++ command: string | null;
++ args: string;
++ mention: boolean;
++ replyToBot: boolean;
++ attachments: Array<{ kind: string; id?: string; name?: string | null; mime?: string | null; url?: string | null }>;
++ button: { id: string; value?: string | null } | null;
++ messageId?: string;
++ requestId?: string | null;
++ threadTs?: string | null;
++ raw: unknown;
++}
++export function verifyTelegram(args: { secret?: string | null; headers?: Record<string, any> }): boolean;
++export function verifySlack(args: { secret?: string | null; raw?: Buffer | string; headers?: Record<string, any>; now?: number }): boolean;
++export function verifyDiscord(args: { publicKey?: string | null; raw?: Buffer | string; headers?: Record<string, any>; now?: number }): boolean;
++export function verifyDelivery(args: DeliveryCheck): boolean;
++export function signCallback(args: { secret: string; payload: CallbackPayload }): string;
++export function verifyCallback(args: { secret: string; token: string; now?: number; maxAgeMs?: number }): CallbackPayload | null;
++export function parseTelegramUpdate(update: any, opts?: { botUsername?: string | null; botUserId?: string | null }): ChatMessage | null;
++export function parseDiscordInteraction(interaction: any, opts?: { botUserId?: string | null }): ChatMessage | null;
++export function parseSlackEvent(event: any, opts?: { botUserId?: string | null }): ChatMessage | null;
++export function parseSlackInteraction(payload: any): ChatMessage | null;
++export function splitText(app: ChatApp | string, body: string, limit?: number): string[];
++export function commandOf(body: string): { command: string; args: string; mentionName: string | null } | null;
++export function safeEqual(a: unknown, b: unknown): boolean;
++export const APP_LIMITS: { telegram: number; discord: number; slack: number };
++export const PROVIDERS: string[];
++export const TOLERANCE_SEC: number;
++
++export function signButton(args: { secret: string; id: string }): string;
++export function verifyButton(args: { secret: string; token: string }): string | null;
++export function parseDiscordMessage(message: any, opts?: { botUserId?: string | null }): ChatMessage | null;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex edc51ac..5cd68ee 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -14,6 +14,7 @@ export * as vision from './vision';
export * as themes from './themes';
export * as redact from './redact';
export * as chat from './chat';
++export * as chatApps from './chat-apps';
export * as ai from './ai';
export * as jobs from './jobs';
export * as tools from './tools';