Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <subcommand>` (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 <subcommand>` (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.

Expand Down
9 changes: 9 additions & 0 deletions docs/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
133 changes: 105 additions & 28 deletions src/channels/slack/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
describeSlackFile,
formatThreadContext,
normalizeSlackMessage,
parseSlackCommand,
routeSlackMessage,
type SlackMessageEvent,
type SlackThreadMessage,
Expand Down Expand Up @@ -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;
Expand All @@ -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<string>();
/**
* 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<string, string>();
readonly #recentEvents = new Set<string>();
readonly #displayNames = new Map<string, string>();
/**
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -470,38 +477,31 @@ export class SlackChannel implements MessagingChannel {
private async withThreadContext(
event: SlackMessageEvent,
botUserId: string,
conversationKey: string,
threadWasEngaged: boolean,
text: string,
): Promise<string> {
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<string, string>();
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}`;
Expand All @@ -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<string> {
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<string, string>();
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<void> {
const handler = this.#handler;
const userId = payload.user_id;
Expand Down Expand Up @@ -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();
},
};
}

Expand Down
28 changes: 26 additions & 2 deletions src/channels/slack/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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[];
Expand Down Expand Up @@ -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"}]`)
Expand Down
6 changes: 6 additions & 0 deletions src/channels/slack/reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export interface SlackMessagingApi {
threadTs: string,
limit: number,
): Promise<readonly SlackThreadMessage[]>;
/** The newest `limit` top-level messages posted before `latestTs`, oldest first. */
fetchHistory(
channel: string,
latestTs: string,
limit: number,
): Promise<readonly SlackThreadMessage[]>;
}

export type SlackChoiceRequester = (
Expand Down
6 changes: 5 additions & 1 deletion src/core/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/core/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -170,10 +173,12 @@ const connectorTraits: Readonly<Record<string, ChannelTraits>> = {
slack: {
commandText: (command) => `/wirebot ${command}`,
supportsFileDelivery: true,
restartCarriesHistory: true,
},
discord: {
commandText: (command) => `/wirebot ${command}`,
supportsFileDelivery: false,
restartCarriesHistory: false,
},
};

Expand Down
Loading