diff --git a/.env.example b/.env.example index 4679e71..40785f5 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,18 @@ -# 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 +# Optional: restrict instance-wide Slack commands to these members. +# SLACK_ADMIN_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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45edc67..69b54d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: contents: read jobs: - test: + verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b0728..0f3c3da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Wirebot are documented in this file. +## [Unreleased] + +### Added + +- A Slack connector over Socket Mode, including direct messages, mention-gated channel threads, + approvals, file transfer, voice transcription, scheduled notifications, `/wirebot` commands, + and an interactive Codex settings UI. Slack can run alongside Telegram or as the only connector. + ## [0.1.0] - 2026-08-05 Initial Wirebot release, forked from [Telex](https://github.com/sadfun/telex) 0.0.28 and synced with Telex through 0.0.34. The bridge behavior β€” conversations, scheduled runs, voice transcription, media handling, the settings Mini App β€” carries over; the product, runtime, and deployment model are new. diff --git a/README.md b/README.md index ceb208a..2c0cb0f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # πŸ€– Wirebot -Wirebot brings Codex to your messenger: a full Codex agent you talk to from Telegram, built on a transport layer designed so Slack, Discord, and others can follow. +Wirebot brings Codex to your messenger: a full Codex agent you talk to from Telegram or Slack, built on a transport layer designed so more messengers can follow. Think of it as OpenClaw or Hermes Agent with a different philosophy: @@ -11,6 +11,7 @@ Think of it as OpenClaw or Hermes Agent with a different philosophy: Out of the box: * **Rich Telegram I/O** β€” photos and files in both directions, voice messages with automatic transcription, forwarded and replied-to context, polls and other structured messages. +* **Slack over Socket Mode** β€” direct messages, channel threads, approvals, files, commands, and scheduled notifications without a public webhook. * **A real agent experience** β€” streamed replies and thinking, interactive approvals, persistent Codex threads, private conversations, and guest mentions. * **Scheduled runs** β€” describe an automation in plain language and Wirebot keeps it running, following Codex Desktop's scheduled-task model. * **Settings Mini App** β€” an authenticated in-Telegram UI for Codex configuration, skills, and schedules. @@ -22,7 +23,7 @@ Out of the box: ### Run with Docker -Requirements: Docker (or any OCI runtime), a bot token from [@BotFather](https://t.me/BotFather), and the numeric Telegram user IDs allowed to use the bot. +Requirements: Docker (or any OCI runtime) and at least one configured connector: Telegram credentials, Slack credentials, or both. Create a directory with a `docker-compose.yml`: @@ -48,6 +49,8 @@ TELEGRAM_ALLOWED_USER_IDS=123456789 `TELEGRAM_ALLOWED_USER_IDS` is a comma-separated list. Messages from other accounts are ignored, including guest-mode mentions. Then: +For Slack instead, follow the [Slack connector setup](docs/slack.md) and set `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `SLACK_ALLOWED_USER_IDS`. + ```sh docker compose up -d ``` @@ -95,8 +98,12 @@ and reverse-proxy that origin to the container's port 8787 (publish it in your c | Variable | Default | Purpose | |-----------------------------|----------------------------|------------------------------------------| -| `TELEGRAM_BOT_TOKEN` | required | Bot token from @BotFather | -| `TELEGRAM_ALLOWED_USER_IDS` | required | Comma-separated numeric allowlist | +| `TELEGRAM_BOT_TOKEN` | connector-dependent | Bot token from @BotFather | +| `TELEGRAM_ALLOWED_USER_IDS` | connector-dependent | Comma-separated numeric allowlist | +| `SLACK_BOT_TOKEN` | connector-dependent | Slack bot OAuth token (`xoxb-…`) | +| `SLACK_APP_TOKEN` | connector-dependent | Slack Socket Mode token (`xapp-…`) | +| `SLACK_ALLOWED_USER_IDS` | connector-dependent | Member IDs, or `*` for workspace members | +| `SLACK_ADMIN_USER_IDS` | unset | Members allowed to run global commands | | `PUBLIC_URL` | unset | Public HTTPS origin for the Mini App | | `WIREBOT_TUNNEL` | `auto` | `off` disables the quick-tunnel fallback | | `TELEGRAM_API_BASE` | `https://api.telegram.org` | Alternate Bot API server for large files | @@ -145,6 +152,12 @@ In the other direction, Wirebot uploads completed Codex image-generation results Telegram's hosted Bot API only allows bots to download files up to 20 MB and upload general files up to 50 MB. Wirebot still forwards the file metadata and a clear limitation notice when a download or upload is unavailable. Set `TELEGRAM_API_BASE` to a [local Bot API server](https://core.telegram.org/bots/api#using-a-local-bot-api-server) to remove the download limit and support larger uploads. +## Slack connector + +Wirebot 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 `/wirebot ` (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.” Wirebot 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. @@ -160,9 +173,9 @@ Scheduled runs follow the [Codex Desktop scheduled-task model](https://developer - Heartbeats can suppress unimportant results. Cron results notify by default, and delivery failures are recorded without rerunning already completed work. - Each schedule gets a small durable memory file under the workspace's `.wirebot/automations` directory, which the run reads and may update. -Notifications deliberately do not change the active task, including in a Telegram chat without topics. Your next ordinary message still goes to the task you were already using. Replying to a scheduled notification also stays in that task, but Wirebot supplies the complete stored result as additional context even when Telegram split or truncated the visible message. **Continue this run** explicitly switches to the notification's source task when the conversation is idle; `/back` returns to the previous task. +Notifications deliberately do not change the active task. Your next ordinary message still goes to the task you were already using. Replying to a scheduled notification also stays in that task, but Wirebot supplies the complete stored result as additional context even when the provider split or truncated the visible message. **Continue this run** explicitly switches to the notification's source task when the conversation is idle; `/back` returns to the previous task. -Scheduling and delivery state use opaque provider references rather than Telegram message or chat fields. Telegram is the first adapter; future messaging providers can define their own destination and message identifier formats without changing the scheduler. +Scheduling and delivery state use opaque provider references rather than provider-specific message or chat fields. Telegram and Slack each define their own destination and message identifier formats without changing the scheduler. Wirebot retains the latest 100 run and notification records for each schedule so local state stays bounded. Since there is no systemd in the container, processes the agent starts do not survive restarts β€” scheduled runs are the supported way to re-establish or monitor long-lived work. @@ -192,7 +205,7 @@ Wirebot keeps the running Codex process synchronized using the [app-server mecha - Skills use Codex's built-in watcher plus a forced `skills/list` refresh. Explicit `$skill-name` mentions are sent as native skill inputs. - MCP definitions use `config/mcpServer/reload`. Codex queues refreshed MCP state for loaded threads, so it becomes active on their next turn. -The runtime card in the Mini App shows the current outcome and offers **Apply changes** and **Restart Codex**. `/reload` and `/restart` provide the same private-chat controls. Restart is the fallback for startup-only state: Wirebot pauses new turns, lets active turns finish, restarts its child app-server with the same `CODEX_HOME`, reloads its resources, and lazily resumes persisted thread IDs. It does not restart the Telegram bridge or discard authentication and conversation history. +The runtime card in the Mini App shows the current outcome and offers **Apply changes** and **Restart Codex**. `/reload` and `/restart` provide the same private-chat controls. Restart is the fallback for startup-only state: Wirebot pauses new turns, lets active turns finish, restarts its child app-server with the same `CODEX_HOME`, reloads its resources, and lazily resumes persisted thread IDs. It does not restart the messaging bridges or discard authentication and conversation history. ## Source development @@ -216,9 +229,7 @@ docker build . # the release image Source runs keep Codex's `workspace-write` sandbox default and store state under `./.wirebot`. They download the pinned Codex CLI on first start; cloudflared and curl-impersonate are invoked from PATH, where the container image bakes the pinned builds. Without cloudflared (or `PUBLIC_URL`) the quick tunnel is skipped, and without curl-impersonate voice messages are forwarded to Codex untranscribed. The compiled executable embeds the app version, the Codex pin, and bytecode with maximum optimizations; Mini App assets and the pinned toolchains are baked into the image alongside it. -The handwritten application is strict TypeScript. Messaging transports depend only on `src/core/channel.ts`; Telegram is the first implementation. - -The previous mock-based unit suite has been removed. Tests are being rebuilt as end-to-end runs that exercise the actual container image. +The handwritten application is strict TypeScript. Messaging transports depend only on `src/core/channel.ts`; Telegram and Slack implement the same contract. ### Codex protocol updates diff --git a/bun.lock b/bun.lock index 9b44082..cd6991e 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,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", @@ -123,6 +125,14 @@ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="], + "@slack/logger": ["@slack/logger@5.0.0", "", { "dependencies": { "@types/node": ">=20" } }, "sha512-VGXhmmgsAo9shdQYh4tFDndd+7nsgp0Y5h0UPDaUp8K359pBasI6YdkMqFW3mCOxLQkq09qj7o7cq6f3DuXcJQ=="], + + "@slack/socket-mode": ["@slack/socket-mode@3.0.0", "", { "dependencies": { "@slack/logger": "^5.0.0", "@slack/web-api": "^8.0.0", "@types/node": ">=20", "eventemitter3": "^5" }, "peerDependencies": { "undici": "^7.0.0" } }, "sha512-QShO60SB0E+HH+TbcKj3CBEQbodToRyiXnxuSB4t1kvUlqEmuGA1nOOjrRDkDJbOECAZ13PLe4ek9SrntpfoYg=="], + + "@slack/types": ["@slack/types@3.0.0", "", {}, "sha512-KNOqpnNAlsFt5Jk9XBclslQ0lobRIg/0tnhpmvZJAglHJx9E8oceN8hC3gaBzkR6UzQ9Wzq4rLsJ98wUcxWPfw=="], + + "@slack/web-api": ["@slack/web-api@8.0.0", "", { "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" } }, "sha512-ORx3XQryQPq2Jnxv5giSKXVoQRUeylrrymIR2S9fPzLjPcCts8RayMeBSZMcpfpAqp6fnBRuPW2UB6dUPUTEZA=="], + "@tailwindcss/cli": ["@tailwindcss/cli@4.3.3", "", { "dependencies": { "@parcel/watcher": "2.5.1", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "enhanced-resolve": "^5.24.1", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.3.3" }, "bin": { "tailwindcss": "./dist/index.mjs" } }, "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], @@ -175,6 +185,8 @@ "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -279,6 +291,8 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -469,6 +483,14 @@ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -491,6 +513,8 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -519,6 +543,8 @@ "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -559,6 +585,8 @@ "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], } } diff --git a/docker-compose.yml b/docker-compose.yml index ced88b1..d6a6a52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,8 @@ services: wirebot: image: ghcr.io/sadfun/wirebot:latest restart: unless-stopped - # TELEGRAM_BOT_TOKEN and TELEGRAM_ALLOWED_USER_IDS are required; see - # .env.example for everything else (PUBLIC_URL, WIREBOT_TUNNEL, ...). + # Configure Telegram, Slack, or both; see .env.example for connector and + # runtime variables (PUBLIC_URL, WIREBOT_TUNNEL, and others). env_file: .env volumes: # One volume holds all user state: the Codex workspace and login, the diff --git a/docs/slack.md b/docs/slack.md new file mode 100644 index 0000000..fc7cc0b --- /dev/null +++ b/docs/slack.md @@ -0,0 +1,176 @@ +# Slack connector + +Wirebot 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 (`@Wirebot 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: `/wirebot new`, `/wirebot status`, and friends (Slack reserves plain + `/new`-style messages for its own slash commands, so Wirebot registers a single + `/wirebot` command with subcommands). + +When Codex creates a report, archive, image, or another deliverable, Wirebot 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; +Wirebot 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 `/wirebot 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: Wirebot + 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: Wirebot + always_online: true + slash_commands: + - command: /wirebot + description: Control Wirebot (new, stop, status, help…) + usage_hint: "new | back | stop | compact | schedules | 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 `wirebot-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 + +Wirebot 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 Wirebot: 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 β€” `/wirebot config`, `login`, `logout`, `reload`, and +`restart` β€” to the listed users. Unset, every authorized user may +run them. `/wirebot 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 Wirebot + +Add the three variables to the `.env` used by the Docker deployment or source checkout: + +```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, Wirebot runs Slack-only (the Telegram bot and the settings Mini +App stay off). Restart Wirebot 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 @Wirebot`), then mention it: + `@Wirebot what does this repo do?`. The reply opens a thread; address it + there with a mention each time (`@Wirebot and now check the tests`) β€” the + thread's Codex conversation continues across mentions. +- **Commands**: `/wirebot help` anywhere, or prefix a command in a mention: + `@Wirebot /new`. In the bot DM, plain `/new` will not reach Wirebot β€” Slack + intercepts everything that starts with `/` β€” so use `/wirebot new`. + Conversation-scoped commands (`new`, `back`, `stop`, `compact`, `schedules`, + `continue`) only work as `/wirebot …` in the bot DM; in a channel each thread + is its own conversation, so run them inside the thread as a mention + (`@Wirebot /stop`). +- **Sign-in**: if Codex is not signed in yet, `/wirebot 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 Wirebot 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.json b/package.json index 7933081..4b50ecf 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "Wirebot β€” a Telegram bridge for the OpenAI Codex app-server.", + "description": "Wirebot β€” a Telegram and Slack bridge for the OpenAI Codex app-server.", "license": "FSL-1.1-MIT", "homepage": "https://github.com/sadfun/wirebot#readme", "bugs": { @@ -30,6 +30,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..34a5701 --- /dev/null +++ b/src/channels/progress.ts @@ -0,0 +1,67 @@ +import type { ProgressSnapshot } from "../core/channel.js"; +import { compactTruncate, truncate } from "../shared/text.js"; + +export function formatThinkingBlock(progress: ProgressSnapshot, limit = 800): string { + const text = + progress.plan.length > 1 ? formatPlanProgress(progress) : formatActionProgress(progress); + return truncate(text, limit); +} + +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 [ + `β–Œ ${compactTruncate(heading, 180)}`, + ...rows.map( + (row, index) => `${index === rows.length - 1 ? "β””" : "β”œ"} ${compactTruncate(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 ? ` (${compactTruncate(context, 140)})` : ""; + lines.push(`${marker} ${compactTruncate(step.step, 180)}${suffix}`); + if (isCurrent && reasoningMessage !== undefined && reasoningMessage !== context) { + lines.push(compactTruncate(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() ?? ""; +} + +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..cba76cb --- /dev/null +++ b/src/channels/slack/channel.ts @@ -0,0 +1,939 @@ +import { rm } from "node:fs/promises"; +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 { + botCommands, + conversationScopedCommands, + instanceAdminCommands, +} from "../../core/bridge.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, +} from "./reply.js"; + +export const slackSlashCommandHelp = botCommands + .map((entry) => `\`/wirebot ${entry.command}\` β€” ${entry.help}`) + .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; + +/** Parse mention-stripped text before handing the provider-owned command to the bridge. */ +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"); + } + await this.#socket.start(); + this.#logger.info("Slack bot connected through Socket Mode", { + botUserId: auth.user_id, + team: auth.team ?? "unknown", + authorization: this.#allowAllWorkspaceMembers ? "workspace-members" : "allowlist", + }); + } + + 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; + // Mentions such as "@Wirebot /new" arrive as plain text. Parse them here + // because the bridge deliberately trusts provider-owned command parsing. + 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 }), + chars: inbound.text.length, + attachments: inbound.attachments.length, + }); + if (command !== undefined && instanceAdminCommands.has(command.name) && !this.isAdmin(userId)) { + await inbound.responder.sendText( + "This command changes Wirebot 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( + command === undefined || inbound.command !== undefined + ? inbound + : { + ...inbound, + command, + }, + ); + } + + 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); + const profile = response.user?.profile; + const name = firstNonEmpty(profile?.display_name, profile?.real_name, response.user?.name); + if (name !== undefined) { + if (this.#displayNames.size >= displayNameCacheLimit) this.#displayNames.clear(); + this.#displayNames.set(userId, name); + } + } 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) }); + }); + const pendingChoices = [...this.#pendingChoices.values()]; + this.#pendingChoices.clear(); + for (const choice of pendingChoices) { + clearTimeout(choice.timer); + choice.result.resolve("decline"); + } + await Promise.allSettled( + pendingChoices.map(async (choice) => { + await this.#api.updateMessage({ + channel: choice.channel, + ts: choice.messageTs, + text: `${choice.baseText}\n\nβ†’ Request cancelled`, + blocks: [], + }); + }), + ); + } + + 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) { + if (normalized.files.length > 0) { + await rm(directory, { recursive: true, force: true }).catch(() => undefined); + } + 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, + ...(normalized.files.length === 0 + ? {} + : { + dispose: async (): Promise => { + await rm(directory, { recursive: true, force: true }); + }, + }), + }; + 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(); + for (const user of uniqueUsers) { + names.set(user, user === botUserId ? "Wirebot (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 Wirebot 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(`Wirebot 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 \`/wirebot ${name}\` cannot tell which one you mean. Mention the bot inside the thread instead (\`@Wirebot /${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 === "wirebot_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 Wirebot admins.", + }) + .catch(() => undefined); + return; + } + await this.#configUi.handleAction(action.value ?? "", channelId, messageTs); + return; + } + if (actionId.startsWith("wirebot_choice")) { + await this.handleChoiceAction(action, userId, channelId); + return; + } + if (actionId.startsWith("wirebot_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, + signal, + ): Promise => { + const isAborted = (): boolean => signal?.aborted === true; + if (options.length === 0 || isAborted()) 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: `wirebot_choice_${index}`, + value: `${token}:${index}`, + })), + }, + ]; + const messageTs = await this.#api.postMessage({ + channel, + text: baseText, + blocks, + ...(threadTs === undefined ? {} : { threadTs }), + }); + const result = deferred(); + const timer = setTimeout( + () => { + const pending = this.#pendingChoices.get(token); + if (pending === undefined) return; + this.#pendingChoices.delete(token); + pending.result.resolve("decline"); + void this.#api + .updateMessage({ + channel: pending.channel, + ts: pending.messageTs, + text: `${pending.baseText}\n\nβ†’ Request expired`, + blocks: [], + }) + .catch(() => undefined); + }, + 5 * 60 * 1_000, + ); + timer.unref(); + this.#pendingChoices.set(token, { + userId, + options, + result, + timer, + channel, + messageTs, + baseText, + }); + const onAbort = (): void => { + const pending = this.#pendingChoices.get(token); + if (pending === undefined) return; + clearTimeout(pending.timer); + this.#pendingChoices.delete(token); + pending.result.resolve("decline"); + void this.#api + .updateMessage({ + channel: pending.channel, + ts: pending.messageTs, + text: `${pending.baseText}\n\nβ†’ Request cancelled`, + blocks: [], + }) + .catch(() => undefined); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + if (isAborted()) onAbort(); + try { + return await result.promise; + } finally { + signal?.removeEventListener("abort", onAbort); + } + }; + + 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 messages: SlackThreadMessage[] = []; + let cursor: string | undefined; + do { + const result = await web.conversations.replies({ + channel, + ts: threadTs, + limit: 200, + ...(cursor === undefined ? {} : { cursor }), + }); + messages.push(...((result.messages ?? []) as unknown as readonly SlackThreadMessage[])); + const next = result.response_metadata?.next_cursor?.trim(); + cursor = next === undefined || next.length === 0 ? undefined : next; + } while (cursor !== undefined); + return messages.slice(-limit); + }, + }; +} + +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..8955ecd --- /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 = "wirebot_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)]), + ...lines, + ...warnings, + "_Everyone using this Wirebot 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..022bb9c --- /dev/null +++ b/src/channels/slack/file.ts @@ -0,0 +1,148 @@ +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; +const slackFileDownloadTimeoutMs = 30_000; + +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}` }, + signal: AbortSignal.timeout(slackFileDownloadTimeoutMs), + }); + } 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..8c242ff --- /dev/null +++ b/src/channels/slack/references.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import type { ProviderReference } from "../../core/channel.js"; +import { decodeBase64UrlJson } from "../../shared/text.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 decodeBase64UrlJson(value); +} diff --git a/src/channels/slack/reply.ts b/src/channels/slack/reply.ts new file mode 100644 index 0000000..2b7e1b5 --- /dev/null +++ b/src/channels/slack/reply.ts @@ -0,0 +1,598 @@ +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[], + signal?: AbortSignal, +) => 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: "wirebot_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: `wirebot_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", { chars: text.length }); + 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[], + signal?: AbortSignal, + ): Promise { + return await this.#requestChoice( + this.#channel, + this.#threadTs, + this.#userId, + prompt, + options, + signal, + ); + } + + 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.debug("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.debug("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, + }); + } + 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 e2961c1..2c58368 100644 --- a/src/channels/telegram/reply.ts +++ b/src/channels/telegram/reply.ts @@ -12,10 +12,13 @@ import type { } from "../../core/channel.js"; import { errorMessage } from "../../shared/errors.js"; import type { Logger } from "../../shared/logger.js"; -import { compactTruncate, truncate } from "../../shared/text.js"; +import { truncate } from "../../shared/text.js"; +import { formatThinkingBlock, splitMessageText } from "../progress.js"; import { safeFileName } from "./message.js"; import type { TelegramReplyRoute } from "./route.js"; +export { formatThinkingBlock }; + export type ChoiceRequester = ( chat: Chat, route: TelegramReplyRoute, @@ -756,69 +759,8 @@ function telegramAttachmentKind(path: string): TelegramAttachmentKind { } } -function formatThinkingBlock(progress: ProgressSnapshot, limit = 800): string { - const text = - progress.plan.length > 1 ? formatPlanProgress(progress) : formatActionProgress(progress); - return truncate(text, limit); -} - -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 [ - `β–Œ ${compactTruncate(heading, 180)}`, - ...rows.map( - (row, index) => `${index === rows.length - 1 ? "β””" : "β”œ"} ${compactTruncate(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 ? ` (${compactTruncate(context, 140)})` : ""; - lines.push(`${marker} ${compactTruncate(step.step, 180)}${suffix}`); - if (isCurrent && reasoningMessage !== undefined && reasoningMessage !== context) { - lines.push(compactTruncate(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 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; +export function splitTelegramText(text: string, limit = 4_096): readonly string[] { + 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 df40749..44c8469 100644 --- a/src/codex/service.ts +++ b/src/codex/service.ts @@ -1134,7 +1134,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 Wirebot can deliver them. + +File delivery: +- ${connectorName} can receive files as native attachments through Wirebot. +- 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)\`. Wirebot 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. Wirebot changes only how the user communicates with Codex.`, }, diff --git a/src/config/env.ts b/src/config/env.ts index ce65f6d..4245013 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -4,10 +4,14 @@ import type { LogLevel } from "../shared/logger.js"; import { jwtPayload } from "../shared/text.js"; const envSchema = z.object({ - 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,6 +41,10 @@ export const bridgeOnlyEnvironmentKeys: 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 container: boolean; @@ -69,21 +93,23 @@ interface AppConfig { 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)", + ); + } const codexChatgptAuth = resolveChatgptAuth(parsed); if (parsed.CODEX_API_KEY !== undefined && codexChatgptAuth !== undefined) { throw new Error("CODEX_API_KEY and CODEX_CHATGPT_TOKEN are mutually exclusive; set only one"); } return { - 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.WIREBOT_TUNNEL, container: parsed.WIREBOT_CONTAINER === "1", @@ -129,3 +155,68 @@ function chatgptAccountIdFromToken(accessToken: string): string | undefined { .safeParse(jwtPayload(accessToken)); return claims.success ? claims.data["https://api.openai.com/auth"].chatgpt_account_id : undefined; } + +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()), + ), + ); +} diff --git a/src/core/bridge.ts b/src/core/bridge.ts index c26b5f4..dc900b7 100644 --- a/src/core/bridge.ts +++ b/src/core/bridge.ts @@ -62,6 +62,26 @@ export const botCommands: readonly { { command: "help", menuDescription: "Show commands", help: "show this help" }, ]; +/** Commands that act on one conversation rather than the whole Wirebot instance. */ +export const conversationScopedCommands: ReadonlySet = new Set([ + "new", + "back", + "stop", + "compact", + "schedules", + // Internal action used by scheduled-run notification buttons. + "continue", +]); + +/** Commands that change shared instance state and require provider-admin gating. */ +export const instanceAdminCommands: ReadonlySet = new Set([ + "config", + "login", + "logout", + "reload", + "restart", +]); + const helpText = [ "Send me a message to work with Codex in this conversation.", "", @@ -73,6 +93,8 @@ const readyText = const loginCodeTtl = 15 * 60 * 1_000; +type SignInDecision = "ready" | "deferred" | "rejected"; + export interface CodexRuntimeCommand { status(): CodexRuntimeStatus; reload(): Promise; @@ -96,11 +118,16 @@ export class CodexBridge { #signedInConfirmed = false; public readonly handleMessage: MessageHandler = async (message) => { + let deferred = false; try { // Command parsing is owned by the channel; the bridge trusts message.command. const command = message.command; if (command === undefined) { - if (!(await this.ensureSignedIn(message))) return; + const signIn = await this.ensureSignedIn(message); + if (signIn !== "ready") { + deferred = signIn === "deferred"; + return; + } await this.runUserTurn(message); return; } @@ -111,6 +138,8 @@ export class CodexBridge { conversation: message.address.key, }); await message.responder.sendText(`Codex error: ${errorMessage(error)}`); + } finally { + if (!deferred) await this.disposeMessage(message); } }; @@ -365,14 +394,14 @@ export class CodexBridge { ); } - private async ensureSignedIn(message: InboundMessage): Promise { - if (this.#signedInConfirmed) return true; + private async ensureSignedIn(message: InboundMessage): Promise { + if (this.#signedInConfirmed) return "ready"; const status = await this.#codex.account().catch(() => undefined); // If the status check itself fails, run the turn anyway so the real error surfaces. - if (status === undefined) return true; + if (status === undefined) return "ready"; if (!needsLogin(status)) { this.#signedInConfirmed = true; - return true; + return "ready"; } if (isPrivate(message)) { await this.sendLogin( @@ -381,12 +410,13 @@ export class CodexBridge { "Almost there β€” I need you to sign in to ChatGPT before I can work on that. I'll start on your message as soon as you're in.", message, ); + return "deferred"; } else { await message.responder.sendText( "Codex isn't signed in yet. Open a private chat with me and send /start to set it up.", ); + return "rejected"; } - return false; } private async handleLoginCompleted( @@ -417,6 +447,8 @@ export class CodexBridge { } } catch (error) { this.#logger.error("Could not deliver sign-in confirmation", error); + } finally { + if (pending.resume !== undefined) await this.disposeMessage(pending.resume); } } } @@ -430,22 +462,22 @@ export class CodexBridge { const prefix = intro === undefined ? "" : `${intro}\n\n`; switch (login.type) { case "chatgptDeviceCode": - this.registerPendingLogin(login.loginId, responder, resume); await responder.sendText( `${prefix}Tap the button below and enter this one-time code on the sign-in page:\n\n${login.userCode}\n\nI'll confirm here the moment you're in.`, { button: { label: "Open sign-in", kind: "url", url: login.verificationUrl }, }, ); + this.registerPendingLogin(login.loginId, responder, resume); return; case "chatgpt": - this.registerPendingLogin(login.loginId, responder, resume); await responder.sendText( `${prefix}Open the sign-in page to continue. I'll confirm here the moment you're in.`, { button: { label: "Open sign-in", kind: "url", url: login.authUrl }, }, ); + this.registerPendingLogin(login.loginId, responder, resume); return; case "apiKey": await responder.sendText("Codex is configured to use an API key."); @@ -461,8 +493,16 @@ export class CodexBridge { resume?: InboundMessage, ): void { const existing = this.#pendingLogins.get(loginId); - if (existing !== undefined) clearTimeout(existing.timer); - const timer = setTimeout(() => this.#pendingLogins.delete(loginId), loginCodeTtl); + if (existing !== undefined) { + clearTimeout(existing.timer); + if (existing.resume !== undefined) void this.disposeMessage(existing.resume); + } + const timer = setTimeout(() => { + const expired = this.#pendingLogins.get(loginId); + if (expired?.timer !== timer) return; + this.#pendingLogins.delete(loginId); + if (expired.resume !== undefined) void this.disposeMessage(expired.resume); + }, loginCodeTtl); timer.unref(); this.#pendingLogins.set(loginId, { responder, @@ -482,6 +522,16 @@ export class CodexBridge { return taken; } + private async disposeMessage(message: InboundMessage): Promise { + await message.dispose?.().catch((error: unknown) => { + this.#logger.debug("Could not release inbound message resources", { + channel: message.address.channel, + conversation: message.address.key, + error: errorMessage(error), + }); + }); + } + private async requirePrivateChat(message: InboundMessage): Promise { if (isPrivate(message)) return true; await message.responder.sendText("This command is available in a private bot chat only."); diff --git a/src/core/channel.ts b/src/core/channel.ts index 6502b68..f543f0a 100644 --- a/src/core/channel.ts +++ b/src/core/channel.ts @@ -131,6 +131,12 @@ export interface InboundMessage { readonly command?: InboundCommand; readonly attachments: readonly InboundAttachment[]; readonly responder: MessageResponder; + /** + * Release provider-owned temporary resources after the message is fully + * handled. The message handler owns this callback, including across a + * deferred sign-in replay, and must invoke it at most once. + */ + readonly dispose?: () => Promise; } export type MessageHandler = (message: InboundMessage) => Promise; diff --git a/src/index.ts b/src/index.ts index 7154926..b43ddf9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { ScheduledRunsEngine } from "./automations/engine.js"; import { AutomationStore } from "./automations/store.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"; @@ -28,7 +29,7 @@ import { CurlImpersonateTransport } from "./transcription/transport.js"; * workspace-write stays. */ function defaultCodexConfig(container: boolean): string { - return `# Managed by Wirebot. You can edit this file or use the Telegram Mini App. + return `# Managed by Wirebot. You can edit this file or use a Wirebot settings UI. approval_policy = "on-request" sandbox_mode = "${container ? "danger-full-access" : "workspace-write"}" web_search = "live" @@ -154,22 +155,27 @@ export async function runWirebot(): 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" }), - ...(config.assetsDirectory === undefined ? {} : { assetDirectory: config.assetsDirectory }), - }); - resources.push(miniApp); - await miniApp.start(); + // The Mini App authenticates through Telegram initData, so it only runs + // when the Telegram connector is configured. + let miniApp: MiniAppServer | undefined; + if (config.telegram !== undefined) { + 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" }), + ...(config.assetsDirectory === undefined ? {} : { assetDirectory: config.assetsDirectory }), + }); + 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 tunnel = new QuickTunnel({ host: config.host, @@ -189,23 +195,38 @@ export async function runWirebot(): Promise { } } - const telegram = new TelegramChannel( - config.telegramToken, - config.telegramApiBase, - config.allowedUserIds, - config.telegramPollTimeout, - join(config.workspace, ".wirebot", "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, ".wirebot", "attachments"), + logger.child({ component: "telegram" }), + publicUrl === undefined ? undefined : `${publicUrl}/miniapp`, + ); + const slack = + config.slack === undefined + ? undefined + : new SlackChannel( + config.slack, + join(config.workspace, ".wirebot", "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" }), }); - miniApp.setScheduledRuns(scheduledRuns); + miniApp?.setScheduledRuns(scheduledRuns); const bridge = new CodexBridge( codex, publicUrl, @@ -213,8 +234,10 @@ export async function runWirebot(): Promise { runtime, scheduledRuns, ); - resources.push(telegram); - await telegram.start(bridge.handleMessage); + for (const channel of channels) { + resources.push(channel); + await channel.start(bridge.handleMessage); + } resources.push(scheduledRuns); await scheduledRuns.start(); @@ -222,7 +245,9 @@ export async function runWirebot(): Promise { version: wirebotVersion, codexVersion: pinnedCodexVersion, 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", }); await shutdown.promise;