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)
This commit is contained in:
Anna Claud 2026-01-25 23:34:18 -05:00
parent 5d6a9da370
commit 1d6780f5f4
5 changed files with 100 additions and 0 deletions

View File

@ -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}`);
}

View File

@ -33,6 +33,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"role-add",
"role-remove",
"channel-info",
"mark-read",
"channel-list",
"channel-create",
"channel-edit",

View File

@ -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}.`);
},
};

View File

@ -38,6 +38,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
"role-add": "none",
"role-remove": "none",
"channel-info": "channelId",
"mark-read": "channelId",
"channel-list": "none",
"channel-create": "none",
"channel-edit": "channelId",

View File

@ -258,3 +258,46 @@ export async function listSlackPins(
const result = await client.pins.list({ channel: channelId });
return (result.items ?? []) as SlackPin[];
}
export type SlackChannelInfo = {
id?: string;
name?: string;
lastRead?: string;
unreadCount?: number;
unreadCountDisplay?: number;
latest?: string;
};
/**
* Get channel info including unread state (last_read, unread_count).
* Useful for tracking what messages the bot hasn't processed yet.
*/
export async function getSlackChannelInfo(
channelId: string,
opts: SlackActionClientOpts = {},
): Promise<SlackChannelInfo> {
const client = await getClient(opts);
const result = await client.conversations.info({ channel: channelId });
const channel = result.channel as Record<string, unknown> | 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<string, unknown> | 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<void> {
const client = await getClient(opts);
await client.conversations.mark({ channel: channelId, ts: timestamp });
}