Skip to content

feat(slack): add writeback resolver — postMessage, thread replies, reactions#33

Merged
khaliqgant merged 2 commits into
mainfrom
feat/slack-writeback
May 6, 2026
Merged

feat(slack): add writeback resolver — postMessage, thread replies, reactions#33
khaliqgant merged 2 commits into
mainfrom
feat/slack-writeback

Conversation

@khaliqgant

Copy link
Copy Markdown
Member

Summary

Slack was the only widely-used adapter without a writeback resolver, despite the path table in the setting-up-relayfile skill listing /slack/channels/<id>/messages/new.json as a write surface. Round-tripping agent → Slack channel was effectively undocumented and unimplemented. This PR ships the missing piece.

Routes

Path Slack endpoint
POST /slack/channels/<channel>/messages/new.json chat.postMessage
POST /slack/channels/<channel>/messages/<msg>/replies/new.json chat.postMessage with thread_ts
POST /slack/channels/<channel>/messages/<msg>/reactions/new.json reactions.add

Channel resolution

The <channel> segment is whatever path-mapper.namedSegment(name, id) emitted — either a Slack id (C01ABC123 / D… / G…) or a slugified channel name (customer-success). Slack's channel parameter accepts both forms, so we forward the id verbatim and prefix the name with #. No metadata lookup needed.

Timestamp resolution

The <msg> segment is path-mapper.messageSegment's output — either <tsToken> or <subjectSlug>--<tsToken>. The token is messageTs.replace(/\./g, '_'). The resolver reverses by replacing the last underscore with a dot to recover 1234567890.001234.

Payload shapes

messages/new.json and replies/new.json accept:

  • a plain string → posted as text
  • a JSON object → forwards text, blocks, attachments, plus thread_ts override, bot-identity overrides (username, icon_emoji, icon_url), unfurl flags, mrkdwn, and reply_broadcast (thread-only).

reactions/new.json accepts:

  • a plain string → emoji name (with or without surrounding colons)
  • a JSON object with name (or reaction)

Test coverage

12 new tests covering post_message, reply_in_thread, add_reaction, and unmatched paths. Slack package: 21 tests pass total, typecheck clean.

Test plan

  • Slack package typecheck + tests
  • Reviewer: smoke-test a chat.postMessage writeback against a sandbox workspace
  • Reviewer: confirm thread-reply via <msg>/replies/new.json correctly reconstructs thread_ts
  • Reviewer: confirm reactions.add with bare emoji name works

🤖 Generated with Claude Code

…actions

Slack was the only widely-used adapter without a writeback resolver, despite
the path table in the setting-up-relayfile skill listing
`/slack/channels/<id>/messages/new.json` as a write surface. Round-tripping
agent → channel was effectively undocumented and unimplemented; this commit
ships the missing piece.

## Routes

  POST /slack/channels/<channel>/messages/new.json                   → chat.postMessage
  POST /slack/channels/<channel>/messages/<msg>/replies/new.json     → chat.postMessage with thread_ts
  POST /slack/channels/<channel>/messages/<msg>/reactions/new.json   → reactions.add

## Channel resolution

The channel path segment is whatever `path-mapper.namedSegment(name, id)`
emitted — either a Slack id (`C01ABC123` / `D…` / `G…`) or a slugified
channel name (`customer-success`). Slack's `channel` parameter accepts
both forms, so we forward the id verbatim and prefix the name with `#`.
Avoids needing a separate metadata lookup.

## Timestamp resolution

The message path segment is `path-mapper.messageSegment`'s output, either
`<tsToken>` or `<subjectSlug>--<tsToken>`. The token is `messageTs.replace(/\./g, '_')`.
We reverse by replacing the *last* underscore with a dot to recover
`1234567890.001234`.

## Payload shapes

`messages/new.json` and `replies/new.json` accept:
- a plain string → posted as `text`
- a JSON object → forwards `text`, `blocks`, `attachments`, plus
  `thread_ts` override, bot-identity overrides (`username`, `icon_emoji`,
  `icon_url`), unfurl flags, mrkdwn flag, and `reply_broadcast`
  (thread-only).

`reactions/new.json` accepts:
- a plain string → emoji name (with or without surrounding colons)
- a JSON object with `name` (or `reaction`)

## Test coverage

12 new tests across post_message, reply_in_thread, add_reaction, and
unmatched paths. Slack package: 21 tests pass total.

Closes the documentation/implementation gap that motivated the slack
question on relayfile-adapters#32.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 788a0e28-76a0-4901-9e7b-a3e82d003ed6

📥 Commits

Reviewing files that changed from the base of the PR and between 654bce8 and 26d960b.

📒 Files selected for processing (3)
  • packages/slack/src/__tests__/writeback.test.ts
  • packages/slack/src/path-mapper.ts
  • packages/slack/src/writeback.ts

📝 Walkthrough

Walkthrough

Adds Slack writeback support: a new resolver maps writeback URL paths to Slack Web API requests (post_message, reactions.add), with payload parsing/validation and overrides. Exposes resolver and types via index; adds tests for writeback behavior and introduces/exports a round-trip-safe channel path mapper.

Changes

Slack Writeback Feature

Layer / File(s) Summary
Type Definition
packages/slack/src/types.ts
Added SlackWritebackRequest interface describing action, method, endpoint, and body for writeback operations.
Core Resolver Implementation
packages/slack/src/writeback.ts
Implemented resolveWritebackRequest(path, content) to parse path segments (channel, thread ts, subject), accept string or JSON payloads, validate required fields, apply payload overrides, and emit requests for chat.postMessage and reactions.add.
Public API Exposure
packages/slack/src/index.ts
Re-exported resolveWritebackRequest and exported SlackWritebackRequest type.
Tests
packages/slack/src/__tests__/writeback.test.ts
Comprehensive tests for post_message (plain/JSON payloads, slug/raw/canonical channel IDs, overrides, validation), reply_in_thread (tsToken handling, subjectSlug, reply_broadcast), add_reaction (name normalization, JSON payloads, validation), and unmatched path errors.

Path Mapping / Package Exports

Layer / File(s) Summary
Path helper
packages/slack/src/path-mapper.ts
Added channelSegment helper that encodes slugified channel name with canonical channel id (slug--id) to enable round-trip-safe channel path segments; replaced prior usage of namedSegment where applicable.
Package Exports
packages/slack/package.json
Added ./path-mapper entry to exports with types, import, and default fields pointing to dist/path-mapper.* to expose the path-mapper module.

Sequence Diagram

sequenceDiagram
    actor Caller
    participant Index as "packages/slack/src/index.ts"
    participant Writeback as "packages/slack/src/writeback.ts"
    participant PathMapper as "packages/slack/src/path-mapper.ts"
    participant Types as "packages/slack/src/types.ts"

    Caller->>Index: import resolveWritebackRequest
    Index->>Writeback: re-exported function
    Caller->>Writeback: resolveWritebackRequest(path, content)
    Writeback->>PathMapper: parse/extract channel segment (slug--id)
    Writeback->>Writeback: decode tsToken/subject, parse payload (string or JSON), validate fields
    Writeback->>Types: construct SlackWritebackRequest
    Writeback-->>Caller: return SlackWritebackRequest (action, method, endpoint, body)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I stitched a path of slug and id so neat,
Messages and reactions find their seat.
I parse the ts, reverse tokens with care,
Tests hop along — all robust and fair.
A tiny rabbit cheering code complete.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature addition: a writeback resolver for Slack covering postMessage, thread replies, and reactions—the core focus of the entire changeset.
Description check ✅ Passed The description is highly detailed and directly related to the changeset, explaining the routes, channel/timestamp resolution logic, payload shapes, and test coverage for the new Slack writeback resolver.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/slack-writeback

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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 654bce8947

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +64 to +68
function extractSlackChannel(segment: string): string {
const decoded = decodeURIComponent(segment);
// Slack ids: C (public), G (private/legacy), D (DM) prefix + uppercase alphanumerics.
if (/^[CDG][A-Z0-9]{7,}$/.test(decoded)) return decoded;
return decoded.startsWith('#') ? decoded : `#${decoded}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve channel identity instead of posting to slugified names

When a synced message path was generated with a Slack channel name that contains an underscore, namedSegment() slugifies it by replacing the underscore with - (packages/slack/src/path-mapper.ts:50-60), so an actual channel like customer_success becomes /slack/channels/customer-success/.... This resolver then treats that lossy slug as the Slack channel name and sends channel: '#customer-success'; because Slack channel names may contain underscores, writeback will either fail with channel_not_found or post to the wrong hyphenated channel if one exists. Use the preserved channel ID or a reversible channel segment rather than resolving the display slug as the API target.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 26d960b. Path-mapper now emits <slug>--<channelId> for channel segments via a new channelSegment(channelName, channelId) helper (mirrors the Notion / Linear round-trip pattern from #32). extractSlackChannel extracts the canonical id from the --<id>$ suffix first, then bare canonical id, then falls back to #<slug> (best-effort, limitation explicitly documented in JSDoc). Both buildPostMessage and buildAddReaction also honor an explicit channel field in the JSON payload as override — the escape hatch for paths that haven't been re-synced. New tests cover the <slug>--<id> extraction and the payload-override path.

@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

🤖 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 `@packages/slack/src/writeback.ts`:
- Around line 167-169: The code only sets body.reply_broadcast when the
URL-derived threadTs is present, so reply_broadcast is ignored if the request
includes thread_ts only in the JSON payload; update the condition around
replyBroadcast (and the assignment to body.reply_broadcast) to check for a
thread timestamp coming from either source (the existing threadTs variable or
the parsed payload's thread_ts field) — e.g., derive a single truthy
threadTimestamp = threadTs || parsed['thread_ts'] (or use the existing parsed
getter for thread_ts) and use that in the if check so reply_broadcast is applied
whenever a thread timestamp exists in either place.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 87bc2d30-dd23-40b8-9630-9de4cbf9e6c7

📥 Commits

Reviewing files that changed from the base of the PR and between 3fcdce4 and 654bce8.

📒 Files selected for processing (5)
  • packages/slack/package.json
  • packages/slack/src/__tests__/writeback.test.ts
  • packages/slack/src/index.ts
  • packages/slack/src/types.ts
  • packages/slack/src/writeback.ts

Comment thread packages/slack/src/writeback.ts Outdated
…d thread_ts

Addresses PR #33 feedback.

## P2 — Codex: lossy channel slug breaks writeback for `_`-named channels

`namedSegment(name, id)` slugified the channel name (replacing `_` with `-`)
and dropped the canonical id, so a synced path for an actual `customer_success`
Slack channel emitted `/slack/channels/customer-success/...`. The writeback
resolver then forwarded `channel: '#customer-success'` to Slack — either 404'd
with `channel_not_found` or silently posted to a different channel that
happened to use the hyphenated form.

Fix mirrors the Notion / Linear `<slug>--<id>` round-trip pattern:

- `path-mapper.ts`: new `channelSegment(channelName, channelId)` emits
  `<slug>--<channelId>` when a name is available, else falls back to the
  normalized id alone. Used by `channelMetadataPath` and
  `channelMessagesDirectory` (which `messagePath` inherits).
- `writeback.ts`: `extractSlackChannel` now extracts the canonical id from
  `--<id>$` segments first, then bare canonical id, then falls back to
  `#<slug>` (best-effort, with the limitation explicitly documented).
- Both `buildPostMessage` and `buildAddReaction` honor an explicit
  `channel` field in the JSON payload as override — the documented escape
  hatch for paths that haven't been re-synced into the new form.

## Minor — CodeRabbit: `reply_broadcast` ignored when thread_ts is payload-only

The check was on the URL-derived `threadTs`, so a top-level
`messages/new.json` with `thread_ts` in the payload had its
`reply_broadcast` flag silently dropped. Now we check `body.thread_ts`,
which reflects URL + payload override.

The `action` field also follows effective thread state — a top-level path
with payload `thread_ts` returns `reply_in_thread`, not `post_message`.

## Tests

- channel id extraction from `<slug>--<id>` form (post_message + add_reaction)
- `channel` payload override on both routes
- `reply_broadcast` honored when `thread_ts` comes from payload
- `reply_broadcast` ignored on top-level message with no thread context

Slack package: **27 tests pass** (12 existing + 15 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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