From 1d6780f5f4e6779f13a22233374cdb5c7c3413d8 Mon Sep 17 00:00:00 2001 From: Anna Claud Date: Sun, 25 Jan 2026 23:34:18 -0500 Subject: [PATCH] feat(slack): add channel-info and mark-read actions for unread tracking Adds two new Slack actions to support unread message tracking: - channel-info: Returns channel metadata including last_read timestamp, unread_count, and unread_count_display. Enables bots to know what messages they haven't processed yet. - mark-read: Updates the bot's read cursor for a channel to a specific timestamp. Allows bots to mark messages as processed. Use case: AI assistants that need to catch up on missed messages after context compaction or session restart. By tracking the read position, they can fetch only new messages and avoid reprocessing. Example workflow: 1. Call channel-info to get lastRead timestamp 2. Call read with after=lastRead to get unread messages 3. Process messages 4. Call mark-read with the latest message timestamp AI-assisted: Built with Claude (Opus 4.5) via Clawdbot Testing: Lightly tested (type-checked, not runtime tested) --- src/agents/tools/slack-actions.ts | 27 ++++++++++++ src/channels/plugins/message-action-names.ts | 1 + src/channels/plugins/slack.actions.ts | 28 +++++++++++++ src/infra/outbound/message-action-spec.ts | 1 + src/slack/actions.ts | 43 ++++++++++++++++++++ 5 files changed, 100 insertions(+) diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 7771c19d4..3d980d0c3 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -5,10 +5,12 @@ import { resolveSlackAccount } from "../../slack/accounts.js"; import { deleteSlackMessage, editSlackMessage, + getSlackChannelInfo, getSlackMemberInfo, listSlackEmojis, listSlackPins, listSlackReactions, + markSlackChannelRead, pinSlackMessage, reactSlackMessage, readSlackMessages, @@ -296,5 +298,30 @@ export async function handleSlackAction( return jsonResult({ ok: true, emojis }); } + if (action === "channelInfo") { + if (!isActionEnabled("channelInfo")) { + throw new Error("Slack channel info is disabled."); + } + const channelId = resolveChannelId(); + const info = readOpts + ? await getSlackChannelInfo(channelId, readOpts) + : await getSlackChannelInfo(channelId); + return jsonResult({ ok: true, ...info }); + } + + if (action === "markRead") { + if (!isActionEnabled("channelInfo")) { + throw new Error("Slack channel info is disabled."); + } + const channelId = resolveChannelId(); + const timestamp = readStringParam(params, "timestamp", { required: true }); + if (writeOpts) { + await markSlackChannelRead(channelId, timestamp, writeOpts); + } else { + await markSlackChannelRead(channelId, timestamp); + } + return jsonResult({ ok: true, markedAt: timestamp }); + } + throw new Error(`Unknown action: ${action}`); } diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index c884f6da3..d8bbac5e9 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -33,6 +33,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "role-add", "role-remove", "channel-info", + "mark-read", "channel-list", "channel-create", "channel-edit", diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index ca8aa6fb8..d04b346a4 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -46,6 +46,10 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap } if (isActionEnabled("memberInfo")) actions.add("member-info"); if (isActionEnabled("emojiList")) actions.add("emoji-list"); + if (isActionEnabled("channelInfo")) { + actions.add("channel-info"); + actions.add("mark-read"); + } return Array.from(actions); }, extractToolSend: ({ args }): ChannelToolSend | null => { @@ -204,6 +208,30 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap ); } + if (action === "channel-info") { + return await handleSlackAction( + { + action: "channelInfo", + channelId: resolveChannelId(), + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "mark-read") { + const timestamp = readStringParam(params, "timestamp", { required: true }); + return await handleSlackAction( + { + action: "markRead", + channelId: resolveChannelId(), + timestamp, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); }, }; diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index c4f712e0f..3ef96ccf4 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -38,6 +38,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record { + const client = await getClient(opts); + const result = await client.conversations.info({ channel: channelId }); + const channel = result.channel as Record | undefined; + return { + id: channel?.id as string | undefined, + name: channel?.name as string | undefined, + lastRead: channel?.last_read as string | undefined, + unreadCount: channel?.unread_count as number | undefined, + unreadCountDisplay: channel?.unread_count_display as number | undefined, + latest: (channel?.latest as Record | undefined)?.ts as string | undefined, + }; +} + +/** + * Mark a channel as read up to a specific timestamp. + * Updates the bot's read cursor for the channel. + */ +export async function markSlackChannelRead( + channelId: string, + timestamp: string, + opts: SlackActionClientOpts = {}, +): Promise { + const client = await getClient(opts); + await client.conversations.mark({ channel: channelId, ts: timestamp }); +}