diff --git a/.changeset/slack-history-whoami.md b/.changeset/slack-history-whoami.md new file mode 100644 index 000000000..9a36aa6ec --- /dev/null +++ b/.changeset/slack-history-whoami.md @@ -0,0 +1,5 @@ +--- +"@sapiom/tools": minor +--- + +`connectors.slack` gains `history()` (Slack `conversations.history`: a channel's top-level messages, paginated, with `oldest` / `latest` bounds) and `whoAmI()` (Slack `auth.test`: the bot's own `user_id` and `bot_id`, to recognise its own posts). Agents that read a channel no longer need their own Slack bot token. diff --git a/examples/support-desk/README.md b/examples/support-desk/README.md index 72b493fe3..839a3f9d5 100644 --- a/examples/support-desk/README.md +++ b/examples/support-desk/README.md @@ -690,14 +690,14 @@ The deployed agents always share the fleet database. ## Known limitations -| Limitation | Why | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **A channel without the bot is invisible.** A customer posting there produces no event, no run and no alert. | Slack sends nothing for a channel the bot is not in. The setup agent probes the channels listed in `channels.customer`; it cannot find unlisted ones. | -| **Post, then record (top-level posts).** A threaded post that is recorded after it is sent is stamped with a block id derived from its key, so a retry finds it in the thread and records it instead of posting again (intake's team-reply mirror records first, so it cannot duplicate). A top-level post (the intake triage card, the urgent-pager DM, the controller's on-call escalation DM, the daily digest, an On Hold notice on an issue with no triage thread) still posts again if Slack accepted it and the next database write failed. | Slack's Web API has no idempotency key, and the Slack connector exposes neither `conversations.history` nor message `metadata`, so a top-level post cannot be found again. | -| **Linear adoption window.** A retry more than 7 days after a crash between creating the Linear issue and recording it creates a second one. | `save_issue` has no idempotency key; escalation adopts by a `:` marker over the last 7 days. | -| **Thread replies under a card left in an old triage channel are not notes.** After a desk's `triageChannel` moves, a teammate's reply under one of its existing cards is not stored on the issue. | Intake routes a message by its channel, and the old channel is no longer a desk's triage channel. | -| **Intake links by content.** A new top-level message joins any open issue Jev judges to be the same problem (p ≥ 0.8). Leftover open issues capture new messages. | Run `pnpm run reset-demo` before a demo. | -| **Latency.** A customer message reaches its draft card in about 35 s. A click shows a working state in about 2 s. | The time goes to the steps: intake makes a Jev call and several Slack calls, and each step starts a fresh sandbox call. | -| **Deploy detection is local.** `pnpm run setup` skips a deploy when the bundle hash in `.sapiom/fleet-state.json` matches the live build; a fresh clone redeploys once. | The server does not expose a content hash for a build. | -| **Customers are recognised by Slack workspace.** Anyone outside `team.slack_team_ids` posting in a channel the bot is in is a customer. Shared channels cannot be detected. A team member's message is kept only in a channel that already has an account. A customer who posts from your workspace needs `customers.test_user_ids`. | The connector has no `conversations.info`. | -| **The docs source is fleet-wide.** Every desk drafts from the same `knowledge.docs_url`. | Desks share it until a per-desk override is needed. | +| Limitation | Why | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A channel without the bot is invisible.** A customer posting there produces no event, no run and no alert. | Slack sends nothing for a channel the bot is not in. The setup agent probes the channels listed in `channels.customer`; it cannot find unlisted ones. | +| **Post, then record (top-level posts).** A threaded post that is recorded after it is sent is stamped with a block id derived from its key, so a retry finds it in the thread and records it instead of posting again (intake's team-reply mirror records first, so it cannot duplicate). A top-level post (the intake triage card, the urgent-pager DM, the controller's on-call escalation DM, the daily digest, an On Hold notice on an issue with no triage thread) still posts again if Slack accepted it and the next database write failed. | Slack's Web API has no idempotency key, and the desk does not yet search channel history (`connectors.slack.history`) for a top-level post's block id, so it cannot find one again. | +| **Linear adoption window.** A retry more than 7 days after a crash between creating the Linear issue and recording it creates a second one. | `save_issue` has no idempotency key; escalation adopts by a `:` marker over the last 7 days. | +| **Thread replies under a card left in an old triage channel are not notes.** After a desk's `triageChannel` moves, a teammate's reply under one of its existing cards is not stored on the issue. | Intake routes a message by its channel, and the old channel is no longer a desk's triage channel. | +| **Intake links by content.** A new top-level message joins any open issue Jev judges to be the same problem (p ≥ 0.8). Leftover open issues capture new messages. | Run `pnpm run reset-demo` before a demo. | +| **Latency.** A customer message reaches its draft card in about 35 s. A click shows a working state in about 2 s. | The time goes to the steps: intake makes a Jev call and several Slack calls, and each step starts a fresh sandbox call. | +| **Deploy detection is local.** `pnpm run setup` skips a deploy when the bundle hash in `.sapiom/fleet-state.json` matches the live build; a fresh clone redeploys once. | The server does not expose a content hash for a build. | +| **Customers are recognised by Slack workspace.** Anyone outside `team.slack_team_ids` posting in a channel the bot is in is a customer. Shared channels cannot be detected. A team member's message is kept only in a channel that already has an account. A customer who posts from your workspace needs `customers.test_user_ids`. | The connector has no `conversations.info`. | +| **The docs source is fleet-wide.** Every desk drafts from the same `knowledge.docs_url`. | Desks share it until a per-desk override is needed. | diff --git a/packages/tools/src/client.ts b/packages/tools/src/client.ts index 571b06db9..89ee3ad5f 100644 --- a/packages/tools/src/client.ts +++ b/packages/tools/src/client.ts @@ -202,6 +202,8 @@ import * as github from "./connectors/github/index.js"; import type { ListReposArgs, GitHubRepo } from "./connectors/github/index.js"; import * as slack from "./connectors/slack/index.js"; import type { + SlackHistoryArgs, + SlackHistoryResult, SlackPostEphemeralArgs, SlackPostEphemeralResult, SlackPostMessageArgs, @@ -214,6 +216,7 @@ import type { SlackUpdateResult, SlackUserInfoArgs, SlackUserInfoResult, + SlackWhoAmIResult, } from "./connectors/slack/index.js"; import { mcp } from "./connectors/mcp/index.js"; import type { McpConnector } from "./connectors/mcp/index.js"; @@ -632,6 +635,10 @@ export interface Sapiom { removeReaction(args: SlackReactionArgs): Promise; /** `conversations.replies`: a thread's parent message and replies, paginated. */ replies(args: SlackRepliesArgs): Promise; + /** `conversations.history`: a channel's top-level messages, paginated. */ + history(args: SlackHistoryArgs): Promise; + /** `auth.test`: the bot's own `user_id` and `bot_id`. */ + whoAmI(): Promise; /** `users.info`: look up a user by id. */ userInfo(args: SlackUserInfoArgs): Promise; }; @@ -921,6 +928,8 @@ function bind(transport: Transport): Sapiom { addReaction: (args) => slack.addReaction(args, transport), removeReaction: (args) => slack.removeReaction(args, transport), replies: (args) => slack.replies(args, transport), + history: (args) => slack.history(args, transport), + whoAmI: () => slack.whoAmI(transport), userInfo: (args) => slack.userInfo(args, transport), }, linear: mcp("linear", transport), diff --git a/packages/tools/src/connectors/slack/README.md b/packages/tools/src/connectors/slack/README.md index 94c8e6614..32e108b0e 100644 --- a/packages/tools/src/connectors/slack/README.md +++ b/packages/tools/src/connectors/slack/README.md @@ -1,6 +1,6 @@ # slack -Post, edit, and react to Slack messages and read threads and users over a tenant's +Post, edit, and react to Slack messages and read channels, threads and users over a tenant's connected Slack workspace. The gateway resolves the bot token server-side; it never reaches your run. An unconnected tenant gets a `404 connector_not_found`. @@ -23,17 +23,25 @@ Ambient import works too: `import { connectors } from "@sapiom/tools"` (then `co ## Operations -| SDK method | Slack method | Args | -| ---------------------- | ----------------------- | -------------------------------------------------- | -| `postMessage(args)` | `chat.postMessage` | `channel`, `text?`, `blocks?`, `threadTs?` | -| `update(args)` | `chat.update` | `channel`, `ts`, `text?`, `blocks?` | -| `postEphemeral(args)` | `chat.postEphemeral` | `channel`, `user`, `text?`, `blocks?`, `threadTs?` | -| `addReaction(args)` | `reactions.add` | `channel`, `timestamp`, `name` | -| `removeReaction(args)` | `reactions.remove` | `channel`, `timestamp`, `name` | -| `replies(args)` | `conversations.replies` | `channel`, `ts`, `cursor?`, `limit?` | -| `userInfo(args)` | `users.info` | `user` | +| SDK method | Slack method | Args | +| ---------------------- | ----------------------- | ------------------------------------------------------------------ | +| `postMessage(args)` | `chat.postMessage` | `channel`, `text?`, `blocks?`, `threadTs?` | +| `update(args)` | `chat.update` | `channel`, `ts`, `text?`, `blocks?` | +| `postEphemeral(args)` | `chat.postEphemeral` | `channel`, `user`, `text?`, `blocks?`, `threadTs?` | +| `addReaction(args)` | `reactions.add` | `channel`, `timestamp`, `name` | +| `removeReaction(args)` | `reactions.remove` | `channel`, `timestamp`, `name` | +| `replies(args)` | `conversations.replies` | `channel`, `ts`, `cursor?`, `limit?` | +| `history(args)` | `conversations.history` | `channel`, `cursor?`, `limit?`, `oldest?`, `latest?`, `inclusive?` | +| `whoAmI()` | `auth.test` | none | +| `userInfo(args)` | `users.info` | `user` | Message methods need `text`, `blocks`, or both. `postMessage` resolves to `{ ok, channel, ts }`; the others resolve to Slack's own response body. Slack answering `ok: false` surfaces as a `502` whose message names Slack's error code (e.g. `channel_not_found`, `not_in_channel`). + +`history` returns a channel's top-level messages, newest first; read a thread's replies +with `replies`. It needs the bot to be in the channel: Slack answers `not_in_channel` +otherwise, so invite the Sapiom app first. `whoAmI` returns the bot's `user_id` and +`bot_id`, the `user` / `bot_id` on messages it posted, so an agent can pick out its own +posts from `history`. diff --git a/packages/tools/src/connectors/slack/index.spec.ts b/packages/tools/src/connectors/slack/index.spec.ts index 5c69eed1d..40b2bff7c 100644 --- a/packages/tools/src/connectors/slack/index.spec.ts +++ b/packages/tools/src/connectors/slack/index.spec.ts @@ -120,6 +120,18 @@ describe("slack methods", () => { "conversations.replies", { channel: "C1", ts: "1.1", cursor: "c2", limit: 50 }, ], + [ + "history", + "conversations.history", + { + channel: "C1", + cursor: "c2", + limit: 200, + oldest: "1.0", + latest: "2.0", + inclusive: true, + }, + ], ["userInfo", "users.info", { user: "U1" }], ] as const)( "%s POSTs methods/%s with the args unchanged and returns Slack's body", @@ -141,6 +153,17 @@ describe("slack methods", () => { }, ); + it("whoAmI POSTs methods/auth.test with an empty body and returns the bot identity", async () => { + const result = { ok: true, user_id: "U1", bot_id: "B1", team_id: "T1" }; + const { transport, calls } = makeTransport([() => jsonResponse(result)]); + + await expect(slack.whoAmI(transport)).resolves.toEqual(result); + + expect(calls[0]!.url).toBe(`${BASE}/connectors/v1/slack/methods/auth.test`); + expect(calls[0]!.init.method).toBe("POST"); + expect(bodyOf(calls[0]!)).toEqual({}); + }); + it("surfaces a 404 (no Slack connector for this tenant) with the connector_not_found body", async () => { const { transport } = makeTransport([ () => diff --git a/packages/tools/src/connectors/slack/index.ts b/packages/tools/src/connectors/slack/index.ts index 19615e787..6475d8584 100644 --- a/packages/tools/src/connectors/slack/index.ts +++ b/packages/tools/src/connectors/slack/index.ts @@ -121,6 +121,43 @@ export interface SlackRepliesResult { [key: string]: unknown; } +export interface SlackHistoryArgs { + channel: string; + /** `response_metadata.next_cursor` from a previous page. */ + cursor?: string; + /** Messages per page, 1-1000. */ + limit?: number; + /** Only messages after this `ts`. */ + oldest?: string; + /** Only messages before this `ts`. */ + latest?: string; + /** Include messages whose `ts` equals `oldest` or `latest`. */ + inclusive?: boolean; +} + +export interface SlackHistoryResult { + ok: true; + /** Top-level messages, newest first. Thread replies are not included; read them with `replies`. */ + messages: SlackMessage[]; + has_more?: boolean; + response_metadata?: { next_cursor?: string }; + [key: string]: unknown; +} + +/** The bot's own identity, as `auth.test` returns it; fields beyond these pass through. */ +export interface SlackWhoAmIResult { + ok: true; + /** The bot user's id (`U…`): the `user` on messages it posts. */ + user_id: string; + /** The bot's id (`B…`): the `bot_id` on messages it posts. */ + bot_id?: string; + team_id: string; + team?: string; + user?: string; + url?: string; + [key: string]: unknown; +} + export interface SlackUserInfoArgs { user: string; } @@ -211,6 +248,21 @@ export async function replies( return callMethod("conversations.replies", { ...args }, transport); } +/** Read a channel's top-level messages, newest first, one page at a time. */ +export async function history( + args: SlackHistoryArgs, + transport: Transport = defaultTransport(), +): Promise { + return callMethod("conversations.history", { ...args }, transport); +} + +/** The bot's own user id and bot id, to tell its own posts apart (`auth.test`). */ +export async function whoAmI( + transport: Transport = defaultTransport(), +): Promise { + return callMethod("auth.test", {}, transport); +} + /** Look up a user by id (name, display name, timezone, bot flag). */ export async function userInfo( args: SlackUserInfoArgs, diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 272fe40ab..19d8281a0 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -260,6 +260,8 @@ export type { export type { ListReposArgs, GitHubRepo } from "./connectors/github/index.js"; export type { SlackBlock, + SlackHistoryArgs, + SlackHistoryResult, SlackMessage, SlackMessageContent, SlackPostEphemeralArgs, @@ -275,6 +277,7 @@ export type { SlackUser, SlackUserInfoArgs, SlackUserInfoResult, + SlackWhoAmIResult, } from "./connectors/slack/index.js"; export { McpRelayError } from "./connectors/mcp/index.js"; export type { diff --git a/packages/tools/src/stub/index.ts b/packages/tools/src/stub/index.ts index 0c992e850..0f3e55eaf 100644 --- a/packages/tools/src/stub/index.ts +++ b/packages/tools/src/stub/index.ts @@ -156,12 +156,14 @@ import type { } from "../connectors/google/index.js"; import type { GitHubRepo } from "../connectors/github/index.js"; import type { + SlackHistoryResult, SlackPostEphemeralResult, SlackPostMessageResult, SlackReactionResult, SlackRepliesResult, SlackUpdateResult, SlackUserInfoResult, + SlackWhoAmIResult, } from "../connectors/slack/index.js"; import type { McpCallToolResult, @@ -1052,7 +1054,8 @@ export function createStubClient(opts: StubClientOptions = {}): Sapiom { fallback: () => T, ): T => { const key = `connectors.slack.${method}`; - const result = r(key, [args], fallback); + // `undefined` is a no-arg method (`whoAmI`): its override is called with no arguments. + const result = r(key, args === undefined ? [] : [args], fallback); if ( matchedOverride([key]) && (result === null || typeof result !== "object" || Array.isArray(result)) @@ -2459,6 +2462,27 @@ export function createStubClient(opts: StubClientOptions = {}): Sapiom { ], has_more: false, })) as SlackRepliesResult, + history: async (args) => + stubSlack("history", args, () => ({ + ok: true, + messages: [ + { + type: "message", + // Not postMessage's stub ts: this is another user's message, not the bot's. + ts: "1700000000.000050", + user: "U0STUBUSER", + text: "stub channel message", + }, + ], + has_more: false, + })) as SlackHistoryResult, + whoAmI: async () => + stubSlack("whoAmI", undefined, () => ({ + ok: true, + user_id: "U0STUBBOT", + bot_id: "B0STUBBOT", + team_id: "T0STUBTEAM", + })) as SlackWhoAmIResult, userInfo: async (args) => stubSlack("userInfo", args, () => ({ ok: true, diff --git a/packages/tools/src/stub/slack-mcp.spec.ts b/packages/tools/src/stub/slack-mcp.spec.ts index 2315bddbc..968043790 100644 --- a/packages/tools/src/stub/slack-mcp.spec.ts +++ b/packages/tools/src/stub/slack-mcp.spec.ts @@ -24,6 +24,12 @@ describe("stub slack + mcp connectors", () => { expect(thread.messages[0]).toMatchObject({ ts: "1.1" }); const info = await slack.userInfo({ user: "U9" }); expect(info.user).toMatchObject({ id: "U9", is_bot: false }); + const channel = await slack.history({ channel: "C1" }); + expect(channel.messages).toHaveLength(1); + await expect(slack.whoAmI()).resolves.toMatchObject({ + user_id: "U0STUBBOT", + bot_id: "B0STUBBOT", + }); }); it("returns and records a Slack override", async () => { @@ -48,6 +54,32 @@ describe("stub slack + mcp connectors", () => { ); }); + it("calls a whoAmI override with no arguments", async () => { + const calls: StubCallRecord[] = []; + const client = createStubClient({ + calls, + overrides: { + "connectors.slack.whoAmI": (...args: unknown[]) => ({ + ok: true, + user_id: "U7", + team_id: "T7", + argCount: args.length, + }), + }, + }); + + await expect(client.connectors.slack.whoAmI()).resolves.toMatchObject({ + user_id: "U7", + argCount: 0, + }); + expect(calls).toContainEqual( + expect.objectContaining({ + capability: "connectors.slack.whoAmI", + args: [], + }), + ); + }); + it("rejects when a Slack override throws", async () => { const client = createStubClient({ overrides: {