From c6d5713057a8ca235fb2bb9c67434eadf1e8e29a Mon Sep 17 00:00:00 2001 From: zfy0701 <1646270+zfy0701@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:00:28 +0800 Subject: [PATCH] feat(daemon): add paginated Slack channel history tool --- packages/daemon/src/mcp/ops.ts | 39 +++++++++++ packages/daemon/src/mcp/tools.ts | 41 ++++++++++- packages/daemon/src/platforms/contract.ts | 25 +++++++ packages/daemon/src/platforms/read-ports.ts | 18 ++++- packages/daemon/src/slack/connection.ts | 70 ++++++++++++++++++- packages/daemon/test/connection.test.ts | 65 +++++++++++++++++ packages/daemon/test/mcp-ops.test.ts | 30 ++++++++ packages/daemon/test/mcp-tools.test.ts | 5 +- .../daemon/test/platform-contract.test.ts | 1 + 9 files changed, 288 insertions(+), 6 deletions(-) diff --git a/packages/daemon/src/mcp/ops.ts b/packages/daemon/src/mcp/ops.ts index 7ea6cc60c..5ba7cd317 100644 --- a/packages/daemon/src/mcp/ops.ts +++ b/packages/daemon/src/mcp/ops.ts @@ -7,12 +7,14 @@ import { threadKeyNeedsDmClassification } from '../platforms/thread-keys.js' import { + channelHistoryPlatformsFor, directMessagePlatformFor, directMessagePlatformList, isAttachmentReadTool, offersDirectMessages, platformLabel } from '../platforms/read-ports.js' +import type { PlatformChannelHistoryOptions, PlatformChannelHistoryPage } from '../platforms/contract.js' import type { ChannelAgentsReq, ChannelAgentsOk, @@ -83,6 +85,7 @@ export interface MessageGateway { listMembers(channel: string): Promise<{ id: string; name?: string; isBot?: boolean }[]> listChannels(): Promise<{ id: string; name?: string; isPrivate?: boolean }[]> getUserProfile(user: string): Promise<{ id: string; name?: string; realName?: string; isBot?: boolean }> + getChannelHistory?(channel: string, options?: PlatformChannelHistoryOptions): Promise /** Download an auth-gated file (Slack url_private / Telegram file_id) with the * bot credentials; null on failure / over-cap. Backs the `read*File` tools so * the agent can read attachments without ever holding the token. */ @@ -1423,6 +1426,42 @@ export async function executeTool( return { platform, users: deps.observedUsers?.(ctx.agentId, platform) ?? [] } } + if (name === 'getChannelHistory') { + const platform = optionalString(args, 'platform') ?? ctx.platform + if (channelHistoryPlatformsFor([platform]).length === 0) { + throw new Error(platformLabel(platform) + ' channel history is unavailable') + } + const wantIntegrationId = optionalString(args, 'integrationId') + const { gw, sameConvo } = resolveGatewayForPlatform(ctx, deps, platform, wantIntegrationId) + if (!gw.getChannelHistory) { + throw new Error(platformLabel(platform) + ' channel history is unavailable on this connection') + } + const channel = optionalString(args, 'channel') ?? (sameConvo ? ctx.channel : undefined) + if (!channel) { + throw new Error( + 'channel is required to read history on ' + platform + ' (a different platform than this session)' + ) + } + const cursor = optionalString(args, 'cursor') + const limit = optionalBoundedInt(args, 'limit', 1, 200) + const oldest = optionalString(args, 'oldest') + const latest = optionalString(args, 'latest') + const options: PlatformChannelHistoryOptions = { + ...(cursor ? { cursor } : {}), + ...(limit !== undefined ? { limit } : {}), + ...(oldest ? { oldest } : {}), + ...(latest ? { latest } : {}) + } + const page = await gw.getChannelHistory(channel, options) + return { + platform, + channel, + messages: page.messages, + hasMore: page.hasMore, + ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}) + } + } + if (name === 'listChannels' || name === 'listChannelMembers' || name === 'getUserProfile') { const platform = optionalString(args, 'platform') ?? ctx.platform const wantIntegrationId = optionalString(args, 'integrationId') diff --git a/packages/daemon/src/mcp/tools.ts b/packages/daemon/src/mcp/tools.ts index 34cb45a29..f5fb43180 100644 --- a/packages/daemon/src/mcp/tools.ts +++ b/packages/daemon/src/mcp/tools.ts @@ -2,7 +2,12 @@ import type { Integration } from '../agents/agent-schema.js' import type { MemoryPluginOperation } from '@agentconnect.md/protocol' import type { JSONValue } from '@modelcontextprotocol/server' import { SESSION_TITLE_TOOL_NAME } from './session-title-tool.js' -import { allAttachmentReadTools, attachmentReadToolsFor } from '../platforms/read-ports.js' +import { + allAttachmentReadTools, + allChannelHistoryPlatforms, + attachmentReadToolsFor, + channelHistoryPlatformsFor +} from '../platforms/read-ports.js' /** * A tool descriptor in MCP's `tools/list` shape: a name, a human/model-facing @@ -342,6 +347,8 @@ function buildReadTools(platforms: string[]): ToolDescriptor[] { type: 'string', description: 'Optional. Pick a specific bot when the agent has multiple integrations on the target platform.' } + const historyPlatforms = channelHistoryPlatformsFor(platforms) + const historyPlatform = { ...platform, enum: historyPlatforms } return [ { name: 'getCurrentChannel', @@ -360,6 +367,36 @@ function buildReadTools(platforms: string[]): ToolDescriptor[] { 'has multiple bots on the platform (history is not attributable to one bot).', inputSchema: obj({ platform, integrationId }) }, + ...(historyPlatforms.length > 0 + ? [ + { + name: 'getChannelHistory', + description: + 'Read one bounded page of channel/chat messages from a platform history API. Results are newest-first. ' + + 'Pass the returned nextCursor as cursor to continue with older messages, and use oldest or latest to ' + + 'bound the provider timestamp range. Omit channel to read the current conversation when targeting the ' + + 'current platform. This returns channel messages, not replies inside a thread.', + inputSchema: obj({ + platform: historyPlatform, + integrationId, + channel: { + type: 'string', + description: + 'Channel/chat id. Required when platform differs from the current conversation; defaults to the current channel otherwise.' + }, + cursor: { type: 'string', description: 'Cursor returned by the previous page.' }, + limit: { + type: 'integer', + minimum: 1, + maximum: 200, + description: 'Number of messages to request, capped at 200 per page.' + }, + oldest: { type: 'string', description: 'Oldest provider message timestamp to include in the range.' }, + latest: { type: 'string', description: 'Latest provider message timestamp to include in the range.' } + }) + } + ] + : []), { name: 'listKnownUsers', description: @@ -843,7 +880,7 @@ export const ALL_TOOL_NAMES = [ // platform-neutral tools — descriptors are built per-agent, but the names // are stable and belong in the permission auto-allow set. buildSendMessageTool([]), - ...buildReadTools([]), + ...buildReadTools(allChannelHistoryPlatforms()), // Every platform's credentialed attachment tool, whatever this agent has: // the auto-allow set is about NAMES, and a name a platform can inject must // be listed even for an agent that will never see it. diff --git a/packages/daemon/src/platforms/contract.ts b/packages/daemon/src/platforms/contract.ts index ad31e0e59..886d88efb 100644 --- a/packages/daemon/src/platforms/contract.ts +++ b/packages/daemon/src/platforms/contract.ts @@ -96,6 +96,29 @@ export interface PlatformThreadWindow { readState?: { truncated: boolean } } +/** One bounded page of channel messages from a provider history read. */ +export interface PlatformChannelHistoryMessage { + sender: string + ts: string + text: string + isBot: boolean + threadTs?: string + replyCount?: number +} + +export interface PlatformChannelHistoryOptions { + cursor?: string + limit?: number + oldest?: string + latest?: string +} + +export interface PlatformChannelHistoryPage { + messages: PlatformChannelHistoryMessage[] + hasMore: boolean + nextCursor?: string +} + /** * The Layer-1 contract every chat-platform connection satisfies. * @@ -134,6 +157,8 @@ export interface PlatformConnection { downloadFile(ref: string, maxBytes?: number): Promise // ── optional facets: what core PROBES for today ── + /** Fetch one provider-paginated page of channel messages. */ + getChannelHistory?(channel: string, options?: PlatformChannelHistoryOptions): Promise /** Provider thread history — backs mid-thread context recovery. Absent ⇒ the * daemon degrades to observed-only transcript rows. */ getThreadReplies?( diff --git a/packages/daemon/src/platforms/read-ports.ts b/packages/daemon/src/platforms/read-ports.ts index 8208008ca..9f9396980 100644 --- a/packages/daemon/src/platforms/read-ports.ts +++ b/packages/daemon/src/platforms/read-ports.ts @@ -42,9 +42,9 @@ import { SLACK_ATTACHMENT_TOOL } from './slack/attachments.js' import { TELEGRAM_ATTACHMENT_TOOL } from './telegram/attachments.js' /** The optional {@link import('./contract.js').PlatformConnection} facets core - * branches on. Deliberately only the two this seam covers: a port earns a name + * branches on. A port earns a name * here when a branch retires onto it, never speculatively. */ -export type ReadPort = 'getThreadReplies' | 'openDirectMessage' +export type ReadPort = 'getThreadReplies' | 'openDirectMessage' | 'getChannelHistory' /** Anything that MAY carry read ports: a real connection, the `MessageGateway` * slice the MCP tools hold, or a duck-typed test fake. Deliberately `object` @@ -72,6 +72,8 @@ export interface PlatformReadPorts { * id to the app's own 1:1 conversation, so `sendMessage`'s `toUser` form has * somewhere to post. */ readonly openDirectMessage?: boolean + /** The agent-facing channel history tool backed by this platform's cursor API. */ + readonly channelHistory?: boolean /** The agent-facing tool that surfaces this platform's CREDENTIALED attachment * read. Present when the platform's file references cannot be fetched without * the bot token, so the agent needs a tool instead of its own network access. @@ -99,6 +101,7 @@ const READ_PORTS = new Map([ platform: 'slack', label: 'Slack', openDirectMessage: true, + channelHistory: true, attachmentReadTool: SLACK_ATTACHMENT_TOOL } ], @@ -169,6 +172,17 @@ export function attachmentReadToolsFor(platforms: Iterable): ToolDescrip ) } +/** The platforms in the input list that expose the bounded channel-history port. */ +export function channelHistoryPlatformsFor(platforms: Iterable): string[] { + const wanted = new Set(platforms) + return [...READ_PORTS.values()].filter((d) => d.channelHistory && wanted.has(d.platform)).map((d) => d.platform) +} + +/** Every platform that exposes the bounded channel-history port, in registry order. */ +export function allChannelHistoryPlatforms(): string[] { + return [...READ_PORTS.values()].filter((d) => d.channelHistory).map((d) => d.platform) +} + /** Is `name` some platform's attachment-read tool? The MCP dispatcher runs ONE * shared body for all of them — the platform contributes only the descriptor, * the download itself is the Layer-1 `downloadFile` every connection has. */ diff --git a/packages/daemon/src/slack/connection.ts b/packages/daemon/src/slack/connection.ts index e34564d61..2fe7a4558 100644 --- a/packages/daemon/src/slack/connection.ts +++ b/packages/daemon/src/slack/connection.ts @@ -26,7 +26,11 @@ import { type StatusBarInfo, type StatusModalIdentity } from './render.js' -import type { PlatformConnection } from '../platforms/contract.js' +import type { + PlatformChannelHistoryOptions, + PlatformChannelHistoryPage, + PlatformConnection +} from '../platforms/contract.js' export interface ConsolidatedGroup { appToken: string @@ -393,6 +397,22 @@ type AppLike = { has_more?: boolean response_metadata?: { next_cursor?: string } }> + history: (a: unknown) => Promise<{ + messages?: { + user?: string + bot_id?: string + app_id?: string + bot_profile?: { app_id?: string } + ts?: string + text?: string + blocks?: unknown + attachments?: unknown + thread_ts?: string + reply_count?: number + }[] + has_more?: boolean + response_metadata?: { next_cursor?: string } + }> } users: { info: (a: unknown) => Promise<{ user?: SlackUserResult }> @@ -454,6 +474,8 @@ function isRoutableMessageEvent(ev: SlackMessageEvent): boolean { /** Cap on members enriched per `listChannelMembers` call (bounds users.info fan-out). */ const MEMBER_ENRICH_CAP = 50 +const SLACK_CHANNEL_HISTORY_DEFAULT_LIMIT = 100 +const SLACK_CHANNEL_HISTORY_MAX_LIMIT = 200 const SLACK_FILE_ORIGIN = 'https://files.slack.com' /** @@ -1232,6 +1254,52 @@ export class SlackConnection implements PlatformConnection { return out } + /** Fetch one bounded, cursor-paginated page of Slack channel messages. */ + async getChannelHistory( + channel: string, + options: PlatformChannelHistoryOptions = {} + ): Promise { + const limit = Math.min( + Math.max(options.limit ?? SLACK_CHANNEL_HISTORY_DEFAULT_LIMIT, 1), + SLACK_CHANNEL_HISTORY_MAX_LIMIT + ) + try { + const res = await this.app.client.conversations.history({ + channel, + limit, + ...(options.cursor ? { cursor: options.cursor } : {}), + ...(options.oldest ? { oldest: options.oldest } : {}), + ...(options.latest ? { latest: options.latest } : {}) + }) + const nextCursor = res.response_metadata?.next_cursor?.trim() || undefined + const messages = (res.messages ?? []).flatMap((m) => { + if (!m.ts) return [] + const appId = m.app_id ?? m.bot_profile?.app_id + const replyCount = typeof m.reply_count === 'number' && m.reply_count > 0 ? m.reply_count : undefined + return [ + { + sender: m.bot_id ?? m.user ?? 'unknown', + ts: m.ts, + text: extractSlackMessageText(m), + isBot: Boolean(m.bot_id || appId), + ...(m.thread_ts ? { threadTs: m.thread_ts } : {}), + ...(replyCount !== undefined ? { replyCount } : {}) + } + ] + }) + return { + messages, + hasMore: Boolean(res.has_more || nextCursor), + ...(nextCursor ? { nextCursor } : {}) + } + } catch (err) { + const code = slackApiErrorCode(err) + const safeCode = code && /^[a-z0-9._:-]{1,64}$/i.test(code) ? code : undefined + this.deps.log?.debug('slack: conversations.history failed (ch=' + channel + '): ' + (safeCode ?? 'unknown')) + throw new Error(safeCode ? 'Slack channel history failed: ' + safeCode : 'Slack channel history failed') + } + } + /** * Download an auth-gated Slack file (url_private[_download]) with the bot token, * up to `maxBytes` (bounds daemon RSS + the inlined prompt frame). Returns the diff --git a/packages/daemon/test/connection.test.ts b/packages/daemon/test/connection.test.ts index 907e2beeb..c77099199 100644 --- a/packages/daemon/test/connection.test.ts +++ b/packages/daemon/test/connection.test.ts @@ -437,6 +437,71 @@ describe('SlackConnection.getThreadReplies', () => { }) }) +describe('SlackConnection.getChannelHistory', () => { + it('forwards the official cursor and time bounds and returns one page', async () => { + const history = vi.fn(async () => ({ + messages: [ + { ts: '100.5', user: 'U1', text: 'latest', thread_ts: '100.1', reply_count: 2 }, + { ts: '100.4', bot_id: 'B1', text: 'bot message' } + ], + has_more: true, + response_metadata: { next_cursor: 'next-page' } + })) + const conn = new SlackConnection( + deps() as any, + () => + ({ + message() {}, + event() {}, + action() {}, + shortcut() {}, + client: { auth: { test: async () => ({ user_id: 'UBOT' }) }, conversations: { history } }, + start: async () => {}, + stop: async () => {} + }) as any + ) + + await expect( + conn.getChannelHistory('C1', { cursor: 'previous-page', limit: 2, oldest: '100.0', latest: '100.5' }) + ).resolves.toEqual({ + messages: [ + { sender: 'U1', ts: '100.5', text: 'latest', isBot: false, threadTs: '100.1', replyCount: 2 }, + { sender: 'B1', ts: '100.4', text: 'bot message', isBot: true } + ], + hasMore: true, + nextCursor: 'next-page' + }) + expect(history).toHaveBeenCalledWith({ + channel: 'C1', + cursor: 'previous-page', + limit: 2, + oldest: '100.0', + latest: '100.5' + }) + }) + + it('surfaces a bounded Slack API error code to the caller', async () => { + const history = vi.fn(async () => { + throw { data: { error: 'missing_scope' } } + }) + const conn = new SlackConnection( + deps() as any, + () => + ({ + message() {}, + event() {}, + action() {}, + shortcut() {}, + client: { auth: { test: async () => ({ user_id: 'UBOT' }) }, conversations: { history } }, + start: async () => {}, + stop: async () => {} + }) as any + ) + + await expect(conn.getChannelHistory('C1')).rejects.toThrow('Slack channel history failed: missing_scope') + }) +}) + describe('SlackConnection membership events', () => { const fakeAppWithEvents = ( handlers: Map unknown>, diff --git a/packages/daemon/test/mcp-ops.test.ts b/packages/daemon/test/mcp-ops.test.ts index 879512d3e..195fb0a9e 100644 --- a/packages/daemon/test/mcp-ops.test.ts +++ b/packages/daemon/test/mcp-ops.test.ts @@ -635,6 +635,36 @@ describe('executeTool: read tools', () => { expect(res.members).toEqual([{ id: 'U1', name: 'alice', isBot: false }]) }) + it('reads one channel-history page and forwards Slack pagination arguments', async () => { + const getChannelHistory = vi.fn(async () => ({ + messages: [{ sender: 'U1', ts: '100.5', text: 'hello', isBot: false }], + hasMore: true, + nextCursor: 'next-page' + })) + const gw = fakeGateway({ getChannelHistory }) + const { deps: d } = deps(gw) + + const res = (await executeTool( + ctx, + 'getChannelHistory', + { cursor: 'previous-page', limit: 2, oldest: '100.0', latest: '100.5' }, + d + )) as Record + + expect(getChannelHistory).toHaveBeenCalledWith('C_CURRENT', { + cursor: 'previous-page', + limit: 2, + oldest: '100.0', + latest: '100.5' + }) + expect(res).toMatchObject({ + platform: 'slack', + channel: 'C_CURRENT', + hasMore: true, + nextCursor: 'next-page' + }) + }) + it('routes a read to another connected platform via the `platform` arg', async () => { // Slack session; agent also has a Telegram bot. Ask for Telegram channels — the // read must resolve the Telegram gateway, not the current Slack one. diff --git a/packages/daemon/test/mcp-tools.test.ts b/packages/daemon/test/mcp-tools.test.ts index d5c4969b8..69731ce42 100644 --- a/packages/daemon/test/mcp-tools.test.ts +++ b/packages/daemon/test/mcp-tools.test.ts @@ -66,6 +66,7 @@ describe('toolsForIntegrations', () => { 'listChannelMembers', 'listChannels', 'getUserProfile', + 'getChannelHistory', 'readSlackFile' ]) ) @@ -98,12 +99,13 @@ describe('toolsForIntegrations', () => { expect(enumOf(readTool([slackInt, telegramInt], 'listChannels'))).toEqual(['slack', 'telegram']) expect(enumOf(readTool([slackInt, telegramInt], 'getUserProfile'))).toEqual(['slack', 'telegram']) expect(enumOf(readTool([slackInt, telegramInt], 'listKnownUsers'))).toEqual(['slack', 'telegram']) + expect(enumOf(readTool([slackInt, telegramInt], 'getChannelHistory'))).toEqual(['slack']) // A single-platform agent's enum is that one platform. expect(enumOf(readTool([slackInt], 'listChannelMembers'))).toEqual(['slack']) // Gateway-backed reads expose integrationId (bot disambiguation) like sendMessage; // listKnownUsers does not (history is not per-integration). const props = (t: { inputSchema: Record }) => t.inputSchema.properties as Record - for (const n of ['listChannels', 'listChannelMembers', 'getUserProfile']) { + for (const n of ['listChannels', 'listChannelMembers', 'getUserProfile', 'getChannelHistory']) { expect(props(readTool([slackInt, telegramInt], n))).toHaveProperty('integrationId') } expect(props(readTool([slackInt, telegramInt], 'listKnownUsers'))).not.toHaveProperty('integrationId') @@ -375,6 +377,7 @@ describe('toolsForIntegrations', () => { 'setSessionTitle', 'sendMessage', 'listChannels', + 'getChannelHistory', 'readTelegramFile', 'searchMemory', 'saveMemory', diff --git a/packages/daemon/test/platform-contract.test.ts b/packages/daemon/test/platform-contract.test.ts index f2d57cf5e..ce8a2d985 100644 --- a/packages/daemon/test/platform-contract.test.ts +++ b/packages/daemon/test/platform-contract.test.ts @@ -62,6 +62,7 @@ describe('Layer-1 platform contract (§7.1)', () => { // (`membershipEnumeration: 'authoritative'`) and provider thread history. expect(has(SlackConnection, 'listBotChannels')).toBe(true) expect(has(SlackConnection, 'getThreadReplies')).toBe(true) + expect(has(SlackConnection, 'getChannelHistory')).toBe(true) expect(has(SlackConnection, 'openDirectMessage')).toBe(true) for (const ctor of [TelegramConnection, DiscordConnection, FeishuConnection]) { expect(has(ctor, 'listBotChannels')).toBe(false)