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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,27 @@ The runtime card in the Mini App shows the current outcome and offers **Apply ch

If the app-server exits on its own — for example the kernel OOM-killer or a stray `kill -9` takes it out — Wirebot relaunches it automatically, retrying five times over roughly 50 seconds while new turns wait. Only when those attempts fail does the runtime stay degraded and ask for a manual `/restart`.

### Message reactions

Wirebot automatically connects a private localhost MCP server using MCP 2026-07-28.
Its `react_to_last_message(reaction)` tool targets the message the active turn is answering.
Routing uses Codex's thread/turn metadata; no message identifiers are added to prompts.
The default messenger context encourages Codex to react when it fits the conversation.
Empty answers send no reply, so a reaction can stand on its own; Slack and Discord progress
messages are deleted when there is no final text.
Wirebot preapproves all tools from its own MCP server. Other MCP tool approvals use
the messenger's Allow once, Allow for session (when supported), and Deny buttons.
Queued messages cannot retarget a running turn. Scheduled runs and calls after a turn
ends have no reaction target.

Reactions add the bot's emoji on Slack and Discord, and replace its single reaction on Telegram.
Unsupported emoji and missing permissions return a tool error without substituting another emoji.
Standard shortcodes use the bundled Emoji Mart Unicode 15 names and aliases (for example
`:thumbsup:`, `:heart:`, and `:thumbsup::skin-tone-4:`); Unicode emoji can also be passed directly.
Custom reactions accept Slack workspace `:names:`, Discord server `:names:` or `<:name:id>`
(`<a:name:id>` for animated emoji), and Telegram `:custom_emoji_123456789:` when that chat permits it.
Slack requires `reactions:write`; Discord requires Add Reactions and Read Message History.

## Source development

Requirements: [Bun](https://bun.com) 1.4 or newer.
Expand Down
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ In **OAuth2 → URL Generator** (or the current **Installation** page), select t
- Send Messages in Threads
- Create Public Threads
- Read Message History
- Add Reactions

Open the generated URL and install the app in the server. Wirebot starts a public thread for a new
mention in a normal text channel. If it cannot create or send in that thread, it explains the
Expand Down
3 changes: 3 additions & 0 deletions docs/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ oauth_config:
scopes:
bot:
- chat:write
- reactions:write
- im:history
- channels:history
- groups:history
Expand All @@ -88,6 +89,8 @@ settings:

## 2. Collect the two tokens

For an existing installation, add `reactions:write` and reinstall the app to enable reactions.

- **App-level token** (`xapp-…`): in the app's **Basic Information** page,
scroll to **App-Level Tokens**, click **Generate Token and Scopes**, name it
(for example `wirebot-socket`), add the `connections:write` scope, and
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
"codex:update": "bun src/cli/main.ts codex check --apply"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
"@grammyjs/runner": "^2.0.3",
"@modelcontextprotocol/server": "^2.0.0",
"@radix-ui/react-switch": "^1.2.6",
"@slack/socket-mode": "^3.0.0",
"@slack/web-api": "^8.0.0",
Expand Down
9 changes: 9 additions & 0 deletions scripts/bun-globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
* skipLibCheck: false, so only what is needed is declared here.
*/
declare const Bun: {
serve(options: {
hostname: string;
port: number;
maxRequestBodySize: number;
fetch(request: Request): Response | Promise<Response>;
}): {
readonly port: number;
stop(closeActiveConnections?: boolean): Promise<void>;
};
build(config: {
entrypoints: readonly string[];
outdir: string;
Expand Down
6 changes: 6 additions & 0 deletions src/channels/discord/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { errorMessage } from "../../shared/errors.js";
import type { Logger } from "../../shared/logger.js";
import { PendingChoices } from "../choices.js";
import { decodeCommandAction } from "../command-actions.js";
import { reactToDiscordMessage } from "../reactions.js";
import { type CodexConfigAccess, DiscordConfigUi, discordConfigActionPrefix } from "./config-ui.js";
import {
discordDisplayName,
Expand Down Expand Up @@ -254,6 +255,7 @@ export class DiscordChannel implements MessagingChannel {
deliveryTarget: discordDeliveryTarget(replyRoute.channelId),
},
reference: discordMessageReference(message.channelId, message.id),
react: (reaction) => reactToDiscordMessage(message, reaction),
...(message.reference?.messageId === undefined
? {}
: {
Expand Down Expand Up @@ -770,6 +772,10 @@ function discordMessagingApi(client: Client): DiscordMessagingApi {
...(options.components === undefined ? {} : { components: options.components }),
});
},
deleteMessage: async (channelId, messageId) => {
const target = await channel(channelId);
await target.messages.delete(messageId);
},
sendTyping: async (channelId) => {
const target = await channel(channelId);
await target.sendTyping();
Expand Down
5 changes: 5 additions & 0 deletions src/channels/discord/reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface DiscordUpdateOptions {
export interface DiscordMessagingApi {
postMessage(options: DiscordPostOptions): Promise<string>;
updateMessage(options: DiscordUpdateOptions): Promise<void>;
deleteMessage(channelId: string, messageId: string): Promise<void>;
sendTyping(channelId: string): Promise<void>;
}

Expand Down Expand Up @@ -199,6 +200,10 @@ export class DiscordReplyStream extends DraftReplyStream {
await this.#api.updateMessage({ channelId: this.#channelId, messageId, content });
}

protected async remove(messageId: string): Promise<void> {
await this.#api.deleteMessage(this.#channelId, messageId);
}

protected renderProgress(block: string): string {
return block;
}
Expand Down
19 changes: 14 additions & 5 deletions src/channels/draft-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { formatThinkingBlock, splitMessageText } from "./progress.js";
/**
* Channel-agnostic streaming reply: one editable message that starts as
* progress and is replaced by the final answer. Edits are throttled to one
* per interval. Connectors supply the transport (post/update) and the
* per interval. Connectors supply the transport (post/update/delete) and the
* channel-specific text rendering; all draft/lifecycle state lives here.
*/
export abstract class DraftReplyStream implements OutboundStream {
Expand Down Expand Up @@ -51,6 +51,9 @@ export abstract class DraftReplyStream implements OutboundStream {
/** Edit the progress message in place. */
protected abstract update(messageId: string, content: string): Promise<void>;

/** Delete the progress message when the turn ends without text. */
protected abstract remove(messageId: string): Promise<void>;

/** Adapt the rendered thinking block for the channel (e.g. entity escaping). */
protected abstract renderProgress(block: string): string;

Expand Down Expand Up @@ -147,11 +150,17 @@ export abstract class DraftReplyStream implements OutboundStream {
const [first, ...remaining] = chunks;
let chunksToPost = chunks;
if (this.#messageId !== undefined) {
const replacement =
first ?? this.renderProgress(formatThinkingBlock(this.#progress)).slice(0, this.#textLimit);
if (replacement !== this.#lastPublishedText) {
if (first === undefined) {
try {
await this.remove(this.#messageId);
} catch (error) {
this.logger.warn(`${this.#channelLabel} progress message could not be deleted`, {
error: errorMessage(error),
});
}
} else if (first !== this.#lastPublishedText) {
try {
await this.update(this.#messageId, replacement);
await this.update(this.#messageId, first);
chunksToPost = remaining;
} catch (error) {
this.logger.debug(`${this.#channelLabel} final replacement failed`, {
Expand Down
107 changes: 107 additions & 0 deletions src/channels/reactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { EmojiMartData } from "@emoji-mart/data";
import data from "@emoji-mart/data/sets/15/native.json";
import type { WebClient } from "@slack/web-api";
import type { Message } from "discord.js";
import type { Api } from "grammy";
import type { ReactionTypeEmoji } from "grammy/types";

const emojiData: EmojiMartData = data;
const unicodeByName = new Map<string, string>();
const nameByUnicode = new Map<string, string>();

// ponytail: Unicode 15 shortcodes; upgrade the data set when newer names are needed.
for (const emoji of Object.values(emojiData.emojis)) {
for (const [index, skin] of emoji.skins.entries()) {
const name = index === 0 ? emoji.id : `${emoji.id}::skin-tone-${index + 1}`;
unicodeByName.set(name, skin.native);
nameByUnicode.set(withoutPresentation(skin.native), name);
}
}

/** A standard or provider-custom shortcode, without its surrounding colons. */
function reactionName(reaction: string): string | undefined {
return /^:([\w+-]+(?:::skin-tone-[2-6])?):$/u.exec(reaction.trim())?.[1];
}

/** Resolve a standard shortcode; leave valid Unicode emoji intact. */
export function unicodeReaction(reaction: string): string {
const value = reaction.trim();
const name = reactionName(value);
if (name !== undefined) {
const [base = "", tone] = name.split("::");
const canonical = emojiData.aliases[base] ?? base;
const unicode = unicodeByName.get(tone === undefined ? canonical : `${canonical}::${tone}`);
if (unicode === undefined) throw new Error(`Unknown emoji shortcode: ${value}`);
return unicode;
}
if (nameByUnicode.has(withoutPresentation(value)) || /^\p{RGI_Emoji}$/v.test(value)) return value;
throw new Error("A reaction must be one emoji or an :emoji_shortcode:.");
}

/** Slack accepts names rather than Unicode, including workspace custom names. */
export function slackReaction(reaction: string): string {
const name = reactionName(reaction) ?? nameByUnicode.get(withoutPresentation(reaction.trim()));
if (name === undefined) throw new Error("No Slack name for this emoji; use its :shortcode:.");
return name;
}

/** Presentation selectors do not change the emoji; Telegram expects them omitted. */
function withoutPresentation(emoji: string): string {
return emoji.replaceAll("\uFE0F", "");
}

export async function reactToTelegramMessage(
api: Pick<Api, "setMessageReaction">,
chatId: number,
messageId: number,
reaction: string,
): Promise<void> {
const customId = /^:custom_emoji_(\d{1,20}):$/u.exec(reaction.trim())?.[1];
await api.setMessageReaction(chatId, messageId, [
customId === undefined
? {
type: "emoji",
// Telegram validates its supported set; do not duplicate that changing list here.
emoji: withoutPresentation(unicodeReaction(reaction)) as ReactionTypeEmoji["emoji"],
}
: { type: "custom_emoji", custom_emoji_id: customId },
]);
}

export async function reactToSlackMessage(
web: Pick<WebClient, "reactions">,
channel: string,
timestamp: string,
reaction: string,
): Promise<void> {
try {
await web.reactions.add({ channel, timestamp, name: slackReaction(reaction) });
} catch (error) {
if ((error as { data?: { error?: string } } | null)?.data?.error !== "already_reacted") {
throw error;
}
}
}

export async function reactToDiscordMessage(
message: Pick<Message, "react" | "guild">,
reaction: string,
): Promise<void> {
let emoji: string;
if (/^<a?:\w{2,32}:\d{15,20}>$/u.test(reaction.trim())) {
emoji = reaction.trim();
} else {
try {
emoji = unicodeReaction(reaction);
} catch (error) {
const name = reactionName(reaction);
if (name === undefined || message.guild === null) throw error;
const emojis = await message.guild.emojis.fetch();
const matches = emojis.filter((candidate) => candidate.name === name);
const custom = matches.size === 1 ? matches.first() : undefined;
if (custom === undefined) throw new Error(`No unique Discord custom emoji named :${name}:.`);
emoji = custom.id;
}
}
await message.react(emoji);
}
5 changes: 5 additions & 0 deletions src/channels/slack/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { errorMessage } from "../../shared/errors.js";
import type { Logger } from "../../shared/logger.js";
import { PendingChoices } from "../choices.js";
import { decodeCommandAction } from "../command-actions.js";
import { reactToSlackMessage } from "../reactions.js";
import { isWorkspaceMember } from "./authorization.js";
import { type CodexConfigAccess, SlackConfigUi, slackConfigActionPrefix } from "./config-ui.js";
import { downloadSlackFile, SlackFileDownloadError } from "./file.js";
Expand Down Expand Up @@ -424,6 +425,7 @@ export class SlackChannel implements MessagingChannel {
deliveryTarget: slackDeliveryTarget(event.channel, event.channel_type, route.replyThreadTs),
},
reference: slackMessageReference(event.channel, event.ts),
react: (reaction) => reactToSlackMessage(this.#web, event.channel, event.ts, reaction),
// Slack threads are flat: a reply references the thread root, not the
// message being answered. When a scheduled-run notification lives in
// this thread, point replyTo at it so its stored context resolves.
Expand Down Expand Up @@ -819,6 +821,9 @@ function webMessagingApi(web: WebClient): SlackMessagingApi {
blocks: options.blocks === undefined ? [] : [...options.blocks],
});
},
async deleteMessage(channel, ts) {
await web.chat.delete({ channel, ts });
},
async uploadFile(options) {
const contents = { file: options.path, filename: options.filename };
if (options.threadTs === undefined) {
Expand Down
5 changes: 5 additions & 0 deletions src/channels/slack/reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface SlackEphemeralOptions {
export interface SlackMessagingApi {
postMessage(options: SlackPostOptions): Promise<string>;
updateMessage(options: SlackUpdateOptions): Promise<void>;
deleteMessage(channel: string, ts: string): Promise<void>;
uploadFile(options: SlackUploadOptions): Promise<void>;
postEphemeral(options: SlackEphemeralOptions): Promise<void>;
fetchThreadReplies(
Expand Down Expand Up @@ -328,6 +329,10 @@ export class SlackReplyStream extends DraftReplyStream {
await this.#api.updateMessage({ channel: this.#channel, ts: messageTs, text: content });
}

protected async remove(messageTs: string): Promise<void> {
await this.#api.deleteMessage(this.#channel, messageTs);
}

// Escaping matters even in previews: `<command>` fragments would vanish and
// `<!channel>` would ping everyone.
protected renderProgress(block: string): string {
Expand Down
3 changes: 3 additions & 0 deletions src/channels/telegram/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { errorMessage } from "../../shared/errors.js";
import type { Logger } from "../../shared/logger.js";
import { PendingChoices } from "../choices.js";
import { decodeCommandAction } from "../command-actions.js";
import { reactToTelegramMessage } from "../reactions.js";
import { downloadTelegramFile, TelegramFileDownloadError } from "./file.js";
import {
describeTelegramFile,
Expand Down Expand Up @@ -392,6 +393,8 @@ export class TelegramChannel implements MessagingChannel {
deliveryTarget: telegramDeliveryTarget(message.chat.id, incomingRoute.reply),
},
reference: telegramMessageReference(message.chat.id, message.message_id),
react: (reaction) =>
reactToTelegramMessage(api, message.chat.id, message.message_id, reaction),
...(content.replyToMessageId === undefined
? {}
: { replyTo: telegramMessageReference(message.chat.id, content.replyToMessageId) }),
Expand Down
Loading
Loading