diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e471f1..98877e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Wirebot are documented in this file. ## [Unreleased] +### Added + +- Slack: `@Wirebot new` inside a thread and plain `new` in the bot DM restart the conversation's + Codex task. The next message there carries the thread's messages from before the restart (the + recent messages, in a DM) as plain-text context, so the fresh task knows the discussion without + the previous task's memory. `/wirebot new` and `@Wirebot /new` gained the same carry-over. + ## [0.3.3] - 2026-09-21 ### Changed diff --git a/README.md b/README.md index d809767..26ef856 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Telegram's hosted Bot API only allows bots to download files up to 20 MB and upl ## Slack connector -Wirebot can additionally bridge Codex into Slack over [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) — no public URL required. Direct messages stream progress like the Telegram private chat; in channels the bot answers mentions in threads, with each thread acting as its own Codex conversation. Approvals arrive as buttons, files flow in both directions, and commands are available as `/wirebot ` (Slack reserves bare `/new`-style messages for its own slash-command system). Scheduled runs created from Slack notify back into the originating channel or thread. +Wirebot can additionally bridge Codex into Slack over [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) — no public URL required. Direct messages stream progress like the Telegram private chat; in channels the bot answers mentions in threads, with each thread acting as its own Codex conversation. Approvals arrive as buttons, files flow in both directions, and commands are available as `/wirebot ` (Slack reserves bare `/new`-style messages for its own slash-command system). `@Wirebot new` in a thread, or `new` in the bot DM, restarts that conversation's Codex task and replays the messages before the restart into the next turn as plain-text context. Scheduled runs created from Slack notify back into the originating channel or thread. Set `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `SLACK_ALLOWED_USER_IDS` together to enable it. [docs/slack.md](docs/slack.md) walks through creating the Slack app from a pasteable manifest, collecting both tokens, and first steps. Admins can open the full web app using `/wirebot config` or `/wirebot web` in a bot DM. No Telegram account is needed. diff --git a/docs/slack.md b/docs/slack.md index 2c3b159..f1da8b1 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -157,6 +157,12 @@ variables set, Wirebot runs Slack-only with the same web app. Restart Wirebot an `continue`) only work as `/wirebot …` in the bot DM; in a channel each thread is its own conversation, so run them inside the thread as a mention (`@Wirebot /stop`). +- **Restart with the thread carried over**: `@Wirebot new` inside a thread (or + plain `new` in the bot DM) starts a fresh Codex task for that conversation. + Your next message there carries the thread's messages from before the + restart — or, in the DM, the recent messages — as plain-text context (newest + 8,000 characters), so Codex knows the discussion without the previous task's + memory. `@Wirebot /new` and `/wirebot new` behave the same way. - **Sign-in**: if Codex is not signed in yet, `/wirebot login` in the bot DM returns the ChatGPT device-code link, exactly like `/login` on Telegram. @@ -168,6 +174,9 @@ variables set, Wirebot runs Slack-only with the same web app. Restart Wirebot an - **Thread context after a restart**: the "already read this thread" memory is in-process, so the first mention after a Wirebot restart re-reads the thread history. The Codex conversation itself is persisted and continues. +- **Pending `new` carry-over**: the "replay the conversation on the next + message" note set by `new` is in-process too; if Wirebot restarts in between, + the next message starts the fresh task without the replayed context. - **Attachments**: inbound files are downloaded through Slack's private file URLs with the bot token (never sent to third-party hosts); generated files are uploaded back with `files.uploadV2`. Slack voice clips are transcribed diff --git a/src/channels/slack/channel.ts b/src/channels/slack/channel.ts index fbe6c6a..ed98d2f 100644 --- a/src/channels/slack/channel.ts +++ b/src/channels/slack/channel.ts @@ -31,6 +31,7 @@ import { describeSlackFile, formatThreadContext, normalizeSlackMessage, + parseSlackCommand, routeSlackMessage, type SlackMessageEvent, type SlackThreadMessage, @@ -102,14 +103,6 @@ const membershipCacheLimit = 1_000; const membershipCacheTtlMs = 10 * 60 * 1_000; const webhookTimeoutMs = 10_000; -/** Parse mention-stripped text before handing the provider-owned command to the bridge. */ -function parseTextCommand(text: string): Readonly<{ name: string; args: string }> | undefined { - const match = /^\/([a-z][a-z0-9_]*)(?:@[a-z0-9_]+)?(?:[ \t]+([^\r\n]*))?$/i.exec(text.trim()); - const name = match?.[1]; - if (name === undefined) return undefined; - return { name: name.toLowerCase(), args: match?.[2]?.trimStart() ?? "" }; -} - export class SlackChannel implements MessagingChannel { public readonly name = "slack"; readonly #web: WebClient; @@ -134,6 +127,13 @@ export class SlackChannel implements MessagingChannel { }); /** Threads the bot already answered in — first mentions there skip the history fetch. */ readonly #engagedThreads = new Set(); + /** + * Conversation key → Slack ts of the `new` command that restarted it. The + * next ordinary message there carries the messages before that ts as + * context, so the fresh Codex task knows the discussion without the old + * task's memory. + */ + readonly #restartedConversations = new Map(); readonly #recentEvents = new Set(); readonly #displayNames = new Map(); /** @@ -233,7 +233,7 @@ export class SlackChannel implements MessagingChannel { // because the bridge deliberately trusts provider-owned command parsing. const command = inbound.command ?? - (inbound.attachments.length === 0 ? parseTextCommand(inbound.text) : undefined); + (inbound.attachments.length === 0 ? parseSlackCommand(inbound.text) : undefined); this.#logger.info("Slack message received", { userId, userName: inbound.sender.displayName, @@ -242,6 +242,7 @@ export class SlackChannel implements MessagingChannel { chars: inbound.text.length, attachments: inbound.attachments.length, }); + if (command?.name === "new") this.rememberRestart(inbound.address.key, inbound.id); if (command?.name === "config" && this.#configUi !== undefined) { // The bridge never sees this command, so its admin gate cannot apply here. if (!inbound.isAdmin) { @@ -401,7 +402,13 @@ export class SlackChannel implements MessagingChannel { if (event.channel_type !== "im") { this.rememberEngagedThread(threadKey); } - const contextualText = await this.withThreadContext(event, botUserId, threadWasEngaged, text); + const contextualText = await this.withThreadContext( + event, + botUserId, + `slack:${threadKey}`, + threadWasEngaged, + text, + ); const senderName = await this.displayName(sender); const responder = new SlackResponder( this.#api, @@ -470,38 +477,31 @@ export class SlackChannel implements MessagingChannel { private async withThreadContext( event: SlackMessageEvent, botUserId: string, + conversationKey: string, threadWasEngaged: boolean, text: string, ): Promise { + const restartTs = this.#restartedConversations.get(conversationKey); + if (restartTs !== undefined && parseSlackCommand(text) === undefined) { + this.#restartedConversations.delete(conversationKey); + return await this.withRestartContext(event, botUserId, restartTs, text); + } if ( event.channel_type === "im" || threadWasEngaged || event.thread_ts === undefined || event.thread_ts === event.ts || event.text?.includes(`<@${botUserId}>`) !== true || - parseTextCommand(text) !== undefined + parseSlackCommand(text) !== undefined ) { return text; } try { const replies = await this.#api.fetchThreadReplies(event.channel, event.thread_ts, 100); - const uniqueUsers = [ - ...new Set( - replies - .map((message) => message.user) - .filter((user): user is string => user !== undefined), - ), - ]; - const names = new Map(); - for (const user of uniqueUsers) { - names.set(user, user === botUserId ? "Wirebot (this bot)" : await this.displayName(user)); - } - const context = formatThreadContext(replies, event.ts, (message) => - message.user !== undefined - ? (names.get(message.user) ?? message.user) - : message.bot_id !== undefined - ? "bot" - : "unknown", + const context = formatThreadContext( + replies, + event.ts, + await this.nameResolver(replies, botUserId), ); if (context === undefined) return text; return `[Context — earlier messages in this Slack thread:]\n${context}\n[End of thread context]\n\n${text}`; @@ -514,6 +514,73 @@ export class SlackChannel implements MessagingChannel { } } + /** + * First ordinary message after `new`: replay the conversation as it stood + * before the restart — the thread's replies, or the DM's recent messages — + * so the fresh Codex task starts informed but without the old task's memory. + */ + private async withRestartContext( + event: SlackMessageEvent, + botUserId: string, + restartTs: string, + text: string, + ): Promise { + const isDirect = event.channel_type === "im"; + try { + const messages = isDirect + ? await this.#api.fetchHistory(event.channel, restartTs, 100) + : await this.#api.fetchThreadReplies(event.channel, event.thread_ts ?? event.ts, 100); + const context = formatThreadContext( + messages, + event.ts, + await this.nameResolver(messages, botUserId), + undefined, + restartTs, + ); + if (context === undefined) return text; + const scope = isDirect ? "conversation" : "thread"; + return `[Context — messages in this Slack ${scope} before the session restart, for reference only; the previous Codex task's memory is gone:]\n${context}\n[End of ${scope} context]\n\n${text}`; + } catch (error) { + this.#logger.warn("Could not fetch Slack context after a restart", { + channel: event.channel, + threadTs: event.thread_ts, + error: errorMessage(error), + }); + return text; + } + } + + private async nameResolver( + messages: readonly SlackThreadMessage[], + botUserId: string, + ): Promise<(message: SlackThreadMessage) => string> { + const uniqueUsers = [ + ...new Set( + messages + .map((message) => message.user) + .filter((user): user is string => user !== undefined), + ), + ]; + const names = new Map(); + for (const user of uniqueUsers) { + names.set(user, user === botUserId ? "Wirebot (this bot)" : await this.displayName(user)); + } + return (message) => + message.user !== undefined + ? (names.get(message.user) ?? message.user) + : message.bot_id !== undefined + ? "bot" + : "unknown"; + } + + private rememberRestart(conversationKey: string, messageId: string): void { + // Slash commands carry no message ts; "now" still excludes the restart itself. + const ts = /^\d+\.\d+$/u.test(messageId) ? messageId : (Date.now() / 1_000).toFixed(6); + this.#restartedConversations.delete(conversationKey); + this.#restartedConversations.set(conversationKey, ts); + trimInsertionOrderedMap(this.#restartedConversations, engagedThreadLimit); + } + private async handleSlashCommand(payload: SlackSlashCommandPayload): Promise { const handler = this.#handler; const userId = payload.user_id; @@ -859,6 +926,16 @@ function webMessagingApi(web: WebClient): SlackMessagingApi { } while (cursor !== undefined); return messages.slice(-limit); }, + async fetchHistory(channel, latestTs, limit) { + const result = await web.conversations.history({ + channel, + latest: latestTs, + inclusive: false, + limit, + }); + // Slack returns newest first. + return [...((result.messages ?? []) as unknown as readonly SlackThreadMessage[])].reverse(); + }, }; } diff --git a/src/channels/slack/message.ts b/src/channels/slack/message.ts index 92739ae..13ed0d9 100644 --- a/src/channels/slack/message.ts +++ b/src/channels/slack/message.ts @@ -60,6 +60,27 @@ export function routeSlackMessage( return { conversationSuffix: threadRoot, replyThreadTs: threadRoot }; } +/** Words that act as commands without a leading slash, in mentions and DMs alike. */ +const bareCommandWords: ReadonlySet = new Set(["new"]); + +/** + * Parse a command out of mention-stripped Slack text: `/name args` (Slack + * only swallows a leading slash in the composer, so mentions carry it through) + * or a bare command word such as `new`. Provider-owned parsing — the bridge + * trusts `InboundMessage.command`. + */ +export function parseSlackCommand( + text: string, +): Readonly<{ name: string; args: string }> | undefined { + const trimmed = text.trim(); + const match = /^\/([a-z][a-z0-9_]*)(?:@[a-z0-9_]+)?(?:[ \t]+([^\r\n]*))?$/i.exec(trimmed); + const name = match?.[1]; + if (name !== undefined) return { name: name.toLowerCase(), args: match?.[2]?.trimStart() ?? "" }; + const bare = /^([a-z]+)[.!]?$/i.exec(trimmed)?.[1]?.toLowerCase(); + if (bare !== undefined && bareCommandWords.has(bare)) return { name: bare, args: "" }; + return undefined; +} + export interface NormalizedSlackMessage { readonly text: string; readonly files: readonly SlackFile[]; @@ -89,18 +110,21 @@ export interface SlackThreadMessage { /** * Render the earlier messages of a thread as context for Codex, oldest first. - * The triggering message itself is excluded; when the thread exceeds the - * character budget the oldest messages are dropped. + * The triggering message itself is excluded, as is everything from `beforeTs` + * on when given; when the thread exceeds the character budget the oldest + * messages are dropped. */ export function formatThreadContext( messages: readonly SlackThreadMessage[], triggerTs: string, nameOf: (message: SlackThreadMessage) => string, characterBudget = 8_000, + beforeTs?: string, ): string | undefined { const lines: string[] = []; for (const message of messages) { if (message.ts === triggerTs) continue; + if (beforeTs !== undefined && Number(message.ts) >= Number(beforeTs)) continue; const text = mrkdwnToPlainText(message.text ?? "").trim(); const attachments = (message.files ?? []) .map((file) => `[attached: ${file.name ?? file.title ?? "file"}]`) diff --git a/src/channels/slack/reply.ts b/src/channels/slack/reply.ts index 00c1790..dd3b0d5 100644 --- a/src/channels/slack/reply.ts +++ b/src/channels/slack/reply.ts @@ -79,6 +79,12 @@ export interface SlackMessagingApi { threadTs: string, limit: number, ): Promise; + /** The newest `limit` top-level messages posted before `latestTs`, oldest first. */ + fetchHistory( + channel: string, + latestTs: string, + limit: number, + ): Promise; } export type SlackChoiceRequester = ( diff --git a/src/core/bridge.ts b/src/core/bridge.ts index 60843f9..a80f6a0 100644 --- a/src/core/bridge.ts +++ b/src/core/bridge.ts @@ -232,7 +232,11 @@ export class CodexBridge { return; case "new": await this.#codex.resetConversation(message.address.key); - await message.responder.sendText("Started a fresh Codex task. What should we work on?"); + await message.responder.sendText( + channelTraits(message.address.channel).restartCarriesHistory + ? "Started a fresh Codex task. Your next message will carry this conversation's earlier messages as context, without the previous task's memory. What should we work on?" + : "Started a fresh Codex task. What should we work on?", + ); return; case "back": { const threadId = await this.#codex.activatePreviousConversationThread( diff --git a/src/core/channel.ts b/src/core/channel.ts index b6ba0d6..b87b166 100644 --- a/src/core/channel.ts +++ b/src/core/channel.ts @@ -157,11 +157,14 @@ export interface ChannelTraits { commandText(command: string): string; /** Whether Wirebot delivers local files as native attachments on this connector. */ readonly supportsFileDelivery: boolean; + /** Whether the first message after `new` carries the conversation so far as context. */ + readonly restartCarriesHistory: boolean; } const defaultTraits: ChannelTraits = { commandText: (command) => `/${command}`, supportsFileDelivery: true, + restartCarriesHistory: false, }; /** Static traits per connector; Telegram matches the defaults. */ @@ -170,10 +173,12 @@ const connectorTraits: Readonly> = { slack: { commandText: (command) => `/wirebot ${command}`, supportsFileDelivery: true, + restartCarriesHistory: true, }, discord: { commandText: (command) => `/wirebot ${command}`, supportsFileDelivery: false, + restartCarriesHistory: false, }, };