From 617a4103c4b8d995db473727da09d203d6df94fb Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 11:10:28 +0300 Subject: [PATCH 01/15] Add Slack connector over Socket Mode Bridge Codex into Slack alongside Telegram: DMs stream progress by editing a single message, channel mentions open threaded conversations, approvals arrive as buttons, files flow both ways, and a /telex slash command carries the bridge commands. Enabled by setting SLACK_BOT_TOKEN, SLACK_APP_TOKEN, and SLACK_ALLOWED_USER_IDS together; docs/slack.md documents the app manifest and setup. --- .env.example | 6 + README.md | 7 + docs/slack.md | 142 +++++++ package-lock.json | 153 +++++++- package.json | 2 + src/channels/progress.ts | 73 ++++ src/channels/slack/channel.ts | 614 +++++++++++++++++++++++++++++++ src/channels/slack/file.ts | 115 ++++++ src/channels/slack/format.ts | 126 +++++++ src/channels/slack/message.ts | 104 ++++++ src/channels/slack/references.ts | 80 ++++ src/channels/slack/reply.ts | 512 ++++++++++++++++++++++++++ src/channels/telegram/reply.ts | 73 +--- src/config/env.ts | 34 ++ src/index.ts | 18 +- test/env.test.ts | 43 +++ test/slack-format.test.ts | 77 ++++ test/slack-message.test.ts | 135 +++++++ test/slack-references.test.ts | 49 +++ test/slack-reply.test.ts | 218 +++++++++++ 20 files changed, 2509 insertions(+), 72 deletions(-) create mode 100644 docs/slack.md create mode 100644 src/channels/progress.ts create mode 100644 src/channels/slack/channel.ts create mode 100644 src/channels/slack/file.ts create mode 100644 src/channels/slack/format.ts create mode 100644 src/channels/slack/message.ts create mode 100644 src/channels/slack/references.ts create mode 100644 src/channels/slack/reply.ts create mode 100644 test/slack-format.test.ts create mode 100644 test/slack-message.test.ts create mode 100644 test/slack-references.test.ts create mode 100644 test/slack-reply.test.ts diff --git a/.env.example b/.env.example index 76040e1..1128a5a 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,12 @@ TELEGRAM_BOT_TOKEN=123456:replace-me # else are ignored, including guest-mode mentions. TELEGRAM_ALLOWED_USER_IDS=123456789 +# Optional Slack connector (Socket Mode). Set all three together to enable it; +# see docs/slack.md for the full setup guide, including a pasteable app manifest. +# SLACK_BOT_TOKEN=xoxb-replace-me +# SLACK_APP_TOKEN=xapp-replace-me +# SLACK_ALLOWED_USER_IDS=U0123ABCDEF + # Public HTTPS origin serving the Mini App, normally through a reverse proxy. # Leave it unset to expose the Mini App through a TryCloudflare quick tunnel # instead: Telex installs a pinned, checksum-verified cloudflared into its diff --git a/README.md b/README.md index 225d03b..112177f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Telex supports private conversations, scheduled runs, automatic Telegram voice-m - A Telegram bot token from [@BotFather](https://t.me/BotFather) - The numeric Telegram user IDs allowed to use the bot - Optionally, a public HTTPS URL for the settings Mini App; without one, Telex exposes it through an automatic quick tunnel +- Optionally, a Slack app for the [Slack connector](docs/slack.md) In BotFather, enable guest mode if the bot should answer mentions in group chats. Guest replies are intentionally one-shot: they do not persist a thread, cannot answer interactive approval prompts, and cannot upload newly generated local files. When a guest result includes a file, Telex explains that file attachments require a direct bot chat instead of silently omitting it. @@ -170,6 +171,12 @@ In the other direction, Telex uploads completed Codex image-generation results a Telegram's hosted Bot API only allows bots to download files up to 20 MB and upload general files up to 50 MB. Telex still forwards the file metadata and a clear limitation notice when a download or upload is unavailable. Set `TELEGRAM_API_BASE` to a [local Bot API server](https://core.telegram.org/bots/api#using-a-local-bot-api-server) to remove the download limit and support larger uploads. +## Slack connector + +Telex can additionally bridge Codex into Slack over [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) — no public URL required. Direct messages stream progress like the Telegram private chat; in channels the bot answers mentions in threads, with each thread acting as its own Codex conversation. Approvals arrive as buttons, files flow in both directions, and commands are available as `/telex ` (Slack reserves bare `/new`-style messages for its own slash-command system). Scheduled runs created from Slack notify back into the originating channel or thread. + +Set `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `SLACK_ALLOWED_USER_IDS` together to enable it. [docs/slack.md](docs/slack.md) walks through creating the Slack app from a pasteable manifest, collecting both tokens, and first steps. The settings Mini App stays Telegram-only because it authenticates through Telegram `initData`. + ## Scheduled runs Ask Codex naturally, for example, “Every weekday at 9, check this project for failed CI runs” or “Revisit this task every hour and notify me only if something changed.” Telex exposes a host-managed `automation_update` tool to new Codex tasks and stores each schedule with an explicit time zone. A task created before upgrading does not have that tool in its persisted definition; send `/new` once before asking it to create or edit schedules. `/schedules` remains available for viewing them. diff --git a/docs/slack.md b/docs/slack.md new file mode 100644 index 0000000..2876444 --- /dev/null +++ b/docs/slack.md @@ -0,0 +1,142 @@ +# Slack connector + +Telex can bridge Codex into Slack alongside Telegram. The connector uses +[Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode), so it +needs no public URL, webhook endpoint, or reverse proxy — the bridge dials out +to Slack exactly like the Telegram long-polling connection. + +What works in Slack: + +- Direct messages with the bot: send a message, watch live progress, get the + final answer, exchange file attachments. +- Channels and group DMs: mention the bot (`@Telex fix the build`) and it + answers in a thread. Follow-ups inside that thread need no further mention; + each thread is its own Codex conversation. +- Approvals: when Codex asks for confirmation, the question arrives as Slack + buttons. +- Scheduled runs: results are delivered to the channel or thread that created + them, with a Continue button. +- Commands: `/telex new`, `/telex status`, and friends (Slack reserves plain + `/new`-style messages for its own slash commands, so Telex registers a single + `/telex` command with subcommands). + +The settings Mini App remains Telegram-only because it authenticates through +Telegram. Everything else — including `/telex login` for the ChatGPT sign-in — +works from Slack. + +## 1. Create the Slack app + +1. Open and click **Create New App**. +2. Choose **From a manifest**, pick your workspace, and paste the manifest + below (YAML tab). Rename the app if you like — the name is what you will + @mention. +3. Click **Create**. + +```yaml +display_information: + name: Telex + description: Codex in your Slack + background_color: "#1a1d21" +features: + app_home: + messages_tab_enabled: true + messages_tab_read_only_enabled: false + bot_user: + display_name: Telex + always_online: true + slash_commands: + - command: /telex + description: Control Telex (new, stop, status, help…) + usage_hint: "new | back | stop | status | help" + should_escape: false +oauth_config: + scopes: + bot: + - chat:write + - im:history + - channels:history + - groups:history + - mpim:history + - files:read + - files:write + - users:read + - commands +settings: + event_subscriptions: + bot_events: + - message.channels + - message.groups + - message.im + - message.mpim + interactivity: + is_enabled: true + org_deploy_enabled: false + socket_mode_enabled: true + token_rotation_enabled: false +``` + +## 2. Collect the two tokens + +- **App-level token** (`xapp-…`): in the app's **Basic Information** page, + scroll to **App-Level Tokens**, click **Generate Token and Scopes**, name it + (for example `telex-socket`), add the `connections:write` scope, and + generate. Copy the `xapp-…` value — this is `SLACK_APP_TOKEN`. +- **Bot token** (`xoxb-…`): open **Install App** (or **OAuth & Permissions**), + click **Install to Workspace**, and approve. Copy the **Bot User OAuth + Token** — this is `SLACK_BOT_TOKEN`. + +## 3. Find your Slack user ID + +Telex answers only allowlisted users. In Slack, open your profile → **⋯ (More)** +→ **Copy member ID**. It looks like `U0123ABCDEF`. Collect one per teammate you +want to allow. + +## 4. Configure Telex + +Add the three variables to the environment (`.env` for a source checkout, or +`~/.config/telex/telex.env` for an installed release): + +```dotenv +SLACK_BOT_TOKEN=xoxb-… +SLACK_APP_TOKEN=xapp-… +SLACK_ALLOWED_USER_IDS=U0123ABCDEF,U0456GHIJKL +``` + +All three must be set together; leaving them all unset keeps the connector +disabled. Restart Telex and check the log for +`Slack bot connected through Socket Mode`. + +## 5. Talk to it + +- **Direct message**: open the app under **Apps** in the Slack sidebar and + send a message. If Slack says the app cannot receive messages, enable the + Messages Tab: app settings → **App Home** → check *Allow users to send Slash + commands and messages from the messages tab* (the manifest above enables it, + but workspaces occasionally need a re-toggle), then reload Slack. +- **Channel**: invite the bot (`/invite @Telex`), then mention it: + `@Telex what does this repo do?`. The reply opens a thread; keep chatting in + the thread without mentioning it again. +- **Commands**: `/telex help` anywhere, or prefix a command in a mention: + `@Telex /new`. In the bot DM, plain `/new` will not reach Telex — Slack + intercepts everything that starts with `/` — so use `/telex new`. +- **Sign-in**: if Codex is not signed in yet, `/telex login` in the bot DM + returns the ChatGPT device-code link, exactly like `/login` on Telegram. + +## Notes and limits + +- **Authorization**: messages, commands, and button clicks from users outside + `SLACK_ALLOWED_USER_IDS` are ignored (and logged). Scheduled runs re-check + the owner against the allowlist before every unattended execution. +- **Threads after a restart**: the "this thread is active" memory is + in-process. After a Telex restart, mention the bot once in an existing + thread to reattach it; the Codex conversation itself is persisted and + continues. +- **Attachments**: inbound files are downloaded through Slack's private file + URLs with the bot token (never sent to third-party hosts); generated files + are uploaded back with `files.uploadV2`. Slack voice clips are transcribed + the same way Telegram voice messages are. +- **Formatting**: Codex's Markdown is converted to Slack mrkdwn (headings + become bold lines, `**bold**` becomes `*bold*`, links become + ``); code blocks pass through untouched. +- **Rate limits**: live progress is streamed by editing a single message at + most every 1.5 seconds, which stays inside Slack's `chat.update` budget. diff --git a/package-lock.json b/package-lock.json index bfd8d6c..98a2aa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@grammyjs/runner": "^2.0.3", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-switch": "^1.2.6", + "@slack/socket-mode": "^3.0.0", + "@slack/web-api": "^8.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "grammy": "^1.45.1", @@ -1883,6 +1885,68 @@ "dev": true, "license": "MIT" }, + "node_modules/@slack/logger": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-5.0.0.tgz", + "integrity": "sha512-VGXhmmgsAo9shdQYh4tFDndd+7nsgp0Y5h0UPDaUp8K359pBasI6YdkMqFW3mCOxLQkq09qj7o7cq6f3DuXcJQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=20" + }, + "engines": { + "node": ">= 20", + "npm": ">=9.6.4" + } + }, + "node_modules/@slack/socket-mode": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-3.0.0.tgz", + "integrity": "sha512-QShO60SB0E+HH+TbcKj3CBEQbodToRyiXnxuSB4t1kvUlqEmuGA1nOOjrRDkDJbOECAZ13PLe4ek9SrntpfoYg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^5.0.0", + "@slack/web-api": "^8.0.0", + "@types/node": ">=20", + "eventemitter3": "^5" + }, + "engines": { + "node": ">=20", + "npm": ">=9.6.4" + }, + "peerDependencies": { + "undici": "^7.0.0" + } + }, + "node_modules/@slack/types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-3.0.0.tgz", + "integrity": "sha512-KNOqpnNAlsFt5Jk9XBclslQ0lobRIg/0tnhpmvZJAglHJx9E8oceN8hC3gaBzkR6UzQ9Wzq4rLsJ98wUcxWPfw==", + "license": "MIT", + "engines": { + "node": ">= 20", + "npm": ">=9.6.4" + } + }, + "node_modules/@slack/web-api": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-8.0.0.tgz", + "integrity": "sha512-ORx3XQryQPq2Jnxv5giSKXVoQRUeylrrymIR2S9fPzLjPcCts8RayMeBSZMcpfpAqp6fnBRuPW2UB6dUPUTEZA==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^5.0.0", + "@slack/types": "^3.0.0", + "@types/node": ">=20", + "@types/retry": "0.12.0", + "eventemitter3": "^5.0.1", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 20", + "npm": ">=9.6.4" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2325,7 +2389,6 @@ "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -2368,6 +2431,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3229,6 +3298,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4888,6 +4963,62 @@ "node": ">=12.20.0" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -5097,6 +5228,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -5873,11 +6013,20 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/unified": { diff --git a/package.json b/package.json index 92c616f..1f703f4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "@grammyjs/runner": "^2.0.3", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-switch": "^1.2.6", + "@slack/socket-mode": "^3.0.0", + "@slack/web-api": "^8.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "grammy": "^1.45.1", diff --git a/src/channels/progress.ts b/src/channels/progress.ts new file mode 100644 index 0000000..5581f88 --- /dev/null +++ b/src/channels/progress.ts @@ -0,0 +1,73 @@ +import type { ProgressSnapshot } from "../core/channel.js"; + +export function formatThinkingBlock(progress: ProgressSnapshot, limit = 800): string { + const text = + progress.plan.length > 1 ? formatPlanProgress(progress) : formatActionProgress(progress); + if (text.length <= limit) return text; + if (limit <= 1) return "…".slice(0, limit); + return `${text.slice(0, limit - 1).trimEnd()}…`; +} + +function formatActionProgress(progress: ProgressSnapshot): string { + const heading = firstLine(progress.summary) || firstLine(progress.message) || "Thinking…"; + const maximumVisibleActions = 4; + const hiddenActions = Math.max(0, progress.actions.length - maximumVisibleActions); + const visibleActions = progress.actions.slice(-maximumVisibleActions); + const rows = [ + ...(hiddenActions === 0 ? [] : [`<${hiddenActions} more actions>`]), + ...visibleActions.map((action) => action.label), + ]; + return [ + `▌ ${truncateLine(heading, 180)}`, + ...rows.map( + (row, index) => `${index === rows.length - 1 ? "└" : "├"} ${truncateLine(row, 180)}`, + ), + ].join("\n"); +} + +function formatPlanProgress(progress: ProgressSnapshot): string { + const currentIndex = progress.plan.findIndex((step) => step.status === "inProgress"); + const fallbackIndex = progress.plan.findIndex((step) => step.status === "pending"); + const activeIndex = currentIndex === -1 ? fallbackIndex : currentIndex; + const context = firstLine(progress.summary) || progress.actions.at(-1)?.label || ""; + const reasoningMessage = progress.message?.trim(); + const lines: string[] = []; + + progress.plan.forEach((step, index) => { + const isCurrent = index === activeIndex; + if (isCurrent && lines.length > 0) lines.push(""); + const marker = step.status === "completed" ? "✓" : isCurrent ? "→" : "○"; + const suffix = isCurrent && context.length > 0 ? ` (${truncateLine(context, 140)})` : ""; + lines.push(`${marker} ${truncateLine(step.step, 180)}${suffix}`); + if (isCurrent && reasoningMessage !== undefined && reasoningMessage !== context) { + lines.push(truncateLine(reasoningMessage, 240)); + } + if (isCurrent && index < progress.plan.length - 1) lines.push(""); + }); + + return lines.join("\n"); +} + +function firstLine(text: string | undefined): string { + return text?.trim().split("\n", 1)[0]?.trim() ?? ""; +} + +function truncateLine(text: string, limit: number): string { + const compact = text.replaceAll(/\s+/g, " ").trim(); + return compact.length <= limit ? compact : `${compact.slice(0, limit - 1).trimEnd()}…`; +} + +export function splitMessageText(text: string, limit: number): readonly string[] { + if (text.length <= limit) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > limit) { + const candidate = remaining.slice(0, limit); + const newline = candidate.lastIndexOf("\n"); + const splitAt = newline > limit / 2 ? newline : limit; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n/, ""); + } + if (remaining.length > 0) chunks.push(remaining); + return chunks; +} diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts new file mode 100644 index 0000000..eb7776a --- /dev/null +++ b/src/channels/slack/channel.ts @@ -0,0 +1,614 @@ +import { join } from "node:path"; +import { SocketModeClient } from "@slack/socket-mode"; +import { LogLevel, WebClient } from "@slack/web-api"; +import type { + ChoiceOption, + DeliveryReceipt, + InboundAttachment, + InboundMessage, + MessageHandler, + MessagingChannel, + OutboundMessage, + ProviderReference, +} from "../../core/channel.js"; +import { type Deferred, deferred } from "../../shared/async.js"; +import { errorMessage } from "../../shared/errors.js"; +import type { Logger } from "../../shared/logger.js"; +import { downloadSlackFile, SlackFileDownloadError } from "./file.js"; +import { + describeSlackFile, + normalizeSlackMessage, + routeSlackMessage, + type SlackMessageEvent, + slackAttachmentKind, +} from "./message.js"; +import { + parseSlackDeliveryTarget, + slackDeliveryTarget, + slackMessageReference, +} from "./references.js"; +import { + decodeSlackCommandValue, + publishSlackMessage, + type SlackBlock, + type SlackChoiceRequester, + type SlackMessagingApi, + SlackResponder, +} from "./reply.js"; + +export const slackSlashCommandHelp = [ + "`/telex new` — start a fresh Codex task", + "`/telex back` — return to the previous Codex task", + "`/telex stop` — stop the running turn", + "`/telex schedules` — list scheduled runs", + "`/telex status` — show Codex status", + "`/telex login` / `/telex logout` — manage the ChatGPT sign-in", + "`/telex config` — open Codex settings", + "`/telex reload` / `/telex restart` — refresh or restart Codex", + "`/telex update` — update Telex", + "`/telex help` — show commands", +].join("\n"); + +interface SocketEnvelope { + readonly ack: (response?: unknown) => Promise; + readonly body?: unknown; + readonly event?: unknown; +} + +interface SlackSlashCommandPayload { + readonly command?: string; + readonly text?: string; + readonly user_id?: string; + readonly user_name?: string; + readonly channel_id?: string; + readonly channel_name?: string; + readonly response_url?: string; +} + +interface SlackBlockAction { + readonly action_id?: string; + readonly value?: string; +} + +interface SlackInteractivePayload { + readonly type?: string; + readonly user?: { readonly id?: string }; + readonly channel?: { readonly id?: string }; + readonly message?: { + readonly ts?: string; + readonly thread_ts?: string; + readonly text?: string; + }; + readonly actions?: readonly SlackBlockAction[]; +} + +interface PendingChoice { + readonly userId: string; + readonly options: readonly ChoiceOption[]; + readonly result: Deferred; + readonly timer: NodeJS.Timeout; + readonly channel: string; + readonly messageTs: string; + readonly baseText: string; +} + +const recentEventLimit = 500; +const activeThreadLimit = 500; +const displayNameCacheLimit = 500; + +export class SlackChannel implements MessagingChannel { + public readonly name = "slack"; + readonly #web: WebClient; + readonly #socket: SocketModeClient; + readonly #api: SlackMessagingApi; + readonly #allowedUserIds: ReadonlySet; + readonly #botToken: string; + readonly #attachmentDirectory: string; + readonly #logger: Logger; + readonly #pendingChoices = new Map(); + readonly #activeThreads = new Set(); + readonly #recentEvents = new Set(); + readonly #displayNames = new Map(); + #handler: MessageHandler | undefined; + #botUserId: string | undefined; + + public constructor( + botToken: string, + appToken: string, + allowedUserIds: ReadonlySet, + attachmentDirectory: string, + logger: Logger, + ) { + this.#botToken = botToken; + this.#allowedUserIds = allowedUserIds; + this.#attachmentDirectory = attachmentDirectory; + this.#logger = logger; + this.#web = new WebClient(botToken, { logLevel: LogLevel.ERROR }); + this.#socket = new SocketModeClient({ appToken, logLevel: LogLevel.ERROR }); + this.#api = webMessagingApi(this.#web); + this.#socket.on("message", (envelope: SocketEnvelope) => { + void this.withAck(envelope, async () => { + await this.handleMessageEvent(envelope.event as SlackMessageEvent); + }); + }); + this.#socket.on("slash_commands", (envelope: SocketEnvelope) => { + void this.withAck(envelope, async () => { + await this.handleSlashCommand(envelope.body as SlackSlashCommandPayload); + }); + }); + this.#socket.on("interactive", (envelope: SocketEnvelope) => { + void this.withAck(envelope, async () => { + await this.handleInteractive(envelope.body as SlackInteractivePayload); + }); + }); + } + + public async start(handler: MessageHandler): Promise { + this.#handler = handler; + const auth = await this.#web.auth.test(); + if (auth.user_id === undefined) { + throw new Error("Slack auth.test did not identify the bot user"); + } + this.#botUserId = auth.user_id; + this.#logger.info("Slack bot connected through Socket Mode", { + botUserId: auth.user_id, + team: auth.team ?? "unknown", + }); + await this.#socket.start(); + } + + public isAuthorized(principal: ProviderReference): boolean { + return ( + principal.provider === this.name && + principal.resource === "user" && + this.#allowedUserIds.has(principal.id) + ); + } + + public async stop(): Promise { + await this.#socket.disconnect().catch((error: unknown) => { + this.#logger.debug("Slack socket disconnect failed", { error: errorMessage(error) }); + }); + for (const choice of this.#pendingChoices.values()) { + clearTimeout(choice.timer); + choice.result.resolve("decline"); + } + this.#pendingChoices.clear(); + } + + public async publish( + targetReference: ProviderReference, + message: OutboundMessage, + ): Promise { + const target = parseSlackDeliveryTarget(targetReference); + const published = await publishSlackMessage(this.#api, target, message, this.#logger); + return { + publishedMessages: published.map((entry) => slackMessageReference(entry.channel, entry.ts)), + }; + } + + private async withAck(envelope: SocketEnvelope, work: () => Promise): Promise { + // Slack retries unacknowledged envelopes after a few seconds, so always + // acknowledge first and process afterwards. + try { + await envelope.ack(); + } catch (error) { + this.#logger.debug("Slack envelope acknowledgement failed", { + error: errorMessage(error), + }); + } + try { + await work(); + } catch (error) { + this.#logger.error("Slack event handling failed", error); + } + } + + private async handleMessageEvent(event: SlackMessageEvent): Promise { + const handler = this.#handler; + const botUserId = this.#botUserId; + if (handler === undefined || botUserId === undefined) return; + if (typeof event.channel !== "string" || typeof event.ts !== "string") return; + if (this.wasRecentlyProcessed(`message:${event.channel}:${event.ts}`)) return; + + const route = routeSlackMessage(event, botUserId, (threadRoot) => + this.#activeThreads.has(`${event.channel}:${threadRoot}`), + ); + const sender = event.user; + if (route === undefined || sender === undefined) return; + if (!this.#allowedUserIds.has(sender)) { + this.#logger.warn("Ignored Slack message from unauthorized user", { userId: sender }); + return; + } + + const normalized = normalizeSlackMessage(event, botUserId); + const directory = join(this.#attachmentDirectory, crypto.randomUUID()); + const attachments: InboundAttachment[] = []; + const failures: string[] = []; + for (const [index, file] of normalized.files.entries()) { + const description = describeSlackFile(file); + try { + const path = await downloadSlackFile(file, { + botToken: this.#botToken, + directory, + index, + }); + attachments.push({ kind: slackAttachmentKind(file), path, description }); + } catch (error) { + this.#logger.warn("Could not download Slack attachment", { + messageTs: event.ts, + description, + error: errorMessage(error).replaceAll(this.#botToken, ""), + }); + const reason = + error instanceof SlackFileDownloadError + ? error.userMessage + : "Slack could not provide the file"; + failures.push(`[${description} was not attached: ${reason}.]`); + } + } + + const text = [normalized.text, ...failures].filter((part) => part.length > 0).join("\n\n"); + if (text.length === 0) return; + if (event.channel_type !== "im") { + this.rememberActiveThread(`${event.channel}:${route.conversationSuffix}`); + } + const responder = new SlackResponder( + this.#api, + event.channel, + route.replyThreadTs, + sender, + this.requestChoice, + this.#logger, + ); + const inbound: InboundMessage = { + id: event.ts, + address: { + channel: this.name, + key: `slack:${event.channel}:${route.conversationSuffix}`, + isPrivate: event.channel_type === "im", + isGuest: false, + deliveryTarget: slackDeliveryTarget(event.channel, event.channel_type, route.replyThreadTs), + }, + reference: slackMessageReference(event.channel, event.ts), + ...(event.thread_ts === undefined || event.thread_ts === event.ts + ? {} + : { replyTo: slackMessageReference(event.channel, event.thread_ts) }), + sender: { + id: sender, + displayName: await this.displayName(sender), + }, + text, + attachments, + responder, + }; + try { + await handler(inbound); + } catch (error) { + this.#logger.error("Slack message handler failed", error, { messageTs: inbound.id }); + await responder.sendText(`Bridge error: ${errorMessage(error)}`).catch(() => undefined); + } + } + + private async handleSlashCommand(payload: SlackSlashCommandPayload): Promise { + const handler = this.#handler; + const userId = payload.user_id; + const channelId = payload.channel_id; + if (handler === undefined || userId === undefined || channelId === undefined) return; + const respondEphemerally = async (text: string): Promise => { + await this.#api.postEphemeral({ channel: channelId, user: userId, text }).catch(async () => { + await this.respondThroughWebhook(payload.response_url, text); + }); + }; + if (!this.#allowedUserIds.has(userId)) { + this.#logger.warn("Ignored Slack slash command from unauthorized user", { userId }); + await this.respondThroughWebhook( + payload.response_url, + "You are not on this Telex instance's allow list.", + ); + return; + } + + const [first, ...restParts] = (payload.text ?? "").trim().split(/\s+/u); + const name = (first ?? "").toLowerCase(); + if (name.length === 0 || !/^[a-z][a-z0-9_]*$/u.test(name)) { + await respondEphemerally(`Telex commands:\n${slackSlashCommandHelp}`); + return; + } + const command = { name, args: restParts.join(" ") }; + const responder = new SlackResponder( + this.#api, + channelId, + undefined, + userId, + this.requestChoice, + this.#logger, + payload.response_url, + ); + const inbound: InboundMessage = { + id: `slash:${crypto.randomUUID()}`, + address: { + channel: this.name, + key: `slack:${channelId}:main`, + isPrivate: payload.channel_name === "directmessage", + isGuest: false, + }, + sender: { + id: userId, + displayName: payload.user_name ?? (await this.displayName(userId)), + }, + text: `/${command.name}${command.args.length === 0 ? "" : ` ${command.args}`}`, + command, + attachments: [], + responder, + }; + try { + await handler(inbound); + } catch (error) { + this.#logger.error("Slack slash command failed", error, { command: command.name }); + await respondEphemerally(`Bridge error: ${errorMessage(error)}`).catch(() => undefined); + } + } + + private async handleInteractive(payload: SlackInteractivePayload): Promise { + if (payload.type !== "block_actions") return; + const action = payload.actions?.[0]; + const userId = payload.user?.id; + const channelId = payload.channel?.id; + const actionId = action?.action_id; + if (action === undefined || actionId === undefined || userId === undefined) return; + if (actionId === "telex_link") return; + if (!this.#allowedUserIds.has(userId)) { + this.#logger.warn("Ignored Slack interaction from unauthorized user", { userId }); + return; + } + if (actionId.startsWith("telex_choice")) { + await this.handleChoiceAction(action, userId, channelId); + return; + } + if (actionId.startsWith("telex_cmd")) { + await this.handleCommandAction(action, payload, userId, channelId); + } + } + + private async handleChoiceAction( + action: SlackBlockAction, + userId: string, + channelId: string | undefined, + ): Promise { + const match = /^([0-9a-f]{16}):(\d+)$/u.exec(action.value ?? ""); + const token = match?.[1]; + const index = Number(match?.[2]); + if (token === undefined) return; + const pending = this.#pendingChoices.get(token); + if (pending === undefined || pending.userId !== userId) { + if (channelId !== undefined) { + await this.#api + .postEphemeral({ channel: channelId, user: userId, text: "This choice has expired." }) + .catch(() => undefined); + } + return; + } + const selected = pending.options[index]; + if (selected === undefined) return; + clearTimeout(pending.timer); + this.#pendingChoices.delete(token); + pending.result.resolve(selected.id); + await this.#api + .updateMessage({ + channel: pending.channel, + ts: pending.messageTs, + text: `${pending.baseText}\n\n→ ${selected.label}`, + blocks: [], + }) + .catch(() => undefined); + } + + private async handleCommandAction( + action: SlackBlockAction, + payload: SlackInteractivePayload, + userId: string, + channelId: string | undefined, + ): Promise { + const handler = this.#handler; + const command = decodeSlackCommandValue(action.value ?? ""); + const messageTs = payload.message?.ts; + if (handler === undefined || command === undefined || channelId === undefined) return; + if (messageTs === undefined) return; + // Conversation IDs starting with D are direct messages with the app. + const isDirect = channelId.startsWith("D"); + const threadRoot = payload.message?.thread_ts ?? messageTs; + const conversationSuffix = isDirect ? "main" : threadRoot; + const replyThreadTs = isDirect ? undefined : threadRoot; + if (!isDirect) this.rememberActiveThread(`${channelId}:${conversationSuffix}`); + const responder = new SlackResponder( + this.#api, + channelId, + replyThreadTs, + userId, + this.requestChoice, + this.#logger, + ); + const inbound: InboundMessage = { + id: `action:${crypto.randomUUID()}`, + address: { + channel: this.name, + key: `slack:${channelId}:${conversationSuffix}`, + isPrivate: isDirect, + isGuest: false, + deliveryTarget: slackDeliveryTarget(channelId, isDirect ? "im" : "channel", replyThreadTs), + }, + reference: slackMessageReference(channelId, messageTs), + sender: { + id: userId, + displayName: await this.displayName(userId), + }, + text: `/${command.name}${command.args.length === 0 ? "" : ` ${command.args}`}`, + command, + attachments: [], + responder, + }; + try { + await handler(inbound); + } catch (error) { + this.#logger.error("Slack command action failed", error, { command: command.name }); + await responder.sendText(`Bridge error: ${errorMessage(error)}`).catch(() => undefined); + } + } + + private readonly requestChoice: SlackChoiceRequester = async ( + channel, + threadTs, + userId, + prompt, + options, + ): Promise => { + if (options.length === 0) return "decline"; + const token = crypto.randomUUID().replaceAll("-", "").slice(0, 16); + const details = options + .filter((option) => option.description !== undefined) + .map((option) => `${option.label}: ${option.description}`) + .join("\n"); + const body = details.length === 0 ? prompt : `${prompt}\n\n${details}`; + const baseText = body.length <= 3_000 ? body : `${body.slice(0, 2_999)}…`; + const blocks: readonly SlackBlock[] = [ + { type: "section", text: { type: "mrkdwn", text: baseText } }, + { + type: "actions", + elements: options.map((option, index) => ({ + type: "button" as const, + text: { type: "plain_text" as const, text: option.label.slice(0, 75) }, + action_id: `telex_choice_${index}`, + value: `${token}:${index}`, + })), + }, + ]; + const messageTs = await this.#api.postMessage({ + channel, + text: baseText, + blocks, + ...(threadTs === undefined ? {} : { threadTs }), + }); + const result = deferred(); + const timer = setTimeout( + () => { + this.#pendingChoices.delete(token); + result.resolve("decline"); + }, + 5 * 60 * 1_000, + ); + timer.unref(); + this.#pendingChoices.set(token, { + userId, + options, + result, + timer, + channel, + messageTs, + baseText, + }); + return await result.promise; + }; + + private async displayName(userId: string): Promise { + const cached = this.#displayNames.get(userId); + if (cached !== undefined) return cached; + let name = userId; + try { + const response = await this.#web.users.info({ user: userId }); + const profile = response.user?.profile; + name = + firstNonEmpty(profile?.display_name, profile?.real_name, response.user?.name) ?? userId; + } catch (error) { + this.#logger.debug("Slack user lookup failed", { userId, error: errorMessage(error) }); + } + if (this.#displayNames.size >= displayNameCacheLimit) this.#displayNames.clear(); + this.#displayNames.set(userId, name); + return name; + } + + private async respondThroughWebhook(url: string | undefined, text: string): Promise { + if (url === undefined) return; + await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ response_type: "ephemeral", text }), + }).catch((error: unknown) => { + this.#logger.debug("Slack response webhook failed", { error: errorMessage(error) }); + }); + } + + private wasRecentlyProcessed(key: string): boolean { + if (this.#recentEvents.has(key)) return true; + this.#recentEvents.add(key); + trimInsertionOrdered(this.#recentEvents, recentEventLimit); + return false; + } + + private rememberActiveThread(key: string): void { + this.#activeThreads.delete(key); + this.#activeThreads.add(key); + trimInsertionOrdered(this.#activeThreads, activeThreadLimit); + } +} + +function webMessagingApi(web: WebClient): SlackMessagingApi { + return { + async postMessage(options) { + const result = await web.chat.postMessage({ + channel: options.channel, + text: options.text, + unfurl_links: false, + unfurl_media: false, + ...(options.threadTs === undefined ? {} : { thread_ts: options.threadTs }), + ...(options.blocks === undefined ? {} : { blocks: [...options.blocks] }), + }); + if (result.ts === undefined) { + throw new Error("Slack did not return a timestamp for the posted message"); + } + return result.ts; + }, + async updateMessage(options) { + await web.chat.update({ + channel: options.channel, + ts: options.ts, + text: options.text, + blocks: options.blocks === undefined ? [] : [...options.blocks], + }); + }, + async uploadFile(options) { + const contents = { file: options.path, filename: options.filename }; + if (options.threadTs === undefined) { + await web.filesUploadV2({ ...contents, channel_id: options.channel }); + } else { + await web.filesUploadV2({ + ...contents, + channel_id: options.channel, + thread_ts: options.threadTs, + }); + } + }, + async postEphemeral(options) { + await web.chat.postEphemeral({ + channel: options.channel, + user: options.user, + text: options.text, + }); + }, + }; +} + +function firstNonEmpty(...values: readonly (string | undefined)[]): string | undefined { + for (const value of values) { + if (value !== undefined && value.trim().length > 0) return value; + } + return undefined; +} + +function trimInsertionOrdered(set: Set, limit: number): void { + while (set.size > limit) { + const oldest = set.values().next().value; + if (oldest === undefined) return; + set.delete(oldest); + } +} diff --git a/src/channels/slack/file.ts b/src/channels/slack/file.ts new file mode 100644 index 0000000..1a5aa8c --- /dev/null +++ b/src/channels/slack/file.ts @@ -0,0 +1,115 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, unlink } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { describeSlackFile, type SlackFile } from "./message.js"; + +export class SlackFileDownloadError extends Error { + public readonly userMessage: string; + + public constructor(message: string, userMessage: string) { + super(message); + this.name = "SlackFileDownloadError"; + this.userMessage = userMessage; + } +} + +interface DownloadOptions { + readonly botToken: string; + readonly directory: string; + readonly index: number; + readonly fetch?: typeof globalThis.fetch; +} + +export async function downloadSlackFile( + file: SlackFile, + options: DownloadOptions, +): Promise { + const description = describeSlackFile(file); + const url = file.url_private_download ?? file.url_private; + if (url === undefined) { + throw new SlackFileDownloadError( + `Slack did not provide a download URL for ${description}`, + "Slack did not make the file downloadable", + ); + } + // The bot token travels in the Authorization header, so only send it to + // Slack's own file hosts. + if (!isSlackFileHost(url)) { + throw new SlackFileDownloadError( + `Refused to download ${description} from a non-Slack host`, + "its download URL does not point at Slack", + ); + } + + await mkdir(options.directory, { recursive: true, mode: 0o700 }); + const target = join( + options.directory, + `${String(options.index + 1).padStart(2, "0")}-${safeName(file.name ?? file.title ?? "", url)}`, + ); + + let response: Response; + try { + response = await (options.fetch ?? globalThis.fetch)(url, { + headers: { authorization: `Bearer ${options.botToken}` }, + }); + } catch { + throw new SlackFileDownloadError( + `The download request for ${description} failed`, + "Slack's file download request failed", + ); + } + if (!response.ok || response.body === null) { + throw new SlackFileDownloadError( + `Slack returned HTTP ${response.status} for ${description}`, + `Slack's file server returned HTTP ${response.status}`, + ); + } + // Without the files:read scope Slack redirects to an HTML sign-in page + // instead of failing the request. + if (response.headers.get("content-type")?.toLowerCase().includes("text/html") === true) { + throw new SlackFileDownloadError( + `Slack served an HTML page instead of ${description}`, + "Slack denied the download; check that the app has the files:read scope", + ); + } + + try { + await pipeline( + Readable.from(response.body), + createWriteStream(target, { flags: "wx", mode: 0o600 }), + ); + return target; + } catch (error) { + await unlink(target).catch(() => undefined); + throw error; + } +} + +function isSlackFileHost(url: string): boolean { + try { + const hostname = new URL(url).hostname; + return ( + hostname === "slack.com" || + hostname.endsWith(".slack.com") || + hostname.endsWith(".slack-edge.com") || + hostname.endsWith(".slack-files.com") + ); + } catch { + return false; + } +} + +function safeName(suggestedName: string, url: string): string { + const suggested = basename(suggestedName).replaceAll(/[^A-Za-z0-9._-]/g, "_"); + const urlPath = new URL(url).pathname; + const fallbackExtension = extname(urlPath).replaceAll(/[^A-Za-z0-9.]/g, ""); + const name = + suggested.length === 0 || suggested === "." || suggested === ".." ? "attachment" : suggested; + const withExtension = + extname(name).length === 0 && fallbackExtension.length > 0 + ? `${name}${fallbackExtension}` + : name; + return withExtension.slice(-120); +} diff --git a/src/channels/slack/format.ts b/src/channels/slack/format.ts new file mode 100644 index 0000000..6d5242a --- /dev/null +++ b/src/channels/slack/format.ts @@ -0,0 +1,126 @@ +const boldOpen = "\u0000B\u0000"; +const boldClose = "\u0000/B\u0000"; + +/** + * Convert standard Markdown produced by Codex into Slack's mrkdwn dialect. + * + * The conversion is heuristic: Slack has no headings or tables, single + * asterisks mean bold instead of italic, and links use ``. Code + * fences and inline code spans pass through untouched apart from the entity + * escaping Slack requires everywhere. + */ +export function markdownToMrkdwn(markdown: string): string { + const segments = splitByCodeFence(markdown.replaceAll("\u0000", "")); + return segments + .map((segment) => + segment.kind === "fence" ? escapeSlackEntities(segment.text) : convertProse(segment.text), + ) + .join(""); +} + +interface Segment { + readonly kind: "prose" | "fence"; + readonly text: string; +} + +function splitByCodeFence(text: string): readonly Segment[] { + const segments: Segment[] = []; + const fence = /^(?:```|~~~)[^\n]*$/mu; + let remaining = text; + let insideFence = false; + while (remaining.length > 0) { + const match = fence.exec(remaining); + if (match === null || match.index === undefined) { + segments.push({ kind: insideFence ? "fence" : "prose", text: remaining }); + break; + } + const lineEnd = match.index + match[0].length; + segments.push({ + kind: insideFence ? "fence" : "prose", + text: remaining.slice(0, lineEnd), + }); + remaining = remaining.slice(lineEnd); + insideFence = !insideFence; + } + return segments; +} + +function convertProse(text: string): string { + const spans = splitByInlineCode(text); + return spans + .map((span) => + span.kind === "code" ? escapeSlackEntities(span.text) : convertPlainProse(span.text), + ) + .join(""); +} + +function splitByInlineCode( + text: string, +): readonly Readonly<{ kind: "plain" | "code"; text: string }>[] { + const spans: { kind: "plain" | "code"; text: string }[] = []; + const pattern = /`[^`\n]+`/gu; + let cursor = 0; + for (const match of text.matchAll(pattern)) { + if (match.index > cursor) spans.push({ kind: "plain", text: text.slice(cursor, match.index) }); + spans.push({ kind: "code", text: match[0] }); + cursor = match.index + match[0].length; + } + if (cursor < text.length) spans.push({ kind: "plain", text: text.slice(cursor) }); + return spans; +} + +function convertPlainProse(text: string): string { + let result = escapeSlackEntities(text); + // Markdown images and links become Slack links. The label drops `|`, which + // Slack reserves as its own separator. + result = result.replaceAll( + /!?\[([^\]\n]*)\]\((\S+?)\)/gu, + (_match, label: string, url: string) => { + const safeLabel = label.replaceAll("|", "/").trim(); + return safeLabel.length === 0 ? `<${url}>` : `<${url}|${safeLabel}>`; + }, + ); + // Headings become bold lines. + result = result.replaceAll(/^#{1,6}[ \t]+(.+?)[ \t]*#*$/gmu, (_match, heading: string) => { + const plain = heading.replaceAll(/\*\*|__/gu, ""); + return `${boldOpen}${plain}${boldClose}`; + }); + // Bold before italic, through placeholders, so `**x**` never reads as + // two nested single-asterisk spans. + result = result.replaceAll(/\*\*(?=\S)([\s\S]+?)(?<=\S)\*\*/gu, `${boldOpen}$1${boldClose}`); + result = result.replaceAll(/__(?=\S)([\s\S]+?)(?<=\S)__/gu, `${boldOpen}$1${boldClose}`); + result = result.replaceAll(/(?`. + result = result.replaceAll(/^>[ \t]?/gmu, "> "); + result = result.replaceAll(boldOpen, "*").replaceAll(boldClose, "*"); + return result; +} + +export function escapeSlackEntities(text: string): string { + return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +/** + * Decode the mrkdwn markup of an incoming Slack message into plain text for + * Codex: entity escapes are reversed and `<...>` references become readable. + */ +export function mrkdwnToPlainText(text: string): string { + let result = text; + result = result.replaceAll( + /<(https?:\/\/[^|>]+)\|([^>]*)>/gu, + (_match, url: string, label: string) => (label.length === 0 ? url : `${label} (${url})`), + ); + result = result.replaceAll(/<(https?:\/\/[^|>]+)>/gu, "$1"); + result = result.replaceAll(/<#[A-Z0-9]+\|([^>]*)>/gu, "#$1"); + result = result.replaceAll(/<@([A-Z0-9]+)>/gu, "@$1"); + result = result.replaceAll( + /]*))?>/gu, + (_match, name: string, label?: string) => + label !== undefined && label.length > 0 ? label : `@${name}`, + ); + result = result.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); + return result; +} diff --git a/src/channels/slack/message.ts b/src/channels/slack/message.ts new file mode 100644 index 0000000..9d11742 --- /dev/null +++ b/src/channels/slack/message.ts @@ -0,0 +1,104 @@ +import { mrkdwnToPlainText } from "./format.js"; + +/** Subset of a Slack file object relevant to attachment handling. */ +export interface SlackFile { + readonly id: string; + readonly name?: string; + readonly title?: string; + readonly mimetype?: string; + readonly size?: number; + readonly mode?: string; + readonly subtype?: string; + readonly url_private?: string; + readonly url_private_download?: string; +} + +/** Subset of a Slack Events API `message` event relevant to the bridge. */ +export interface SlackMessageEvent { + readonly type: "message"; + readonly subtype?: string; + readonly channel: string; + readonly channel_type: "im" | "channel" | "group" | "mpim"; + readonly user?: string; + readonly bot_id?: string; + readonly text?: string; + readonly ts: string; + readonly thread_ts?: string; + readonly files?: readonly SlackFile[]; +} + +export interface SlackIncomingRoute { + /** Stable per-conversation suffix: `main` for DMs, the thread root ts elsewhere. */ + readonly conversationSuffix: string; + /** Thread to reply into; undefined keeps DM replies unthreaded. */ + readonly replyThreadTs: string | undefined; +} + +const handledSubtypes = new Set([undefined, "file_share", "thread_broadcast"]); + +/** + * Decide whether and where to handle a message event. + * + * DMs are always handled. In channels and group DMs the bot answers when it + * is mentioned, or when the message continues a thread it already works in. + */ +export function routeSlackMessage( + event: SlackMessageEvent, + botUserId: string, + isThreadActive: (conversationSuffix: string) => boolean, +): SlackIncomingRoute | undefined { + if (!handledSubtypes.has(event.subtype)) return undefined; + if (event.bot_id !== undefined || event.user === undefined || event.user === botUserId) { + return undefined; + } + if (event.channel_type === "im") { + return { conversationSuffix: "main", replyThreadTs: undefined }; + } + const threadRoot = event.thread_ts ?? event.ts; + const mentioned = event.text?.includes(`<@${botUserId}>`) === true; + if (!mentioned && !(event.thread_ts !== undefined && isThreadActive(event.thread_ts))) { + return undefined; + } + return { conversationSuffix: threadRoot, replyThreadTs: threadRoot }; +} + +export interface NormalizedSlackMessage { + readonly text: string; + readonly files: readonly SlackFile[]; +} + +export function normalizeSlackMessage( + event: SlackMessageEvent, + botUserId: string, +): NormalizedSlackMessage { + const withoutBotMention = (event.text ?? "") + .replaceAll(`<@${botUserId}>`, " ") + .replaceAll(/[ \t]{2,}/gu, " "); + return { + text: mrkdwnToPlainText(withoutBotMention).trim(), + files: event.files ?? [], + }; +} + +export function describeSlackFile(file: SlackFile): string { + const name = file.name ?? file.title ?? "attachment"; + const metadata = [ + file.mimetype, + file.size === undefined ? undefined : formatBytes(file.size), + ].filter((value): value is string => value !== undefined); + return metadata.length === 0 ? name : `${name} (${metadata.join(", ")})`; +} + +export function slackAttachmentKind(file: SlackFile): "image" | "file" | "voice" { + if (file.subtype === "slack_audio") return "voice"; + const mimetype = file.mimetype ?? ""; + if (mimetype.startsWith("image/")) return "image"; + if (mimetype.startsWith("audio/")) return "voice"; + return "file"; +} + +function formatBytes(bytes: number): string { + if (bytes < 1_024) return `${bytes} B`; + if (bytes < 1_024 * 1_024) return `${Math.round(bytes / 1_024)} KB`; + return `${Math.round((bytes / (1_024 * 1_024)) * 10) / 10} MB`; +} diff --git a/src/channels/slack/references.ts b/src/channels/slack/references.ts new file mode 100644 index 0000000..ebfd065 --- /dev/null +++ b/src/channels/slack/references.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import type { ProviderReference } from "../../core/channel.js"; + +const slackChannelTypeSchema = z.enum(["im", "mpim", "group", "channel"]); + +const targetSchema = z.object({ + version: z.literal(1), + channel: z.string().min(1), + channelType: slackChannelTypeSchema, + threadTs: z.string().min(1).optional(), +}); + +const messageSchema = z.object({ + version: z.literal(1), + channel: z.string().min(1), + ts: z.string().min(1), +}); + +export type SlackChannelType = z.infer; + +export interface SlackDeliveryTarget { + readonly channel: string; + readonly channelType: SlackChannelType; + readonly threadTs: string | undefined; +} + +export function slackDeliveryTarget( + channel: string, + channelType: SlackChannelType, + threadTs?: string, +): ProviderReference { + return { + provider: "slack", + resource: "destination", + id: encodeReference({ + version: 1, + channel, + channelType, + ...(threadTs === undefined ? {} : { threadTs }), + }), + }; +} + +export function parseSlackDeliveryTarget(reference: ProviderReference): SlackDeliveryTarget { + if (reference.provider !== "slack" || reference.resource !== "destination") { + throw new Error("The delivery target does not belong to Slack"); + } + const parsed = targetSchema.parse(decodeReference(reference.id)); + return { + channel: parsed.channel, + channelType: parsed.channelType, + threadTs: parsed.threadTs, + }; +} + +export function slackMessageReference(channel: string, ts: string): ProviderReference { + return { + provider: "slack", + resource: "message", + id: encodeReference({ version: 1, channel, ts }), + }; +} + +export function parseSlackMessageReference( + reference: ProviderReference, +): Readonly<{ channel: string; ts: string }> { + if (reference.provider !== "slack" || reference.resource !== "message") { + throw new Error("The message reference does not belong to Slack"); + } + const parsed = messageSchema.parse(decodeReference(reference.id)); + return { channel: parsed.channel, ts: parsed.ts }; +} + +function encodeReference(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +function decodeReference(value: string): unknown { + return JSON.parse(Buffer.from(value, "base64url").toString("utf8")); +} diff --git a/src/channels/slack/reply.ts b/src/channels/slack/reply.ts new file mode 100644 index 0000000..452feaf --- /dev/null +++ b/src/channels/slack/reply.ts @@ -0,0 +1,512 @@ +import { basename } from "node:path"; +import type { + ChoiceOption, + MessageResponder, + OutboundAttachment, + OutboundMessage, + OutboundStream, + ProgressSnapshot, + SendOptions, +} from "../../core/channel.js"; +import type { Logger } from "../../shared/logger.js"; +import { formatThinkingBlock, splitMessageText } from "../progress.js"; +import { escapeSlackEntities, markdownToMrkdwn } from "./format.js"; +import type { SlackDeliveryTarget } from "./references.js"; + +/** Slack truncates around 40k characters; shorter chunks stay readable. */ +export const slackTextLimit = 12_000; + +export type SlackBlock = + | { + readonly type: "section"; + readonly text: { readonly type: "mrkdwn"; readonly text: string }; + } + | { readonly type: "actions"; readonly elements: readonly SlackButtonElement[] }; + +export interface SlackButtonElement { + readonly type: "button"; + readonly text: { readonly type: "plain_text"; readonly text: string }; + readonly action_id: string; + readonly value?: string; + readonly url?: string; +} + +export interface SlackPostOptions { + readonly channel: string; + readonly text: string; + readonly threadTs?: string; + readonly blocks?: readonly SlackBlock[]; +} + +export interface SlackUpdateOptions { + readonly channel: string; + readonly ts: string; + readonly text: string; + readonly blocks?: readonly SlackBlock[]; +} + +export interface SlackUploadOptions { + readonly channel: string; + readonly threadTs?: string; + readonly path: string; + readonly filename: string; +} + +export interface SlackEphemeralOptions { + readonly channel: string; + readonly user: string; + readonly text: string; +} + +/** Narrow messaging port over the Slack Web API, easy to fake in tests. */ +export interface SlackMessagingApi { + postMessage(options: SlackPostOptions): Promise; + updateMessage(options: SlackUpdateOptions): Promise; + uploadFile(options: SlackUploadOptions): Promise; + postEphemeral(options: SlackEphemeralOptions): Promise; +} + +export type SlackChoiceRequester = ( + channel: string, + threadTs: string | undefined, + userId: string, + prompt: string, + options: readonly ChoiceOption[], +) => Promise; + +export function decodeSlackCommandValue( + value: string, +): Readonly<{ name: string; args: string }> | undefined { + const match = /^tx:([a-z][a-z0-9_]*):(.*)$/u.exec(value); + const name = match?.[1]; + const args = match?.[2]; + return name === undefined || args === undefined ? undefined : { name, args }; +} + +function encodeSlackCommandValue(name: string, args: string): string { + if ( + !/^[a-z][a-z0-9_]*$/u.test(name) || + [...args].some((character) => character === ":" || character.charCodeAt(0) < 32) + ) { + throw new Error("Provider command action is not safe for a Slack button value"); + } + const value = `tx:${name}:${args}`; + if (Buffer.byteLength(value, "utf8") > 2_000) { + throw new Error("Provider command action exceeds Slack's button value limit"); + } + return value; +} + +function urlButtonBlocks(options: SendOptions | undefined): readonly SlackBlock[] | undefined { + const button = options?.button; + if (button === undefined) return undefined; + return [ + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: button.label.slice(0, 75) }, + action_id: "telex_link", + url: button.url, + }, + ], + }, + ]; +} + +function commandButtonBlocks(message: OutboundMessage): readonly SlackBlock[] | undefined { + const actions = message.actions; + if (actions === undefined || actions.length === 0) return undefined; + return [ + { + type: "actions", + elements: actions.map( + (action, index): SlackButtonElement => ({ + type: "button", + text: { type: "plain_text", text: action.label.slice(0, 75) }, + action_id: `telex_cmd_${index}`, + value: encodeSlackCommandValue(action.command.name, action.command.args), + }), + ), + }, + ]; +} + +interface ThreadOption { + readonly threadTs?: string; +} + +function threadOption(threadTs: string | undefined): ThreadOption { + return threadTs === undefined ? {} : { threadTs }; +} + +export async function publishSlackMessage( + api: SlackMessagingApi, + target: SlackDeliveryTarget, + message: OutboundMessage, + logger: Logger, +): Promise[]> { + const published: { channel: string; ts: string }[] = []; + const thread = threadOption(target.threadTs); + for (const chunk of splitMessageText(markdownToMrkdwn(message.text), slackTextLimit)) { + const ts = await api.postMessage({ channel: target.channel, text: chunk, ...thread }); + published.push({ channel: target.channel, ts }); + } + const blocks = commandButtonBlocks(message); + if (blocks !== undefined) { + const ts = await api.postMessage({ + channel: target.channel, + text: "Choose an action", + blocks, + ...thread, + }); + published.push({ channel: target.channel, ts }); + } + const attachmentTimestamps = await sendSlackAttachments( + api, + target.channel, + target.threadTs, + message.attachments ?? [], + logger, + ); + published.push(...attachmentTimestamps.map((ts) => ({ channel: target.channel, ts }))); + return published; +} + +export class SlackResponder implements MessageResponder { + readonly #api: SlackMessagingApi; + readonly #channel: string; + readonly #threadTs: string | undefined; + readonly #userId: string; + readonly #requestChoice: SlackChoiceRequester; + readonly #logger: Logger; + readonly #fallbackWebhookUrl: string | undefined; + readonly #fetch: typeof globalThis.fetch; + + public constructor( + api: SlackMessagingApi, + channel: string, + threadTs: string | undefined, + userId: string, + requestChoice: SlackChoiceRequester, + logger: Logger, + fallbackWebhookUrl?: string, + fetchImplementation: typeof globalThis.fetch = globalThis.fetch, + ) { + this.#api = api; + this.#channel = channel; + this.#threadTs = threadTs; + this.#userId = userId; + this.#requestChoice = requestChoice; + this.#logger = logger; + this.#fallbackWebhookUrl = fallbackWebhookUrl; + this.#fetch = fetchImplementation; + } + + public createStream(): OutboundStream { + return new SlackReplyStream(this.#api, this.#channel, this.#threadTs, this.#logger); + } + + public async sendText(text: string, options?: SendOptions): Promise { + const chunks = splitMessageText(markdownToMrkdwn(text), slackTextLimit); + try { + for (const chunk of chunks) { + await this.#api.postMessage({ + channel: this.#channel, + text: chunk, + ...threadOption(this.#threadTs), + }); + } + } catch (error) { + if (this.#fallbackWebhookUrl === undefined) throw error; + // Slash commands can arrive from channels the bot is not a member of; + // their response webhook still accepts an ephemeral reply. + await this.respondThroughWebhook(chunks.join("\n\n")); + return; + } + const blocks = urlButtonBlocks(options); + if (blocks !== undefined) { + const label = options?.button?.label ?? "Open"; + await this.#api.postMessage({ + channel: this.#channel, + text: label, + blocks, + ...threadOption(this.#threadTs), + }); + } + } + + public async askChoice(prompt: string, options: readonly ChoiceOption[]): Promise { + return await this.#requestChoice(this.#channel, this.#threadTs, this.#userId, prompt, options); + } + + private async respondThroughWebhook(text: string): Promise { + const url = this.#fallbackWebhookUrl; + if (url === undefined) return; + const response = await this.#fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ response_type: "ephemeral", text: text.slice(0, slackTextLimit) }), + }); + if (!response.ok) { + throw new Error(`Slack's response webhook returned HTTP ${response.status}`); + } + } +} + +export class SlackReplyStream implements OutboundStream { + static readonly #draftIntervalMs = 1_500; + #progress: ProgressSnapshot = { actions: [], plan: [] }; + #finalText = ""; + #messageTs: string | undefined; + #starting: Promise | undefined; + #lastDraftAt = 0; + #lastPublishedText = ""; + #draftDirty = false; + #draftTimer: NodeJS.Timeout | undefined; + #draftInFlight: Promise | undefined; + #closing = false; + #completing: Promise | undefined; + #completed = false; + readonly #api: SlackMessagingApi; + readonly #channel: string; + readonly #threadTs: string | undefined; + readonly #logger: Logger; + + public constructor( + api: SlackMessagingApi, + channel: string, + threadTs: string | undefined, + logger: Logger, + ) { + this.#api = api; + this.#channel = channel; + this.#threadTs = threadTs; + this.#logger = logger; + } + + public async start(initialProgress?: ProgressSnapshot): Promise { + if (this.#closing || this.#completed || this.#messageTs !== undefined) return; + if (this.#starting !== undefined) return await this.#starting; + if (initialProgress !== undefined) this.#progress = initialProgress; + const preview = this.preview(); + const post = this.#api + .postMessage({ + channel: this.#channel, + text: preview, + ...threadOption(this.#threadTs), + }) + .then((ts) => { + this.#messageTs = ts; + this.#lastDraftAt = Date.now(); + this.#lastPublishedText = preview; + if (this.#draftDirty) this.scheduleDraft(true); + }) + .catch((error: unknown) => { + this.#logger.debug("Slack progress message could not be posted", { + error: error instanceof Error ? error.message : String(error), + }); + }); + this.#starting = post; + try { + await post; + } finally { + this.#starting = undefined; + } + } + + public setProgress(progress: ProgressSnapshot): void { + if (this.#closing || this.#completed) return; + this.#progress = progress; + this.scheduleDraft(); + } + + public appendFinal(delta: string): void { + if (this.#closing || this.#completed) return; + this.#finalText += delta; + this.scheduleDraft(); + } + + public async complete( + text: string, + attachments: readonly OutboundAttachment[] = [], + ): Promise { + if (this.#completed) return; + if (this.#completing !== undefined) return await this.#completing; + + this.#closing = true; + const completion = this.finish(text, attachments); + this.#completing = completion; + try { + await completion; + this.#completed = true; + } finally { + if (this.#completing === completion) this.#completing = undefined; + if (!this.#completed) this.#closing = false; + } + } + + public async fail(message: string): Promise { + await this.complete(`Codex error: ${message}`); + } + + private async finish(text: string, attachments: readonly OutboundAttachment[]): Promise { + this.clearTimer(); + await this.#starting?.catch(() => undefined); + await this.#draftInFlight?.catch(() => undefined); + const chunks = + text.length === 0 ? [] : splitMessageText(markdownToMrkdwn(text), slackTextLimit); + const [first, ...rest] = chunks; + try { + if (first === undefined) { + // Nothing to say: freeze the progress message without the cursor. + if (this.#messageTs !== undefined) { + await this.#api.updateMessage({ + channel: this.#channel, + ts: this.#messageTs, + text: escapeSlackEntities(formatThinkingBlock(this.#progress)), + }); + } + } else if (this.#messageTs === undefined) { + for (const chunk of chunks) { + await this.#api.postMessage({ + channel: this.#channel, + text: chunk, + ...threadOption(this.#threadTs), + }); + } + } else { + await this.#api.updateMessage({ + channel: this.#channel, + ts: this.#messageTs, + text: first, + }); + for (const chunk of rest) { + await this.#api.postMessage({ + channel: this.#channel, + text: chunk, + ...threadOption(this.#threadTs), + }); + } + } + } catch (error) { + this.#logger.warn("Slack final text delivery failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + await sendSlackAttachments(this.#api, this.#channel, this.#threadTs, attachments, this.#logger); + } + + private scheduleDraft(immediate = false): void { + if (this.#closing || this.#completed) return; + this.#draftDirty = true; + if (this.#messageTs === undefined || this.#draftInFlight !== undefined) return; + + const wait = immediate + ? 0 + : Math.max(0, SlackReplyStream.#draftIntervalMs - (Date.now() - this.#lastDraftAt)); + if (wait === 0) { + this.startDraftUpdate(); + return; + } + if (this.#draftTimer !== undefined) return; + this.#draftTimer = setTimeout(() => { + this.#draftTimer = undefined; + this.startDraftUpdate(); + }, wait); + this.#draftTimer.unref(); + } + + private startDraftUpdate(): void { + if ( + this.#closing || + this.#completed || + this.#messageTs === undefined || + this.#draftInFlight !== undefined || + !this.#draftDirty + ) { + return; + } + + this.#draftDirty = false; + const update = this.flushDraft().catch((error: unknown) => { + this.#logger.debug("Slack draft update failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + this.#draftInFlight = update; + void update.finally(() => { + if (this.#draftInFlight === update) this.#draftInFlight = undefined; + if (this.#draftDirty) this.scheduleDraft(); + }); + } + + private async flushDraft(): Promise { + const messageTs = this.#messageTs; + if (this.#closing || this.#completed || messageTs === undefined) return; + const preview = this.preview(); + if (preview === this.#lastPublishedText) return; + this.#lastDraftAt = Date.now(); + await this.#api.updateMessage({ channel: this.#channel, ts: messageTs, text: preview }); + this.#lastPublishedText = preview; + } + + private preview(): string { + const progress = escapeSlackEntities(formatThinkingBlock(this.#progress)); + if (this.#finalText.length === 0) return `${progress}\n\n▌`; + const available = Math.max(0, slackTextLimit - progress.length - 3); + const finalText = available === 0 ? "" : markdownToMrkdwn(this.#finalText).slice(-available); + return `${progress}\n\n${finalText}▌`; + } + + private clearTimer(): void { + if (this.#draftTimer !== undefined) clearTimeout(this.#draftTimer); + this.#draftTimer = undefined; + this.#draftDirty = false; + } +} + +async function sendSlackAttachments( + api: SlackMessagingApi, + channel: string, + threadTs: string | undefined, + attachments: readonly OutboundAttachment[], + logger: Logger, +): Promise { + const timestamps: string[] = []; + const failed: string[] = []; + for (const attachment of attachments) { + const filename = safeAttachmentName(attachment.filename); + try { + await api.uploadFile({ + channel, + path: attachment.path, + filename, + ...threadOption(threadTs), + }); + } catch (error) { + failed.push(filename); + logger.warn("Slack attachment upload failed", { + filename, + error: error instanceof Error ? error.message : String(error), + }); + } + } + if (failed.length === 0) return timestamps; + + const notice = `Could not send ${failed.join(", ")} as ${failed.length === 1 ? "an attachment" : "attachments"}.`; + try { + const ts = await api.postMessage({ channel, text: notice, ...threadOption(threadTs) }); + timestamps.push(ts); + } catch (error) { + logger.warn("Slack attachment failure notice could not be sent", { + error: error instanceof Error ? error.message : String(error), + }); + } + return timestamps; +} + +function safeAttachmentName(path: string): string { + return basename(path).replace(/[\r\n]/g, "_") || "attachment"; +} diff --git a/src/channels/telegram/reply.ts b/src/channels/telegram/reply.ts index 42bb4c0..16e2139 100644 --- a/src/channels/telegram/reply.ts +++ b/src/channels/telegram/reply.ts @@ -11,8 +11,11 @@ import type { SendOptions, } from "../../core/channel.js"; import type { Logger } from "../../shared/logger.js"; +import { formatThinkingBlock, splitMessageText } from "../progress.js"; import type { TelegramDestination, TelegramReplyRoute } from "./route.js"; +export { formatThinkingBlock }; + export type ChoiceRequester = ( chat: Chat, route: TelegramReplyRoute, @@ -784,76 +787,8 @@ function safeAttachmentName(path: string): string { return basename(path).replace(/[\r\n]/g, "_") || "attachment"; } -export function formatThinkingBlock(progress: ProgressSnapshot, limit = 800): string { - const text = - progress.plan.length > 1 ? formatPlanProgress(progress) : formatActionProgress(progress); - if (text.length <= limit) return text; - if (limit <= 1) return "…".slice(0, limit); - return `${text.slice(0, limit - 1).trimEnd()}…`; -} - -function formatActionProgress(progress: ProgressSnapshot): string { - const heading = firstLine(progress.summary) || firstLine(progress.message) || "Thinking…"; - const maximumVisibleActions = 4; - const hiddenActions = Math.max(0, progress.actions.length - maximumVisibleActions); - const visibleActions = progress.actions.slice(-maximumVisibleActions); - const rows = [ - ...(hiddenActions === 0 ? [] : [`<${hiddenActions} more actions>`]), - ...visibleActions.map((action) => action.label), - ]; - return [ - `▌ ${truncateLine(heading, 180)}`, - ...rows.map( - (row, index) => `${index === rows.length - 1 ? "└" : "├"} ${truncateLine(row, 180)}`, - ), - ].join("\n"); -} - -function formatPlanProgress(progress: ProgressSnapshot): string { - const currentIndex = progress.plan.findIndex((step) => step.status === "inProgress"); - const fallbackIndex = progress.plan.findIndex((step) => step.status === "pending"); - const activeIndex = currentIndex === -1 ? fallbackIndex : currentIndex; - const context = firstLine(progress.summary) || progress.actions.at(-1)?.label || ""; - const reasoningMessage = progress.message?.trim(); - const lines: string[] = []; - - progress.plan.forEach((step, index) => { - const isCurrent = index === activeIndex; - if (isCurrent && lines.length > 0) lines.push(""); - const marker = step.status === "completed" ? "✓" : isCurrent ? "→" : "○"; - const suffix = isCurrent && context.length > 0 ? ` (${truncateLine(context, 140)})` : ""; - lines.push(`${marker} ${truncateLine(step.step, 180)}${suffix}`); - if (isCurrent && reasoningMessage !== undefined && reasoningMessage !== context) { - lines.push(truncateLine(reasoningMessage, 240)); - } - if (isCurrent && index < progress.plan.length - 1) lines.push(""); - }); - - return lines.join("\n"); -} - -function firstLine(text: string | undefined): string { - return text?.trim().split("\n", 1)[0]?.trim() ?? ""; -} - -function truncateLine(text: string, limit: number): string { - const compact = text.replaceAll(/\s+/g, " ").trim(); - return compact.length <= limit ? compact : `${compact.slice(0, limit - 1).trimEnd()}…`; -} - export function splitTelegramText(text: string, limit = 4_096): readonly string[] { - if (text.length <= limit) return [text]; - const chunks: string[] = []; - let remaining = text; - while (remaining.length > limit) { - const candidate = remaining.slice(0, limit); - const newline = candidate.lastIndexOf("\n"); - const splitAt = newline > limit / 2 ? newline : limit; - chunks.push(remaining.slice(0, splitAt)); - remaining = remaining.slice(splitAt).replace(/^\n/, ""); - } - if (remaining.length > 0) chunks.push(remaining); - return chunks; + return splitMessageText(text, limit); } function truncateTelegramText(text: string, limit = 4_096): string { diff --git a/src/config/env.ts b/src/config/env.ts index 941a1fa..914a9c6 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -24,6 +24,9 @@ const envSchema = z.object({ TELEGRAM_ALLOWED_USER_IDS: z.string().min(1), TELEGRAM_API_BASE: z.url().default("https://api.telegram.org"), TELEGRAM_POLL_TIMEOUT: z.coerce.number().int().min(1).max(50).default(30), + SLACK_BOT_TOKEN: z.string().startsWith("xoxb-").optional(), + SLACK_APP_TOKEN: z.string().startsWith("xapp-").optional(), + SLACK_ALLOWED_USER_IDS: z.string().min(1).optional(), PUBLIC_URL: z .url() .refine((value) => new URL(value).protocol === "https:", "PUBLIC_URL must use HTTPS") @@ -37,11 +40,18 @@ const envSchema = z.object({ LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), }); +export interface SlackConfig { + readonly botToken: string; + readonly appToken: string; + readonly allowedUserIds: ReadonlySet; +} + export interface AppConfig { readonly telegramToken: string; readonly allowedUserIds: ReadonlySet; readonly telegramApiBase: string; readonly telegramPollTimeout: number; + readonly slack: SlackConfig | undefined; readonly publicUrl: string | undefined; readonly tunnelMode: "auto" | "off"; readonly dataDirectory: string; @@ -77,6 +87,7 @@ export function loadAppConfig(environment: NodeJS.ProcessEnv = process.env): App allowedUserIds, telegramApiBase: parsed.TELEGRAM_API_BASE.replace(/\/$/, ""), telegramPollTimeout: parsed.TELEGRAM_POLL_TIMEOUT, + slack: slackConfigFromParsed(parsed), publicUrl: parsed.PUBLIC_URL?.replace(/\/$/, ""), tunnelMode: parsed.TELEX_TUNNEL, dataDirectory: resolve(parsed.TELEX_DATA_DIR), @@ -92,6 +103,29 @@ export function loadUpdateConfig(environment: NodeJS.ProcessEnv = process.env): return updateConfigFromParsed(updateEnvSchema.parse(environment)); } +function slackConfigFromParsed(parsed: z.infer): SlackConfig | undefined { + const fields = [parsed.SLACK_BOT_TOKEN, parsed.SLACK_APP_TOKEN, parsed.SLACK_ALLOWED_USER_IDS]; + if (fields.every((field) => field === undefined)) return undefined; + if (fields.some((field) => field === undefined)) { + throw new Error( + "The Slack connector needs SLACK_BOT_TOKEN, SLACK_APP_TOKEN, and SLACK_ALLOWED_USER_IDS set together", + ); + } + const allowedUserIds = new Set( + (parsed.SLACK_ALLOWED_USER_IDS ?? "").split(",").map((part) => + z + .string() + .regex(/^[UW][A-Z0-9]{2,}$/u, "Slack user IDs look like U0123ABCDEF") + .parse(part.trim().toUpperCase()), + ), + ); + return { + botToken: parsed.SLACK_BOT_TOKEN ?? "", + appToken: parsed.SLACK_APP_TOKEN ?? "", + allowedUserIds, + }; +} + function updateConfigFromParsed(parsed: z.infer): UpdateConfig { return { updateMode: parsed.TELEX_UPDATE_MODE, diff --git a/src/index.ts b/src/index.ts index 925c75b..af6f6d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { access, readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { AutomationStore, ScheduledRunsEngine } from "./automations/index.js"; +import { SlackChannel } from "./channels/slack/channel.js"; import { TelegramChannel } from "./channels/telegram/channel.js"; import { CodexConfigService } from "./codex/config-service.js"; import { CodexAppServer } from "./codex/rpc.js"; @@ -204,10 +205,20 @@ export async function runTelex(): Promise { logger.child({ component: "telegram" }), publicUrl === undefined ? undefined : `${publicUrl}/miniapp`, ); + const slack = + config.slack === undefined + ? undefined + : new SlackChannel( + config.slack.botToken, + config.slack.appToken, + config.slack.allowedUserIds, + join(config.workspace, ".telex", "attachments"), + logger.child({ component: "slack" }), + ); const scheduledRuns = new ScheduledRunsEngine({ store: automations, codex, - channels: [telegram], + channels: slack === undefined ? [telegram] : [telegram, slack], workspace: config.workspace, logger: logger.child({ component: "scheduled-runs" }), }); @@ -236,6 +247,10 @@ export async function runTelex(): Promise { ); resources.push(telegram); await telegram.start(bridge.handleMessage); + if (slack !== undefined) { + resources.push(slack); + await slack.start(bridge.handleMessage); + } resources.push(scheduledRuns); await scheduledRuns.start(); @@ -244,6 +259,7 @@ export async function runTelex(): Promise { codexVersion: pinnedVersion, workspace: config.workspace, miniApp: `${config.host}:${config.port}`, + slack: slack === undefined ? "disabled" : "enabled", }); if (config.updateMode !== "off") { diff --git a/test/env.test.ts b/test/env.test.ts index 794bdad..02379bb 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -54,4 +54,47 @@ describe("loadAppConfig", () => { }), ).toThrow(); }); + + it("leaves the Slack connector disabled by default", () => { + expect(loadAppConfig(required).slack).toBeUndefined(); + }); + + it("parses complete Slack settings", () => { + const config = loadAppConfig({ + ...required, + SLACK_BOT_TOKEN: "xoxb-123", + SLACK_APP_TOKEN: "xapp-1-A1-123-abc", + SLACK_ALLOWED_USER_IDS: "U0123ABC, w0999xyz", + }); + expect(config.slack).toMatchObject({ + botToken: "xoxb-123", + appToken: "xapp-1-A1-123-abc", + }); + expect([...(config.slack?.allowedUserIds ?? [])]).toEqual(["U0123ABC", "W0999XYZ"]); + }); + + it("rejects partial Slack settings", () => { + expect(() => loadAppConfig({ ...required, SLACK_BOT_TOKEN: "xoxb-123" })).toThrow( + /set together/, + ); + }); + + it("rejects Slack tokens with the wrong prefix and malformed user IDs", () => { + expect(() => + loadAppConfig({ + ...required, + SLACK_BOT_TOKEN: "xoxp-user-token", + SLACK_APP_TOKEN: "xapp-1", + SLACK_ALLOWED_USER_IDS: "U0123ABC", + }), + ).toThrow(); + expect(() => + loadAppConfig({ + ...required, + SLACK_BOT_TOKEN: "xoxb-123", + SLACK_APP_TOKEN: "xapp-1", + SLACK_ALLOWED_USER_IDS: "not-a-user", + }), + ).toThrow(); + }); }); diff --git a/test/slack-format.test.ts b/test/slack-format.test.ts new file mode 100644 index 0000000..fa58da8 --- /dev/null +++ b/test/slack-format.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + escapeSlackEntities, + markdownToMrkdwn, + mrkdwnToPlainText, +} from "../src/channels/slack/format.js"; + +describe("markdownToMrkdwn", () => { + it("converts bold and italic to Slack's dialect", () => { + expect(markdownToMrkdwn("**bold** and *italic* and __also bold__")).toBe( + "*bold* and _italic_ and *also bold*", + ); + }); + + it("keeps plain text with single letters intact", () => { + expect(markdownToMrkdwn("A B C /B and a*b multiplication")).toBe( + "A B C /B and a*b multiplication", + ); + }); + + it("turns headings into bold lines", () => { + expect(markdownToMrkdwn("# Title\n\n## Sub **section**\n\nBody")).toBe( + "*Title*\n\n*Sub section*\n\nBody", + ); + }); + + it("rewrites links and images", () => { + expect(markdownToMrkdwn("See [the docs](https://example.com/a) now")).toBe( + "See now", + ); + expect(markdownToMrkdwn("![diagram](https://example.com/i.png)")).toBe( + "", + ); + expect(markdownToMrkdwn("[](https://example.com)")).toBe(""); + }); + + it("escapes Slack entities outside and inside code", () => { + expect(markdownToMrkdwn("1 < 2 & 3 > 2")).toBe("1 < 2 & 3 > 2"); + expect(markdownToMrkdwn("`a < b`")).toBe("`a < b`"); + }); + + it("leaves fenced code untouched apart from entity escaping", () => { + const input = "```ts\nconst a = b ** 2; // **not bold**\n```"; + expect(markdownToMrkdwn(input)).toBe("```ts\nconst a = b ** 2; // **not bold**\n```"); + }); + + it("converts list markers and strikethrough", () => { + expect(markdownToMrkdwn("- one\n* two\n + three\n~~gone~~")).toBe( + "• one\n• two\n • three\n~gone~", + ); + }); + + it("restores blockquote markers after escaping", () => { + expect(markdownToMrkdwn("> quoted line")).toBe("> quoted line"); + }); + + it("does not treat multi-line asterisk pairs as italic", () => { + expect(markdownToMrkdwn("2 * 3\n4 * 5")).toBe("2 * 3\n4 * 5"); + }); +}); + +describe("escapeSlackEntities", () => { + it("escapes ampersands before angle brackets", () => { + expect(escapeSlackEntities("<")).toBe("&lt;"); + }); +}); + +describe("mrkdwnToPlainText", () => { + it("decodes links, mentions, and entities", () => { + expect(mrkdwnToPlainText("see & ")).toBe( + "see docs (https://example.com) & https://a.dev", + ); + expect(mrkdwnToPlainText("<@U123ABC> in <#C42|general> ")).toBe( + "@U123ABC in #general @here", + ); + }); +}); diff --git a/test/slack-message.test.ts b/test/slack-message.test.ts new file mode 100644 index 0000000..f8a1985 --- /dev/null +++ b/test/slack-message.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { + describeSlackFile, + normalizeSlackMessage, + routeSlackMessage, + type SlackMessageEvent, + slackAttachmentKind, +} from "../src/channels/slack/message.js"; + +const botUserId = "U0BOT"; + +function event(overrides: Partial): SlackMessageEvent { + return { + type: "message", + channel: "C123", + channel_type: "channel", + user: "U1", + text: "hello", + ts: "1700000000.000100", + ...overrides, + }; +} + +const noActiveThreads = (): boolean => false; + +describe("routeSlackMessage", () => { + it("always handles direct messages without threading", () => { + const route = routeSlackMessage( + event({ channel_type: "im", channel: "D1" }), + botUserId, + noActiveThreads, + ); + expect(route).toEqual({ conversationSuffix: "main", replyThreadTs: undefined }); + }); + + it("requires a mention in channels", () => { + expect(routeSlackMessage(event({}), botUserId, noActiveThreads)).toBeUndefined(); + const route = routeSlackMessage( + event({ text: `<@${botUserId}> hi` }), + botUserId, + noActiveThreads, + ); + expect(route).toEqual({ + conversationSuffix: "1700000000.000100", + replyThreadTs: "1700000000.000100", + }); + }); + + it("keys threaded mentions by the thread root", () => { + const route = routeSlackMessage( + event({ text: `<@${botUserId}> continue`, thread_ts: "1699.5", ts: "1700.9" }), + botUserId, + noActiveThreads, + ); + expect(route).toEqual({ conversationSuffix: "1699.5", replyThreadTs: "1699.5" }); + }); + + it("continues active threads without a mention", () => { + const route = routeSlackMessage( + event({ thread_ts: "1699.5", ts: "1700.9" }), + botUserId, + (threadRoot) => threadRoot === "1699.5", + ); + expect(route).toEqual({ conversationSuffix: "1699.5", replyThreadTs: "1699.5" }); + }); + + it("ignores bot echoes and unsupported subtypes", () => { + expect( + routeSlackMessage(event({ channel_type: "im", bot_id: "B1" }), botUserId, noActiveThreads), + ).toBeUndefined(); + expect( + routeSlackMessage(event({ channel_type: "im", user: botUserId }), botUserId, noActiveThreads), + ).toBeUndefined(); + expect( + routeSlackMessage( + event({ channel_type: "im", subtype: "message_changed" }), + botUserId, + noActiveThreads, + ), + ).toBeUndefined(); + const { user: _ignored, ...anonymous } = event({ channel_type: "im" }); + expect(routeSlackMessage(anonymous, botUserId, noActiveThreads)).toBeUndefined(); + }); + + it("handles file_share and thread_broadcast subtypes", () => { + expect( + routeSlackMessage( + event({ channel_type: "im", subtype: "file_share" }), + botUserId, + noActiveThreads, + ), + ).toBeDefined(); + expect( + routeSlackMessage( + event({ text: `<@${botUserId}> x`, subtype: "thread_broadcast" }), + botUserId, + noActiveThreads, + ), + ).toBeDefined(); + }); +}); + +describe("normalizeSlackMessage", () => { + it("strips the bot mention and decodes mrkdwn", () => { + const normalized = normalizeSlackMessage( + event({ text: `<@${botUserId}> check & more` }), + botUserId, + ); + expect(normalized.text).toBe("check this (https://example.com) & more"); + }); + + it("keeps other user mentions readable", () => { + const normalized = normalizeSlackMessage(event({ text: "ask <@U999>" }), botUserId); + expect(normalized.text).toBe("ask @U999"); + }); +}); + +describe("slack file helpers", () => { + it("classifies attachment kinds", () => { + expect(slackAttachmentKind({ id: "F1", mimetype: "image/png" })).toBe("image"); + expect(slackAttachmentKind({ id: "F2", subtype: "slack_audio", mimetype: "audio/mp4" })).toBe( + "voice", + ); + expect(slackAttachmentKind({ id: "F3", mimetype: "audio/mpeg" })).toBe("voice"); + expect(slackAttachmentKind({ id: "F4", mimetype: "application/pdf" })).toBe("file"); + expect(slackAttachmentKind({ id: "F5" })).toBe("file"); + }); + + it("describes files with metadata", () => { + expect( + describeSlackFile({ id: "F1", name: "report.pdf", mimetype: "application/pdf", size: 2_048 }), + ).toBe("report.pdf (application/pdf, 2 KB)"); + expect(describeSlackFile({ id: "F2" })).toBe("attachment"); + }); +}); diff --git a/test/slack-references.test.ts b/test/slack-references.test.ts new file mode 100644 index 0000000..9dac0d3 --- /dev/null +++ b/test/slack-references.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + parseSlackDeliveryTarget, + parseSlackMessageReference, + slackDeliveryTarget, + slackMessageReference, +} from "../src/channels/slack/references.js"; + +describe("slack delivery targets", () => { + it("round-trips a threaded channel target", () => { + const reference = slackDeliveryTarget("C123", "channel", "1700000000.000100"); + expect(reference.provider).toBe("slack"); + expect(reference.resource).toBe("destination"); + expect(parseSlackDeliveryTarget(reference)).toEqual({ + channel: "C123", + channelType: "channel", + threadTs: "1700000000.000100", + }); + }); + + it("round-trips an unthreaded direct-message target", () => { + const reference = slackDeliveryTarget("D555", "im"); + expect(parseSlackDeliveryTarget(reference)).toEqual({ + channel: "D555", + channelType: "im", + threadTs: undefined, + }); + }); + + it("rejects references from other providers", () => { + const foreign = { provider: "telegram", resource: "destination", id: "x" } as const; + expect(() => parseSlackDeliveryTarget(foreign)).toThrow(/does not belong to Slack/); + }); +}); + +describe("slack message references", () => { + it("round-trips channel and ts", () => { + const reference = slackMessageReference("C9", "1700000000.000200"); + expect(parseSlackMessageReference(reference)).toEqual({ + channel: "C9", + ts: "1700000000.000200", + }); + }); + + it("rejects delivery targets", () => { + const target = slackDeliveryTarget("C9", "channel"); + expect(() => parseSlackMessageReference(target)).toThrow(/does not belong to Slack/); + }); +}); diff --git a/test/slack-reply.test.ts b/test/slack-reply.test.ts new file mode 100644 index 0000000..9e3b3c6 --- /dev/null +++ b/test/slack-reply.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + SlackEphemeralOptions, + SlackMessagingApi, + SlackPostOptions, + SlackUpdateOptions, + SlackUploadOptions, +} from "../src/channels/slack/reply.js"; +import { + publishSlackMessage, + SlackReplyStream, + SlackResponder, +} from "../src/channels/slack/reply.js"; +import { Logger } from "../src/shared/logger.js"; + +afterEach(() => { + vi.useRealTimers(); +}); + +interface RecordedCalls { + readonly posts: SlackPostOptions[]; + readonly updates: SlackUpdateOptions[]; + readonly uploads: SlackUploadOptions[]; + readonly ephemerals: SlackEphemeralOptions[]; +} + +function fakeApi(overrides: Partial = {}): { + api: SlackMessagingApi; + calls: RecordedCalls; +} { + const calls: RecordedCalls = { posts: [], updates: [], uploads: [], ephemerals: [] }; + let sequence = 0; + const api: SlackMessagingApi = { + async postMessage(options) { + calls.posts.push(options); + sequence += 1; + return `170000000${sequence}.000100`; + }, + async updateMessage(options) { + calls.updates.push(options); + }, + async uploadFile(options) { + calls.uploads.push(options); + }, + async postEphemeral(options) { + calls.ephemerals.push(options); + }, + ...overrides, + }; + return { api, calls }; +} + +function responder( + api: SlackMessagingApi, + fallbackUrl?: string, + fetchImplementation?: typeof fetch, +) { + return new SlackResponder( + api, + "C1", + "1699.5", + "U1", + async () => "decline", + new Logger("error"), + fallbackUrl, + fetchImplementation, + ); +} + +describe("SlackResponder", () => { + it("posts converted mrkdwn into the reply thread", async () => { + const { api, calls } = fakeApi(); + await responder(api).sendText("**done** with [it](https://x.dev)"); + expect(calls.posts).toHaveLength(1); + expect(calls.posts[0]).toMatchObject({ + channel: "C1", + threadTs: "1699.5", + text: "*done* with ", + }); + }); + + it("appends a URL button as a follow-up block message", async () => { + const { api, calls } = fakeApi(); + await responder(api).sendText("Open settings", { + button: { label: "Open", kind: "url", url: "https://example.com" }, + }); + expect(calls.posts).toHaveLength(2); + const buttonPost = calls.posts[1]; + expect(buttonPost?.blocks?.[0]).toMatchObject({ + type: "actions", + elements: [expect.objectContaining({ action_id: "telex_link", url: "https://example.com" })], + }); + }); + + it("splits long replies into multiple messages", async () => { + const { api, calls } = fakeApi(); + await responder(api).sendText(`${"a".repeat(12_500)}\n${"b".repeat(300)}`); + expect(calls.posts.length).toBeGreaterThan(1); + }); + + it("falls back to the response webhook when posting fails", async () => { + const { api } = fakeApi({ + postMessage: async () => { + throw new Error("not_in_channel"); + }, + }); + const fetchMock = vi.fn(async () => new Response("ok", { status: 200 })); + await responder(api, "https://hooks.slack.com/respond", fetchMock).sendText("hello"); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(url).toBe("https://hooks.slack.com/respond"); + expect(JSON.parse(String(init?.body))).toEqual({ + response_type: "ephemeral", + text: "hello", + }); + }); +}); + +describe("SlackReplyStream", () => { + function stream(api: SlackMessagingApi) { + return new SlackReplyStream(api, "C1", "1699.5", new Logger("error")); + } + + it("posts a progress message and replaces it with the final text", async () => { + const { api, calls } = fakeApi(); + const reply = stream(api); + await reply.start({ summary: "Reading files", actions: [], plan: [] }); + expect(calls.posts).toHaveLength(1); + expect(calls.posts[0]?.text).toContain("Reading files"); + expect(calls.posts[0]?.text).toContain("▌"); + await reply.complete("All **done**"); + expect(calls.updates).toHaveLength(1); + expect(calls.updates[0]).toMatchObject({ channel: "C1", text: "All *done*" }); + }); + + it("throttles draft updates", async () => { + vi.useFakeTimers(); + const { api, calls } = fakeApi(); + const reply = stream(api); + await reply.start(); + reply.appendFinal("first "); + await vi.advanceTimersByTimeAsync(100); + reply.appendFinal("second "); + reply.appendFinal("third "); + await vi.advanceTimersByTimeAsync(5_000); + expect(calls.updates.length).toBeGreaterThan(0); + expect(calls.updates.length).toBeLessThan(3); + const lastUpdate = calls.updates.at(-1); + expect(lastUpdate?.text).toContain("third"); + }); + + it("freezes the progress text when the turn ends without output", async () => { + const { api, calls } = fakeApi(); + const reply = stream(api); + await reply.start({ summary: "Working", actions: [], plan: [] }); + await reply.complete(""); + expect(calls.updates).toHaveLength(1); + expect(calls.updates[0]?.text).toContain("Working"); + expect(calls.updates[0]?.text.endsWith("▌")).toBe(false); + }); + + it("posts the final text directly when no progress message exists", async () => { + const { api, calls } = fakeApi({ + postMessage: vi + .fn() + .mockRejectedValueOnce(new Error("temporarily unavailable")) + .mockResolvedValue("1700.1"), + }); + const reply = stream(api); + await reply.start(); + await reply.complete("result"); + expect(calls.updates).toHaveLength(0); + }); + + it("uploads attachments and reports failures", async () => { + const { api, calls } = fakeApi({ + uploadFile: async (options) => { + if (options.filename === "bad.bin") throw new Error("upload_error"); + calls.uploads.push(options); + }, + }); + const reply = stream(api); + await reply.start(); + await reply.complete("done", [ + { path: "/tmp/a/good.txt", filename: "good.txt" }, + { path: "/tmp/a/bad.bin", filename: "bad.bin" }, + ]); + expect(calls.uploads).toHaveLength(1); + expect(calls.uploads[0]).toMatchObject({ filename: "good.txt", threadTs: "1699.5" }); + const notice = calls.posts.at(-1); + expect(notice?.text).toContain("bad.bin"); + }); +}); + +describe("publishSlackMessage", () => { + it("posts chunks, action buttons, and attachments to the target", async () => { + const { api, calls } = fakeApi(); + const published = await publishSlackMessage( + api, + { channel: "C7", channelType: "channel", threadTs: "1690.1" }, + { + text: "# Report\nAll good", + actions: [{ label: "Continue", command: { name: "continue", args: "run-1" } }], + attachments: [{ path: "/tmp/r/report.csv", filename: "report.csv" }], + }, + new Logger("error"), + ); + expect(calls.posts[0]).toMatchObject({ channel: "C7", threadTs: "1690.1" }); + expect(calls.posts[0]?.text).toContain("*Report*"); + const buttons = calls.posts[1]; + expect(buttons?.blocks?.[0]).toMatchObject({ + type: "actions", + elements: [expect.objectContaining({ value: "tx:continue:run-1" })], + }); + expect(calls.uploads).toHaveLength(1); + expect(published.length).toBeGreaterThanOrEqual(2); + }); +}); From eca5ecffd3ddb64be1b06607b90d7588615c3f37 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 11:36:27 +0300 Subject: [PATCH 02/15] Harden the Slack connector after adversarial review - Deliver caption-less file uploads to Codex with attachment descriptions instead of silently dropping them after download. - Reject conversation-scoped /telex subcommands in channels with guidance (each thread is its own conversation; a slash command carries no thread), instead of acting on a conversation key no message flow creates. - Anchor thread replies to the latest scheduled-run notification published in that thread so reply context resolves despite Slack's flat threads. - Escape Slack entities in approval prompts, selection updates, and attachment-failure notices; raw < > & mangled approval text and could ping @channel through Codex-controlled content. - Deduplicate redelivered Socket Mode envelopes by envelope_id so slash commands and button clicks cannot execute twice after a reconnect. - Send only unposted chunks through the slash-command response webhook. - Refresh active-thread recency when scheduled results are published. --- docs/slack.md | 4 ++ src/channels/slack/channel.ts | 76 ++++++++++++++++++++++++++++++----- src/channels/slack/reply.ts | 25 ++++++++++-- test/slack-reply.test.ts | 38 ++++++++++++++++++ 4 files changed, 129 insertions(+), 14 deletions(-) diff --git a/docs/slack.md b/docs/slack.md index 2876444..12c6be7 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -119,6 +119,10 @@ disabled. Restart Telex and check the log for - **Commands**: `/telex help` anywhere, or prefix a command in a mention: `@Telex /new`. In the bot DM, plain `/new` will not reach Telex — Slack intercepts everything that starts with `/` — so use `/telex new`. + Conversation-scoped commands (`new`, `back`, `stop`, `schedules`, + `continue`) only work as `/telex …` in the bot DM; in a channel each thread + is its own conversation, so run them inside the thread as a mention + (`@Telex /stop`). - **Sign-in**: if Codex is not signed in yet, `/telex login` in the bot DM returns the ChatGPT device-code link, exactly like `/login` on Telegram. diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts index eb7776a..cd1c9ad 100644 --- a/src/channels/slack/channel.ts +++ b/src/channels/slack/channel.ts @@ -15,6 +15,7 @@ import { type Deferred, deferred } from "../../shared/async.js"; import { errorMessage } from "../../shared/errors.js"; import type { Logger } from "../../shared/logger.js"; import { downloadSlackFile, SlackFileDownloadError } from "./file.js"; +import { escapeSlackEntities } from "./format.js"; import { describeSlackFile, normalizeSlackMessage, @@ -28,6 +29,7 @@ import { slackMessageReference, } from "./references.js"; import { + choicePromptText, decodeSlackCommandValue, publishSlackMessage, type SlackBlock, @@ -51,6 +53,7 @@ export const slackSlashCommandHelp = [ interface SocketEnvelope { readonly ack: (response?: unknown) => Promise; + readonly envelope_id?: string; readonly body?: unknown; readonly event?: unknown; } @@ -96,6 +99,9 @@ const recentEventLimit = 500; const activeThreadLimit = 500; const displayNameCacheLimit = 500; +/** Commands that act on one conversation and therefore need a thread in channels. */ +const conversationScopedCommands = new Set(["new", "back", "stop", "schedules", "continue"]); + export class SlackChannel implements MessagingChannel { public readonly name = "slack"; readonly #web: WebClient; @@ -109,6 +115,12 @@ export class SlackChannel implements MessagingChannel { readonly #activeThreads = new Set(); readonly #recentEvents = new Set(); readonly #displayNames = new Map(); + /** + * Thread root → ts of the latest scheduled-run notification published there. + * Slack replies in a thread reference only the root, so this restores the + * notification message for reply-context lookups. + */ + readonly #threadNotifications = new Map(); #handler: MessageHandler | undefined; #botUserId: string | undefined; @@ -181,7 +193,16 @@ export class SlackChannel implements MessagingChannel { message: OutboundMessage, ): Promise { const target = parseSlackDeliveryTarget(targetReference); + // Keep threads that receive scheduled results routable without a mention. + if (target.channelType !== "im" && target.threadTs !== undefined) { + this.rememberActiveThread(`${target.channel}:${target.threadTs}`); + } const published = await publishSlackMessage(this.#api, target, message, this.#logger); + const primary = published[0]; + if (target.threadTs !== undefined && primary !== undefined) { + this.#threadNotifications.set(`${target.channel}:${target.threadTs}`, primary.ts); + trimInsertionOrderedMap(this.#threadNotifications, activeThreadLimit); + } return { publishedMessages: published.map((entry) => slackMessageReference(entry.channel, entry.ts)), }; @@ -197,6 +218,10 @@ export class SlackChannel implements MessagingChannel { error: errorMessage(error), }); } + // A dropped connection can redeliver an envelope whose ack was lost; + // slash commands and button clicks must not execute twice. + const envelopeId = envelope.envelope_id; + if (envelopeId !== undefined && this.wasRecentlyProcessed(`envelope:${envelopeId}`)) return; try { await work(); } catch (error) { @@ -248,7 +273,13 @@ export class SlackChannel implements MessagingChannel { } } - const text = [normalized.text, ...failures].filter((part) => part.length > 0).join("\n\n"); + const caption = [normalized.text, ...failures].filter((part) => part.length > 0).join("\n\n"); + // A bare file upload has no text; describe the attachments so the message + // still reaches Codex instead of being dropped after the download. + const text = + caption.length > 0 + ? caption + : attachments.map((attachment) => `[Attached: ${attachment.description}]`).join("\n"); if (text.length === 0) return; if (event.channel_type !== "im") { this.rememberActiveThread(`${event.channel}:${route.conversationSuffix}`); @@ -271,9 +302,18 @@ export class SlackChannel implements MessagingChannel { deliveryTarget: slackDeliveryTarget(event.channel, event.channel_type, route.replyThreadTs), }, reference: slackMessageReference(event.channel, event.ts), + // Slack threads are flat: a reply references the thread root, not the + // message being answered. When a scheduled-run notification lives in + // this thread, point replyTo at it so its stored context resolves. ...(event.thread_ts === undefined || event.thread_ts === event.ts ? {} - : { replyTo: slackMessageReference(event.channel, event.thread_ts) }), + : { + replyTo: slackMessageReference( + event.channel, + this.#threadNotifications.get(`${event.channel}:${event.thread_ts}`) ?? + event.thread_ts, + ), + }), sender: { id: sender, displayName: await this.displayName(sender), @@ -311,10 +351,21 @@ export class SlackChannel implements MessagingChannel { const [first, ...restParts] = (payload.text ?? "").trim().split(/\s+/u); const name = (first ?? "").toLowerCase(); - if (name.length === 0 || !/^[a-z][a-z0-9_]*$/u.test(name)) { + if (name.length === 0 || name === "help" || !/^[a-z][a-z0-9_]*$/u.test(name)) { + // The bridge's generic help lists bare /commands, which Slack reserves + // for its own slash-command system; answer with Slack-shaped help. await respondEphemerally(`Telex commands:\n${slackSlashCommandHelp}`); return; } + const isDirect = payload.channel_name === "directmessage"; + if (!isDirect && conversationScopedCommands.has(name)) { + // In channels every thread is its own conversation, and a slash command + // carries no thread information, so these commands cannot pick a target. + await respondEphemerally( + `In channels each thread is its own Codex conversation, so \`/telex ${name}\` cannot tell which one you mean. Mention the bot inside the thread instead (\`@Telex /${name}\`), or run it in a direct message with the bot.`, + ); + return; + } const command = { name, args: restParts.join(" ") }; const responder = new SlackResponder( this.#api, @@ -330,7 +381,7 @@ export class SlackChannel implements MessagingChannel { address: { channel: this.name, key: `slack:${channelId}:main`, - isPrivate: payload.channel_name === "directmessage", + isPrivate: isDirect, isGuest: false, }, sender: { @@ -398,7 +449,7 @@ export class SlackChannel implements MessagingChannel { .updateMessage({ channel: pending.channel, ts: pending.messageTs, - text: `${pending.baseText}\n\n→ ${selected.label}`, + text: `${pending.baseText}\n\n→ ${escapeSlackEntities(selected.label)}`, blocks: [], }) .catch(() => undefined); @@ -465,12 +516,7 @@ export class SlackChannel implements MessagingChannel { ): Promise => { if (options.length === 0) return "decline"; const token = crypto.randomUUID().replaceAll("-", "").slice(0, 16); - const details = options - .filter((option) => option.description !== undefined) - .map((option) => `${option.label}: ${option.description}`) - .join("\n"); - const body = details.length === 0 ? prompt : `${prompt}\n\n${details}`; - const baseText = body.length <= 3_000 ? body : `${body.slice(0, 2_999)}…`; + const baseText = choicePromptText(prompt, options); const blocks: readonly SlackBlock[] = [ { type: "section", text: { type: "mrkdwn", text: baseText } }, { @@ -612,3 +658,11 @@ function trimInsertionOrdered(set: Set, limit: number): void { set.delete(oldest); } } + +function trimInsertionOrderedMap(map: Map, limit: number): void { + while (map.size > limit) { + const oldest = map.keys().next().value; + if (oldest === undefined) return; + map.delete(oldest); + } +} diff --git a/src/channels/slack/reply.ts b/src/channels/slack/reply.ts index 452feaf..1b22eee 100644 --- a/src/channels/slack/reply.ts +++ b/src/channels/slack/reply.ts @@ -74,6 +74,20 @@ export type SlackChoiceRequester = ( options: readonly ChoiceOption[], ) => Promise; +/** + * Render an approval prompt for a Slack section block. The prompt and option + * details are Codex-controlled free text, so Slack entities are escaped — + * otherwise `` fragments vanish and `` would ping everyone. + */ +export function choicePromptText(prompt: string, options: readonly ChoiceOption[]): string { + const details = options + .filter((option) => option.description !== undefined) + .map((option) => `${option.label}: ${option.description}`) + .join("\n"); + const body = escapeSlackEntities(details.length === 0 ? prompt : `${prompt}\n\n${details}`); + return body.length <= 3_000 ? body : `${body.slice(0, 2_999)}…`; +} + export function decodeSlackCommandValue( value: string, ): Readonly<{ name: string; args: string }> | undefined { @@ -210,6 +224,7 @@ export class SlackResponder implements MessageResponder { public async sendText(text: string, options?: SendOptions): Promise { const chunks = splitMessageText(markdownToMrkdwn(text), slackTextLimit); + let posted = 0; try { for (const chunk of chunks) { await this.#api.postMessage({ @@ -217,12 +232,14 @@ export class SlackResponder implements MessageResponder { text: chunk, ...threadOption(this.#threadTs), }); + posted += 1; } } catch (error) { if (this.#fallbackWebhookUrl === undefined) throw error; // Slash commands can arrive from channels the bot is not a member of; - // their response webhook still accepts an ephemeral reply. - await this.respondThroughWebhook(chunks.join("\n\n")); + // their response webhook still accepts an ephemeral reply. Deliver only + // what has not already been posted. + await this.respondThroughWebhook(chunks.slice(posted).join("\n\n")); return; } const blocks = urlButtonBlocks(options); @@ -495,7 +512,9 @@ async function sendSlackAttachments( } if (failed.length === 0) return timestamps; - const notice = `Could not send ${failed.join(", ")} as ${failed.length === 1 ? "an attachment" : "attachments"}.`; + const notice = escapeSlackEntities( + `Could not send ${failed.join(", ")} as ${failed.length === 1 ? "an attachment" : "attachments"}.`, + ); try { const ts = await api.postMessage({ channel, text: notice, ...threadOption(threadTs) }); timestamps.push(ts); diff --git a/test/slack-reply.test.ts b/test/slack-reply.test.ts index 9e3b3c6..5e59212 100644 --- a/test/slack-reply.test.ts +++ b/test/slack-reply.test.ts @@ -7,6 +7,7 @@ import type { SlackUploadOptions, } from "../src/channels/slack/reply.js"; import { + choicePromptText, publishSlackMessage, SlackReplyStream, SlackResponder, @@ -114,6 +115,26 @@ describe("SlackResponder", () => { text: "hello", }); }); + + it("sends only unposted chunks through the webhook fallback", async () => { + let attempts = 0; + const { api, calls } = fakeApi({ + postMessage: async (options) => { + attempts += 1; + if (attempts > 1) throw new Error("rate_limited"); + calls.posts.push(options); + return "1700.1"; + }, + }); + const fetchMock = vi.fn(async () => new Response("ok", { status: 200 })); + const text = `${"a".repeat(12_500)}\n${"b".repeat(300)}`; + await responder(api, "https://hooks.slack.com/respond", fetchMock).sendText(text); + expect(calls.posts).toHaveLength(1); + expect(calls.posts[0]?.text).toBe("a".repeat(12_000)); + const [, init] = fetchMock.mock.calls[0] ?? []; + const body = JSON.parse(String(init?.body)) as { text: string }; + expect(body.text).toBe(`${"a".repeat(500)}\n${"b".repeat(300)}`); + }); }); describe("SlackReplyStream", () => { @@ -192,6 +213,23 @@ describe("SlackReplyStream", () => { }); }); +describe("choicePromptText", () => { + it("escapes Slack entities in the prompt and option details", () => { + const text = choicePromptText("Codex wants to run: sort data.txt > out.txt", [ + { id: "approve", label: "Approve", description: "runs cat & more" }, + ]); + expect(text).toBe( + "Codex wants to run: sort data.txt > out.txt\n\nApprove: runs cat <file> & more", + ); + }); + + it("truncates very long prompts to Slack's section limit", () => { + const text = choicePromptText("p".repeat(4_000), []); + expect(text.length).toBeLessThanOrEqual(3_000); + expect(text.endsWith("…")).toBe(true); + }); +}); + describe("publishSlackMessage", () => { it("posts chunks, action buttons, and attachments to the target", async () => { const { api, calls } = fakeApi(); From a534fce99a10c9ee49cee16a1b7ca8965b781be8 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 11:55:36 +0300 Subject: [PATCH 03/15] Support Slack-only operation Telegram credentials become optional: each connector is an all-or-nothing env group and at least one must be configured. Without Telegram, the Telegram channel, the settings Mini App, and the quick tunnel stay off; the Slack connector runs standalone. --- .env.example | 7 ++--- docs/slack.md | 4 ++- src/config/env.ts | 45 +++++++++++++++++++++++--------- src/index.ts | 65 ++++++++++++++++++++++++++++------------------- test/env.test.ts | 22 +++++++++++++++- 5 files changed, 100 insertions(+), 43 deletions(-) diff --git a/.env.example b/.env.example index 1128a5a..3fe33ee 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,9 @@ -# Required. Create the bot with @BotFather. +# Telegram connector. Create the bot with @BotFather. Optional when the Slack +# connector below is configured; at least one connector must be set. TELEGRAM_BOT_TOKEN=123456:replace-me -# Required. Comma-separated Telegram numeric user IDs. Messages from everyone -# else are ignored, including guest-mode mentions. +# Comma-separated Telegram numeric user IDs. Messages from everyone else are +# ignored, including guest-mode mentions. Set together with the bot token. TELEGRAM_ALLOWED_USER_IDS=123456789 # Optional Slack connector (Socket Mode). Set all three together to enable it; diff --git a/docs/slack.md b/docs/slack.md index 12c6be7..f4db47e 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -103,7 +103,9 @@ SLACK_ALLOWED_USER_IDS=U0123ABCDEF,U0456GHIJKL ``` All three must be set together; leaving them all unset keeps the connector -disabled. Restart Telex and check the log for +disabled. Telegram is optional when Slack is configured — with only the Slack +variables set, Telex runs Slack-only (the Telegram bot and the settings Mini +App stay off). Restart Telex and check the log for `Slack bot connected through Socket Mode`. ## 5. Talk to it diff --git a/src/config/env.ts b/src/config/env.ts index 914a9c6..f15ffff 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -20,8 +20,8 @@ const updateEnvSchema = z.object({ const envSchema = z.object({ ...updateEnvSchema.shape, - TELEGRAM_BOT_TOKEN: z.string().min(20), - TELEGRAM_ALLOWED_USER_IDS: z.string().min(1), + TELEGRAM_BOT_TOKEN: z.string().min(20).optional(), + TELEGRAM_ALLOWED_USER_IDS: z.string().min(1).optional(), TELEGRAM_API_BASE: z.url().default("https://api.telegram.org"), TELEGRAM_POLL_TIMEOUT: z.coerce.number().int().min(1).max(50).default(30), SLACK_BOT_TOKEN: z.string().startsWith("xoxb-").optional(), @@ -46,9 +46,13 @@ export interface SlackConfig { readonly allowedUserIds: ReadonlySet; } -export interface AppConfig { - readonly telegramToken: string; +export interface TelegramConfig { + readonly botToken: string; readonly allowedUserIds: ReadonlySet; +} + +export interface AppConfig { + readonly telegram: TelegramConfig | undefined; readonly telegramApiBase: string; readonly telegramPollTimeout: number; readonly slack: SlackConfig | undefined; @@ -75,19 +79,20 @@ export interface UpdateConfig { export function loadAppConfig(environment: NodeJS.ProcessEnv = process.env): AppConfig { const parsed = envSchema.parse(environment); - const allowedUserIds = new Set( - parsed.TELEGRAM_ALLOWED_USER_IDS.split(",").map((part) => - z.coerce.number().int().positive().safe().parse(part.trim()), - ), - ); + const telegram = telegramConfigFromParsed(parsed); + const slack = slackConfigFromParsed(parsed); + if (telegram === undefined && slack === undefined) { + throw new Error( + "Configure at least one connector: Telegram (TELEGRAM_BOT_TOKEN + TELEGRAM_ALLOWED_USER_IDS) or Slack (SLACK_BOT_TOKEN + SLACK_APP_TOKEN + SLACK_ALLOWED_USER_IDS)", + ); + } return { ...updateConfigFromParsed(parsed), - telegramToken: parsed.TELEGRAM_BOT_TOKEN, - allowedUserIds, + telegram, telegramApiBase: parsed.TELEGRAM_API_BASE.replace(/\/$/, ""), telegramPollTimeout: parsed.TELEGRAM_POLL_TIMEOUT, - slack: slackConfigFromParsed(parsed), + slack, publicUrl: parsed.PUBLIC_URL?.replace(/\/$/, ""), tunnelMode: parsed.TELEX_TUNNEL, dataDirectory: resolve(parsed.TELEX_DATA_DIR), @@ -103,6 +108,22 @@ export function loadUpdateConfig(environment: NodeJS.ProcessEnv = process.env): return updateConfigFromParsed(updateEnvSchema.parse(environment)); } +function telegramConfigFromParsed(parsed: z.infer): TelegramConfig | undefined { + const fields = [parsed.TELEGRAM_BOT_TOKEN, parsed.TELEGRAM_ALLOWED_USER_IDS]; + if (fields.every((field) => field === undefined)) return undefined; + if (fields.some((field) => field === undefined)) { + throw new Error( + "The Telegram connector needs TELEGRAM_BOT_TOKEN and TELEGRAM_ALLOWED_USER_IDS set together", + ); + } + const allowedUserIds = new Set( + (parsed.TELEGRAM_ALLOWED_USER_IDS ?? "") + .split(",") + .map((part) => z.coerce.number().int().positive().safe().parse(part.trim())), + ); + return { botToken: parsed.TELEGRAM_BOT_TOKEN ?? "", allowedUserIds }; +} + function slackConfigFromParsed(parsed: z.infer): SlackConfig | undefined { const fields = [parsed.SLACK_BOT_TOKEN, parsed.SLACK_APP_TOKEN, parsed.SLACK_ALLOWED_USER_IDS]; if (fields.every((field) => field === undefined)) return undefined; diff --git a/src/index.ts b/src/index.ts index af6f6d2..8a3f1d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -149,21 +149,25 @@ export async function runTelex(): Promise { resources.push(runtime); await runtime.start(); - const miniApp = new MiniAppServer({ - host: config.host, - port: config.port, - botToken: config.telegramToken, - allowedUserIds: config.allowedUserIds, - configService, - runtime, - settings, - logger: logger.child({ component: "miniapp" }), - }); - resources.push(miniApp); - await miniApp.start(); + // The Mini App authenticates through Telegram initData, so it only runs + // when the Telegram connector is configured. + if (config.telegram !== undefined) { + const miniApp = new MiniAppServer({ + host: config.host, + port: config.port, + botToken: config.telegram.botToken, + allowedUserIds: config.telegram.allowedUserIds, + configService, + runtime, + settings, + logger: logger.child({ component: "miniapp" }), + }); + resources.push(miniApp); + await miniApp.start(); + } let publicUrl = config.publicUrl; - if (publicUrl === undefined && config.tunnelMode === "auto") { + if (publicUrl === undefined && config.telegram !== undefined && config.tunnelMode === "auto") { try { const binary = await ensureCloudflared( toolchainsDirectory, @@ -196,15 +200,18 @@ export async function runTelex(): Promise { : { installDirectory: config.installDirectory }), logger: logger.child({ component: "updater" }), }); - const telegram = new TelegramChannel( - config.telegramToken, - config.telegramApiBase, - config.allowedUserIds, - config.telegramPollTimeout, - join(config.workspace, ".telex", "attachments"), - logger.child({ component: "telegram" }), - publicUrl === undefined ? undefined : `${publicUrl}/miniapp`, - ); + const telegram = + config.telegram === undefined + ? undefined + : new TelegramChannel( + config.telegram.botToken, + config.telegramApiBase, + config.telegram.allowedUserIds, + config.telegramPollTimeout, + join(config.workspace, ".telex", "attachments"), + logger.child({ component: "telegram" }), + publicUrl === undefined ? undefined : `${publicUrl}/miniapp`, + ); const slack = config.slack === undefined ? undefined @@ -215,10 +222,13 @@ export async function runTelex(): Promise { join(config.workspace, ".telex", "attachments"), logger.child({ component: "slack" }), ); + const channels = [telegram, slack].filter( + (channel): channel is NonNullable => channel !== undefined, + ); const scheduledRuns = new ScheduledRunsEngine({ store: automations, codex, - channels: slack === undefined ? [telegram] : [telegram, slack], + channels, workspace: config.workspace, logger: logger.child({ component: "scheduled-runs" }), }); @@ -245,8 +255,10 @@ export async function runTelex(): Promise { runtime, scheduledRuns, ); - resources.push(telegram); - await telegram.start(bridge.handleMessage); + if (telegram !== undefined) { + resources.push(telegram); + await telegram.start(bridge.handleMessage); + } if (slack !== undefined) { resources.push(slack); await slack.start(bridge.handleMessage); @@ -258,7 +270,8 @@ export async function runTelex(): Promise { version: bridgeVersion, codexVersion: pinnedVersion, workspace: config.workspace, - miniApp: `${config.host}:${config.port}`, + miniApp: config.telegram === undefined ? "disabled" : `${config.host}:${config.port}`, + telegram: telegram === undefined ? "disabled" : "enabled", slack: slack === undefined ? "disabled" : "enabled", }); diff --git a/test/env.test.ts b/test/env.test.ts index 02379bb..41df618 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -9,7 +9,7 @@ const required = { describe("loadAppConfig", () => { it("parses allowlisted users and safe defaults", () => { const config = loadAppConfig(required); - expect([...config.allowedUserIds]).toEqual([42, 9001]); + expect([...(config.telegram?.allowedUserIds ?? [])]).toEqual([42, 9001]); expect(config.telegramApiBase).toBe("https://api.telegram.org"); expect(config.checkCodexUpdates).toBe(true); expect(config.updateMode).toBe("notify"); @@ -59,6 +59,26 @@ describe("loadAppConfig", () => { expect(loadAppConfig(required).slack).toBeUndefined(); }); + it("supports Slack-only operation without Telegram credentials", () => { + const config = loadAppConfig({ + SLACK_BOT_TOKEN: "xoxb-123", + SLACK_APP_TOKEN: "xapp-1", + SLACK_ALLOWED_USER_IDS: "U0123ABC", + }); + expect(config.telegram).toBeUndefined(); + expect(config.slack).toBeDefined(); + }); + + it("rejects a configuration with no connectors at all", () => { + expect(() => loadAppConfig({})).toThrow(/at least one connector/); + }); + + it("rejects partial Telegram settings", () => { + expect(() => loadAppConfig({ TELEGRAM_BOT_TOKEN: "12345678901234567890:token" })).toThrow( + /set together/, + ); + }); + it("parses complete Slack settings", () => { const config = loadAppConfig({ ...required, From e54a3bb326707ce9c52f890665e67f52d5b80d75 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 12:25:48 +0300 Subject: [PATCH 04/15] Add Docker deployment Multi-stage image running Telex as the unprivileged telex user with all state under a /data volume. The entrypoint seeds Codex config with sandbox_mode danger-full-access on fresh volumes: Codex's bubblewrap sandbox needs user namespaces that Docker's default confinement blocks, so the container itself is the isolation boundary. Includes a Compose example and docs/docker.md. --- .dockerignore | 6 +++++ Dockerfile | 27 ++++++++++++++++++++ README.md | 7 ++++++ docker/docker-compose.example.yml | 19 ++++++++++++++ docker/entrypoint.sh | 24 ++++++++++++++++++ docs/docker.md | 41 +++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker/docker-compose.example.yml create mode 100644 docker/entrypoint.sh create mode 100644 docs/docker.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..041ed34 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +node_modules +dist +.telex +.env +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55d9674 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Telex container image. The container is the isolation boundary: the process +# runs as the unprivileged `telex` user and all state lives under /data. +FROM node:24-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY . . +RUN npm run build && npm prune --omit=dev + +FROM node:24-slim +ARG TELEX_UID=1001 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git ripgrep \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid "${TELEX_UID}" --user-group telex +WORKDIR /app +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/package.json /app/codex.version ./ +COPY docker/entrypoint.sh /usr/local/bin/telex-entrypoint +RUN chmod 0755 /usr/local/bin/telex-entrypoint +ENV TELEX_DATA_DIR=/data/telex \ + CODEX_WORKSPACE=/data/workspace +VOLUME /data +USER telex +ENTRYPOINT ["telex-entrypoint"] +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 112177f..557f67c 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,13 @@ In the other direction, Telex uploads completed Codex image-generation results a Telegram's hosted Bot API only allows bots to download files up to 20 MB and upload general files up to 50 MB. Telex still forwards the file metadata and a clear limitation notice when a download or upload is unavailable. Set `TELEGRAM_API_BASE` to a [local Bot API server](https://core.telegram.org/bots/api#using-a-local-bot-api-server) to remove the download limit and support larger uploads. +## Docker + +[docs/docker.md](docs/docker.md) describes the container image: Telex runs as +an unprivileged user with all state under a `/data` volume, and the container +replaces Codex's Linux sandbox as the isolation boundary. A Compose example +lives in [docker/docker-compose.example.yml](docker/docker-compose.example.yml). + ## Slack connector Telex can additionally bridge Codex into Slack over [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) — no public URL required. Direct messages stream progress like the Telegram private chat; in channels the bot answers mentions in threads, with each thread acting as its own Codex conversation. Approvals arrive as buttons, files flow in both directions, and commands are available as `/telex ` (Slack reserves bare `/new`-style messages for its own slash-command system). Scheduled runs created from Slack notify back into the originating channel or thread. diff --git a/docker/docker-compose.example.yml b/docker/docker-compose.example.yml new file mode 100644 index 0000000..993bedd --- /dev/null +++ b/docker/docker-compose.example.yml @@ -0,0 +1,19 @@ +# Example Compose deployment. Copy next to your .env (SLACK_* and/or +# TELEGRAM_* variables) and run: docker compose up -d --build +# +# State lives in the `telex-data` volume. To keep it on the host under a +# dedicated user instead, replace the volume with a bind mount owned by +# uid 1001 (the in-container `telex` user): +# volumes: +# - /srv/telex/data:/data +services: + telex: + build: .. + env_file: .env + init: true + restart: unless-stopped + volumes: + - telex-data:/data + +volumes: + telex-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..3359800 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Seed the Codex configuration on a fresh volume. Codex's own Linux sandbox +# needs user namespaces, which Docker's default seccomp/AppArmor confinement +# blocks; the container itself is the isolation boundary instead. +set -e + +data_dir="${TELEX_DATA_DIR:-/data/telex}" +workspace="${CODEX_WORKSPACE:-/data/workspace}" +mkdir -p "${data_dir}/codex-home" "${workspace}" + +config="${data_dir}/codex-home/config.toml" +if [ ! -f "${config}" ]; then + cat > "${config}" <<'EOF' +# Managed by Telex. You can edit this file. +# The container provides isolation; Codex's Linux sandbox is unavailable here. +approval_policy = "on-request" +sandbox_mode = "danger-full-access" +web_search = "live" +cli_auth_credentials_store = "file" +project_root_markers = [] +EOF +fi + +exec "$@" diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..5c041c2 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,41 @@ +# Running Telex in Docker + +The image runs Telex as the unprivileged `telex` user (uid 1001) with all +state — Codex home (auth, config, threads), the pinned Codex toolchain, the +workspace, and conversation/automation stores — under the `/data` volume. +No ports need to be published: both the Telegram and Slack connectors dial +out (long polling / Socket Mode). + +```bash +cp docker/docker-compose.example.yml docker-compose.yml +cp .env.example .env # fill in SLACK_* and/or TELEGRAM_* variables +docker compose up -d --build +docker compose logs -f +``` + +To keep state on the host under a dedicated user instead of a named volume: + +```bash +useradd --system --uid 1001 --user-group --shell /usr/sbin/nologin telex +mkdir -p /srv/telex/data && chown -R telex:telex /srv/telex/data +# then bind-mount /srv/telex/data:/data in the compose file +``` + +## Codex sandboxing inside the container + +On bare Linux, Codex sandboxes shell commands with a bubblewrap helper that +needs unprivileged user namespaces. Docker's default seccomp and AppArmor +confinement blocks that, so the entrypoint seeds `config.toml` with +`sandbox_mode = "danger-full-access"` on a fresh volume: the container — an +isolated filesystem, an unprivileged user, and no host mounts beyond `/data` +— is the sandbox boundary instead. Keep that in mind before bind-mounting +anything sensitive into the container. + +## Notes + +- The settings Mini App binds to `HOST:PORT` inside the container; publish + the port and set `PUBLIC_URL` if you use it with the Telegram connector. +- Telex's release self-update (`/update`, `TELEX_UPDATE_MODE=auto`) does not + apply to containers — rebuild the image to update instead. +- The first start on a fresh volume downloads the pinned Codex CLI from npm + into `/data/telex/toolchains`. From 759524f47da25baa64411d3021bcf121e3d10f0e Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 12:40:54 +0300 Subject: [PATCH 05/15] Allow authorizing every Slack workspace member SLACK_ALLOWED_USER_IDS=* authorizes any regular member of the installed workspace. Membership is verified through users.info against the bot's team and cached for ten minutes: bots, deactivated accounts, guests, and Slack Connect participants from other workspaces stay rejected, and deactivating someone locks them out without a restart. Scheduled-run owner re-checks go through the same gate. --- docs/slack.md | 21 ++++++-- src/channels/slack/authorization.ts | 28 ++++++++++ src/channels/slack/channel.ts | 80 +++++++++++++++++++++-------- src/config/env.ts | 17 +++++- src/index.ts | 4 +- test/env.test.ts | 20 ++++++++ test/slack-authorization.test.ts | 28 ++++++++++ 7 files changed, 169 insertions(+), 29 deletions(-) create mode 100644 src/channels/slack/authorization.ts create mode 100644 test/slack-authorization.test.ts diff --git a/docs/slack.md b/docs/slack.md index f4db47e..ad24815 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -85,11 +85,22 @@ settings: click **Install to Workspace**, and approve. Copy the **Bot User OAuth Token** — this is `SLACK_BOT_TOKEN`. -## 3. Find your Slack user ID - -Telex answers only allowlisted users. In Slack, open your profile → **⋯ (More)** -→ **Copy member ID**. It looks like `U0123ABCDEF`. Collect one per teammate you -want to allow. +## 3. Decide who is allowed + +Telex answers only authorized users. Two modes: + +- **Allowlist**: comma-separated member IDs. In Slack, open a profile → + **⋯ (More)** → **Copy member ID**; it looks like `U0123ABCDEF`. +- **Whole workspace**: `SLACK_ALLOWED_USER_IDS=*` authorizes every regular + member of the workspace the app is installed in. Bots, deactivated + accounts, single/multi-channel guests, and Slack Connect participants from + other workspaces are still rejected (membership is verified through + `users.info` and cached for ten minutes, so deactivating someone in Slack + locks them out without a restart). + +Everyone shares one Telex: the same Codex account, the same workspace +directory on the host, and the same conversation state per channel/thread. +Open it to the whole workspace only if that is acceptable. ## 4. Configure Telex diff --git a/src/channels/slack/authorization.ts b/src/channels/slack/authorization.ts new file mode 100644 index 0000000..80497f0 --- /dev/null +++ b/src/channels/slack/authorization.ts @@ -0,0 +1,28 @@ +/** Subset of a Slack `users.info` user object relevant to authorization. */ +export interface SlackUserProfileForAuthorization { + readonly id?: string; + readonly team_id?: string; + readonly deleted?: boolean; + readonly is_bot?: boolean; + readonly is_stranger?: boolean; + readonly is_restricted?: boolean; + readonly is_ultra_restricted?: boolean; +} + +/** + * Decide whether a user counts as a regular member of the bot's workspace. + * + * Socket Mode delivers events only for the installed workspace, but shared + * channels can still surface outsiders: Slack Connect participants belong to + * a different team, and single/multi-channel guests are not full members. + * Bots and deactivated accounts never qualify. + */ +export function isWorkspaceMember( + user: SlackUserProfileForAuthorization | undefined, + botTeamId: string, +): boolean { + if (user === undefined) return false; + if (user.deleted === true || user.is_bot === true || user.is_stranger === true) return false; + if (user.is_restricted === true || user.is_ultra_restricted === true) return false; + return user.team_id === botTeamId; +} diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts index cd1c9ad..5cbcd3f 100644 --- a/src/channels/slack/channel.ts +++ b/src/channels/slack/channel.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { SocketModeClient } from "@slack/socket-mode"; import { LogLevel, WebClient } from "@slack/web-api"; +import type { SlackConfig } from "../../config/env.js"; import type { ChoiceOption, DeliveryReceipt, @@ -14,6 +15,7 @@ import type { import { type Deferred, deferred } from "../../shared/async.js"; import { errorMessage } from "../../shared/errors.js"; import type { Logger } from "../../shared/logger.js"; +import { isWorkspaceMember } from "./authorization.js"; import { downloadSlackFile, SlackFileDownloadError } from "./file.js"; import { escapeSlackEntities } from "./format.js"; import { @@ -98,6 +100,9 @@ interface PendingChoice { const recentEventLimit = 500; const activeThreadLimit = 500; const displayNameCacheLimit = 500; +const membershipCacheLimit = 1_000; +/** Deactivations and role changes must take effect without a restart. */ +const membershipCacheTtlMs = 10 * 60 * 1_000; /** Commands that act on one conversation and therefore need a thread in channels. */ const conversationScopedCommands = new Set(["new", "back", "stop", "schedules", "continue"]); @@ -108,6 +113,9 @@ export class SlackChannel implements MessagingChannel { readonly #socket: SocketModeClient; readonly #api: SlackMessagingApi; readonly #allowedUserIds: ReadonlySet; + readonly #allowAllWorkspaceMembers: boolean; + readonly #membership = new Map>(); + #botTeamId: string | undefined; readonly #botToken: string; readonly #attachmentDirectory: string; readonly #logger: Logger; @@ -124,19 +132,14 @@ export class SlackChannel implements MessagingChannel { #handler: MessageHandler | undefined; #botUserId: string | undefined; - public constructor( - botToken: string, - appToken: string, - allowedUserIds: ReadonlySet, - attachmentDirectory: string, - logger: Logger, - ) { - this.#botToken = botToken; - this.#allowedUserIds = allowedUserIds; + public constructor(config: SlackConfig, attachmentDirectory: string, logger: Logger) { + this.#botToken = config.botToken; + this.#allowedUserIds = config.allowedUserIds; + this.#allowAllWorkspaceMembers = config.allowAllWorkspaceMembers; this.#attachmentDirectory = attachmentDirectory; this.#logger = logger; - this.#web = new WebClient(botToken, { logLevel: LogLevel.ERROR }); - this.#socket = new SocketModeClient({ appToken, logLevel: LogLevel.ERROR }); + this.#web = new WebClient(config.botToken, { logLevel: LogLevel.ERROR }); + this.#socket = new SocketModeClient({ appToken: config.appToken, logLevel: LogLevel.ERROR }); this.#api = webMessagingApi(this.#web); this.#socket.on("message", (envelope: SocketEnvelope) => { void this.withAck(envelope, async () => { @@ -162,19 +165,56 @@ export class SlackChannel implements MessagingChannel { throw new Error("Slack auth.test did not identify the bot user"); } this.#botUserId = auth.user_id; + this.#botTeamId = auth.team_id; + if (this.#allowAllWorkspaceMembers && this.#botTeamId === undefined) { + throw new Error("Slack auth.test did not identify the workspace for member authorization"); + } this.#logger.info("Slack bot connected through Socket Mode", { botUserId: auth.user_id, team: auth.team ?? "unknown", + authorization: this.#allowAllWorkspaceMembers ? "workspace-members" : "allowlist", }); await this.#socket.start(); } - public isAuthorized(principal: ProviderReference): boolean { - return ( - principal.provider === this.name && - principal.resource === "user" && - this.#allowedUserIds.has(principal.id) - ); + public isAuthorized(principal: ProviderReference): boolean | Promise { + if (principal.provider !== this.name || principal.resource !== "user") return false; + return this.isUserAllowed(principal.id); + } + + private isUserAllowed(userId: string): boolean | Promise { + if (!this.#allowAllWorkspaceMembers) return this.#allowedUserIds.has(userId); + const cached = this.#membership.get(userId); + if (cached !== undefined && Date.now() - cached.checkedAt < membershipCacheTtlMs) { + return cached.allowed; + } + return this.checkWorkspaceMembership(userId); + } + + private async checkWorkspaceMembership(userId: string): Promise { + const botTeamId = this.#botTeamId; + if (botTeamId === undefined) return false; + let allowed = false; + try { + const response = await this.#web.users.info({ user: userId }); + allowed = isWorkspaceMember(response.user, botTeamId); + } catch (error) { + // Fail closed: an unknown user (e.g. a Slack Connect outsider the bot + // token cannot see) is not a workspace member. + this.#logger.debug("Slack membership lookup failed", { + userId, + error: errorMessage(error), + }); + return false; + } + this.#membership.delete(userId); + this.#membership.set(userId, { allowed, checkedAt: Date.now() }); + while (this.#membership.size > membershipCacheLimit) { + const oldest = this.#membership.keys().next().value; + if (oldest === undefined) break; + this.#membership.delete(oldest); + } + return allowed; } public async stop(): Promise { @@ -241,7 +281,7 @@ export class SlackChannel implements MessagingChannel { ); const sender = event.user; if (route === undefined || sender === undefined) return; - if (!this.#allowedUserIds.has(sender)) { + if (!(await this.isUserAllowed(sender))) { this.#logger.warn("Ignored Slack message from unauthorized user", { userId: sender }); return; } @@ -340,7 +380,7 @@ export class SlackChannel implements MessagingChannel { await this.respondThroughWebhook(payload.response_url, text); }); }; - if (!this.#allowedUserIds.has(userId)) { + if (!(await this.isUserAllowed(userId))) { this.#logger.warn("Ignored Slack slash command from unauthorized user", { userId }); await this.respondThroughWebhook( payload.response_url, @@ -409,7 +449,7 @@ export class SlackChannel implements MessagingChannel { const actionId = action?.action_id; if (action === undefined || actionId === undefined || userId === undefined) return; if (actionId === "telex_link") return; - if (!this.#allowedUserIds.has(userId)) { + if (!(await this.isUserAllowed(userId))) { this.#logger.warn("Ignored Slack interaction from unauthorized user", { userId }); return; } diff --git a/src/config/env.ts b/src/config/env.ts index f15ffff..49b0826 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -43,7 +43,10 @@ const envSchema = z.object({ export interface SlackConfig { readonly botToken: string; readonly appToken: string; + /** Empty when {@link allowAllWorkspaceMembers} is on. */ readonly allowedUserIds: ReadonlySet; + /** `SLACK_ALLOWED_USER_IDS=*`: every regular member of the workspace. */ + readonly allowAllWorkspaceMembers: boolean; } export interface TelegramConfig { @@ -132,11 +135,22 @@ function slackConfigFromParsed(parsed: z.infer): SlackConfig | "The Slack connector needs SLACK_BOT_TOKEN, SLACK_APP_TOKEN, and SLACK_ALLOWED_USER_IDS set together", ); } + if ((parsed.SLACK_ALLOWED_USER_IDS ?? "").trim() === "*") { + return { + botToken: parsed.SLACK_BOT_TOKEN ?? "", + appToken: parsed.SLACK_APP_TOKEN ?? "", + allowedUserIds: new Set(), + allowAllWorkspaceMembers: true, + }; + } const allowedUserIds = new Set( (parsed.SLACK_ALLOWED_USER_IDS ?? "").split(",").map((part) => z .string() - .regex(/^[UW][A-Z0-9]{2,}$/u, "Slack user IDs look like U0123ABCDEF") + .regex( + /^[UW][A-Z0-9]{2,}$/u, + "Slack user IDs look like U0123ABCDEF, or * for every workspace member", + ) .parse(part.trim().toUpperCase()), ), ); @@ -144,6 +158,7 @@ function slackConfigFromParsed(parsed: z.infer): SlackConfig | botToken: parsed.SLACK_BOT_TOKEN ?? "", appToken: parsed.SLACK_APP_TOKEN ?? "", allowedUserIds, + allowAllWorkspaceMembers: false, }; } diff --git a/src/index.ts b/src/index.ts index 8a3f1d6..cd531c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -216,9 +216,7 @@ export async function runTelex(): Promise { config.slack === undefined ? undefined : new SlackChannel( - config.slack.botToken, - config.slack.appToken, - config.slack.allowedUserIds, + config.slack, join(config.workspace, ".telex", "attachments"), logger.child({ component: "slack" }), ); diff --git a/test/env.test.ts b/test/env.test.ts index 41df618..1e01716 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -69,6 +69,26 @@ describe("loadAppConfig", () => { expect(config.slack).toBeDefined(); }); + it("supports * to allow every workspace member on Slack", () => { + const config = loadAppConfig({ + SLACK_BOT_TOKEN: "xoxb-123", + SLACK_APP_TOKEN: "xapp-1", + SLACK_ALLOWED_USER_IDS: "*", + }); + expect(config.slack?.allowAllWorkspaceMembers).toBe(true); + expect([...(config.slack?.allowedUserIds ?? ["sentinel"])]).toEqual([]); + }); + + it("rejects mixing * with explicit Slack user IDs", () => { + expect(() => + loadAppConfig({ + SLACK_BOT_TOKEN: "xoxb-123", + SLACK_APP_TOKEN: "xapp-1", + SLACK_ALLOWED_USER_IDS: "*,U0123ABC", + }), + ).toThrow(); + }); + it("rejects a configuration with no connectors at all", () => { expect(() => loadAppConfig({})).toThrow(/at least one connector/); }); diff --git a/test/slack-authorization.test.ts b/test/slack-authorization.test.ts new file mode 100644 index 0000000..1c56794 --- /dev/null +++ b/test/slack-authorization.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { isWorkspaceMember } from "../src/channels/slack/authorization.js"; + +const team = "T0BRASK"; + +describe("isWorkspaceMember", () => { + it("accepts a regular member of the bot's workspace", () => { + expect(isWorkspaceMember({ id: "U1", team_id: team }, team)).toBe(true); + }); + + it("rejects users from other workspaces and Slack Connect strangers", () => { + expect(isWorkspaceMember({ id: "U1", team_id: "T0OTHER" }, team)).toBe(false); + expect(isWorkspaceMember({ id: "U1", team_id: team, is_stranger: true }, team)).toBe(false); + }); + + it("rejects bots, deactivated accounts, and guests", () => { + expect(isWorkspaceMember({ id: "U1", team_id: team, is_bot: true }, team)).toBe(false); + expect(isWorkspaceMember({ id: "U1", team_id: team, deleted: true }, team)).toBe(false); + expect(isWorkspaceMember({ id: "U1", team_id: team, is_restricted: true }, team)).toBe(false); + expect(isWorkspaceMember({ id: "U1", team_id: team, is_ultra_restricted: true }, team)).toBe( + false, + ); + }); + + it("rejects a missing user", () => { + expect(isWorkspaceMember(undefined, team)).toBe(false); + }); +}); From cbd4f18a233f6e155c18e15e2b7481a9a78e2f3f Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 12:58:54 +0300 Subject: [PATCH 06/15] Ship GitHub CLI in the container image gh reads GH_TOKEN from the environment; when set, the entrypoint also wires git's HTTPS credential helper through gh so clones and fetches work headlessly. --- Dockerfile | 6 ++++++ docker/entrypoint.sh | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/Dockerfile b/Dockerfile index 55d9674..765fe9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,12 @@ FROM node:24-slim ARG TELEX_UID=1001 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl git ripgrep \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/* \ && useradd --create-home --uid "${TELEX_UID}" --user-group telex WORKDIR /app diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 3359800..6342d9a 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -21,4 +21,10 @@ project_root_markers = [] EOF fi +# With GH_TOKEN set, let git clone/fetch over HTTPS through gh's credential +# helper (gh itself reads GH_TOKEN directly). +if [ -n "${GH_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then + gh auth setup-git >/dev/null 2>&1 || true +fi + exec "$@" From ad2c325a56821982075271d05b6f982819114dc3 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 13:45:50 +0300 Subject: [PATCH 07/15] Slack-native /telex config and an optional admin list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /telex config in the bot DM now renders interactive Codex settings from Slack blocks (model, reasoning effort, speed tier, approvals, sandbox, web search) through CodexConfigService with optimistic versioning — replacing the Telegram-only Mini App pointer. SLACK_ADMIN_USER_IDS optionally restricts instance-wide commands (config, login, logout, reload, restart, update) to listed users; the gate also covers the mention-text command form and config buttons. --- docs/slack.md | 7 + src/channels/slack/channel.ts | 83 +++++++++- src/channels/slack/config-ui.ts | 280 ++++++++++++++++++++++++++++++++ src/config/env.ts | 35 +++- src/index.ts | 1 + test/env.test.ts | 23 +++ test/slack-config-ui.test.ts | 211 ++++++++++++++++++++++++ 7 files changed, 627 insertions(+), 13 deletions(-) create mode 100644 src/channels/slack/config-ui.ts create mode 100644 test/slack-config-ui.test.ts diff --git a/docs/slack.md b/docs/slack.md index ad24815..993d251 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -102,6 +102,13 @@ Everyone shares one Telex: the same Codex account, the same workspace directory on the host, and the same conversation state per channel/thread. Open it to the whole workspace only if that is acceptable. +Optionally, `SLACK_ADMIN_USER_IDS` (comma-separated member IDs) restricts +instance-wide commands — `/telex config`, `login`, `logout`, `reload`, +`restart`, `update` — to the listed users. Unset, every authorized user may +run them. `/telex config` opens interactive Codex settings built from Slack +buttons (model, reasoning effort, speed tier, approvals, sandbox, web +search) in the bot DM — the Slack counterpart of the Telegram Mini App. + ## 4. Configure Telex Add the three variables to the environment (`.env` for a source checkout, or diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts index 5cbcd3f..74ec207 100644 --- a/src/channels/slack/channel.ts +++ b/src/channels/slack/channel.ts @@ -16,6 +16,7 @@ import { type Deferred, deferred } from "../../shared/async.js"; import { errorMessage } from "../../shared/errors.js"; import type { Logger } from "../../shared/logger.js"; import { isWorkspaceMember } from "./authorization.js"; +import { type CodexConfigAccess, SlackConfigUi, slackConfigActionPrefix } from "./config-ui.js"; import { downloadSlackFile, SlackFileDownloadError } from "./file.js"; import { escapeSlackEntities } from "./format.js"; import { @@ -107,6 +108,17 @@ const membershipCacheTtlMs = 10 * 60 * 1_000; /** Commands that act on one conversation and therefore need a thread in channels. */ const conversationScopedCommands = new Set(["new", "back", "stop", "schedules", "continue"]); +/** Commands that change this Telex instance for everyone using it. */ +const adminCommands = new Set(["config", "login", "logout", "reload", "restart", "update"]); + +/** Mirror of the bridge's plain-text command parser, for gating before dispatch. */ +function parseTextCommand(text: string): Readonly<{ name: string; args: string }> | undefined { + const match = /^\/([a-z][a-z0-9_]*)(?:@[a-z0-9_]+)?(?:[ \t]+([^\r\n]*))?$/i.exec(text.trim()); + const name = match?.[1]; + if (name === undefined) return undefined; + return { name: name.toLowerCase(), args: match?.[2]?.trimStart() ?? "" }; +} + export class SlackChannel implements MessagingChannel { public readonly name = "slack"; readonly #web: WebClient; @@ -114,6 +126,8 @@ export class SlackChannel implements MessagingChannel { readonly #api: SlackMessagingApi; readonly #allowedUserIds: ReadonlySet; readonly #allowAllWorkspaceMembers: boolean; + readonly #adminUserIds: ReadonlySet | undefined; + readonly #configUi: SlackConfigUi | undefined; readonly #membership = new Map>(); #botTeamId: string | undefined; readonly #botToken: string; @@ -132,15 +146,25 @@ export class SlackChannel implements MessagingChannel { #handler: MessageHandler | undefined; #botUserId: string | undefined; - public constructor(config: SlackConfig, attachmentDirectory: string, logger: Logger) { + public constructor( + config: SlackConfig, + attachmentDirectory: string, + logger: Logger, + configAccess?: CodexConfigAccess, + ) { this.#botToken = config.botToken; this.#allowedUserIds = config.allowedUserIds; this.#allowAllWorkspaceMembers = config.allowAllWorkspaceMembers; + this.#adminUserIds = config.adminUserIds; this.#attachmentDirectory = attachmentDirectory; this.#logger = logger; this.#web = new WebClient(config.botToken, { logLevel: LogLevel.ERROR }); this.#socket = new SocketModeClient({ appToken: config.appToken, logLevel: LogLevel.ERROR }); this.#api = webMessagingApi(this.#web); + this.#configUi = + configAccess === undefined + ? undefined + : new SlackConfigUi(this.#api, configAccess, logger.child({ component: "slack-config" })); this.#socket.on("message", (envelope: SocketEnvelope) => { void this.withAck(envelope, async () => { await this.handleMessageEvent(envelope.event as SlackMessageEvent); @@ -191,6 +215,39 @@ export class SlackChannel implements MessagingChannel { return this.checkWorkspaceMembership(userId); } + private isAdmin(userId: string): boolean { + return this.#adminUserIds === undefined || this.#adminUserIds.has(userId); + } + + private async dispatch( + inbound: InboundMessage, + channelId: string, + userId: string, + ): Promise { + const handler = this.#handler; + if (handler === undefined) return; + // The bridge also parses bare "/command" text (e.g. from "@Telex /config" + // mentions), so gate on that form as well, not only on slash commands. + const command = + inbound.command ?? + (inbound.attachments.length === 0 ? parseTextCommand(inbound.text) : undefined); + if (command !== undefined && adminCommands.has(command.name) && !this.isAdmin(userId)) { + await inbound.responder.sendText( + "This command changes Telex for everyone using it and is limited to its admins.", + ); + return; + } + if (command?.name === "config" && this.#configUi !== undefined) { + if (!inbound.address.isPrivate) { + await inbound.responder.sendText("Open Codex settings in a direct message with the bot."); + return; + } + await this.#configUi.open(channelId); + return; + } + await handler(inbound); + } + private async checkWorkspaceMembership(userId: string): Promise { const botTeamId = this.#botTeamId; if (botTeamId === undefined) return false; @@ -363,7 +420,7 @@ export class SlackChannel implements MessagingChannel { responder, }; try { - await handler(inbound); + await this.dispatch(inbound, event.channel, sender); } catch (error) { this.#logger.error("Slack message handler failed", error, { messageTs: inbound.id }); await responder.sendText(`Bridge error: ${errorMessage(error)}`).catch(() => undefined); @@ -434,7 +491,7 @@ export class SlackChannel implements MessagingChannel { responder, }; try { - await handler(inbound); + await this.dispatch(inbound, channelId, userId); } catch (error) { this.#logger.error("Slack slash command failed", error, { command: command.name }); await respondEphemerally(`Bridge error: ${errorMessage(error)}`).catch(() => undefined); @@ -453,6 +510,24 @@ export class SlackChannel implements MessagingChannel { this.#logger.warn("Ignored Slack interaction from unauthorized user", { userId }); return; } + if (actionId.startsWith(slackConfigActionPrefix)) { + const messageTs = payload.message?.ts; + if (this.#configUi === undefined || channelId === undefined || messageTs === undefined) { + return; + } + if (!this.isAdmin(userId)) { + await this.#api + .postEphemeral({ + channel: channelId, + user: userId, + text: "Codex settings are limited to Telex admins.", + }) + .catch(() => undefined); + return; + } + await this.#configUi.handleAction(action.value ?? "", channelId, messageTs); + return; + } if (actionId.startsWith("telex_choice")) { await this.handleChoiceAction(action, userId, channelId); return; @@ -540,7 +615,7 @@ export class SlackChannel implements MessagingChannel { responder, }; try { - await handler(inbound); + await this.dispatch(inbound, channelId, userId); } catch (error) { this.#logger.error("Slack command action failed", error, { command: command.name }); await responder.sendText(`Bridge error: ${errorMessage(error)}`).catch(() => undefined); diff --git a/src/channels/slack/config-ui.ts b/src/channels/slack/config-ui.ts new file mode 100644 index 0000000..5c6bc5b --- /dev/null +++ b/src/channels/slack/config-ui.ts @@ -0,0 +1,280 @@ +import { + ConfigValidationError, + type EditableConfigSnapshot, + type ModelCapability, +} from "../../codex/config-service.js"; +import { errorMessage } from "../../shared/errors.js"; +import type { Logger } from "../../shared/logger.js"; +import { escapeSlackEntities } from "./format.js"; +import type { SlackBlock, SlackButtonElement, SlackMessagingApi } from "./reply.js"; + +/** Narrow port over CodexConfigService, easy to fake in tests. */ +export interface CodexConfigAccess { + read(): Promise; + update(input: unknown): Promise; +} + +export const slackConfigActionPrefix = "telex_cfg"; + +const configFieldKeys = [ + "model", + "model_reasoning_effort", + "service_tier", + "approval_policy", + "sandbox_mode", + "web_search", +] as const; + +type ConfigFieldKey = (typeof configFieldKeys)[number]; + +const fieldLabels: Readonly> = { + model: "Model", + model_reasoning_effort: "Reasoning effort", + service_tier: "Speed", + approval_policy: "Approvals", + sandbox_mode: "Sandbox", + web_search: "Web search", +}; + +const fieldNotes: Partial> = { + sandbox_mode: + "In the Docker container only danger-full-access executes commands reliably; the container itself is the isolation boundary.", +}; + +const defaultOptionValue = "__default__"; + +interface FieldOption { + readonly value: string | null; + readonly label: string; +} + +/** + * Interactive Codex settings rendered as Slack blocks — the Slack counterpart + * of the Telegram Mini App's settings screen. One message is edited in place: + * an overview screen with a button per setting, and per-setting picker + * screens whose buttons apply the change through CodexConfigService. + */ +export class SlackConfigUi { + readonly #api: SlackMessagingApi; + readonly #config: CodexConfigAccess; + readonly #logger: Logger; + + public constructor(api: SlackMessagingApi, config: CodexConfigAccess, logger: Logger) { + this.#api = api; + this.#config = config; + this.#logger = logger; + } + + public async open(channel: string): Promise { + const snapshot = await this.#config.read(); + const { text, blocks } = overviewScreen(snapshot); + await this.#api.postMessage({ channel, text, blocks }); + } + + public async handleAction(value: string, channel: string, messageTs: string): Promise { + try { + if (value === "menu") { + await this.showOverview(channel, messageTs, undefined); + return; + } + const pick = /^pick:([a-z_]+)$/u.exec(value); + const pickField = fieldKey(pick?.[1]); + if (pickField !== undefined) { + const snapshot = await this.#config.read(); + const { text, blocks } = pickerScreen(snapshot, pickField); + await this.#api.updateMessage({ channel, ts: messageTs, text, blocks }); + return; + } + const set = /^set:([a-z_]+):(.*)$/u.exec(value); + const setField = fieldKey(set?.[1]); + if (setField !== undefined && set?.[2] !== undefined) { + await this.applyValue(channel, messageTs, setField, set[2]); + } + } catch (error) { + this.#logger.warn("Slack config action failed", { error: errorMessage(error) }); + await this.showOverview(channel, messageTs, `⚠️ ${errorMessage(error)}`).catch( + () => undefined, + ); + } + } + + private async applyValue( + channel: string, + messageTs: string, + field: ConfigFieldKey, + encoded: string, + ): Promise { + const value = encoded === defaultOptionValue ? null : decodeURIComponent(encoded); + let status: string; + try { + const snapshot = await this.#config.read(); + await this.#config.update({ + expectedVersion: snapshot.version, + values: { [field]: value }, + }); + status = `✅ ${fieldLabels[field]} updated.`; + } catch (error) { + status = + error instanceof ConfigValidationError + ? `⚠️ ${error.issues.map((issue) => issue.message).join(" ") || "The change was rejected."}` + : `⚠️ ${errorMessage(error)}`; + } + await this.showOverview(channel, messageTs, status); + } + + private async showOverview( + channel: string, + messageTs: string, + status: string | undefined, + ): Promise { + const snapshot = await this.#config.read(); + const { text, blocks } = overviewScreen(snapshot, status); + await this.#api.updateMessage({ channel, ts: messageTs, text, blocks }); + } +} + +function fieldKey(candidate: string | undefined): ConfigFieldKey | undefined { + return configFieldKeys.find((key) => key === candidate); +} + +function currentModel(snapshot: EditableConfigSnapshot): ModelCapability | undefined { + const selected = snapshot.values.model; + const models = snapshot.capabilities.models; + if (selected !== null) { + const match = models.find((model) => model.model === selected); + if (match !== undefined) return match; + } + return models.find((model) => model.isDefault) ?? models[0]; +} + +function displayValue(snapshot: EditableConfigSnapshot, field: ConfigFieldKey): string { + const raw = snapshot.values[field]; + if (raw === null || raw === undefined) return "default"; + if (typeof raw === "string") return raw; + // approval_policy can be a granular object; summarize it. + return "granular"; +} + +export function overviewScreen( + snapshot: EditableConfigSnapshot, + status?: string, +): { text: string; blocks: readonly SlackBlock[] } { + const lines = configFieldKeys.map( + (field) => `*${fieldLabels[field]}*: ${escapeSlackEntities(displayValue(snapshot, field))}`, + ); + const warnings = snapshot.validation.issues + .map((issue) => `⚠️ ${escapeSlackEntities(`${issue.path}: ${issue.message}`)}`) + .slice(0, 3); + const header = [ + "*Codex settings*", + ...(status === undefined ? [] : [escapeSlackEntities(status).replace(/^⚠️|^✅/u, (m) => m)]), + ...lines, + ...warnings, + "_Everyone using this Telex shares these settings._", + ].join("\n"); + const buttons = configFieldKeys.map( + (field, index): SlackButtonElement => ({ + type: "button", + text: { type: "plain_text", text: fieldLabels[field] }, + action_id: `${slackConfigActionPrefix}_pick_${index}`, + value: `pick:${field}`, + }), + ); + return { + text: "Codex settings", + blocks: [ + { type: "section", text: { type: "mrkdwn", text: header.slice(0, 3_000) } }, + ...chunkButtons(buttons), + ], + }; +} + +export function pickerScreen( + snapshot: EditableConfigSnapshot, + field: ConfigFieldKey, +): { text: string; blocks: readonly SlackBlock[] } { + const note = fieldNotes[field]; + const header = [ + `*${fieldLabels[field]}* — current: ${escapeSlackEntities(displayValue(snapshot, field))}`, + ...(note === undefined ? [] : [escapeSlackEntities(note)]), + ].join("\n"); + const current = snapshot.values[field]; + const buttons = fieldOptions(snapshot, field).map( + (option, index): SlackButtonElement => ({ + type: "button", + text: { + type: "plain_text", + text: `${option.value === current ? "✓ " : ""}${option.label}`.slice(0, 75), + }, + action_id: `${slackConfigActionPrefix}_set_${index}`, + value: `set:${field}:${option.value === null ? defaultOptionValue : encodeURIComponent(option.value)}`, + }), + ); + const back: SlackButtonElement = { + type: "button", + text: { type: "plain_text", text: "← Back" }, + action_id: `${slackConfigActionPrefix}_back`, + value: "menu", + }; + return { + text: `Codex settings — ${fieldLabels[field]}`, + blocks: [ + { type: "section", text: { type: "mrkdwn", text: header.slice(0, 3_000) } }, + ...chunkButtons([...buttons, back]), + ], + }; +} + +function fieldOptions(snapshot: EditableConfigSnapshot, field: ConfigFieldKey): FieldOption[] { + const model = currentModel(snapshot); + switch (field) { + case "model": + return snapshot.capabilities.models.map((candidate) => ({ + value: candidate.model, + label: `${candidate.displayName}${candidate.isDefault ? " (default)" : ""}`, + })); + case "model_reasoning_effort": + return [ + ...(model?.supportedReasoningEfforts ?? []).map((option) => ({ + value: option.reasoningEffort, + label: option.reasoningEffort, + })), + { value: null, label: `default (${model?.defaultReasoningEffort ?? "model default"})` }, + ]; + case "service_tier": + return [ + ...(model?.serviceTiers ?? []).map((tier) => ({ value: tier.id, label: tier.name })), + { value: null, label: "standard (default)" }, + ]; + case "approval_policy": + return [ + { value: "untrusted", label: "untrusted — approve most actions" }, + { value: "on-request", label: "on-request — Codex decides when to ask" }, + { value: "never", label: "never — fully unattended" }, + { value: null, label: "default" }, + ]; + case "sandbox_mode": + return [ + { value: "read-only", label: "read-only" }, + { value: "workspace-write", label: "workspace-write" }, + { value: "danger-full-access", label: "danger-full-access" }, + { value: null, label: "default" }, + ]; + case "web_search": + return [ + { value: "disabled", label: "disabled" }, + { value: "cached", label: "cached" }, + { value: "indexed", label: "indexed" }, + { value: "live", label: "live" }, + { value: null, label: "default" }, + ]; + } +} + +function chunkButtons(buttons: readonly SlackButtonElement[]): SlackBlock[] { + const blocks: SlackBlock[] = []; + for (let index = 0; index < buttons.length; index += 5) { + blocks.push({ type: "actions", elements: buttons.slice(index, index + 5) }); + } + return blocks; +} diff --git a/src/config/env.ts b/src/config/env.ts index 49b0826..90a0577 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -27,6 +27,7 @@ const envSchema = z.object({ SLACK_BOT_TOKEN: z.string().startsWith("xoxb-").optional(), SLACK_APP_TOKEN: z.string().startsWith("xapp-").optional(), SLACK_ALLOWED_USER_IDS: z.string().min(1).optional(), + SLACK_ADMIN_USER_IDS: z.string().min(1).optional(), PUBLIC_URL: z .url() .refine((value) => new URL(value).protocol === "https:", "PUBLIC_URL must use HTTPS") @@ -47,6 +48,8 @@ export interface SlackConfig { readonly allowedUserIds: ReadonlySet; /** `SLACK_ALLOWED_USER_IDS=*`: every regular member of the workspace. */ readonly allowAllWorkspaceMembers: boolean; + /** When set, instance-wide commands (config, login, restart…) are limited to these users. */ + readonly adminUserIds: ReadonlySet | undefined; } export interface TelegramConfig { @@ -129,22 +132,42 @@ function telegramConfigFromParsed(parsed: z.infer): TelegramCo function slackConfigFromParsed(parsed: z.infer): SlackConfig | undefined { const fields = [parsed.SLACK_BOT_TOKEN, parsed.SLACK_APP_TOKEN, parsed.SLACK_ALLOWED_USER_IDS]; - if (fields.every((field) => field === undefined)) return undefined; + if (fields.every((field) => field === undefined)) { + if (parsed.SLACK_ADMIN_USER_IDS !== undefined) { + throw new Error("SLACK_ADMIN_USER_IDS requires the Slack connector to be configured"); + } + return undefined; + } if (fields.some((field) => field === undefined)) { throw new Error( "The Slack connector needs SLACK_BOT_TOKEN, SLACK_APP_TOKEN, and SLACK_ALLOWED_USER_IDS set together", ); } + const adminUserIds = + parsed.SLACK_ADMIN_USER_IDS === undefined + ? undefined + : parseSlackUserIds(parsed.SLACK_ADMIN_USER_IDS); if ((parsed.SLACK_ALLOWED_USER_IDS ?? "").trim() === "*") { return { botToken: parsed.SLACK_BOT_TOKEN ?? "", appToken: parsed.SLACK_APP_TOKEN ?? "", allowedUserIds: new Set(), allowAllWorkspaceMembers: true, + adminUserIds, }; } - const allowedUserIds = new Set( - (parsed.SLACK_ALLOWED_USER_IDS ?? "").split(",").map((part) => + return { + botToken: parsed.SLACK_BOT_TOKEN ?? "", + appToken: parsed.SLACK_APP_TOKEN ?? "", + allowedUserIds: parseSlackUserIds(parsed.SLACK_ALLOWED_USER_IDS ?? ""), + allowAllWorkspaceMembers: false, + adminUserIds, + }; +} + +function parseSlackUserIds(raw: string): ReadonlySet { + return new Set( + raw.split(",").map((part) => z .string() .regex( @@ -154,12 +177,6 @@ function slackConfigFromParsed(parsed: z.infer): SlackConfig | .parse(part.trim().toUpperCase()), ), ); - return { - botToken: parsed.SLACK_BOT_TOKEN ?? "", - appToken: parsed.SLACK_APP_TOKEN ?? "", - allowedUserIds, - allowAllWorkspaceMembers: false, - }; } function updateConfigFromParsed(parsed: z.infer): UpdateConfig { diff --git a/src/index.ts b/src/index.ts index cd531c0..63c9ff2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -219,6 +219,7 @@ export async function runTelex(): Promise { config.slack, join(config.workspace, ".telex", "attachments"), logger.child({ component: "slack" }), + configService, ); const channels = [telegram, slack].filter( (channel): channel is NonNullable => channel !== undefined, diff --git a/test/env.test.ts b/test/env.test.ts index 1e01716..9ca8017 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -79,6 +79,29 @@ describe("loadAppConfig", () => { expect([...(config.slack?.allowedUserIds ?? ["sentinel"])]).toEqual([]); }); + it("parses the optional Slack admin list", () => { + const config = loadAppConfig({ + SLACK_BOT_TOKEN: "xoxb-123", + SLACK_APP_TOKEN: "xapp-1", + SLACK_ALLOWED_USER_IDS: "*", + SLACK_ADMIN_USER_IDS: "U0AFAK0FB46", + }); + expect([...(config.slack?.adminUserIds ?? [])]).toEqual(["U0AFAK0FB46"]); + expect( + loadAppConfig({ + SLACK_BOT_TOKEN: "xoxb-123", + SLACK_APP_TOKEN: "xapp-1", + SLACK_ALLOWED_USER_IDS: "*", + }).slack?.adminUserIds, + ).toBeUndefined(); + }); + + it("rejects an admin list without the Slack connector", () => { + expect(() => loadAppConfig({ ...required, SLACK_ADMIN_USER_IDS: "U0AFAK0FB46" })).toThrow( + /requires the Slack connector/, + ); + }); + it("rejects mixing * with explicit Slack user IDs", () => { expect(() => loadAppConfig({ diff --git a/test/slack-config-ui.test.ts b/test/slack-config-ui.test.ts new file mode 100644 index 0000000..89100a9 --- /dev/null +++ b/test/slack-config-ui.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from "vitest"; +import { overviewScreen, pickerScreen, SlackConfigUi } from "../src/channels/slack/config-ui.js"; +import type { + SlackMessagingApi, + SlackPostOptions, + SlackUpdateOptions, +} from "../src/channels/slack/reply.js"; +import type { EditableConfigSnapshot } from "../src/codex/config-service.js"; +import { Logger } from "../src/shared/logger.js"; + +function snapshot( + overrides: Partial = {}, +): EditableConfigSnapshot { + return { + version: "v42", + values: { + model: "gpt-5.6-sol", + model_provider: null, + approval_policy: "on-request", + approvals_reviewer: null, + sandbox_mode: "danger-full-access", + default_permissions: null, + web_search: "live", + model_reasoning_effort: null, + model_reasoning_summary: null, + model_verbosity: null, + service_tier: "priority", + personality: null, + windows_sandbox: null, + shell_environment_include_only: null, + features: { + apps: null, + goals: null, + hooks: null, + fast_mode: null, + memories: null, + multi_agent: null, + personality: null, + remote_plugin: null, + shell_snapshot: null, + shell_tool: null, + unified_exec: null, + }, + ...overrides, + }, + capabilities: { + platform: "linux", + models: [ + { + model: "gpt-5.6-sol", + displayName: "GPT-5.6-Sol", + description: "frontier", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "fast" }, + { reasoningEffort: "high", description: "deep" }, + ], + defaultReasoningEffort: "low", + serviceTiers: [{ id: "priority", name: "Fast", description: "1.5x" }], + defaultServiceTier: null, + isDefault: true, + }, + { + model: "gpt-5.4-mini", + displayName: "GPT-5.4 Mini", + description: "small", + supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "mid" }], + defaultReasoningEffort: "medium", + serviceTiers: [], + defaultServiceTier: null, + isDefault: false, + }, + ], + modelProviders: [], + permissionProfiles: [], + features: [], + requirements: null, + }, + validation: { valid: true, issues: [] }, + } as unknown as EditableConfigSnapshot; +} + +function fakeUi(read = vi.fn(async () => snapshot()), update = vi.fn(async () => ({}))) { + const posts: SlackPostOptions[] = []; + const updates: SlackUpdateOptions[] = []; + const api: SlackMessagingApi = { + async postMessage(options) { + posts.push(options); + return "1700.1"; + }, + async updateMessage(options) { + updates.push(options); + }, + async uploadFile() {}, + async postEphemeral() {}, + }; + const ui = new SlackConfigUi(api, { read, update }, new Logger("error")); + return { ui, posts, updates, read, update }; +} + +describe("overviewScreen", () => { + it("lists current values and one button per setting", () => { + const { text, blocks } = overviewScreen(snapshot()); + expect(text).toBe("Codex settings"); + const section = blocks[0]; + expect(section?.type).toBe("section"); + if (section?.type === "section") { + expect(section.text.text).toContain("*Model*: gpt-5.6-sol"); + expect(section.text.text).toContain("*Speed*: priority"); + expect(section.text.text).toContain("shares these settings"); + } + const buttons = blocks + .filter((block) => block.type === "actions") + .flatMap((block) => (block.type === "actions" ? block.elements : [])); + expect(buttons.map((button) => button.value)).toContain("pick:model"); + expect(buttons).toHaveLength(6); + }); +}); + +describe("pickerScreen", () => { + it("marks the current option and offers a default", () => { + const { blocks } = pickerScreen(snapshot(), "service_tier"); + const buttons = blocks + .filter((block) => block.type === "actions") + .flatMap((block) => (block.type === "actions" ? block.elements : [])); + expect(buttons.map((button) => button.text.text)).toEqual([ + "✓ Fast", + "standard (default)", + "← Back", + ]); + expect(buttons[1]?.value).toBe("set:service_tier:__default__"); + }); + + it("derives effort options from the selected model", () => { + const { blocks } = pickerScreen(snapshot(), "model_reasoning_effort"); + const buttons = blocks + .filter((block) => block.type === "actions") + .flatMap((block) => (block.type === "actions" ? block.elements : [])); + expect(buttons.map((button) => button.text.text)).toEqual([ + "low", + "high", + "✓ default (low)", + "← Back", + ]); + }); + + it("warns about the container on the sandbox screen", () => { + const { blocks } = pickerScreen(snapshot(), "sandbox_mode"); + const section = blocks[0]; + if (section?.type === "section") { + expect(section.text.text).toContain("danger-full-access executes commands reliably"); + } else { + expect.unreachable("first block must be a section"); + } + }); +}); + +describe("SlackConfigUi.handleAction", () => { + it("applies a chosen value with the snapshot version and re-renders", async () => { + const { ui, updates, update } = fakeUi(); + await ui.handleAction("set:web_search:cached", "D1", "1700.5"); + expect(update).toHaveBeenCalledWith({ + expectedVersion: "v42", + values: { web_search: "cached" }, + }); + const rendered = updates.at(-1); + expect(rendered?.ts).toBe("1700.5"); + const section = rendered?.blocks?.[0]; + if (section?.type === "section") { + expect(section.text.text).toContain("✅ Web search updated."); + } + }); + + it("maps the default option to null", async () => { + const { ui, update } = fakeUi(); + await ui.handleAction("set:service_tier:__default__", "D1", "1700.5"); + expect(update).toHaveBeenCalledWith({ + expectedVersion: "v42", + values: { service_tier: null }, + }); + }); + + it("shows the picker for a field and returns to the menu", async () => { + const { ui, updates } = fakeUi(); + await ui.handleAction("pick:model", "D1", "1700.5"); + expect(updates.at(-1)?.text).toBe("Codex settings — Model"); + await ui.handleAction("menu", "D1", "1700.5"); + expect(updates.at(-1)?.text).toBe("Codex settings"); + }); + + it("surfaces update failures as a status line", async () => { + const failing = vi.fn(async () => { + throw new Error("version conflict"); + }); + const { ui, updates } = fakeUi(undefined, failing); + await ui.handleAction("set:web_search:live", "D1", "1700.5"); + const section = updates.at(-1)?.blocks?.[0]; + if (section?.type === "section") { + expect(section.text.text).toContain("⚠️ version conflict"); + } else { + expect.unreachable("expected a rendered section"); + } + }); + + it("ignores malformed action values", async () => { + const { ui, updates, update } = fakeUi(); + await ui.handleAction("set:not_a_field:x", "D1", "1700.5"); + await ui.handleAction("garbage", "D1", "1700.5"); + expect(update).not.toHaveBeenCalled(); + expect(updates).toHaveLength(0); + }); +}); From 3cf3f06914ff37f9ad1e0b029963d7028681a0de Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 15:43:22 +0300 Subject: [PATCH 08/15] Deliver Slack answers as separate messages and fix truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack rejects chat.update/postMessage payloads far below the documented 40k ceiling (msg_too_long observed at 12k), which both truncated long answers and, because delivery shared one try block, dropped every remaining chunk after the first failure. The message limit drops to 3,900 characters and each chunk now posts independently, with a notice when parts fail. The final answer no longer silently edits the thinking message — the progress message freezes without the streaming cursor and the answer arrives as fresh messages that actually notify. Markdown tables render as aligned monospace blocks instead of raw pipes. --- src/channels/slack/format.ts | 66 ++++++++++++++++++++++++++++++ src/channels/slack/reply.ts | 78 ++++++++++++++++++++---------------- test/slack-format.test.ts | 10 +++++ test/slack-reply.test.ts | 30 ++++++++++++-- 4 files changed, 145 insertions(+), 39 deletions(-) diff --git a/src/channels/slack/format.ts b/src/channels/slack/format.ts index 6d5242a..9a92b87 100644 --- a/src/channels/slack/format.ts +++ b/src/channels/slack/format.ts @@ -46,6 +46,72 @@ function splitByCodeFence(text: string): readonly Segment[] { } function convertProse(text: string): string { + // Slack has no table rendering: markdown tables become aligned monospace + // blocks, and everything else flows through the inline conversions. + const lines = text.split("\n"); + const parts: string[] = []; + let prose: string[] = []; + const flushProse = (): void => { + if (prose.length > 0) { + parts.push(convertRichProse(prose.join("\n"))); + prose = []; + } + }; + let index = 0; + while (index < lines.length) { + const line = lines[index]; + if (line === undefined) break; + if (isTableLine(line) && isTableLine(lines[index + 1])) { + flushProse(); + const tableLines: string[] = []; + for (; index < lines.length; index += 1) { + const candidate = lines[index]; + if (candidate === undefined || !isTableLine(candidate)) break; + tableLines.push(candidate); + } + parts.push(renderTable(tableLines)); + continue; + } + prose.push(line); + index += 1; + } + flushProse(); + return parts.join("\n"); +} + +function isTableLine(line: string | undefined): boolean { + return line !== undefined && /^\s*\|.*\|\s*$/u.test(line); +} + +function renderTable(tableLines: readonly string[]): string { + const rows = tableLines + .map((line) => + line + .trim() + .replace(/^\|/u, "") + .replace(/\|$/u, "") + .split("|") + .map((cell) => cell.trim()), + ) + .filter((cells) => !cells.every((cell) => cell.length === 0 || /^:?-+:?$/u.test(cell))); + const widths: number[] = []; + for (const row of rows) { + row.forEach((cell, column) => { + widths[column] = Math.max(widths[column] ?? 0, cell.length); + }); + } + const body = rows + .map((row) => + row + .map((cell, column) => cell.padEnd(widths[column] ?? 0)) + .join(" ") + .trimEnd(), + ) + .join("\n"); + return `\`\`\`\n${escapeSlackEntities(body)}\n\`\`\``; +} + +function convertRichProse(text: string): string { const spans = splitByInlineCode(text); return spans .map((span) => diff --git a/src/channels/slack/reply.ts b/src/channels/slack/reply.ts index 1b22eee..c541a4a 100644 --- a/src/channels/slack/reply.ts +++ b/src/channels/slack/reply.ts @@ -13,8 +13,13 @@ import { formatThinkingBlock, splitMessageText } from "../progress.js"; import { escapeSlackEntities, markdownToMrkdwn } from "./format.js"; import type { SlackDeliveryTarget } from "./references.js"; -/** Slack truncates around 40k characters; shorter chunks stay readable. */ -export const slackTextLimit = 12_000; +/** + * Slack's documented ceiling is 40k characters, but chat.update and + * chat.postMessage reject far shorter payloads with msg_too_long in practice + * (observed at 12k on 2026-07-28). 3,900 stays under the reliable 4k mark and + * matches what Slack renders without collapsing. + */ +export const slackTextLimit = 3_900; export type SlackBlock = | { @@ -374,43 +379,46 @@ export class SlackReplyStream implements OutboundStream { await this.#draftInFlight?.catch(() => undefined); const chunks = text.length === 0 ? [] : splitMessageText(markdownToMrkdwn(text), slackTextLimit); - const [first, ...rest] = chunks; - try { - if (first === undefined) { - // Nothing to say: freeze the progress message without the cursor. - if (this.#messageTs !== undefined) { - await this.#api.updateMessage({ - channel: this.#channel, - ts: this.#messageTs, - text: escapeSlackEntities(formatThinkingBlock(this.#progress)), - }); - } - } else if (this.#messageTs === undefined) { - for (const chunk of chunks) { - await this.#api.postMessage({ - channel: this.#channel, - text: chunk, - ...threadOption(this.#threadTs), - }); - } - } else { - await this.#api.updateMessage({ + // Freeze the progress message without the streaming cursor. The answer + // itself arrives as separate messages below: a silent edit of the + // thinking message never notifies anyone, and a failed edit must not + // take the answer down with it. + if (this.#messageTs !== undefined) { + await this.#api + .updateMessage({ channel: this.#channel, ts: this.#messageTs, - text: first, - }); - for (const chunk of rest) { - await this.#api.postMessage({ - channel: this.#channel, - text: chunk, - ...threadOption(this.#threadTs), + text: escapeSlackEntities(formatThinkingBlock(this.#progress)), + }) + .catch((error: unknown) => { + this.#logger.debug("Slack progress freeze failed", { + error: error instanceof Error ? error.message : String(error), }); - } + }); + } + let undelivered = 0; + for (const chunk of chunks) { + try { + await this.#api.postMessage({ + channel: this.#channel, + text: chunk, + ...threadOption(this.#threadTs), + }); + } catch (error) { + undelivered += 1; + this.#logger.warn("Slack final text delivery failed", { + error: error instanceof Error ? error.message : String(error), + }); } - } catch (error) { - this.#logger.warn("Slack final text delivery failed", { - error: error instanceof Error ? error.message : String(error), - }); + } + if (undelivered > 0) { + await this.#api + .postMessage({ + channel: this.#channel, + text: `⚠️ ${undelivered} part${undelivered === 1 ? "" : "s"} of the reply could not be delivered.`, + ...threadOption(this.#threadTs), + }) + .catch(() => undefined); } await sendSlackAttachments(this.#api, this.#channel, this.#threadTs, attachments, this.#logger); } diff --git a/test/slack-format.test.ts b/test/slack-format.test.ts index fa58da8..4104832 100644 --- a/test/slack-format.test.ts +++ b/test/slack-format.test.ts @@ -57,6 +57,16 @@ describe("markdownToMrkdwn", () => { it("does not treat multi-line asterisk pairs as italic", () => { expect(markdownToMrkdwn("2 * 3\n4 * 5")).toBe("2 * 3\n4 * 5"); }); + + it("renders markdown tables as aligned monospace blocks", () => { + const input = "| Result | Clicks |\n|---|---:|\n| Video | 107 |\n| Audio | 30 |"; + expect(markdownToMrkdwn(input)).toBe("```\nResult Clicks\nVideo 107\nAudio 30\n```"); + }); + + it("keeps prose around tables intact", () => { + const input = "Downloads:\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nDone **ok**"; + expect(markdownToMrkdwn(input)).toBe("Downloads:\n\n```\nA B\n1 2\n```\n\nDone *ok*"); + }); }); describe("escapeSlackEntities", () => { diff --git a/test/slack-reply.test.ts b/test/slack-reply.test.ts index 5e59212..0602bdf 100644 --- a/test/slack-reply.test.ts +++ b/test/slack-reply.test.ts @@ -130,10 +130,11 @@ describe("SlackResponder", () => { const text = `${"a".repeat(12_500)}\n${"b".repeat(300)}`; await responder(api, "https://hooks.slack.com/respond", fetchMock).sendText(text); expect(calls.posts).toHaveLength(1); - expect(calls.posts[0]?.text).toBe("a".repeat(12_000)); + expect(calls.posts[0]?.text).toBe("a".repeat(3_900)); const [, init] = fetchMock.mock.calls[0] ?? []; const body = JSON.parse(String(init?.body)) as { text: string }; - expect(body.text).toBe(`${"a".repeat(500)}\n${"b".repeat(300)}`); + // The ephemeral webhook fallback carries at most one message worth of text. + expect(body.text).toBe("a".repeat(3_900)); }); }); @@ -142,7 +143,7 @@ describe("SlackReplyStream", () => { return new SlackReplyStream(api, "C1", "1699.5", new Logger("error")); } - it("posts a progress message and replaces it with the final text", async () => { + it("freezes the progress message and posts the answer separately", async () => { const { api, calls } = fakeApi(); const reply = stream(api); await reply.start({ summary: "Reading files", actions: [], plan: [] }); @@ -151,7 +152,28 @@ describe("SlackReplyStream", () => { expect(calls.posts[0]?.text).toContain("▌"); await reply.complete("All **done**"); expect(calls.updates).toHaveLength(1); - expect(calls.updates[0]).toMatchObject({ channel: "C1", text: "All *done*" }); + expect(calls.updates[0]?.text).toContain("Reading files"); + expect(calls.updates[0]?.text.endsWith("▌")).toBe(false); + expect(calls.posts).toHaveLength(2); + expect(calls.posts[1]).toMatchObject({ channel: "C1", threadTs: "1699.5", text: "All *done*" }); + }); + + it("delivers the remaining chunks even when one post fails", async () => { + let posted = 0; + const { api, calls } = fakeApi({ + postMessage: async (options) => { + posted += 1; + if (posted === 2) throw new Error("msg_too_long"); + calls.posts.push(options); + return `1700.${posted}`; + }, + }); + const reply = stream(api); + await reply.start(); + await reply.complete(`${"a".repeat(4_000)}\n${"b".repeat(4_000)}\n${"c".repeat(300)}`); + const texts = calls.posts.map((post) => post.text); + expect(texts.some((text) => text.includes("c".repeat(300)))).toBe(true); + expect(texts.at(-1)).toContain("could not be delivered"); }); it("throttles draft updates", async () => { From be7949a3ac4623a4b976b96e2d994c756230bf65 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 16:15:55 +0300 Subject: [PATCH 09/15] Read the surrounding thread when first mentioned in it A mention inside an existing thread calls the bot into a running discussion. The channel now fetches the earlier thread messages through conversations.replies (up to 100, oldest dropped over an 8k character budget) and prefixes them as context for Codex, with display names resolved and the triggering message excluded. Commands and already active threads skip the fetch, and a fetch failure degrades to the bare message. --- docs/slack.md | 5 ++- src/channels/slack/channel.ts | 63 +++++++++++++++++++++++++++++++++-- src/channels/slack/message.ts | 41 +++++++++++++++++++++++ src/channels/slack/reply.ts | 6 ++++ test/slack-config-ui.test.ts | 3 ++ test/slack-message.test.ts | 42 +++++++++++++++++++++++ test/slack-reply.test.ts | 3 ++ 7 files changed, 160 insertions(+), 3 deletions(-) diff --git a/docs/slack.md b/docs/slack.md index 993d251..f9c2370 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -11,7 +11,10 @@ What works in Slack: final answer, exchange file attachments. - Channels and group DMs: mention the bot (`@Telex fix the build`) and it answers in a thread. Follow-ups inside that thread need no further mention; - each thread is its own Codex conversation. + each thread is its own Codex conversation. When first mentioned inside an + existing thread, the bot reads the earlier thread messages (up to 100, + newest-biased) as context, so it understands the discussion it was called + into. - Approvals: when Codex asks for confirmation, the question arrives as Slack buttons. - Scheduled runs: results are delivered to the channel or thread that created diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts index 74ec207..db65908 100644 --- a/src/channels/slack/channel.ts +++ b/src/channels/slack/channel.ts @@ -21,9 +21,11 @@ import { downloadSlackFile, SlackFileDownloadError } from "./file.js"; import { escapeSlackEntities } from "./format.js"; import { describeSlackFile, + formatThreadContext, normalizeSlackMessage, routeSlackMessage, type SlackMessageEvent, + type SlackThreadMessage, slackAttachmentKind, } from "./message.js"; import { @@ -378,9 +380,12 @@ export class SlackChannel implements MessagingChannel { ? caption : attachments.map((attachment) => `[Attached: ${attachment.description}]`).join("\n"); if (text.length === 0) return; + const threadKey = `${event.channel}:${route.conversationSuffix}`; + const threadWasActive = this.#activeThreads.has(threadKey); if (event.channel_type !== "im") { - this.rememberActiveThread(`${event.channel}:${route.conversationSuffix}`); + this.rememberActiveThread(threadKey); } + const contextualText = await this.withThreadContext(event, botUserId, threadWasActive, text); const responder = new SlackResponder( this.#api, event.channel, @@ -415,7 +420,7 @@ export class SlackChannel implements MessagingChannel { id: sender, displayName: await this.displayName(sender), }, - text, + text: contextualText, attachments, responder, }; @@ -427,6 +432,56 @@ export class SlackChannel implements MessagingChannel { } } + /** + * A first mention inside an existing thread calls the bot into a running + * discussion; fetch the earlier messages so Codex sees what it is about. + * Commands stay bare — a context prefix would defeat command parsing. + */ + private async withThreadContext( + event: SlackMessageEvent, + botUserId: string, + threadWasActive: boolean, + text: string, + ): Promise { + if ( + event.channel_type === "im" || + threadWasActive || + event.thread_ts === undefined || + event.thread_ts === event.ts || + event.text?.includes(`<@${botUserId}>`) !== true || + parseTextCommand(text) !== undefined + ) { + return text; + } + try { + const replies = await this.#api.fetchThreadReplies(event.channel, event.thread_ts, 100); + const names = new Map(); + for (const message of replies) { + if (message.user !== undefined && !names.has(message.user)) { + names.set( + message.user, + message.user === botUserId ? "Telex (this bot)" : await this.displayName(message.user), + ); + } + } + const context = formatThreadContext(replies, event.ts, (message) => + message.user !== undefined + ? (names.get(message.user) ?? message.user) + : message.bot_id !== undefined + ? "bot" + : "unknown", + ); + if (context === undefined) return text; + return `[Context — earlier messages in this Slack thread:]\n${context}\n[End of thread context]\n\n${text}`; + } catch (error) { + this.#logger.warn("Could not fetch Slack thread context", { + threadTs: event.thread_ts, + error: errorMessage(error), + }); + return text; + } + } + private async handleSlashCommand(payload: SlackSlashCommandPayload): Promise { const handler = this.#handler; const userId = payload.user_id; @@ -756,6 +811,10 @@ function webMessagingApi(web: WebClient): SlackMessagingApi { text: options.text, }); }, + async fetchThreadReplies(channel, threadTs, limit) { + const result = await web.conversations.replies({ channel, ts: threadTs, limit }); + return (result.messages ?? []) as unknown as readonly SlackThreadMessage[]; + }, }; } diff --git a/src/channels/slack/message.ts b/src/channels/slack/message.ts index 9d11742..bec58ae 100644 --- a/src/channels/slack/message.ts +++ b/src/channels/slack/message.ts @@ -80,6 +80,47 @@ export function normalizeSlackMessage( }; } +/** Subset of a `conversations.replies` entry relevant to thread context. */ +export interface SlackThreadMessage { + readonly user?: string; + readonly bot_id?: string; + readonly text?: string; + readonly ts: string; + readonly files?: readonly SlackFile[]; +} + +/** + * Render the earlier messages of a thread as context for Codex, oldest first. + * The triggering message itself is excluded; when the thread exceeds the + * character budget the oldest messages are dropped. + */ +export function formatThreadContext( + messages: readonly SlackThreadMessage[], + triggerTs: string, + nameOf: (message: SlackThreadMessage) => string, + characterBudget = 8_000, +): string | undefined { + const lines: string[] = []; + for (const message of messages) { + if (message.ts === triggerTs) continue; + const text = mrkdwnToPlainText(message.text ?? "").trim(); + const attachments = (message.files ?? []) + .map((file) => `[attached: ${file.name ?? file.title ?? "file"}]`) + .join(" "); + const body = [text, attachments].filter((part) => part.length > 0).join(" "); + if (body.length === 0) continue; + lines.push(`${nameOf(message)}: ${body}`); + } + if (lines.length === 0) return undefined; + let dropped = 0; + while (lines.length > 1 && lines.join("\n").length > characterBudget) { + lines.shift(); + dropped += 1; + } + const parts = dropped === 0 ? lines : [`[${dropped} earlier messages omitted]`, ...lines]; + return parts.join("\n"); +} + export function describeSlackFile(file: SlackFile): string { const name = file.name ?? file.title ?? "attachment"; const metadata = [ diff --git a/src/channels/slack/reply.ts b/src/channels/slack/reply.ts index c541a4a..0e8e010 100644 --- a/src/channels/slack/reply.ts +++ b/src/channels/slack/reply.ts @@ -11,6 +11,7 @@ import type { import type { Logger } from "../../shared/logger.js"; import { formatThinkingBlock, splitMessageText } from "../progress.js"; import { escapeSlackEntities, markdownToMrkdwn } from "./format.js"; +import type { SlackThreadMessage } from "./message.js"; import type { SlackDeliveryTarget } from "./references.js"; /** @@ -69,6 +70,11 @@ export interface SlackMessagingApi { updateMessage(options: SlackUpdateOptions): Promise; uploadFile(options: SlackUploadOptions): Promise; postEphemeral(options: SlackEphemeralOptions): Promise; + fetchThreadReplies( + channel: string, + threadTs: string, + limit: number, + ): Promise; } export type SlackChoiceRequester = ( diff --git a/test/slack-config-ui.test.ts b/test/slack-config-ui.test.ts index 89100a9..924653f 100644 --- a/test/slack-config-ui.test.ts +++ b/test/slack-config-ui.test.ts @@ -92,6 +92,9 @@ function fakeUi(read = vi.fn(async () => snapshot()), update = vi.fn(async () => }, async uploadFile() {}, async postEphemeral() {}, + async fetchThreadReplies() { + return []; + }, }; const ui = new SlackConfigUi(api, { read, update }, new Logger("error")); return { ui, posts, updates, read, update }; diff --git a/test/slack-message.test.ts b/test/slack-message.test.ts index f8a1985..48dc752 100644 --- a/test/slack-message.test.ts +++ b/test/slack-message.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { describeSlackFile, + formatThreadContext, normalizeSlackMessage, routeSlackMessage, type SlackMessageEvent, + type SlackThreadMessage, slackAttachmentKind, } from "../src/channels/slack/message.js"; @@ -115,6 +117,46 @@ describe("normalizeSlackMessage", () => { }); }); +describe("formatThreadContext", () => { + const nameOf = (message: SlackThreadMessage): string => message.user ?? "bot"; + + it("renders earlier messages and excludes the trigger", () => { + const context = formatThreadContext( + [ + { ts: "1", user: "U1", text: "refund request from <user>" }, + { + ts: "2", + user: "U2", + text: "checking mixpanel", + files: [{ id: "F1", name: "report.csv" }], + }, + { ts: "3", user: "U3", text: "<@U0BOT> collect the facts" }, + ], + "3", + nameOf, + ); + expect(context).toBe( + "U1: refund request from \nU2: checking mixpanel [attached: report.csv]", + ); + }); + + it("returns undefined when nothing besides the trigger exists", () => { + expect(formatThreadContext([{ ts: "3", user: "U1", text: "hi" }], "3", nameOf)).toBeUndefined(); + }); + + it("drops the oldest messages over the character budget", () => { + const messages: SlackThreadMessage[] = [ + { ts: "1", user: "U1", text: "x".repeat(80) }, + { ts: "2", user: "U2", text: "y".repeat(80) }, + { ts: "3", user: "U3", text: "z".repeat(80) }, + ]; + const context = formatThreadContext(messages, "9", nameOf, 200); + expect(context).toContain("[1 earlier messages omitted]"); + expect(context).not.toContain("x".repeat(80)); + expect(context).toContain("z".repeat(80)); + }); +}); + describe("slack file helpers", () => { it("classifies attachment kinds", () => { expect(slackAttachmentKind({ id: "F1", mimetype: "image/png" })).toBe("image"); diff --git a/test/slack-reply.test.ts b/test/slack-reply.test.ts index 0602bdf..636a8ac 100644 --- a/test/slack-reply.test.ts +++ b/test/slack-reply.test.ts @@ -46,6 +46,9 @@ function fakeApi(overrides: Partial = {}): { async postEphemeral(options) { calls.ephemerals.push(options); }, + async fetchThreadReplies() { + return []; + }, ...overrides, }; return { api, calls }; From 9da8e6205db00d6a1fb38af97a9a0d302144e5e1 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 16:46:08 +0300 Subject: [PATCH 10/15] Require a mention for every channel-thread message Follow-ups in a thread the bot already answered no longer trigger it: with workspace-wide access, humans discussing inside such a thread had every message routed to Codex. A mention is now required each time; the engaged-thread set survives only to skip re-reading thread history on repeat mentions, and thread context still arrives on the first one. --- docs/slack.md | 23 +++++++++-------- src/channels/slack/channel.ts | 35 +++++++++++--------------- src/channels/slack/message.ts | 11 +++----- test/slack-message.test.ts | 47 +++++++++-------------------------- 4 files changed, 43 insertions(+), 73 deletions(-) diff --git a/docs/slack.md b/docs/slack.md index f9c2370..8277e48 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -10,11 +10,12 @@ What works in Slack: - Direct messages with the bot: send a message, watch live progress, get the final answer, exchange file attachments. - Channels and group DMs: mention the bot (`@Telex fix the build`) and it - answers in a thread. Follow-ups inside that thread need no further mention; - each thread is its own Codex conversation. When first mentioned inside an - existing thread, the bot reads the earlier thread messages (up to 100, - newest-biased) as context, so it understands the discussion it was called - into. + answers in a thread. Every message addressed to the bot needs a mention — + including follow-ups in the same thread — so human discussion around it + stays untouched. Each thread is its own Codex conversation with persistent + context. When first mentioned inside an existing thread, the bot reads the + earlier thread messages (up to 100, newest-biased) as context, so it + understands the discussion it was called into. - Approvals: when Codex asks for confirmation, the question arrives as Slack buttons. - Scheduled runs: results are delivered to the channel or thread that created @@ -137,8 +138,9 @@ App stay off). Restart Telex and check the log for commands and messages from the messages tab* (the manifest above enables it, but workspaces occasionally need a re-toggle), then reload Slack. - **Channel**: invite the bot (`/invite @Telex`), then mention it: - `@Telex what does this repo do?`. The reply opens a thread; keep chatting in - the thread without mentioning it again. + `@Telex what does this repo do?`. The reply opens a thread; address it + there with a mention each time (`@Telex and now check the tests`) — the + thread's Codex conversation continues across mentions. - **Commands**: `/telex help` anywhere, or prefix a command in a mention: `@Telex /new`. In the bot DM, plain `/new` will not reach Telex — Slack intercepts everything that starts with `/` — so use `/telex new`. @@ -154,10 +156,9 @@ App stay off). Restart Telex and check the log for - **Authorization**: messages, commands, and button clicks from users outside `SLACK_ALLOWED_USER_IDS` are ignored (and logged). Scheduled runs re-check the owner against the allowlist before every unattended execution. -- **Threads after a restart**: the "this thread is active" memory is - in-process. After a Telex restart, mention the bot once in an existing - thread to reattach it; the Codex conversation itself is persisted and - continues. +- **Thread context after a restart**: the "already read this thread" memory + is in-process, so the first mention after a Telex restart re-reads the + thread history. The Codex conversation itself is persisted and continues. - **Attachments**: inbound files are downloaded through Slack's private file URLs with the bot token (never sent to third-party hosts); generated files are uploaded back with `files.uploadV2`. Slack voice clips are transcribed diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts index db65908..61ca67c 100644 --- a/src/channels/slack/channel.ts +++ b/src/channels/slack/channel.ts @@ -101,7 +101,7 @@ interface PendingChoice { } const recentEventLimit = 500; -const activeThreadLimit = 500; +const engagedThreadLimit = 500; const displayNameCacheLimit = 500; const membershipCacheLimit = 1_000; /** Deactivations and role changes must take effect without a restart. */ @@ -136,7 +136,8 @@ export class SlackChannel implements MessagingChannel { readonly #attachmentDirectory: string; readonly #logger: Logger; readonly #pendingChoices = new Map(); - readonly #activeThreads = new Set(); + /** Threads the bot already answered in — first mentions there skip the history fetch. */ + readonly #engagedThreads = new Set(); readonly #recentEvents = new Set(); readonly #displayNames = new Map(); /** @@ -292,15 +293,11 @@ export class SlackChannel implements MessagingChannel { message: OutboundMessage, ): Promise { const target = parseSlackDeliveryTarget(targetReference); - // Keep threads that receive scheduled results routable without a mention. - if (target.channelType !== "im" && target.threadTs !== undefined) { - this.rememberActiveThread(`${target.channel}:${target.threadTs}`); - } const published = await publishSlackMessage(this.#api, target, message, this.#logger); const primary = published[0]; if (target.threadTs !== undefined && primary !== undefined) { this.#threadNotifications.set(`${target.channel}:${target.threadTs}`, primary.ts); - trimInsertionOrderedMap(this.#threadNotifications, activeThreadLimit); + trimInsertionOrderedMap(this.#threadNotifications, engagedThreadLimit); } return { publishedMessages: published.map((entry) => slackMessageReference(entry.channel, entry.ts)), @@ -335,9 +332,7 @@ export class SlackChannel implements MessagingChannel { if (typeof event.channel !== "string" || typeof event.ts !== "string") return; if (this.wasRecentlyProcessed(`message:${event.channel}:${event.ts}`)) return; - const route = routeSlackMessage(event, botUserId, (threadRoot) => - this.#activeThreads.has(`${event.channel}:${threadRoot}`), - ); + const route = routeSlackMessage(event, botUserId); const sender = event.user; if (route === undefined || sender === undefined) return; if (!(await this.isUserAllowed(sender))) { @@ -381,11 +376,11 @@ export class SlackChannel implements MessagingChannel { : attachments.map((attachment) => `[Attached: ${attachment.description}]`).join("\n"); if (text.length === 0) return; const threadKey = `${event.channel}:${route.conversationSuffix}`; - const threadWasActive = this.#activeThreads.has(threadKey); + const threadWasEngaged = this.#engagedThreads.has(threadKey); if (event.channel_type !== "im") { - this.rememberActiveThread(threadKey); + this.rememberEngagedThread(threadKey); } - const contextualText = await this.withThreadContext(event, botUserId, threadWasActive, text); + const contextualText = await this.withThreadContext(event, botUserId, threadWasEngaged, text); const responder = new SlackResponder( this.#api, event.channel, @@ -440,12 +435,12 @@ export class SlackChannel implements MessagingChannel { private async withThreadContext( event: SlackMessageEvent, botUserId: string, - threadWasActive: boolean, + threadWasEngaged: boolean, text: string, ): Promise { if ( event.channel_type === "im" || - threadWasActive || + threadWasEngaged || event.thread_ts === undefined || event.thread_ts === event.ts || event.text?.includes(`<@${botUserId}>`) !== true || @@ -641,7 +636,7 @@ export class SlackChannel implements MessagingChannel { const threadRoot = payload.message?.thread_ts ?? messageTs; const conversationSuffix = isDirect ? "main" : threadRoot; const replyThreadTs = isDirect ? undefined : threadRoot; - if (!isDirect) this.rememberActiveThread(`${channelId}:${conversationSuffix}`); + if (!isDirect) this.rememberEngagedThread(`${channelId}:${conversationSuffix}`); const responder = new SlackResponder( this.#api, channelId, @@ -761,10 +756,10 @@ export class SlackChannel implements MessagingChannel { return false; } - private rememberActiveThread(key: string): void { - this.#activeThreads.delete(key); - this.#activeThreads.add(key); - trimInsertionOrdered(this.#activeThreads, activeThreadLimit); + private rememberEngagedThread(key: string): void { + this.#engagedThreads.delete(key); + this.#engagedThreads.add(key); + trimInsertionOrdered(this.#engagedThreads, engagedThreadLimit); } } diff --git a/src/channels/slack/message.ts b/src/channels/slack/message.ts index bec58ae..2a59211 100644 --- a/src/channels/slack/message.ts +++ b/src/channels/slack/message.ts @@ -39,13 +39,13 @@ const handledSubtypes = new Set([undefined, "file_share", "thread_broadcast"]); /** * Decide whether and where to handle a message event. * - * DMs are always handled. In channels and group DMs the bot answers when it - * is mentioned, or when the message continues a thread it already works in. + * DMs are always handled. In channels and group DMs every message for the + * bot needs an explicit mention — including follow-ups in a thread it + * already answered in — so human discussion in the thread stays untouched. */ export function routeSlackMessage( event: SlackMessageEvent, botUserId: string, - isThreadActive: (conversationSuffix: string) => boolean, ): SlackIncomingRoute | undefined { if (!handledSubtypes.has(event.subtype)) return undefined; if (event.bot_id !== undefined || event.user === undefined || event.user === botUserId) { @@ -54,11 +54,8 @@ export function routeSlackMessage( if (event.channel_type === "im") { return { conversationSuffix: "main", replyThreadTs: undefined }; } + if (event.text?.includes(`<@${botUserId}>`) !== true) return undefined; const threadRoot = event.thread_ts ?? event.ts; - const mentioned = event.text?.includes(`<@${botUserId}>`) === true; - if (!mentioned && !(event.thread_ts !== undefined && isThreadActive(event.thread_ts))) { - return undefined; - } return { conversationSuffix: threadRoot, replyThreadTs: threadRoot }; } diff --git a/test/slack-message.test.ts b/test/slack-message.test.ts index 48dc752..0a46cab 100644 --- a/test/slack-message.test.ts +++ b/test/slack-message.test.ts @@ -23,25 +23,15 @@ function event(overrides: Partial): SlackMessageEvent { }; } -const noActiveThreads = (): boolean => false; - describe("routeSlackMessage", () => { it("always handles direct messages without threading", () => { - const route = routeSlackMessage( - event({ channel_type: "im", channel: "D1" }), - botUserId, - noActiveThreads, - ); + const route = routeSlackMessage(event({ channel_type: "im", channel: "D1" }), botUserId); expect(route).toEqual({ conversationSuffix: "main", replyThreadTs: undefined }); }); it("requires a mention in channels", () => { - expect(routeSlackMessage(event({}), botUserId, noActiveThreads)).toBeUndefined(); - const route = routeSlackMessage( - event({ text: `<@${botUserId}> hi` }), - botUserId, - noActiveThreads, - ); + expect(routeSlackMessage(event({}), botUserId)).toBeUndefined(); + const route = routeSlackMessage(event({ text: `<@${botUserId}> hi` }), botUserId); expect(route).toEqual({ conversationSuffix: "1700000000.000100", replyThreadTs: "1700000000.000100", @@ -52,51 +42,38 @@ describe("routeSlackMessage", () => { const route = routeSlackMessage( event({ text: `<@${botUserId}> continue`, thread_ts: "1699.5", ts: "1700.9" }), botUserId, - noActiveThreads, ); expect(route).toEqual({ conversationSuffix: "1699.5", replyThreadTs: "1699.5" }); }); - it("continues active threads without a mention", () => { - const route = routeSlackMessage( - event({ thread_ts: "1699.5", ts: "1700.9" }), - botUserId, - (threadRoot) => threadRoot === "1699.5", - ); - expect(route).toEqual({ conversationSuffix: "1699.5", replyThreadTs: "1699.5" }); + it("ignores thread replies without a mention", () => { + expect( + routeSlackMessage(event({ thread_ts: "1699.5", ts: "1700.9" }), botUserId), + ).toBeUndefined(); }); it("ignores bot echoes and unsupported subtypes", () => { expect( - routeSlackMessage(event({ channel_type: "im", bot_id: "B1" }), botUserId, noActiveThreads), + routeSlackMessage(event({ channel_type: "im", bot_id: "B1" }), botUserId), ).toBeUndefined(); expect( - routeSlackMessage(event({ channel_type: "im", user: botUserId }), botUserId, noActiveThreads), + routeSlackMessage(event({ channel_type: "im", user: botUserId }), botUserId), ).toBeUndefined(); expect( - routeSlackMessage( - event({ channel_type: "im", subtype: "message_changed" }), - botUserId, - noActiveThreads, - ), + routeSlackMessage(event({ channel_type: "im", subtype: "message_changed" }), botUserId), ).toBeUndefined(); const { user: _ignored, ...anonymous } = event({ channel_type: "im" }); - expect(routeSlackMessage(anonymous, botUserId, noActiveThreads)).toBeUndefined(); + expect(routeSlackMessage(anonymous, botUserId)).toBeUndefined(); }); it("handles file_share and thread_broadcast subtypes", () => { expect( - routeSlackMessage( - event({ channel_type: "im", subtype: "file_share" }), - botUserId, - noActiveThreads, - ), + routeSlackMessage(event({ channel_type: "im", subtype: "file_share" }), botUserId), ).toBeDefined(); expect( routeSlackMessage( event({ text: `<@${botUserId}> x`, subtype: "thread_broadcast" }), botUserId, - noActiveThreads, ), ).toBeDefined(); }); From 6152f5046ab8aba73332b8d588efd2b2d245ec69 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 18:57:18 +0300 Subject: [PATCH 11/15] Log Slack activity to stdout Every inbound message logs the sender (user ID and display name), conversation, command, and text; per-turn child loggers carry that identity into the stream, which now mirrors the run: each Codex tool call once, reasoning summaries as they change, and the delivered answer. Rollouts never recorded who triggered a turn, so operators had no way to attribute usage. --- src/channels/slack/channel.ts | 35 +++++++++++++++++++++++++++++------ src/channels/slack/reply.ts | 25 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts index 61ca67c..a991b9b 100644 --- a/src/channels/slack/channel.ts +++ b/src/channels/slack/channel.ts @@ -41,6 +41,7 @@ import { type SlackChoiceRequester, type SlackMessagingApi, SlackResponder, + truncateForLog, } from "./reply.js"; export const slackSlashCommandHelp = [ @@ -234,6 +235,13 @@ export class SlackChannel implements MessagingChannel { const command = inbound.command ?? (inbound.attachments.length === 0 ? parseTextCommand(inbound.text) : undefined); + this.#logger.info("Slack message received", { + userId, + userName: inbound.sender.displayName, + conversation: inbound.address.key, + ...(command === undefined ? {} : { command: command.name }), + text: truncateForLog(inbound.text, 1_000), + }); if (command !== undefined && adminCommands.has(command.name) && !this.isAdmin(userId)) { await inbound.responder.sendText( "This command changes Telex for everyone using it and is limited to its admins.", @@ -381,13 +389,18 @@ export class SlackChannel implements MessagingChannel { this.rememberEngagedThread(threadKey); } const contextualText = await this.withThreadContext(event, botUserId, threadWasEngaged, text); + const senderName = await this.displayName(sender); const responder = new SlackResponder( this.#api, event.channel, route.replyThreadTs, sender, this.requestChoice, - this.#logger, + this.#logger.child({ + userId: sender, + userName: senderName, + conversation: `slack:${event.channel}:${route.conversationSuffix}`, + }), ); const inbound: InboundMessage = { id: event.ts, @@ -413,7 +426,7 @@ export class SlackChannel implements MessagingChannel { }), sender: { id: sender, - displayName: await this.displayName(sender), + displayName: senderName, }, text: contextualText, attachments, @@ -514,13 +527,18 @@ export class SlackChannel implements MessagingChannel { return; } const command = { name, args: restParts.join(" ") }; + const commandSenderName = payload.user_name ?? (await this.displayName(userId)); const responder = new SlackResponder( this.#api, channelId, undefined, userId, this.requestChoice, - this.#logger, + this.#logger.child({ + userId, + userName: commandSenderName, + conversation: `slack:${channelId}:main`, + }), payload.response_url, ); const inbound: InboundMessage = { @@ -533,7 +551,7 @@ export class SlackChannel implements MessagingChannel { }, sender: { id: userId, - displayName: payload.user_name ?? (await this.displayName(userId)), + displayName: commandSenderName, }, text: `/${command.name}${command.args.length === 0 ? "" : ` ${command.args}`}`, command, @@ -637,13 +655,18 @@ export class SlackChannel implements MessagingChannel { const conversationSuffix = isDirect ? "main" : threadRoot; const replyThreadTs = isDirect ? undefined : threadRoot; if (!isDirect) this.rememberEngagedThread(`${channelId}:${conversationSuffix}`); + const actorName = await this.displayName(userId); const responder = new SlackResponder( this.#api, channelId, replyThreadTs, userId, this.requestChoice, - this.#logger, + this.#logger.child({ + userId, + userName: actorName, + conversation: `slack:${channelId}:${conversationSuffix}`, + }), ); const inbound: InboundMessage = { id: `action:${crypto.randomUUID()}`, @@ -657,7 +680,7 @@ export class SlackChannel implements MessagingChannel { reference: slackMessageReference(channelId, messageTs), sender: { id: userId, - displayName: await this.displayName(userId), + displayName: actorName, }, text: `/${command.name}${command.args.length === 0 ? "" : ` ${command.args}`}`, command, diff --git a/src/channels/slack/reply.ts b/src/channels/slack/reply.ts index 0e8e010..219cb5f 100644 --- a/src/channels/slack/reply.ts +++ b/src/channels/slack/reply.ts @@ -166,6 +166,11 @@ function threadOption(threadTs: string | undefined): ThreadOption { return threadTs === undefined ? {} : { threadTs }; } +export function truncateForLog(text: string, limit = 1_500): string { + const compact = text.trim(); + return compact.length <= limit ? compact : `${compact.slice(0, limit - 1)}…`; +} + export async function publishSlackMessage( api: SlackMessagingApi, target: SlackDeliveryTarget, @@ -234,6 +239,7 @@ export class SlackResponder implements MessageResponder { } public async sendText(text: string, options?: SendOptions): Promise { + this.#logger.info("Slack reply", { text: truncateForLog(text) }); const chunks = splitMessageText(markdownToMrkdwn(text), slackTextLimit); let posted = 0; try { @@ -297,6 +303,8 @@ export class SlackReplyStream implements OutboundStream { #closing = false; #completing: Promise | undefined; #completed = false; + #loggedActions = 0; + #lastReasoning = ""; readonly #api: SlackMessagingApi; readonly #channel: string; readonly #threadTs: string | undefined; @@ -346,6 +354,17 @@ export class SlackReplyStream implements OutboundStream { public setProgress(progress: ProgressSnapshot): void { if (this.#closing || this.#completed) return; + // Mirror the run into stdout: every tool call once, and reasoning + // summaries as they change. + for (const action of progress.actions.slice(this.#loggedActions)) { + this.#logger.info("Codex tool call", { action: action.label }); + } + this.#loggedActions = Math.max(this.#loggedActions, progress.actions.length); + const reasoning = (progress.summary ?? progress.message)?.trim(); + if (reasoning !== undefined && reasoning.length > 0 && reasoning !== this.#lastReasoning) { + this.#lastReasoning = reasoning; + this.#logger.info("Codex reasoning", { text: truncateForLog(reasoning, 600) }); + } this.#progress = progress; this.scheduleDraft(); } @@ -383,6 +402,12 @@ export class SlackReplyStream implements OutboundStream { this.clearTimer(); await this.#starting?.catch(() => undefined); await this.#draftInFlight?.catch(() => undefined); + if (text.length > 0) { + this.#logger.info("Codex answer delivered", { + chars: text.length, + text: truncateForLog(text), + }); + } const chunks = text.length === 0 ? [] : splitMessageText(markdownToMrkdwn(text), slackTextLimit); // Freeze the progress message without the streaming cursor. The answer From 58af771a18dc1608c79a00e70ee92df569883d12 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Tue, 28 Jul 2026 19:00:55 +0300 Subject: [PATCH 12/15] Use neutral fixture identifiers in Slack tests --- test/env.test.ts | 6 +++--- test/slack-authorization.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/env.test.ts b/test/env.test.ts index 9ca8017..6e2b2f3 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -84,9 +84,9 @@ describe("loadAppConfig", () => { SLACK_BOT_TOKEN: "xoxb-123", SLACK_APP_TOKEN: "xapp-1", SLACK_ALLOWED_USER_IDS: "*", - SLACK_ADMIN_USER_IDS: "U0AFAK0FB46", + SLACK_ADMIN_USER_IDS: "U0ADMIN0AAA", }); - expect([...(config.slack?.adminUserIds ?? [])]).toEqual(["U0AFAK0FB46"]); + expect([...(config.slack?.adminUserIds ?? [])]).toEqual(["U0ADMIN0AAA"]); expect( loadAppConfig({ SLACK_BOT_TOKEN: "xoxb-123", @@ -97,7 +97,7 @@ describe("loadAppConfig", () => { }); it("rejects an admin list without the Slack connector", () => { - expect(() => loadAppConfig({ ...required, SLACK_ADMIN_USER_IDS: "U0AFAK0FB46" })).toThrow( + expect(() => loadAppConfig({ ...required, SLACK_ADMIN_USER_IDS: "U0ADMIN0AAA" })).toThrow( /requires the Slack connector/, ); }); diff --git a/test/slack-authorization.test.ts b/test/slack-authorization.test.ts index 1c56794..7894cf4 100644 --- a/test/slack-authorization.test.ts +++ b/test/slack-authorization.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { isWorkspaceMember } from "../src/channels/slack/authorization.js"; -const team = "T0BRASK"; +const team = "T0EXAMPLE"; describe("isWorkspaceMember", () => { it("accepts a regular member of the bot's workspace", () => { From 15fc844440e804ff7fe9accaef27427576b66efa Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Fri, 31 Jul 2026 15:10:28 +0700 Subject: [PATCH 13/15] Keep local file paths out of chat links Codex referenced workspace files as markdown links to container paths, which render as dead links in Slack and Telegram. The remote-client context now instructs Codex to cite code as repo-relative inline paths or full repository URLs, and the Slack converter renders any non-URL link target as inline code instead of a broken link. --- src/channels/slack/format.ts | 6 +++++- src/codex/service.ts | 5 +++++ test/codex-input.test.ts | 6 ++++++ test/slack-format.test.ts | 7 +++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/channels/slack/format.ts b/src/channels/slack/format.ts index 9a92b87..46cc31a 100644 --- a/src/channels/slack/format.ts +++ b/src/channels/slack/format.ts @@ -138,11 +138,15 @@ function splitByInlineCode( function convertPlainProse(text: string): string { let result = escapeSlackEntities(text); // Markdown images and links become Slack links. The label drops `|`, which - // Slack reserves as its own separator. + // Slack reserves as its own separator. Local filesystem targets cannot be + // opened by the reader, so they render as inline code instead of a link. result = result.replaceAll( /!?\[([^\]\n]*)\]\((\S+?)\)/gu, (_match, label: string, url: string) => { const safeLabel = label.replaceAll("|", "/").trim(); + if (!/^(?:https?|mailto):/iu.test(url)) { + return safeLabel.length === 0 ? `\`${url}\`` : `${safeLabel} (\`${url}\`)`; + } return safeLabel.length === 0 ? `<${url}>` : `<${url}|${safeLabel}>`; }, ); diff --git a/src/codex/service.ts b/src/codex/service.ts index d6869c7..81328ec 100644 --- a/src/codex/service.ts +++ b/src/codex/service.ts @@ -1114,6 +1114,11 @@ Host-local UI is not visible or accessible to the user: - Do not assume the user can see the host screen, clipboard, notifications, or spawned windows. - Explicitly link files intended for the user in the final response so Telex can deliver them. +When referencing code or files in replies: +- Never format a local filesystem path as a markdown link target; the user cannot open it. This includes workspace paths and home-relative paths. +- Refer to code with a repository-relative path and line number as inline code, for example \`src/app/main.ts:42\`. +- When you know the repository's public remote (for example on GitHub) and the relevant branch or commit, prefer a full https URL to the file and line so the reference is clickable in chat. + All normal Codex filesystem, shell, network, approval, and project behavior remains unchanged. Telex changes only how the user communicates with Codex.`, }, }; diff --git a/test/codex-input.test.ts b/test/codex-input.test.ts index 94dad93..771df43 100644 --- a/test/codex-input.test.ts +++ b/test/codex-input.test.ts @@ -95,4 +95,10 @@ describe("createRemoteClientContext", () => { }); expect(discord["telex.remote-client"]?.value).not.toContain("Telegram"); }); + + it("tells Codex not to link local filesystem paths in replies", () => { + const value = createRemoteClientContext("slack")["telex.remote-client"]?.value ?? ""; + expect(value).toContain("Never format a local filesystem path as a markdown link target"); + expect(value).toContain("repository-relative path and line number"); + }); }); diff --git a/test/slack-format.test.ts b/test/slack-format.test.ts index 4104832..d8bd08a 100644 --- a/test/slack-format.test.ts +++ b/test/slack-format.test.ts @@ -24,6 +24,13 @@ describe("markdownToMrkdwn", () => { ); }); + it("renders local path links as inline code instead of broken links", () => { + expect(markdownToMrkdwn("see [code](/data/workspace/app/src/main.ts:42) here")).toBe( + "see code (`/data/workspace/app/src/main.ts:42`) here", + ); + expect(markdownToMrkdwn("[](src/local/file.tsx:7)")).toBe("`src/local/file.tsx:7`"); + }); + it("rewrites links and images", () => { expect(markdownToMrkdwn("See [the docs](https://example.com/a) now")).toBe( "See now", From 2323320c96b98c1bbe441cf9b3c5eb59729c2f99 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Fri, 31 Jul 2026 16:03:48 +0700 Subject: [PATCH 14/15] Address review findings - Comment out the Telegram samples in .env.example: uncommented placeholders fail validation in a Slack-only copy. - Let the entrypoint start as root only to chown freshly created volumes (non-recursively), then drop to the telex user via runuser; fresh named volumes are root-owned and previously broke first start. - Install gh for the image architecture instead of hard-coded amd64. - Cache display names only on successful lookups so a transient users.info failure does not pin the raw ID until restart. - Derive slash-command isDirect from the channel ID prefix; a channel literally named directmessage could spoof channel_name. - Require https in the Slack file-host check, cap downloads at 100 MB up front, and count streamed bytes so a wrong size cannot bypass it. - Clamp the streaming preview and the frozen progress text so entity escaping cannot push a chat.update past the message limit. - Drop unencodable command buttons with a warning instead of failing the whole scheduled delivery; bound both response webhooks with a 10s abort timeout; resolve thread-context names concurrently. - Extend the fenced-code and final-delivery tests per review. --- .env.example | 4 +-- Dockerfile | 5 ++-- docker/entrypoint.sh | 9 +++++++ src/channels/slack/channel.ts | 43 ++++++++++++++++++----------- src/channels/slack/file.ts | 35 ++++++++++++++++++++++-- src/channels/slack/reply.ts | 51 +++++++++++++++++++++++------------ test/slack-format.test.ts | 6 +++-- test/slack-reply.test.ts | 15 ++++++----- 8 files changed, 122 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index 3fe33ee..423496e 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ # Telegram connector. Create the bot with @BotFather. Optional when the Slack # connector below is configured; at least one connector must be set. -TELEGRAM_BOT_TOKEN=123456:replace-me +# TELEGRAM_BOT_TOKEN=123456:replace-me # Comma-separated Telegram numeric user IDs. Messages from everyone else are # ignored, including guest-mode mentions. Set together with the bot token. -TELEGRAM_ALLOWED_USER_IDS=123456789 +# TELEGRAM_ALLOWED_USER_IDS=123456789 # Optional Slack connector (Socket Mode). Set all three together to enable it; # see docs/slack.md for the full setup guide, including a pasteable app manifest. diff --git a/Dockerfile b/Dockerfile index 765fe9c..5bda8c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl git ripgrep \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ > /etc/apt/sources.list.d/github-cli.list \ && apt-get update \ && apt-get install -y --no-install-recommends gh \ @@ -28,6 +28,7 @@ RUN chmod 0755 /usr/local/bin/telex-entrypoint ENV TELEX_DATA_DIR=/data/telex \ CODEX_WORKSPACE=/data/workspace VOLUME /data -USER telex +# The entrypoint starts as root only to take ownership of freshly created +# volumes, then drops to the unprivileged telex user before running Telex. ENTRYPOINT ["telex-entrypoint"] CMD ["node", "dist/index.js"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 6342d9a..cfcc8ff 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -6,6 +6,15 @@ set -e data_dir="${TELEX_DATA_DIR:-/data/telex}" workspace="${CODEX_WORKSPACE:-/data/workspace}" + +# Fresh named volumes are created owned by root; take ownership of the state +# roots (non-recursively), then continue as the unprivileged telex user. +if [ "$(id -u)" = "0" ]; then + mkdir -p "${data_dir}" "${workspace}" + chown telex:telex /data "${data_dir}" "${workspace}" 2>/dev/null || true + exec env HOME=/home/telex runuser -u telex -- "$0" "$@" +fi + mkdir -p "${data_dir}/codex-home" "${workspace}" config="${data_dir}/codex-home/config.toml" diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts index a991b9b..5e3861c 100644 --- a/src/channels/slack/channel.ts +++ b/src/channels/slack/channel.ts @@ -107,6 +107,7 @@ const displayNameCacheLimit = 500; const membershipCacheLimit = 1_000; /** Deactivations and role changes must take effect without a restart. */ const membershipCacheTtlMs = 10 * 60 * 1_000; +const webhookTimeoutMs = 10_000; /** Commands that act on one conversation and therefore need a thread in channels. */ const conversationScopedCommands = new Set(["new", "back", "stop", "schedules", "continue"]); @@ -463,15 +464,23 @@ export class SlackChannel implements MessagingChannel { } try { const replies = await this.#api.fetchThreadReplies(event.channel, event.thread_ts, 100); - const names = new Map(); - for (const message of replies) { - if (message.user !== undefined && !names.has(message.user)) { - names.set( - message.user, - message.user === botUserId ? "Telex (this bot)" : await this.displayName(message.user), - ); - } - } + const uniqueUsers = [ + ...new Set( + replies + .map((message) => message.user) + .filter((user): user is string => user !== undefined), + ), + ]; + const names = new Map( + await Promise.all( + uniqueUsers.map( + async (user): Promise<[string, string]> => [ + user, + user === botUserId ? "Telex (this bot)" : await this.displayName(user), + ], + ), + ), + ); const context = formatThreadContext(replies, event.ts, (message) => message.user !== undefined ? (names.get(message.user) ?? message.user) @@ -517,7 +526,9 @@ export class SlackChannel implements MessagingChannel { await respondEphemerally(`Telex commands:\n${slackSlashCommandHelp}`); return; } - const isDirect = payload.channel_name === "directmessage"; + // Conversation IDs starting with D are direct messages; channel_name is + // spoofable (a channel can literally be named "directmessage"). + const isDirect = channelId.startsWith("D"); if (!isDirect && conversationScopedCommands.has(name)) { // In channels every thread is its own conversation, and a slash command // carries no thread information, so these commands cannot pick a target. @@ -747,18 +758,19 @@ export class SlackChannel implements MessagingChannel { private async displayName(userId: string): Promise { const cached = this.#displayNames.get(userId); if (cached !== undefined) return cached; - let name = userId; try { const response = await this.#web.users.info({ user: userId }); const profile = response.user?.profile; - name = + const name = firstNonEmpty(profile?.display_name, profile?.real_name, response.user?.name) ?? userId; + if (this.#displayNames.size >= displayNameCacheLimit) this.#displayNames.clear(); + this.#displayNames.set(userId, name); + return name; } catch (error) { + // A transient lookup failure must not pin the raw ID until a restart. this.#logger.debug("Slack user lookup failed", { userId, error: errorMessage(error) }); + return userId; } - if (this.#displayNames.size >= displayNameCacheLimit) this.#displayNames.clear(); - this.#displayNames.set(userId, name); - return name; } private async respondThroughWebhook(url: string | undefined, text: string): Promise { @@ -767,6 +779,7 @@ export class SlackChannel implements MessagingChannel { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response_type: "ephemeral", text }), + signal: AbortSignal.timeout(webhookTimeoutMs), }).catch((error: unknown) => { this.#logger.debug("Slack response webhook failed", { error: errorMessage(error) }); }); diff --git a/src/channels/slack/file.ts b/src/channels/slack/file.ts index 1a5aa8c..d77ee17 100644 --- a/src/channels/slack/file.ts +++ b/src/channels/slack/file.ts @@ -1,10 +1,12 @@ import { createWriteStream } from "node:fs"; import { mkdir, unlink } from "node:fs/promises"; import { basename, extname, join } from "node:path"; -import { Readable } from "node:stream"; +import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { describeSlackFile, type SlackFile } from "./message.js"; +const slackFileSizeLimit = 100 * 1_024 * 1_024; + export class SlackFileDownloadError extends Error { public readonly userMessage: string; @@ -42,6 +44,12 @@ export async function downloadSlackFile( "its download URL does not point at Slack", ); } + if ((file.size ?? 0) > slackFileSizeLimit) { + throw new SlackFileDownloadError( + `${description} exceeds the download size limit`, + `it is larger than the ${Math.round(slackFileSizeLimit / (1_024 * 1_024))} MB download limit`, + ); + } await mkdir(options.directory, { recursive: true, mode: 0o700 }); const target = join( @@ -76,8 +84,27 @@ export async function downloadSlackFile( } try { + // Slack's reported size is advisory; count the actual bytes so a + // mismatched or missing size cannot exhaust the disk. + let received = 0; + const limitGuard = new Transform({ + transform(chunk: Buffer, _encoding, callback): void { + received += chunk.length; + if (received > slackFileSizeLimit) { + callback( + new SlackFileDownloadError( + `${description} exceeded the download size limit mid-stream`, + `it is larger than the ${Math.round(slackFileSizeLimit / (1_024 * 1_024))} MB download limit`, + ), + ); + return; + } + callback(null, chunk); + }, + }); await pipeline( Readable.from(response.body), + limitGuard, createWriteStream(target, { flags: "wx", mode: 0o600 }), ); return target; @@ -89,7 +116,11 @@ export async function downloadSlackFile( function isSlackFileHost(url: string): boolean { try { - const hostname = new URL(url).hostname; + const parsed = new URL(url); + // The bot token rides in the Authorization header; never send it over + // plaintext, even to a Slack hostname. + if (parsed.protocol !== "https:") return false; + const hostname = parsed.hostname; return ( hostname === "slack.com" || hostname.endsWith(".slack.com") || diff --git a/src/channels/slack/reply.ts b/src/channels/slack/reply.ts index 219cb5f..f21c72f 100644 --- a/src/channels/slack/reply.ts +++ b/src/channels/slack/reply.ts @@ -22,6 +22,8 @@ import type { SlackDeliveryTarget } from "./references.js"; */ export const slackTextLimit = 3_900; +const webhookTimeoutMs = 10_000; + export type SlackBlock = | { readonly type: "section"; @@ -140,22 +142,31 @@ function urlButtonBlocks(options: SendOptions | undefined): readonly SlackBlock[ ]; } -function commandButtonBlocks(message: OutboundMessage): readonly SlackBlock[] | undefined { +function commandButtonBlocks( + message: OutboundMessage, + logger: Logger, +): readonly SlackBlock[] | undefined { const actions = message.actions; if (actions === undefined || actions.length === 0) return undefined; - return [ - { - type: "actions", - elements: actions.map( - (action, index): SlackButtonElement => ({ - type: "button", - text: { type: "plain_text", text: action.label.slice(0, 75) }, - action_id: `telex_cmd_${index}`, - value: encodeSlackCommandValue(action.command.name, action.command.args), - }), - ), - }, - ]; + // One unencodable action must not take down the whole delivery. + const elements: SlackButtonElement[] = []; + for (const [index, action] of actions.entries()) { + try { + elements.push({ + type: "button", + text: { type: "plain_text", text: action.label.slice(0, 75) }, + action_id: `telex_cmd_${index}`, + value: encodeSlackCommandValue(action.command.name, action.command.args), + }); + } catch (error) { + logger.warn("Dropped a Slack command action", { + command: action.command.name, + error: error instanceof Error ? error.message : String(error), + }); + } + } + if (elements.length === 0) return undefined; + return [{ type: "actions", elements }]; } interface ThreadOption { @@ -183,7 +194,7 @@ export async function publishSlackMessage( const ts = await api.postMessage({ channel: target.channel, text: chunk, ...thread }); published.push({ channel: target.channel, ts }); } - const blocks = commandButtonBlocks(message); + const blocks = commandButtonBlocks(message, logger); if (blocks !== undefined) { const ts = await api.postMessage({ channel: target.channel, @@ -282,6 +293,7 @@ export class SlackResponder implements MessageResponder { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response_type: "ephemeral", text: text.slice(0, slackTextLimit) }), + signal: AbortSignal.timeout(webhookTimeoutMs), }); if (!response.ok) { throw new Error(`Slack's response webhook returned HTTP ${response.status}`); @@ -419,7 +431,7 @@ export class SlackReplyStream implements OutboundStream { .updateMessage({ channel: this.#channel, ts: this.#messageTs, - text: escapeSlackEntities(formatThinkingBlock(this.#progress)), + text: escapeSlackEntities(formatThinkingBlock(this.#progress)).slice(0, slackTextLimit), }) .catch((error: unknown) => { this.#logger.debug("Slack progress freeze failed", { @@ -509,7 +521,12 @@ export class SlackReplyStream implements OutboundStream { } private preview(): string { - const progress = escapeSlackEntities(formatThinkingBlock(this.#progress)); + // Entity escaping can expand the progress block past the message limit, + // so clamp it before budgeting the final-text tail. + const progress = escapeSlackEntities(formatThinkingBlock(this.#progress)).slice( + 0, + slackTextLimit - 3, + ); if (this.#finalText.length === 0) return `${progress}\n\n▌`; const available = Math.max(0, slackTextLimit - progress.length - 3); const finalText = available === 0 ? "" : markdownToMrkdwn(this.#finalText).slice(-available); diff --git a/test/slack-format.test.ts b/test/slack-format.test.ts index d8bd08a..3415b21 100644 --- a/test/slack-format.test.ts +++ b/test/slack-format.test.ts @@ -47,8 +47,10 @@ describe("markdownToMrkdwn", () => { }); it("leaves fenced code untouched apart from entity escaping", () => { - const input = "```ts\nconst a = b ** 2; // **not bold**\n```"; - expect(markdownToMrkdwn(input)).toBe("```ts\nconst a = b ** 2; // **not bold**\n```"); + const input = "```ts\nif (a < b && c) { /* **not bold** */ }\n```"; + expect(markdownToMrkdwn(input)).toBe( + "```ts\nif (a < b && c) { /* **not bold** */ }\n```", + ); }); it("converts list markers and strikethrough", () => { diff --git a/test/slack-reply.test.ts b/test/slack-reply.test.ts index 636a8ac..f5fd8a7 100644 --- a/test/slack-reply.test.ts +++ b/test/slack-reply.test.ts @@ -206,16 +206,19 @@ describe("SlackReplyStream", () => { }); it("posts the final text directly when no progress message exists", async () => { - const { api, calls } = fakeApi({ - postMessage: vi - .fn() - .mockRejectedValueOnce(new Error("temporarily unavailable")) - .mockResolvedValue("1700.1"), - }); + let attempts = 0; + const { api, calls } = fakeApi(); + api.postMessage = async (options) => { + attempts += 1; + if (attempts === 1) throw new Error("temporarily unavailable"); + calls.posts.push(options); + return "1700.1"; + }; const reply = stream(api); await reply.start(); await reply.complete("result"); expect(calls.updates).toHaveLength(0); + expect(calls.posts.map((post) => post.text)).toContain("result"); }); it("uploads attachments and reports failures", async () => { From 981fb8070c660df67be3b6545facc5691d8d0f86 Mon Sep 17 00:00:00 2001 From: Oleg Zuev Date: Sat, 1 Aug 2026 01:53:23 +0700 Subject: [PATCH 15/15] Teach Codex to deliver Slack file attachments --- docs/slack.md | 7 +++++++ src/codex/service.ts | 9 +++++++-- test/codex-input.test.ts | 14 ++++++++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/slack.md b/docs/slack.md index 8277e48..4e4b011 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -24,6 +24,13 @@ What works in Slack: `/new`-style messages for its own slash commands, so Telex registers a single `/telex` command with subcommands). +When Codex creates a report, archive, image, or another deliverable, Telex can +upload it into the same Slack DM or thread. The connector-aware system context +instructs Codex to link the workspace-local deliverable in its final answer; +Telex validates and snapshots that file before uploading it with Slack's +`files:write` permission. Local links used only as code references are not +uploaded. + The settings Mini App remains Telegram-only because it authenticates through Telegram. Everything else — including `/telex login` for the ChatGPT sign-in — works from Slack. diff --git a/src/codex/service.ts b/src/codex/service.ts index 81328ec..6f21b31 100644 --- a/src/codex/service.ts +++ b/src/codex/service.ts @@ -1112,10 +1112,15 @@ Host-local UI is not visible or accessible to the user: - You may run and access local services yourself for development and testing. Only present a URL to the user when it is reachable from their device. - For authentication, prefer a device-code flow or a publicly reachable HTTPS flow and send the URL and code through chat. If only a local callback exists, explain the constraint and offer a remote-safe alternative such as a device flow, tunnel, or SSH port forwarding. - Do not assume the user can see the host screen, clipboard, notifications, or spawned windows. -- Explicitly link files intended for the user in the final response so Telex can deliver them. + +File delivery: +- ${connectorName} can receive files as native attachments through Telex. +- When the user asks for a report, archive, image, or another local deliverable, save it inside the workspace and include a Markdown link to its workspace-relative path in the final response, for example \`[Download report](artifacts/report.pdf)\`. Telex resolves that link and uploads the file; do not use a file:// URL. +- Link only files deliberately intended for the user. Never attach secrets, credentials, environment files, authentication data, or unrelated workspace files. +- Codex-generated images are attached automatically, but still mention the delivered file in the final response. When referencing code or files in replies: -- Never format a local filesystem path as a markdown link target; the user cannot open it. This includes workspace paths and home-relative paths. +- Except for deliberate attachment links described above, never format a local filesystem path as a Markdown link target; the user cannot open it. This includes workspace paths and home-relative paths. - Refer to code with a repository-relative path and line number as inline code, for example \`src/app/main.ts:42\`. - When you know the repository's public remote (for example on GitHub) and the relevant branch or commit, prefer a full https URL to the file and line so the reference is clickable in chat. diff --git a/test/codex-input.test.ts b/test/codex-input.test.ts index 771df43..1313570 100644 --- a/test/codex-input.test.ts +++ b/test/codex-input.test.ts @@ -96,9 +96,19 @@ describe("createRemoteClientContext", () => { expect(discord["telex.remote-client"]?.value).not.toContain("Telegram"); }); - it("tells Codex not to link local filesystem paths in replies", () => { + it("teaches Codex how to deliver files through Slack", () => { const value = createRemoteClientContext("slack")["telex.remote-client"]?.value ?? ""; - expect(value).toContain("Never format a local filesystem path as a markdown link target"); + expect(value).toContain("Slack can receive files as native attachments through Telex"); + expect(value).toContain("[Download report](artifacts/report.pdf)"); + expect(value).toContain("Telex resolves that link and uploads the file"); + expect(value).toContain("Never attach secrets, credentials, environment files"); + }); + + it("reserves local Markdown links for deliberate attachments", () => { + const value = createRemoteClientContext("slack")["telex.remote-client"]?.value ?? ""; + expect(value).toContain( + "Except for deliberate attachment links described above, never format a local filesystem path as a Markdown link target", + ); expect(value).toContain("repository-relative path and line number"); }); });