Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions packages/slack/discovery/slack/.adapter.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,23 @@
# Slack adapter

The Slack adapter exposes channels, users, messages, threads, replies, files, and reactions under `/slack`, with writeback routes for posting channel messages, direct messages, replies, and reactions. It also advertises history-independent discovery lookup indexes for Slack channel and user ids under `/discovery/slack`.
The Slack adapter exposes channels, users, messages, threads, replies, files, and reactions under `/slack`, with writeback routes for posting channel messages, direct messages, replies, and reactions. Direct messages use `/slack/users/<userId>/messages` as the product contract for both reads and writes; Slack internal `D...` IM channel ids stay in record payload metadata. It also advertises history-independent discovery lookup indexes for Slack channel and user ids under `/discovery/slack`.

Read-only mounts:
- `/slack/channels/<channelId>.json` - Channel records.
- `/slack/channels/<channelId>/messages/<messageTs>/meta.json` - Message records.
- `/slack/channels/<channelId>/messages/<messageTs>/replies/<replyTs>.json` - Thread reply records.
- `/slack/channels/<channelId>/threads/<threadTs>/replies/<replyTs>/meta.json` - Thread reply records (directory records; legacy flat `/messages/<messageTs>/replies/<replyTs>.json` writeback paths still route for edits/deletes).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Correct the legacy thread-reply path wording to avoid contract confusion.

The legacy flat reply path here should reference the thread path family (/threads/<threadTs>/replies/<replyTs>.json), not /messages/..., to match the thread-reply migration contract.

Suggested doc fix
-- `/slack/channels/<channelId>/threads/<threadTs>/replies/<replyTs>/meta.json` - Thread reply records (directory records; legacy flat `/messages/<messageTs>/replies/<replyTs>.json` writeback paths still route for edits/deletes).
+- `/slack/channels/<channelId>/threads/<threadTs>/replies/<replyTs>/meta.json` - Thread reply records (directory records; legacy flat `/threads/<threadTs>/replies/<replyTs>.json` paths still route for back-compat edits/deletes).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `/slack/channels/<channelId>/threads/<threadTs>/replies/<replyTs>/meta.json` - Thread reply records (directory records; legacy flat `/messages/<messageTs>/replies/<replyTs>.json` writeback paths still route for edits/deletes).
- `/slack/channels/<channelId>/threads/<threadTs>/replies/<replyTs>/meta.json` - Thread reply records (directory records; legacy flat `/threads/<threadTs>/replies/<replyTs>.json` paths still route for back-compat edits/deletes).
🤖 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 `@packages/slack/discovery/slack/.adapter.md` at line 8, Update the legacy flat
reply path wording in the Slack adapter docs: replace the incorrect
`/messages/<messageTs>/replies/<replyTs>.json` reference with the thread-path
family `/threads/<threadTs>/replies/<replyTs>.json` in the line describing
`/slack/channels/<channelId>/threads/<threadTs>/replies/<replyTs>/meta.json` so
the legacy path matches the thread-reply migration contract.

- `/slack/users/<userId>.json` - User records.
- `/slack/users/<userId>/messages/<messageTs>/meta.json` - 1:1 direct message records addressed by bare Slack user id.
- `/slack/users/<userId>/messages/<messageTs>/replies/<replyTs>.json` - Threaded replies in a 1:1 direct message.
- `/slack/users/<userId>/messages/<messageTs>/replies/<replyTs>/meta.json` - Threaded replies in a 1:1 direct message (directory records; legacy flat `<replyTs>.json` still readable).
- `/discovery/slack/channels/_index.json` - History-independent channel id/name lookup rows for writeback context, materialized from Slack channel discovery syncs.
- `/discovery/slack/users/_index.json` - History-independent user id/name lookup rows for direct-message writeback context, materialized from Slack user discovery syncs.

Direct messages use `/slack/users/<userId>/messages` as the product contract for both reads and writes. Slack's internal `D...` IM channel id is preserved in record payload metadata as the source conversation id; raw `D...` channel paths are diagnostic/legacy only and are not the mount contract for DMs.

Resources:

| Resource | Schema | Create example | ID pattern | What it does |
|---|---|---|---|---|
| `/slack/channels/{channelId}/messages/<id>.json` | `/slack/channels/{channelId}/messages/.schema.json` | `/slack/channels/{channelId}/messages/.create.example.json` | `^(?:meta\|(?:[A-Za-z0-9_.:-]+--)?\d{10,}(?:_\d+)?)$` | Posts a top-level Slack message. |
| `/slack/users/{userId}/messages/<id>.json` | `/slack/users/{userId}/messages/.schema.json` | `/slack/users/{userId}/messages/.create.example.json` | `^$` | Reads 1:1 direct message records and opens or reuses a direct message conversation when posting a new draft. |
| `/slack/users/{userId}/messages/<id>.json` | `/slack/users/{userId}/messages/.schema.json` | `/slack/users/{userId}/messages/.create.example.json` | `^$` | Opens or reuses a direct message conversation and posts a Slack message. |
| `/slack/channels/{channelId}/messages/{messageTs}/replies/<id>.json` | `/slack/channels/{channelId}/messages/{messageTs}/replies/.schema.json` | `/slack/channels/{channelId}/messages/{messageTs}/replies/.create.example.json` | `^(?:[A-Za-z0-9_.:-]+--)?\d{10,}(?:_\d+)?$` | Posts a reply in a Slack thread. |
| `/slack/channels/{channelId}/messages/{messageTs}/reactions/<id>.json` | `/slack/channels/{channelId}/messages/{messageTs}/reactions/.schema.json` | `/slack/channels/{channelId}/messages/{messageTs}/reactions/.create.example.json` | `^[A-Za-z0-9_.:-]+(?:--[A-Za-z0-9_.:-]+)*$` | Adds an emoji reaction to a Slack message. |

Expand Down Expand Up @@ -64,7 +62,7 @@ Fields:
- `unfurl_media` (optional, boolean) - Whether Slack should unfurl media.
- `mrkdwn` (optional, boolean) - Whether Slack should parse mrkdwn in text.

### Slack direct message
### Post Slack direct message

Resource: `/slack/users/{userId}/messages/<id>.json`
Schema: `/slack/users/{userId}/messages/.schema.json`
Expand All @@ -73,8 +71,6 @@ Required fields: none at the top level.
Optional fields: `text`, `blocks`, `attachments`, `username`, `icon_emoji`, `icon_url`, `unfurl_links`, `unfurl_media`, `mrkdwn`.
Validation: provide at least one of `text`, `blocks`, `attachments`.

Synced 1:1 DM records are materialized at `/slack/users/<userId>/messages/<messageTs>/meta.json` and keep Slack's raw `D...` conversation id in read-only fields such as `channel`, `channelId`, or `_webhook.raw_event.channel` when present. New draft writes in the same directory use `conversations.open` with the bare `<userId>` and then post the message.

Fields:

- `text` (optional, string) - Message text. Required unless blocks or attachments are supplied.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Slack direct message",
"title": "Post Slack direct message",
"type": "object",
"required": [],
"anyOf": [
Expand Down Expand Up @@ -135,5 +135,5 @@
}
},
"additionalProperties": false,
"description": "Full direct message resource record schema. Synced records are read under /slack/users/{userId}/messages/<messageTs>/meta.json; create drafts in the same directory post a Slack DM. Fields marked readOnly are synced from the provider and cannot be written by agents."
"description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents."
}
79 changes: 78 additions & 1 deletion packages/slack/src/__tests__/path-mapper-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@ import {
channelMessagesDirectory,
directMessageDirectory,
directMessagePath,
directMessageThreadReplyLegacyPath,
directMessageThreadReplyPath,
messageLegacyPath,
messagePath,
parseSlackDirectMessagePath,
parseSlackDirectMessageThreadReplyPath,
reactionPath,
slackBotsAliasPath,
slackDirectMessageThreadReplyReadCandidatePaths,
slackThreadReplyReadCandidatePaths,
threadReplyLegacyPath,
threadReplyPath,
slackByNameChannelAliasPath,
slackByNameUserAliasPath,
slackChannelsIndexPath,
Expand Down Expand Up @@ -112,7 +118,7 @@ test('direct message paths use bare user id message roots', () => {
);
assert.equal(
directMessageThreadReplyPath('U0123ABCDEF', '1711111111.000100', '1711111222.000200'),
'/slack/users/U0123ABCDEF/messages/1711111111_000100/replies/1711111222_000200.json',
'/slack/users/U0123ABCDEF/messages/1711111111_000100/replies/1711111222_000200/meta.json',
);
assert.deepEqual(
parseSlackDirectMessagePath(directMessagePath('U0123ABCDEF', '1711111111.000100')),
Expand All @@ -131,6 +137,28 @@ test('direct message paths use bare user id message roots', () => {
replyTs: '1711111222.000200',
},
);
// Legacy flat reply paths must still parse so routing works mid-migration.
assert.deepEqual(
parseSlackDirectMessageThreadReplyPath(
directMessageThreadReplyLegacyPath('U0123ABCDEF', '1711111111.000100', '1711111222.000200'),
),
{
userId: 'U0123ABCDEF',
messageTs: '1711111111.000100',
replyTs: '1711111222.000200',
},
);
assert.deepEqual(
slackDirectMessageThreadReplyReadCandidatePaths(
'U0123ABCDEF',
'1711111111.000100',
'1711111222.000200',
),
[
'/slack/users/U0123ABCDEF/messages/1711111111_000100/replies/1711111222_000200/meta.json',
'/slack/users/U0123ABCDEF/messages/1711111111_000100/replies/1711111222_000200.json',
],
);
assert.equal(parseSlackDirectMessagePath('/slack/channels/D123/messages/1711111111_000100/meta.json'), null);
});

Expand Down Expand Up @@ -213,3 +241,52 @@ test('slackBotsAliasPath emits /slack/users/bots/<id>__<slug>.json', () => {
test('slackBotsAliasPath falls back to bare id when no name is given', () => {
assert.equal(slackBotsAliasPath('B0123BOT'), '/slack/users/bots/B0123BOT.json');
});

test('threadReplyPath is a directory record and does not collide with its reaction children', () => {
const channelId = 'C123';
const threadTs = '1711111111.000100';
const replyTs = '1711111222.000200';

const reply = threadReplyPath(channelId, threadTs, replyTs);
assert.equal(
reply,
'/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200/meta.json',
);

// The reply's children (reactions) must nest UNDER the reply's directory —
// never as a sibling that shares the reply's name with a different node type.
// This is the invariant whose violation wedged the mount: a flat leaf file
// `replies/<ts>.json` could not coexist with the `replies/<ts>/` directory.
const replyDir = reply.replace(/\/meta\.json$/u, '');
const reaction = reactionPath({
targetType: 'thread_reply',
channelId,
threadTs,
replyTs,
reaction: 'tada',
userId: 'U1',
});
assert.equal(
reaction,
`${replyDir}/reactions/tada--U1.json`,
);
assert.ok(
reaction.startsWith(`${replyDir}/`),
'reaction must nest under the reply directory',
);
assert.ok(
!reaction.startsWith(`${replyDir}.json`),
'reply stem must be a directory, not a flat .json file',
);

// Back-compat: readers can still resolve a reply mirrored by a pre-0.8.x
// adapter at the legacy flat path.
assert.deepEqual(slackThreadReplyReadCandidatePaths(channelId, threadTs, replyTs), [
reply,
threadReplyLegacyPath(channelId, threadTs, replyTs),
]);
assert.equal(
threadReplyLegacyPath(channelId, threadTs, replyTs),
'/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200.json',
);
});
4 changes: 2 additions & 2 deletions packages/slack/src/__tests__/slack-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,11 +386,11 @@ test('message and thread path mapping is deterministic', () => {

assert.equal(
adapter.computePath('thread_reply', replyId),
'/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200.json',
'/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200/meta.json',
);
assert.equal(
computeSlackPath('thread_reply', replyId),
'/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200.json',
'/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200/meta.json',
);
});

Expand Down
8 changes: 4 additions & 4 deletions packages/slack/src/emit-auxiliary-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@
* Bot-flip (`is_bot: true → false`) deletes the stale `bots/` alias
* while leaving `by-name` in place.
*
* 3. **Message** / **Thread** / **Thread reply** records emit only the
* canonical `meta.json` (and per-reply `<ts>.json`) under their
* channel directory. No alias fan-out and no index file at this
* level. Because the message record itself doesn't carry the parent
* 3. **Message** / **Thread** / **Thread reply** records emit only their
* canonical `meta.json` under the channel directory. No alias fan-out
* and no index file at this level. Because the message record itself
* doesn't carry the parent
* channel name, the path falls back to the bare `<channelId>` segment
* — readers join via the channel index to discover the human-readable
* directory name.
Expand Down
4 changes: 4 additions & 0 deletions packages/slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
createSlackThreadReplyObjectId,
directMessageDirectory,
directMessagePath,
directMessageThreadReplyLegacyPath,
directMessageThreadReplyPath,
fileCommentPath,
fileMetadataPath,
Expand All @@ -26,12 +27,15 @@ export {
slackByNameChannelAliasPath,
slackByNameUserAliasPath,
slackChannelsIndexPath,
slackDirectMessageThreadReplyReadCandidatePaths,
slackMessageReadCandidatePaths,
slackNameWithId,
slackRootIndexPath,
slackThreadReplyReadCandidatePaths,
slackTimestampToPathToken,
slackUsersIndexPath,
threadPath,
threadReplyLegacyPath,
threadReplyPath,
userMetadataPath,
} from './path-mapper.js';
Expand Down
4 changes: 2 additions & 2 deletions packages/slack/src/layout-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ Always run \`ls\` before constructing a path. v2 standardizes resource directory
\`/slack/channels/<channelId>__<channelName>/\` owns per-channel records:
- \`meta.json\` — canonical channel record.
- \`messages/<ts>/meta.json\` — canonical top-level message records. Message text is mutable, so the stable Slack timestamp is the directory key.
- \`threads/<ts>/meta.json\` and \`threads/<ts>/replies/<ts>.json\` — thread roots and replies.
- \`threads/<ts>/meta.json\` and \`threads/<ts>/replies/<ts>/meta.json\` — thread roots and replies (each a directory record, so a reply can carry \`reactions/\`).
- \`messages/<ts>/reactions/<emoji>--<userId>.json\` — reaction records.
\`/slack/users/<userId>__<userName>/meta.json\` — canonical user record.
\`/slack/users/<userId>/messages/<ts>/meta.json\` — canonical 1:1 direct message records addressed by bare user id. Slack's internal \`D…\` IM channel id stays inside the JSON payload as source metadata; do not mount or write raw \`D…\` paths as the product contract.
\`/slack/users/<userId>/messages/<ts>/replies/<ts>.json\` — threaded replies in a 1:1 direct message.
\`/slack/users/<userId>/messages/<ts>/replies/<ts>/meta.json\` — threaded replies in a 1:1 direct message (directory record).
\`/slack/users/by-name/<slug>.json\` and \`/slack/channels/by-name/<slug>.json\` — name-keyed alias files pointing to canonical records. Collisions are disambiguated with a short id-derived hash suffix (e.g. \`sam-3b1a9f7c.json\`).
\`/slack/users/bots/<userId>__<userName>.json\` — alias subtree of bot users only, for \`ls\`-style discovery.
\`/discovery/slack/channels/_index.json\` and \`/discovery/slack/users/_index.json\` are history-independent lookup indexes for writeback context. They are populated from Slack channel/user discovery syncs and can be mounted even when historical message records under \`/slack/channels/**\` or \`/slack/users/**\` are not mounted.
Expand Down
99 changes: 98 additions & 1 deletion packages/slack/src/path-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,37 @@ export function directMessagePath(userId: string, messageTs: string): string {
);
}

/**
* Canonical 1:1 direct-message thread-reply record path. Like
* {@link threadReplyPath}, the reply is a directory record
* (`replies/<ts>/meta.json`) so its stem is a directory and can carry children
* without the file/dir name collision that wedges a POSIX mount. Pre-0.8.x
* emitted a flat `replies/<ts>.json` leaf — read it back via
* {@link slackDirectMessageThreadReplyReadCandidatePaths}.
*/
export function directMessageThreadReplyPath(
userId: string,
threadTs: string,
replyTs: string,
): string {
return joinPath(
directMessageDirectory(userId),
messageSegmentV2(threadTs),
'replies',
messageSegmentV2(replyTs),
'meta.json',
);
}

/**
* @deprecated Pre-0.8.x emitted a flat `.../replies/<ts>.json` leaf. Use
* {@link directMessageThreadReplyPath}. Retained for back-compat reads only —
* see {@link slackDirectMessageThreadReplyReadCandidatePaths}.
*/
export function directMessageThreadReplyLegacyPath(
userId: string,
threadTs: string,
replyTs: string,
): string {
return joinPath(
directMessageDirectory(userId),
Expand All @@ -364,6 +391,22 @@ export function directMessageThreadReplyPath(
);
}

/**
* Reader hint: candidate paths for a DM thread-reply canonical record, current
* (`<ts>/meta.json`) then legacy (`<ts>.json`), so a reply mirrored by either
* the current or a pre-0.8.x adapter still reads.
*/
export function slackDirectMessageThreadReplyReadCandidatePaths(
userId: string,
threadTs: string,
replyTs: string,
): string[] {
return [
directMessageThreadReplyPath(userId, threadTs, replyTs),
directMessageThreadReplyLegacyPath(userId, threadTs, replyTs),
];
}

export function parseSlackDirectMessagePath(path: string): SlackDirectMessageReference | null {
const match = /^\/slack\/users\/([^/]+)\/messages\/([^/]+)\/meta\.json$/.exec(path);
if (!match?.[1] || !match[2]) {
Expand All @@ -379,7 +422,11 @@ export function parseSlackDirectMessagePath(path: string): SlackDirectMessageRef
export function parseSlackDirectMessageThreadReplyPath(
path: string,
): SlackDirectMessageThreadReplyReference | null {
const match = /^\/slack\/users\/([^/]+)\/messages\/([^/]+)\/replies\/([^/]+)\.json$/.exec(path);
// Accept both the current reply record (`replies/<ts>/meta.json`) and the
// legacy flat leaf (`replies/<ts>.json`) so routing works mid-migration.
const match = /^\/slack\/users\/([^/]+)\/messages\/([^/]+)\/replies\/([^/]+?)(?:\/meta)?\.json$/.exec(
path,
);
if (!match?.[1] || !match[2] || !match[3]) {
return null;
}
Expand All @@ -403,11 +450,44 @@ export function threadPath(channelId: string, threadTs: string, channelName?: st
);
}

/**
* Canonical thread-reply record path. The reply is a **directory record**
* (`replies/<ts>/meta.json`) — matching `messagePath`, `directMessagePath`, and
* `threadPath`, all of which use `<ts>/meta.json`. This is deliberate: a reply
* can carry children (reactions live at `replies/<ts>/reactions/...`, see
* {@link reactionPath}), so its stem MUST be a directory. The pre-0.8.x adapter
* wrote a flat leaf file `replies/<ts>.json`, which collided with that same
* `<ts>` directory — one name as both a file and a directory — and could not be
* materialized on a POSIX mount (`mkdir ... : not a directory`), wedging the
* whole mirror. Readers should fall back to the legacy filename via
* {@link slackThreadReplyReadCandidatePaths}.
*/
export function threadReplyPath(
channelId: string,
threadTs: string,
replyTs: string,
channelName?: string,
): string {
return joinPath(
channelThreadsDirectory(channelId, channelName),
slackTimestampToPathToken(threadTs),
'replies',
slackTimestampToPathToken(replyTs),
'meta.json',
);
}

/**
* @deprecated Pre-0.8.x emitted a flat `.../replies/<ts>.json` leaf file, which
* collided with the `<ts>` reaction directory. Use {@link threadReplyPath}.
* Retained for back-compat reads only — see
* {@link slackThreadReplyReadCandidatePaths}.
*/
export function threadReplyLegacyPath(
channelId: string,
threadTs: string,
replyTs: string,
channelName?: string,
): string {
return joinPath(
channelThreadsDirectory(channelId, channelName),
Expand All @@ -417,6 +497,23 @@ export function threadReplyPath(
);
}

/**
* Reader hint: candidate paths for a Slack thread-reply canonical record, in
* order of preference — current (`<ts>/meta.json`) then legacy (`<ts>.json`) —
* so a reply mirrored by either the current or a pre-0.8.x adapter still reads.
*/
export function slackThreadReplyReadCandidatePaths(
channelId: string,
threadTs: string,
replyTs: string,
channelName?: string,
): string[] {
return [
threadReplyPath(channelId, threadTs, replyTs, channelName),
threadReplyLegacyPath(channelId, threadTs, replyTs, channelName),
];
}

export function userMetadataPath(userId: string, userName?: string): string {
return joinPath(SLACK_ROOT, 'users', slackNameWithId(userName, userId), 'meta.json');
}
Expand Down
7 changes: 6 additions & 1 deletion packages/slack/src/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ function extractRepliesPath(path: string): string | null {
return path.replace(/\/meta\.json$/u, '/replies');
}

const match = path.match(/^(\/slack\/channels\/[^/]+\/threads\/[^/]+)\/replies\/[^/]+\.json$/u);
// Accept both the current reply record (`replies/<ts>/meta.json`) and the
// legacy flat leaf (`replies/<ts>.json`) so a mirror mid-migration still maps
// a reply path back to its parent replies listing.
const match = path.match(
/^(\/slack\/channels\/[^/]+\/threads\/[^/]+)\/replies\/[^/]+(?:\/meta)?\.json$/u,
);
return match?.[1] ? `${match[1]}/replies` : null;
}

Expand Down
Loading
Loading