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
1 change: 1 addition & 0 deletions packages/core/src/triggers/catalog.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
"calendar.sync"
],
"granola": [
"file.created",
"recording.created"
],
"hubspot": [
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/triggers/catalog.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export const KNOWN_TRIGGER_CATALOG = {
"calendar.sync"
],
"granola": [
"file.created",
"recording.created"
],
"hubspot": [
Expand Down
6 changes: 6 additions & 0 deletions packages/granola/granola.mapping.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ webhooks:
- created_at
- updated_at
- web_url
# Storage-bridge event: emitted when any pipeline (e.g. NB-Whisper) writes
# a note directly to the granola VFS mount (/granola/notes/<id>.json).
# This is NOT a Granola API webhook — it fires via the storage-change
# dispatcher the same way gmail/google-drive file.created events do.
file.created:
description: A note was written to the granola VFS mount (storage-bridge path, e.g. from the NB-Whisper transcript pipeline).
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).
- `/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
Loading
Loading