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/.env.example b/.env.example index 76040e1..423496e 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,16 @@ -# Required. Create the bot with @BotFather. -TELEGRAM_BOT_TOKEN=123456:replace-me +# 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. -TELEGRAM_ALLOWED_USER_IDS=123456789 +# 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; +# 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5bda8c6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# 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 \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && 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 \ + && 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 +# 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/README.md b/README.md index 34707f2..c41ca7b 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,19 @@ 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. + +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/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..cfcc8ff --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,39 @@ +#!/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}" + +# 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" +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 + +# 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 "$@" 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`. diff --git a/docs/slack.md b/docs/slack.md new file mode 100644 index 0000000..4e4b011 --- /dev/null +++ b/docs/slack.md @@ -0,0 +1,177 @@ +# 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. 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 + 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). + +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. + +## 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. 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. + +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 +`~/.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. 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 + +- **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; 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`. + 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. + +## 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. +- **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 + 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 2056ec5..3ab56ca 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 d3255f9..d69f5af 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/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 new file mode 100644 index 0000000..5e3861c --- /dev/null +++ b/src/channels/slack/channel.ts @@ -0,0 +1,873 @@ +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, + 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 { 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 { + describeSlackFile, + formatThreadContext, + normalizeSlackMessage, + routeSlackMessage, + type SlackMessageEvent, + type SlackThreadMessage, + slackAttachmentKind, +} from "./message.js"; +import { + parseSlackDeliveryTarget, + slackDeliveryTarget, + slackMessageReference, +} from "./references.js"; +import { + choicePromptText, + decodeSlackCommandValue, + publishSlackMessage, + type SlackBlock, + type SlackChoiceRequester, + type SlackMessagingApi, + SlackResponder, + truncateForLog, +} 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 envelope_id?: string; + 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 engagedThreadLimit = 500; +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"]); + +/** 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; + readonly #socket: SocketModeClient; + 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; + readonly #attachmentDirectory: string; + readonly #logger: Logger; + readonly #pendingChoices = new Map(); + /** 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(); + /** + * 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; + + 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); + }); + }); + 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.#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 | 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 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); + 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.", + ); + 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; + 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 { + 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); + const primary = published[0]; + if (target.threadTs !== undefined && primary !== undefined) { + this.#threadNotifications.set(`${target.channel}:${target.threadTs}`, primary.ts); + trimInsertionOrderedMap(this.#threadNotifications, engagedThreadLimit); + } + 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), + }); + } + // 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) { + 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); + const sender = event.user; + if (route === undefined || sender === undefined) return; + if (!(await this.isUserAllowed(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 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; + const threadKey = `${event.channel}:${route.conversationSuffix}`; + const threadWasEngaged = this.#engagedThreads.has(threadKey); + if (event.channel_type !== "im") { + 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.child({ + userId: sender, + userName: senderName, + conversation: `slack:${event.channel}:${route.conversationSuffix}`, + }), + ); + 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), + // 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, + this.#threadNotifications.get(`${event.channel}:${event.thread_ts}`) ?? + event.thread_ts, + ), + }), + sender: { + id: sender, + displayName: senderName, + }, + text: contextualText, + attachments, + responder, + }; + try { + 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); + } + } + + /** + * 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, + threadWasEngaged: boolean, + text: string, + ): Promise { + if ( + event.channel_type === "im" || + threadWasEngaged || + 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 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) + : 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; + 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 (!(await this.isUserAllowed(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 || 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; + } + // 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. + 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 commandSenderName = payload.user_name ?? (await this.displayName(userId)); + const responder = new SlackResponder( + this.#api, + channelId, + undefined, + userId, + this.requestChoice, + this.#logger.child({ + userId, + userName: commandSenderName, + conversation: `slack:${channelId}:main`, + }), + payload.response_url, + ); + const inbound: InboundMessage = { + id: `slash:${crypto.randomUUID()}`, + address: { + channel: this.name, + key: `slack:${channelId}:main`, + isPrivate: isDirect, + isGuest: false, + }, + sender: { + id: userId, + displayName: commandSenderName, + }, + text: `/${command.name}${command.args.length === 0 ? "" : ` ${command.args}`}`, + command, + attachments: [], + responder, + }; + try { + 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); + } + } + + 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 (!(await this.isUserAllowed(userId))) { + 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; + } + 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→ ${escapeSlackEntities(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.rememberEngagedThread(`${channelId}:${conversationSuffix}`); + const actorName = await this.displayName(userId); + const responder = new SlackResponder( + this.#api, + channelId, + replyThreadTs, + userId, + this.requestChoice, + this.#logger.child({ + userId, + userName: actorName, + conversation: `slack:${channelId}:${conversationSuffix}`, + }), + ); + 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: actorName, + }, + text: `/${command.name}${command.args.length === 0 ? "" : ` ${command.args}`}`, + command, + attachments: [], + responder, + }; + try { + 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); + } + } + + 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 baseText = choicePromptText(prompt, options); + 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; + try { + const response = await this.#web.users.info({ user: userId }); + const profile = response.user?.profile; + 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; + } + } + + 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 }), + signal: AbortSignal.timeout(webhookTimeoutMs), + }).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 rememberEngagedThread(key: string): void { + this.#engagedThreads.delete(key); + this.#engagedThreads.add(key); + trimInsertionOrdered(this.#engagedThreads, engagedThreadLimit); + } +} + +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, + }); + }, + async fetchThreadReplies(channel, threadTs, limit) { + const result = await web.conversations.replies({ channel, ts: threadTs, limit }); + return (result.messages ?? []) as unknown as readonly SlackThreadMessage[]; + }, + }; +} + +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); + } +} + +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/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/channels/slack/file.ts b/src/channels/slack/file.ts new file mode 100644 index 0000000..d77ee17 --- /dev/null +++ b/src/channels/slack/file.ts @@ -0,0 +1,146 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, unlink } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; +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; + + 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", + ); + } + 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( + 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 { + // 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; + } catch (error) { + await unlink(target).catch(() => undefined); + throw error; + } +} + +function isSlackFileHost(url: string): boolean { + try { + 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") || + 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..46cc31a --- /dev/null +++ b/src/channels/slack/format.ts @@ -0,0 +1,196 @@ +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 { + // 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) => + 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. 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}>`; + }, + ); + // 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..2a59211 --- /dev/null +++ b/src/channels/slack/message.ts @@ -0,0 +1,142 @@ +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 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, +): 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 }; + } + if (event.text?.includes(`<@${botUserId}>`) !== true) return undefined; + const threadRoot = event.thread_ts ?? event.ts; + 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 ?? [], + }; +} + +/** 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 = [ + 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..f21c72f --- /dev/null +++ b/src/channels/slack/reply.ts @@ -0,0 +1,587 @@ +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 { SlackThreadMessage } from "./message.js"; +import type { SlackDeliveryTarget } from "./references.js"; + +/** + * 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; + +const webhookTimeoutMs = 10_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; + fetchThreadReplies( + channel: string, + threadTs: string, + limit: number, + ): Promise; +} + +export type SlackChoiceRequester = ( + channel: string, + threadTs: string | undefined, + userId: string, + prompt: string, + 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 { + 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, + logger: Logger, +): readonly SlackBlock[] | undefined { + const actions = message.actions; + if (actions === undefined || actions.length === 0) return undefined; + // 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 { + readonly threadTs?: string; +} + +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, + 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, logger); + 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 { + this.#logger.info("Slack reply", { text: truncateForLog(text) }); + const chunks = splitMessageText(markdownToMrkdwn(text), slackTextLimit); + let posted = 0; + try { + for (const chunk of chunks) { + await this.#api.postMessage({ + channel: this.#channel, + 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. Deliver only + // what has not already been posted. + await this.respondThroughWebhook(chunks.slice(posted).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) }), + signal: AbortSignal.timeout(webhookTimeoutMs), + }); + 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; + #loggedActions = 0; + #lastReasoning = ""; + 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; + // 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(); + } + + 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); + 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 + // 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: escapeSlackEntities(formatThinkingBlock(this.#progress)).slice(0, slackTextLimit), + }) + .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), + }); + } + } + 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); + } + + 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 { + // 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); + 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 = 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); + } 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/codex/service.ts b/src/codex/service.ts index d6869c7..6f21b31 100644 --- a/src/codex/service.ts +++ b/src/codex/service.ts @@ -1112,7 +1112,17 @@ 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: +- 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. All normal Codex filesystem, shell, network, approval, and project behavior remains unchanged. Telex changes only how the user communicates with Codex.`, }, diff --git a/src/config/env.ts b/src/config/env.ts index 941a1fa..90a0577 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -20,10 +20,14 @@ 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(), + 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") @@ -37,11 +41,27 @@ const envSchema = z.object({ LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), }); -export interface AppConfig { - readonly telegramToken: string; +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; + /** When set, instance-wide commands (config, login, restart…) are limited to these users. */ + readonly adminUserIds: ReadonlySet | undefined; +} + +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; readonly publicUrl: string | undefined; readonly tunnelMode: "auto" | "off"; readonly dataDirectory: string; @@ -65,18 +85,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, publicUrl: parsed.PUBLIC_URL?.replace(/\/$/, ""), tunnelMode: parsed.TELEX_TUNNEL, dataDirectory: resolve(parsed.TELEX_DATA_DIR), @@ -92,6 +114,71 @@ 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)) { + 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, + }; + } + 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( + /^[UW][A-Z0-9]{2,}$/u, + "Slack user IDs look like U0123ABCDEF, or * for every workspace member", + ) + .parse(part.trim().toUpperCase()), + ), + ); +} + function updateConfigFromParsed(parsed: z.infer): UpdateConfig { return { updateMode: parsed.TELEX_UPDATE_MODE, diff --git a/src/index.ts b/src/index.ts index 925c75b..63c9ff2 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"; @@ -148,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, @@ -195,19 +200,34 @@ 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 + : new SlackChannel( + config.slack, + join(config.workspace, ".telex", "attachments"), + logger.child({ component: "slack" }), + configService, + ); + const channels = [telegram, slack].filter( + (channel): channel is NonNullable => channel !== undefined, ); const scheduledRuns = new ScheduledRunsEngine({ store: automations, codex, - channels: [telegram], + channels, workspace: config.workspace, logger: logger.child({ component: "scheduled-runs" }), }); @@ -234,8 +254,14 @@ 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); + } resources.push(scheduledRuns); await scheduledRuns.start(); @@ -243,7 +269,9 @@ 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", }); if (config.updateMode !== "off") { diff --git a/test/codex-input.test.ts b/test/codex-input.test.ts index 94dad93..1313570 100644 --- a/test/codex-input.test.ts +++ b/test/codex-input.test.ts @@ -95,4 +95,20 @@ describe("createRemoteClientContext", () => { }); expect(discord["telex.remote-client"]?.value).not.toContain("Telegram"); }); + + it("teaches Codex how to deliver files through Slack", () => { + const value = createRemoteClientContext("slack")["telex.remote-client"]?.value ?? ""; + 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"); + }); }); diff --git a/test/env.test.ts b/test/env.test.ts index 794bdad..6e2b2f3 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"); @@ -54,4 +54,110 @@ describe("loadAppConfig", () => { }), ).toThrow(); }); + + it("leaves the Slack connector disabled by default", () => { + 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("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("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: "U0ADMIN0AAA", + }); + expect([...(config.slack?.adminUserIds ?? [])]).toEqual(["U0ADMIN0AAA"]); + 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: "U0ADMIN0AAA" })).toThrow( + /requires the Slack connector/, + ); + }); + + 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/); + }); + + it("rejects partial Telegram settings", () => { + expect(() => loadAppConfig({ TELEGRAM_BOT_TOKEN: "12345678901234567890:token" })).toThrow( + /set together/, + ); + }); + + 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-authorization.test.ts b/test/slack-authorization.test.ts new file mode 100644 index 0000000..7894cf4 --- /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 = "T0EXAMPLE"; + +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); + }); +}); diff --git a/test/slack-config-ui.test.ts b/test/slack-config-ui.test.ts new file mode 100644 index 0000000..924653f --- /dev/null +++ b/test/slack-config-ui.test.ts @@ -0,0 +1,214 @@ +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() {}, + async fetchThreadReplies() { + return []; + }, + }; + 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); + }); +}); diff --git a/test/slack-format.test.ts b/test/slack-format.test.ts new file mode 100644 index 0000000..3415b21 --- /dev/null +++ b/test/slack-format.test.ts @@ -0,0 +1,96 @@ +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("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", + ); + 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\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", () => { + 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"); + }); + + 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", () => { + 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..0a46cab --- /dev/null +++ b/test/slack-message.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + describeSlackFile, + formatThreadContext, + normalizeSlackMessage, + routeSlackMessage, + type SlackMessageEvent, + type SlackThreadMessage, + 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, + }; +} + +describe("routeSlackMessage", () => { + it("always handles direct messages without threading", () => { + 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)).toBeUndefined(); + const route = routeSlackMessage(event({ text: `<@${botUserId}> hi` }), botUserId); + 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, + ); + 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), + ).toBeUndefined(); + expect( + routeSlackMessage(event({ channel_type: "im", user: botUserId }), botUserId), + ).toBeUndefined(); + expect( + routeSlackMessage(event({ channel_type: "im", subtype: "message_changed" }), botUserId), + ).toBeUndefined(); + const { user: _ignored, ...anonymous } = event({ channel_type: "im" }); + expect(routeSlackMessage(anonymous, botUserId)).toBeUndefined(); + }); + + it("handles file_share and thread_broadcast subtypes", () => { + expect( + routeSlackMessage(event({ channel_type: "im", subtype: "file_share" }), botUserId), + ).toBeDefined(); + expect( + routeSlackMessage( + event({ text: `<@${botUserId}> x`, subtype: "thread_broadcast" }), + botUserId, + ), + ).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("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"); + 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..f5fd8a7 --- /dev/null +++ b/test/slack-reply.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + SlackEphemeralOptions, + SlackMessagingApi, + SlackPostOptions, + SlackUpdateOptions, + SlackUploadOptions, +} from "../src/channels/slack/reply.js"; +import { + choicePromptText, + 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); + }, + async fetchThreadReplies() { + return []; + }, + ...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", + }); + }); + + 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(3_900)); + const [, init] = fetchMock.mock.calls[0] ?? []; + const body = JSON.parse(String(init?.body)) as { text: string }; + // The ephemeral webhook fallback carries at most one message worth of text. + expect(body.text).toBe("a".repeat(3_900)); + }); +}); + +describe("SlackReplyStream", () => { + function stream(api: SlackMessagingApi) { + return new SlackReplyStream(api, "C1", "1699.5", new Logger("error")); + } + + 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: [] }); + 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]?.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 () => { + 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 () => { + 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 () => { + 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("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(); + 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); + }); +});