Skip to content

Add a Slack connector - #1

Open
nocell wants to merge 16 commits into
sadfun:mainfrom
nocell:slack-connector
Open

nocell wants to merge 16 commits into
sadfun:mainfrom
nocell:slack-connector

Conversation

@nocell

@nocell nocell commented Jul 28, 2026

Copy link
Copy Markdown

This adds Slack as a second messaging channel next to Telegram, implementing the existing MessagingChannel contract over Socket Mode — no public URL, webhook, or reverse proxy needed, matching the Telegram long-polling model.

Features

  • DMs stream progress and results like the Telegram private chat; long answers arrive as separate messages split under Slack's practical 4k limit (empirically, chat.update/postMessage reject far below the documented 40k with msg_too_long), with per-chunk delivery isolation.
  • Channels: mention the bot and it answers in a thread; every message addressed to it requires a mention, so surrounding human discussion is never routed to Codex. Each thread is its own persistent Codex conversation. On the first mention inside an existing thread the bot reads the earlier thread messages (conversations.replies, capped) as context.
  • Approvals arrive as interactive buttons; generated files upload back via files.uploadV2; inbound files (including Slack voice clips, which transcribe like Telegram voice messages) download through Slack's private URLs with host validation.
  • Commands via a single /telex slash command (Slack reserves bare /new-style messages) or @Bot /new mentions inside threads; conversation-scoped commands are rejected outside their thread with guidance.
  • /telex config renders interactive Codex settings from Block Kit buttons (model, reasoning effort, service tier, approvals, sandbox, web search) through CodexConfigService with optimistic versioning — a Slack counterpart of the Telegram Mini App, which stays Telegram-only.
  • Authorization: an explicit user allowlist, or SLACK_ALLOWED_USER_IDS=* for every regular workspace member (verified via users.info against the bot's team — bots, deactivated accounts, guests, and Slack Connect outsiders stay rejected; cached with a TTL). An optional SLACK_ADMIN_USER_IDS restricts instance-wide commands (config/login/logout/reload/restart/update).
  • Slack-only mode: Telegram credentials become optional; each connector is an all-or-nothing env group and at least one must be configured.
  • Scheduled runs deliver to the originating channel or thread with a Continue button; reply context survives Slack's flat threading via a thread→notification anchor.
  • Markdown → mrkdwn conversion with entity escaping everywhere (including approval prompts, where unescaped <...> would mangle command text or ping @channel), and markdown tables rendered as aligned monospace blocks.
  • Operational logging: each inbound message logs the sender identity, and streams mirror tool calls, reasoning summaries, and delivered answers to stdout.
  • Docker: a multi-stage image running as an unprivileged user with state under a /data volume (docs/docker.md explains the sandbox trade-off), plus a Compose example.
  • Socket Mode envelopes are acked before processing and deduplicated by envelope_id so redeliveries after a reconnect cannot double-execute commands or button clicks.

Design notes

  • The bridge and core stay channel-agnostic: provider-specific behavior (the /telex help, config UI, admin gate) lives in the Slack channel via a dispatch seam in front of the shared handler.
  • Progress formatting was extracted from the Telegram reply module into src/channels/progress.ts and re-exported, keeping the Telegram public API unchanged.
  • docs/slack.md walks through app creation from a pasteable manifest, token collection, and usage.

Tests cover formatting, routing, references, streaming/throttling, thread context, authorization, config UI, and env validation (307 passing); npm run check and npm run build are clean on top of current main.

Summary by CodeRabbit

  • New Features

    • Added Slack integration via Socket Mode for messages, threads, commands, approvals, attachments, and scheduled notifications.
    • Added interactive Slack controls for updating Codex settings.
    • Added Docker and Docker Compose support with persistent application data.
    • Telegram and Slack can now be enabled independently, with at least one connector required.
    • Improved formatting, progress updates, and long-response handling.
  • Documentation

    • Added Docker and Slack setup guides.
    • Updated environment configuration examples for Slack.

nocell added 13 commits July 28, 2026 11:10
Bridge Codex into Slack alongside Telegram: DMs stream progress by
editing a single message, channel mentions open threaded conversations,
approvals arrive as buttons, files flow both ways, and a /telex slash
command carries the bridge commands. Enabled by setting SLACK_BOT_TOKEN,
SLACK_APP_TOKEN, and SLACK_ALLOWED_USER_IDS together; docs/slack.md
documents the app manifest and setup.
- Deliver caption-less file uploads to Codex with attachment descriptions
  instead of silently dropping them after download.
- Reject conversation-scoped /telex subcommands in channels with guidance
  (each thread is its own conversation; a slash command carries no thread),
  instead of acting on a conversation key no message flow creates.
- Anchor thread replies to the latest scheduled-run notification published
  in that thread so reply context resolves despite Slack's flat threads.
- Escape Slack entities in approval prompts, selection updates, and
  attachment-failure notices; raw < > & mangled approval text and could
  ping @channel through Codex-controlled content.
- Deduplicate redelivered Socket Mode envelopes by envelope_id so slash
  commands and button clicks cannot execute twice after a reconnect.
- Send only unposted chunks through the slash-command response webhook.
- Refresh active-thread recency when scheduled results are published.
Telegram credentials become optional: each connector is an all-or-nothing
env group and at least one must be configured. Without Telegram, the
Telegram channel, the settings Mini App, and the quick tunnel stay off;
the Slack connector runs standalone.
Multi-stage image running Telex as the unprivileged telex user with all
state under a /data volume. The entrypoint seeds Codex config with
sandbox_mode danger-full-access on fresh volumes: Codex's bubblewrap
sandbox needs user namespaces that Docker's default confinement blocks,
so the container itself is the isolation boundary. Includes a Compose
example and docs/docker.md.
SLACK_ALLOWED_USER_IDS=* authorizes any regular member of the installed
workspace. Membership is verified through users.info against the bot's
team and cached for ten minutes: bots, deactivated accounts, guests, and
Slack Connect participants from other workspaces stay rejected, and
deactivating someone locks them out without a restart. Scheduled-run
owner re-checks go through the same gate.
gh reads GH_TOKEN from the environment; when set, the entrypoint also
wires git's HTTPS credential helper through gh so clones and fetches
work headlessly.
/telex config in the bot DM now renders interactive Codex settings from
Slack blocks (model, reasoning effort, speed tier, approvals, sandbox,
web search) through CodexConfigService with optimistic versioning —
replacing the Telegram-only Mini App pointer.

SLACK_ADMIN_USER_IDS optionally restricts instance-wide commands
(config, login, logout, reload, restart, update) to listed users; the
gate also covers the mention-text command form and config buttons.
Slack rejects chat.update/postMessage payloads far below the documented
40k ceiling (msg_too_long observed at 12k), which both truncated long
answers and, because delivery shared one try block, dropped every
remaining chunk after the first failure. The message limit drops to
3,900 characters and each chunk now posts independently, with a notice
when parts fail.

The final answer no longer silently edits the thinking message — the
progress message freezes without the streaming cursor and the answer
arrives as fresh messages that actually notify. Markdown tables render
as aligned monospace blocks instead of raw pipes.
A mention inside an existing thread calls the bot into a running
discussion. The channel now fetches the earlier thread messages through
conversations.replies (up to 100, oldest dropped over an 8k character
budget) and prefixes them as context for Codex, with display names
resolved and the triggering message excluded. Commands and already
active threads skip the fetch, and a fetch failure degrades to the
bare message.
Follow-ups in a thread the bot already answered no longer trigger it:
with workspace-wide access, humans discussing inside such a thread had
every message routed to Codex. A mention is now required each time; the
engaged-thread set survives only to skip re-reading thread history on
repeat mentions, and thread context still arrives on the first one.
Every inbound message logs the sender (user ID and display name),
conversation, command, and text; per-turn child loggers carry that
identity into the stream, which now mirrors the run: each Codex tool
call once, reasoning summaries as they change, and the delivered
answer. Rollouts never recorded who triggered a turn, so operators had
no way to attribute usage.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d5ed6262-b596-48fb-ab39-1e2ccc559948

📥 Commits

Reviewing files that changed from the base of the PR and between 2323320 and 981fb80.

📒 Files selected for processing (3)
  • docs/slack.md
  • src/codex/service.ts
  • test/codex-input.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/codex/service.ts
  • test/codex-input.test.ts
  • docs/slack.md

📝 Walkthrough

Walkthrough

Adds an optional Slack Socket Mode connector with authorization, message routing, threading, interactive controls, file handling, and shared progress utilities. Adds Docker packaging with persistent state initialization and an unprivileged runtime user. Refactors Telegram and Slack as independent optional connectors.

Changes

Slack connector and container deployment

Layer / File(s) Summary
Connector configuration and runtime wiring
src/config/env.ts, src/index.ts, package.json, test/env.test.ts
Telegram and Slack use independent optional configuration objects. Validation requires at least one connector. Startup and scheduled runs use only configured channels.
Slack ingress and conversation routing
src/channels/slack/authorization.ts, src/channels/slack/channel.ts, src/channels/slack/message.ts, src/channels/slack/references.ts, src/channels/slack/file.ts, test/slack-authorization.test.ts, test/slack-message.test.ts, test/slack-references.test.ts
Slack events use Socket Mode, authorization checks, mention and thread routing, slash commands, interactive actions, encoded references, thread context, and authenticated file downloads.
Slack output formatting and reply streaming
src/channels/progress.ts, src/channels/slack/reply.ts, src/channels/telegram/reply.ts, test/slack-reply.test.ts, test/slack-format.test.ts
Shared progress and chunking utilities support both connectors. Slack converts Markdown to mrkdwn, streams replies, handles buttons and attachments, and uses fallback delivery.
Slack configuration controls
src/channels/slack/config-ui.ts, test/slack-config-ui.test.ts
Interactive Slack screens display and update Codex settings with picker actions, optimistic version checks, validation status, and navigation controls.
Container packaging and operational setup
Dockerfile, docker/docker-compose.example.yml, docker/entrypoint.sh, .dockerignore, .env.example, README.md, docs/docker.md, docs/slack.md, src/codex/service.ts, test/codex-input.test.ts
Docker builds a production image with persistent /data state and an unprivileged telex user. The entrypoint initializes Codex configuration and optional Git credentials. Compose and documentation describe deployment, Slack setup, and remote file-reference guidance.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.80% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the PR's main change: adding a Slack connector alongside Telegram.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (6)
src/channels/slack/channel.ts (2)

764-773: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No timeout on the response_url POST.

Node's fetch has no default timeout, so a stalled Slack endpoint keeps this request — and the awaiting slash-command handler — pending indefinitely. Slack response URLs are only valid for 30 minutes; bound the call.

🛡️ Proposed fix
     await fetch(url, {
       method: "POST",
       headers: { "content-type": "application/json" },
       body: JSON.stringify({ response_type: "ephemeral", text }),
+      signal: AbortSignal.timeout(10_000),
     }).catch((error: unknown) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/slack/channel.ts` around lines 764 - 773, Update
respondThroughWebhook to bound the response_url fetch with an
AbortController-based timeout, ensuring a stalled Slack POST is aborted and the
existing debug logging handles the resulting error. Keep the current webhook
payload and successful response behavior unchanged.

466-474: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Sequential users.info lookups add latency to the ingress path.

Distinct thread participants are resolved one at a time before Codex ever sees the message. Resolving them concurrently keeps first-mention latency flat as the thread grows.

♻️ Proposed refactor
-      const names = new Map<string, string>();
-      for (const message of replies) {
-        if (message.user !== undefined && !names.has(message.user)) {
-          names.set(
-            message.user,
-            message.user === botUserId ? "Telex (this bot)" : await this.displayName(message.user),
-          );
-        }
-      }
+      const participants = [
+        ...new Set(replies.flatMap((message) => (message.user === undefined ? [] : [message.user]))),
+      ];
+      const names = new Map(
+        await Promise.all(
+          participants.map(
+            async (user): Promise<[string, string]> => [
+              user,
+              user === botUserId ? "Telex (this bot)" : await this.displayName(user),
+            ],
+          ),
+        ),
+      );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/slack/channel.ts` around lines 466 - 474, Update the
participant-name resolution in the replies processing flow to perform distinct
non-bot user lookups concurrently rather than awaiting displayName sequentially
inside the loop. Preserve the names Map, bot label, deduplication, and resulting
user-to-name mappings while using Promise.all or equivalent before Codex
receives the message.
src/channels/slack/reply.ts (2)

143-159: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Button encoding failure aborts publishSlackMessage after text is already posted.

encodeSlackCommandValue throws for unsafe/oversized action args, so a scheduled-run notification can land as text with the whole call rejecting afterwards. Consider dropping the offending action (with a warning) instead of throwing, so the message stays deliverable.

Also applies to: 174-205

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/slack/reply.ts` around lines 143 - 159, Update
commandButtonBlocks so failures from encodeSlackCommandValue do not propagate
into publishSlackMessage. Encode each action independently, omit actions whose
command value cannot be encoded, and emit a warning for each dropped action;
preserve valid buttons and return undefined when none remain.

278-289: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No timeout on the fallback webhook call.

this.#fetch has no default timeout, so a stalled response_url request can pin the reply path indefinitely. Consider AbortSignal.timeout(...).

⏱️ Suggested change
     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(10_000),
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/slack/reply.ts` around lines 278 - 289, Update
respondThroughWebhook to enforce a timeout on the fallback webhook request by
passing an AbortSignal.timeout(...) signal in the this.#fetch options. Preserve
the existing response handling and error behavior while ensuring stalled
response_url calls terminate within the established webhook timeout.
test/slack-format.test.ts (1)

42-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test name promises entity escaping but the fixture has no entities.

Add </& inside the fence to actually lock the escaping behavior.

🧪 Suggested case
-    const input = "```ts\nconst a = b ** 2; // **not bold**\n```";
-    expect(markdownToMrkdwn(input)).toBe("```ts\nconst a = b ** 2; // **not bold**\n```");
+    const input = "```ts\nconst a = b ** 2; // **not bold** & a < b\n```";
+    expect(markdownToMrkdwn(input)).toBe(
+      "```ts\nconst a = b ** 2; // **not bold** &amp; a &lt; b\n```",
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/slack-format.test.ts` around lines 42 - 45, Update the fenced-code
fixture in the test “leaves fenced code untouched apart from entity escaping” to
include ampersand and less-than characters, and change the expected output to
assert they are entity-escaped while Markdown markers remain unchanged.
test/slack-reply.test.ts (1)

208-219: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test does not assert what its name claims.

The override never records into calls.posts, so only updates is checked. Assert the mock received the final text.

🧪 Suggested tightening
-    const { api, calls } = fakeApi({
-      postMessage: vi
-        .fn<SlackMessagingApi["postMessage"]>()
-        .mockRejectedValueOnce(new Error("temporarily unavailable"))
-        .mockResolvedValue("1700.1"),
-    });
+    const postMessage = vi
+      .fn<SlackMessagingApi["postMessage"]>()
+      .mockRejectedValueOnce(new Error("temporarily unavailable"))
+      .mockResolvedValue("1700.1");
+    const { api, calls } = fakeApi({ postMessage });
     const reply = stream(api);
     await reply.start();
     await reply.complete("result");
     expect(calls.updates).toHaveLength(0);
+    expect(postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ text: "result" }));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/slack-reply.test.ts` around lines 208 - 219, Update the test “posts the
final text directly when no progress message exists” to assert that the
postMessage mock recorded the final text “result” in calls.posts, while
retaining the existing assertion that no updates occurred.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Around line 1-13: Comment out the optional Telegram sample assignments
TELEGRAM_BOT_TOKEN and TELEGRAM_ALLOWED_USER_IDS in the environment template,
matching the existing Slack examples. Preserve their explanatory comments so
users can uncomment and configure Telegram explicitly without enabling it
accidentally.

In `@Dockerfile`:
- Around line 30-31: The Dockerfile must start the entrypoint as root, while
docker/entrypoint.sh must chown the required /data paths before state creation,
then drop to UID 1001 and exec Telex as telex; update Dockerfile lines 30-31 and
docker/entrypoint.sh lines 7-9 accordingly.
- Around line 12-19: Update the GitHub CLI APT source in the Dockerfile
installation chain to use the platform architecture returned by dpkg
--print-architecture instead of hard-coding amd64, while preserving the existing
signed keyring and gh installation flow.

In `@src/channels/slack/channel.ts`:
- Around line 750-761: Update the user-name resolution flow around the
users.info lookup so failed Slack lookups are not written to `#displayNames`.
Cache and return the resolved name only after a successful response; on failure,
log the error and return the raw userId without modifying the cache.
- Line 520: Update the isDirect determination in the surrounding Slack payload
handling to inspect payload.channel_id using the existing “D” prefix convention,
rather than comparing the user-controlled payload.channel_name to
“directmessage”. Keep the resulting isPrivate behavior unchanged for genuine
direct-message channels.
- Around line 352-385: Ensure the message-level attachment directory created
before the normalized.files loop is removed after Slack message handling
completes, including when processing or downstream handling fails. Update the
enclosing attachment-processing flow around directory and attachments so cleanup
runs exactly once after use, while preserving the existing per-file download
failure behavior.

In `@src/channels/slack/file.ts`:
- Around line 90-102: Update isSlackFileHost to require the parsed URL’s
protocol to be exactly https: before accepting any Slack hostname, while
preserving the existing hostname allowlist and false result for invalid URLs.
- Around line 78-88: Cap downloads before and during the pipeline that writes
the Slack response to target. Reject files whose available file.size exceeds the
configured maximum, then add a byte-counting stream guard so mismatched or
missing sizes cannot exceed that limit. Preserve the existing unlink cleanup and
error propagation when the limit is exceeded.

In `@src/channels/slack/reply.ts`:
- Around line 511-517: Clamp the composed Slack preview in preview() so the
final returned string, including escaped progress, final text, separator, and
cursor, never exceeds slackTextLimit; truncate the escaped progress as needed
before calculating available. Apply the same composed-length clamping to the
freeze update near the existing preview/freeze construction so both draft update
paths remain within Slack’s limit.

---

Nitpick comments:
In `@src/channels/slack/channel.ts`:
- Around line 764-773: Update respondThroughWebhook to bound the response_url
fetch with an AbortController-based timeout, ensuring a stalled Slack POST is
aborted and the existing debug logging handles the resulting error. Keep the
current webhook payload and successful response behavior unchanged.
- Around line 466-474: Update the participant-name resolution in the replies
processing flow to perform distinct non-bot user lookups concurrently rather
than awaiting displayName sequentially inside the loop. Preserve the names Map,
bot label, deduplication, and resulting user-to-name mappings while using
Promise.all or equivalent before Codex receives the message.

In `@src/channels/slack/reply.ts`:
- Around line 143-159: Update commandButtonBlocks so failures from
encodeSlackCommandValue do not propagate into publishSlackMessage. Encode each
action independently, omit actions whose command value cannot be encoded, and
emit a warning for each dropped action; preserve valid buttons and return
undefined when none remain.
- Around line 278-289: Update respondThroughWebhook to enforce a timeout on the
fallback webhook request by passing an AbortSignal.timeout(...) signal in the
this.#fetch options. Preserve the existing response handling and error behavior
while ensuring stalled response_url calls terminate within the established
webhook timeout.

In `@test/slack-format.test.ts`:
- Around line 42-45: Update the fenced-code fixture in the test “leaves fenced
code untouched apart from entity escaping” to include ampersand and less-than
characters, and change the expected output to assert they are entity-escaped
while Markdown markers remain unchanged.

In `@test/slack-reply.test.ts`:
- Around line 208-219: Update the test “posts the final text directly when no
progress message exists” to assert that the postMessage mock recorded the final
text “result” in calls.posts, while retaining the existing assertion that no
updates occurred.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 08477283-9c25-4e88-9f3b-c34b3c7aafb9

📥 Commits

Reviewing files that changed from the base of the PR and between aeb6237 and 58af771.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (28)
  • .dockerignore
  • .env.example
  • Dockerfile
  • README.md
  • docker/docker-compose.example.yml
  • docker/entrypoint.sh
  • docs/docker.md
  • docs/slack.md
  • package.json
  • src/channels/progress.ts
  • src/channels/slack/authorization.ts
  • src/channels/slack/channel.ts
  • src/channels/slack/config-ui.ts
  • src/channels/slack/file.ts
  • src/channels/slack/format.ts
  • src/channels/slack/message.ts
  • src/channels/slack/references.ts
  • src/channels/slack/reply.ts
  • src/channels/telegram/reply.ts
  • src/config/env.ts
  • src/index.ts
  • test/env.test.ts
  • test/slack-authorization.test.ts
  • test/slack-config-ui.test.ts
  • test/slack-format.test.ts
  • test/slack-message.test.ts
  • test/slack-references.test.ts
  • test/slack-reply.test.ts

Comment thread .env.example
Comment thread Dockerfile
Comment thread Dockerfile Outdated
Comment on lines +352 to +385
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, "<redacted>"),
});
const reason =
error instanceof SlackFileDownloadError
? error.userMessage
: "Slack could not provide the file";
failures.push(`[${description} was not attached: ${reason}.]`);
}
}

const caption = [normalized.text, ...failures].filter((part) => part.length > 0).join("\n\n");
// A bare file upload has no text; describe the attachments so the message
// still reaches Codex instead of being dropped after the download.
const text =
caption.length > 0
? caption
: attachments.map((attachment) => `[Attached: ${attachment.description}]`).join("\n");
if (text.length === 0) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find who owns cleanup of the attachment directory
rg -nP -C4 '\battachmentDirectory\b|attachment_?[Dd]ir' --type=ts
rg -nP -C3 '\b(rm|unlink|rmdir|cleanup|purge|sweep)\w*\s*\(' --type=ts -g '!test/**' | rg -i 'attach|tmp|temp|download'

Repository: sadfun/telex

Length of output: 150


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tracked TS files near src/channels/slack/channel.ts =="
git ls-files | rg '(^|/)src/channels/slack/channel\.ts$|test/|spec/|\.test\.(ts|tsx)$|\.spec\.(ts|tsx)$' | sed -n '1,120p'

echo
echo "== attachmentDirectory references (all types) =="
rg -n -C3 'attachmentDirectory|attachment_[Dd]ir|randomUUID\(\)|downloadSlackFile' . --glob '!node_modules' --glob '!dist' --glob '!build' | sed -n '1,240p'

echo
echo "== cleanup/deletion references near attach/temp/download =="
rg -n -C2 '\b(unlink|unlinkSync|rmdir|rmdirSync|rm|rmSync|cleanup|purge|sweep|removeDir)\b|cleanupDir|delete.*Download|download.*Delete|attachmentDirectory' . --glob '!node_modules' --glob '!dist' --glob '!build' --glob '!test/**' --glob '!node_modules/**' | sed -n '1,240p'

Repository: sadfun/telex

Length of output: 30078


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== src/channels/slack/channel.ts handle/construct lifecycle =="
sed -n '130,175p' src/channels/slack/channel.ts
sed -n '330,405p' src/channels/slack/channel.ts

echo
echo "== Slack inbound constructors / bridge service refs =="
rg -n -C3 'new Slack(\b|<)|slack/|SlackConfig|attachmentDirectory:|attachmentDirectory =' src test --glob '!test/**' | sed -n '1,260p'

echo
echo "== programmatic check: Slack channel has no rm/unlink cleanup =="
python3 - <<'PY'
from pathlib import Path
text = Path("src/channels/slack/channel.ts").read_text()
for target in ["rm(", "rmSync(", "unlink(", "unlinkSync(", "rmdir(", "rmdirSync("):
    occurrences = [i for i in range(len(text)) if text.startswith(target, i)]
    print(f"{target}: {len(occurrences)}")
print("rm/import present:", "rm" in text)
print("fs/promises imported:", "node:fs/promises" in text)
print("unlink present in file:", "unlink" in text)
PY

Repository: sadfun/telex

Length of output: 13420


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Slack constructor lifecycle (source lines 132-182) =="
sed -n '132,182p' src/channels/slack/channel.ts

echo
echo "== SlackChannel references creating/use =="
rg -n -C3 'new Slack\(.*SlackChannel|new SlackChannel|SlackChannel\s*=' src test --glob '!node_modules' --glob '!dist' --glob '!build' | sed -n '1,220p'

echo
echo "== filesystem cleanup calls in src/channels/slack =="
python3 - <<'PY'
from pathlib import Path
text = Path("src/channels/slack/channel.ts").read_text() + "\n" + Path("src/channels/slack/file.ts").read_text()
for term in ["rm(", "rmSync(", "unlink(", "unlinkSync(", "rmdir(", "rmdirSync(", "fs/promises", "unlink", "rm"]:
    print(f"{term!r}: {term in text}")
PY
sed -n '24,88p' src/channels/slack/file.ts

Repository: sadfun/telex

Length of output: 5246


Clean up Slack attachment directories after message handling.

src/channels/slack/channel.ts creates a UUID-named directory under join(config.workspace, ".telex", "attachments") for every Slack message with attachments, and downloadSlackFile() only removes the individual file on download failure. No code removes those message directories, so the Slack channel leaves dangling data behind a long-running container.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/slack/channel.ts` around lines 352 - 385, Ensure the
message-level attachment directory created before the normalized.files loop is
removed after Slack message handling completes, including when processing or
downstream handling fails. Update the enclosing attachment-processing flow
around directory and attachments so cleanup runs exactly once after use, while
preserving the existing per-file download failure behavior.

Comment thread src/channels/slack/channel.ts Outdated
Comment thread src/channels/slack/channel.ts Outdated
Comment thread src/channels/slack/file.ts
Comment thread src/channels/slack/file.ts
Comment thread src/channels/slack/reply.ts
nocell added 2 commits July 31, 2026 15:10
Codex referenced workspace files as markdown links to container paths,
which render as dead links in Slack and Telegram. The remote-client
context now instructs Codex to cite code as repo-relative inline paths
or full repository URLs, and the Slack converter renders any non-URL
link target as inline code instead of a broken <path|label> link.
- Comment out the Telegram samples in .env.example: uncommented
  placeholders fail validation in a Slack-only copy.
- Let the entrypoint start as root only to chown freshly created
  volumes (non-recursively), then drop to the telex user via runuser;
  fresh named volumes are root-owned and previously broke first start.
- Install gh for the image architecture instead of hard-coded amd64.
- Cache display names only on successful lookups so a transient
  users.info failure does not pin the raw ID until restart.
- Derive slash-command isDirect from the channel ID prefix; a channel
  literally named directmessage could spoof channel_name.
- Require https in the Slack file-host check, cap downloads at 100 MB
  up front, and count streamed bytes so a wrong size cannot bypass it.
- Clamp the streaming preview and the frozen progress text so entity
  escaping cannot push a chat.update past the message limit.
- Drop unencodable command buttons with a warning instead of failing
  the whole scheduled delivery; bound both response webhooks with a
  10s abort timeout; resolve thread-context names concurrently.
- Extend the fenced-code and final-delivery tests per review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docker/entrypoint.sh (1)

33-37: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Configure GitHub CLI for the target host and fail on setup errors.

With only GH_TOKEN in a fresh container, gh auth setup-git cannot select an authenticated host. || true hides the failure, so later HTTPS Git operations cannot use the credential helper. Run it with --hostname "${GH_HOST:-github.com}" --force and do not suppress errors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker/entrypoint.sh` around lines 33 - 37, Update the GH_TOKEN setup block
in docker/entrypoint.sh to invoke gh auth setup-git with --hostname
"${GH_HOST:-github.com}" and --force, and remove the || true suppression so
setup failures propagate. Keep the existing GH_TOKEN and gh availability checks
unchanged.
src/channels/slack/file.ts (1)

60-115: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a timeout to the Slack file download request.

If the Slack file host stalls before headers or during body streaming, the sequential attachment loop waits indefinitely. Pass signal: AbortSignal.timeout(downloadTimeoutMs) to fetch() so the request and response.body have a deadline. The existing cleanup and failure handling then run when the download aborts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/slack/file.ts` around lines 60 - 115, Update the fetch call in
the Slack file download function to pass signal:
AbortSignal.timeout(downloadTimeoutMs), ensuring both response headers and body
streaming are bounded by the deadline. Preserve the existing catch, pipeline,
cleanup, and SlackFileDownloadError handling so aborted requests follow the
current failure path.
♻️ Duplicate comments (1)
src/channels/slack/channel.ts (1)

352-386: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clean up the Slack attachment directory after message handling.

This creates a UUID-named directory under #attachmentDirectory for every message with attachments, and downloadSlackFile() only removes the individual file on a per-file failure. Nothing removes the message-level directory after dispatch() completes (lines 436-441), so a long-running container accumulates one directory per attachment-bearing message indefinitely on the persistent /data volume.

🧹 Proposed fix
     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);
+    } finally {
+      if (attachments.length > 0) {
+        await rm(directory, { recursive: true, force: true }).catch(() => undefined);
+      }
     }

Import rm from node:fs/promises if not already present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/slack/channel.ts` around lines 352 - 386, Clean up the
per-message attachment directory created in the Slack message handling flow
after dispatch completes. Update the handler containing normalized, directory,
attachments, and failures to wrap the downstream dispatch in a finally block
that calls rm on directory with recursive and force options, ensuring cleanup
occurs on success and failure; import rm from node:fs/promises if needed.
🧹 Nitpick comments (1)
src/channels/slack/channel.ts (1)

466-483: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Bound the concurrency of thread-participant name lookups.

uniqueUsers.map(...) fires one users.info call per unique thread participant, all at once via Promise.all, for up to 100 replies fetched by fetchThreadReplies. In an active thread with many distinct participants this bursts dozens of concurrent Slack Web API calls on the first mention, on top of whatever else is happening on the same bot token, risking rate-limit responses that silently degrade to raw user IDs (via displayName's catch-and-fallback) instead of failing loudly.

Resolve names sequentially, or in small batches, so the lookup burst does not compete with other Slack API traffic on the same token.

🔧 Proposed fix (sequential, reuses the existing cache)
-      const names = new Map<string, string>(
-        await Promise.all(
-          uniqueUsers.map(
-            async (user): Promise<[string, string]> => [
-              user,
-              user === botUserId ? "Telex (this bot)" : await this.displayName(user),
-            ],
-          ),
-        ),
-      );
+      const names = new Map<string, string>();
+      for (const user of uniqueUsers) {
+        names.set(user, user === botUserId ? "Telex (this bot)" : await this.displayName(user));
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/slack/channel.ts` around lines 466 - 483, Update the
thread-participant name lookup around uniqueUsers and the names Map to resolve
users sequentially or in small bounded batches instead of launching every
displayName call through Promise.all. Preserve the botUserId mapping and
existing displayName cache/fallback behavior while ensuring concurrent Slack
users.info requests remain limited.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docker/entrypoint.sh`:
- Around line 10-18: The root branch of the entrypoint currently ignores
ownership failures and only updates top-level paths. In the id-u check around
mkdir/chown, recursively migrate ownership of ${data_dir} and ${workspace} to
telex:telex, and remove the silent failure fallback so any chown failure exits
before the runuser transition.

---

Outside diff comments:
In `@docker/entrypoint.sh`:
- Around line 33-37: Update the GH_TOKEN setup block in docker/entrypoint.sh to
invoke gh auth setup-git with --hostname "${GH_HOST:-github.com}" and --force,
and remove the || true suppression so setup failures propagate. Keep the
existing GH_TOKEN and gh availability checks unchanged.

In `@src/channels/slack/file.ts`:
- Around line 60-115: Update the fetch call in the Slack file download function
to pass signal: AbortSignal.timeout(downloadTimeoutMs), ensuring both response
headers and body streaming are bounded by the deadline. Preserve the existing
catch, pipeline, cleanup, and SlackFileDownloadError handling so aborted
requests follow the current failure path.

---

Duplicate comments:
In `@src/channels/slack/channel.ts`:
- Around line 352-386: Clean up the per-message attachment directory created in
the Slack message handling flow after dispatch completes. Update the handler
containing normalized, directory, attachments, and failures to wrap the
downstream dispatch in a finally block that calls rm on directory with recursive
and force options, ensuring cleanup occurs on success and failure; import rm
from node:fs/promises if needed.

---

Nitpick comments:
In `@src/channels/slack/channel.ts`:
- Around line 466-483: Update the thread-participant name lookup around
uniqueUsers and the names Map to resolve users sequentially or in small bounded
batches instead of launching every displayName call through Promise.all.
Preserve the botUserId mapping and existing displayName cache/fallback behavior
while ensuring concurrent Slack users.info requests remain limited.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 85b4df07-2b4c-492f-ace8-a8856f2a31a6

📥 Commits

Reviewing files that changed from the base of the PR and between 15fc844 and 2323320.

📒 Files selected for processing (8)
  • .env.example
  • Dockerfile
  • docker/entrypoint.sh
  • src/channels/slack/channel.ts
  • src/channels/slack/file.ts
  • src/channels/slack/reply.ts
  • test/slack-format.test.ts
  • test/slack-reply.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • .env.example
  • Dockerfile
  • test/slack-format.test.ts
  • test/slack-reply.test.ts
  • src/channels/slack/reply.ts

Comment thread docker/entrypoint.sh
Comment on lines +10 to +18
# Fresh named volumes are created owned by root; take ownership of the state
# roots (non-recursively), then continue as the unprivileged telex user.
if [ "$(id -u)" = "0" ]; then
mkdir -p "${data_dir}" "${workspace}"
chown telex:telex /data "${data_dir}" "${workspace}" 2>/dev/null || true
exec env HOME=/home/telex runuser -u telex -- "$0" "$@"
fi

mkdir -p "${data_dir}/codex-home" "${workspace}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- docker/entrypoint.sh ---'
cat -n docker/entrypoint.sh
printf '%s\n' '--- related Docker configuration ---'
rg -n -C 3 'entrypoint|data_dir|workspace|codex-home|/data|/workspace|runuser|USER ' Dockerfile* docker compose*.yml compose*.yml 2>/dev/null || true
printf '%s\n' '--- references to state paths ---'
rg -n -C 2 'codex-home|workspace|data_dir|HOME=/home/telex|/data' . --glob '!node_modules' --glob '!dist' --glob '!build'

Repository: sadfun/telex

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- Dockerfile ---'
cat -n Dockerfile
printf '%s\n' '--- docker/docs state and ownership guidance ---'
cat -n docs/docker.md
printf '%s\n' '--- application directory creation and persistence writes ---'
rg -n -C 4 'ensureDirectory|writeFile|appendFile|mkdir\(|rm\(|rename\(|dataDirectory|workspace|codexHome|outbound|conversations|automations' src/index.ts src --glob '*.ts' | head -n 500
printf '%s\n' '--- entrypoint behavior model ---'
python3 - <<'PY'
import os
import pathlib
import tempfile

with tempfile.TemporaryDirectory() as root:
    data = pathlib.Path(root) / "data"
    state = data / "telex"
    workspace = data / "workspace"
    nested = state / "codex-home" / "config.toml"
    nested.parent.mkdir(parents=True)
    workspace.mkdir(parents=True)
    nested.write_text("existing")
    os.chown(data, 0, 0)
    os.chown(state, 0, 0)
    os.chown(workspace, 0, 0)
    os.chown(nested.parent, 0, 0)
    os.chown(nested, 0, 0)

    # This models chown telex:telex /data "$data_dir" "$workspace":
    # only the named paths change; descendants do not.
    target_uid = target_gid = os.getuid()
    for path in (data, state, workspace):
        os.chown(path, target_uid, target_gid)

    print("data owner:", (data.stat().st_uid, data.stat().st_gid))
    print("state owner:", (state.stat().st_uid, state.stat().st_gid))
    print("workspace owner:", (workspace.stat().st_uid, workspace.stat().st_gid))
    print("codex-home owner:", (nested.parent.stat().st_uid, nested.parent.stat().st_gid))
    print("config owner:", (nested.stat().st_uid, nested.stat().st_gid))
PY

Repository: sadfun/telex

Length of output: 34700


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- relevant application startup and filesystem helpers ---'
cat -n src/index.ts | sed -n '45,115p'
cat -n src/shared/fs.ts
printf '%s\n' '--- persistent store write paths ---'
rg -n -C 5 'atomicWrite|writeFile|rename|mkdir' src --glob '*store*.ts' --glob 'src/automations/*.ts' --glob 'src/shared/fs.ts'
printf '%s\n' '--- deterministic entrypoint checks ---'
python3 - <<'PY'
from pathlib import Path
import re

text = Path("docker/entrypoint.sh").read_text()
match = re.search(r'^\s*chown\s+(.+?)\s+2>/dev/null\s+\|\|\s+true\s*$', text, re.M)
assert match, "The suppressed chown command was not found"
command = match.group(0)
print("suppressed chown:", command)
print("recursive flag present:", "-R" in match.group(1).split())
print("failure is ignored:", "|| true" in command)
print("runuser follows chown:", text.index("runuser") > text.index("chown"))
print("post-drop mkdir follows runuser:", text.index('mkdir -p "${data_dir}/codex-home"') > text.index("runuser"))
PY

Repository: sadfun/telex

Length of output: 12309


Fail before dropping privileges when ownership setup is incomplete.

Line 14 ignores chown failures and changes ownership only on the named paths. A reused /data volume can retain root-owned descendants such as codex-home, toolchains, or workspace state. The telex process can then fail during startup or later writes.

Fail when chown fails and apply a scoped recursive ownership migration to ${data_dir} and ${workspace} before runuser, or validate all required descendants and exit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker/entrypoint.sh` around lines 10 - 18, The root branch of the entrypoint
currently ignores ownership failures and only updates top-level paths. In the
id-u check around mkdir/chown, recursively migrate ownership of ${data_dir} and
${workspace} to telex:telex, and remove the silent failure fallback so any chown
failure exits before the runuser transition.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant