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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
# Optional: restrict instance-wide Discord commands to these users.
# DISCORD_ADMIN_USER_IDS=123456789012345678

# Public HTTPS origin serving the Mini App, normally through a reverse proxy.
# Leave it unset to expose the Mini App through a TryCloudflare quick tunnel
# Public HTTPS origin serving the web app and Telegram Mini App, normally through a reverse proxy.
# Leave it unset to expose the web app (with any messenger) through a TryCloudflare quick tunnel
# instead: Wirebot installs a pinned, checksum-verified cloudflared into its
# toolchains directory (or uses one already on the PATH) and gets a fresh
# trycloudflare.com URL on every start. Set WIREBOT_TUNNEL=off to disable the
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
bun-version: 1.4.0
- run: bun install --frozen-lockfile
- run: bun run check
- run: bun test
- run: bun run compile
- name: Compiled binary smoke test
run: |
Expand Down
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,15 @@ docker compose pull && docker compose up -d

State lives in the volume, so recreating the container is safe. [Watchtower](https://containrrr.dev/watchtower/) or your orchestrator can automate the pull. Images are tagged `latest` and `X.Y.Z` on GHCR; pin a version tag if you prefer explicit upgrades.

### The settings Mini App URL
### The web app URL

To expose the authenticated settings Mini App, either set its public HTTPS origin:
To expose the web app and Telegram Mini App, set a public HTTPS origin:

```dotenv
PUBLIC_URL=https://codex.example.com
```

and reverse-proxy that origin to the container's port 8787 (publish it in your compose file), or leave `PUBLIC_URL` unset: Wirebot then opens a [TryCloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) using the bundled pinned cloudflared. Quick tunnels are best-effort — the URL changes on every start and Cloudflare offers no uptime guarantee — so set `PUBLIC_URL` for a persistent deployment. Set `WIREBOT_TUNNEL=off` to never open a tunnel; without a tunnel or `PUBLIC_URL`, Wirebot runs without the `/config` button. The Mini App validates signed Telegram `initData` against the allowlist regardless of how it is exposed.
and reverse-proxy that origin to the container's port 8787 (publish it in your compose file), or leave `PUBLIC_URL` unset: Wirebot then opens a [TryCloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) using the bundled pinned cloudflared. Quick tunnels are best-effort — the URL changes on every start and Cloudflare offers no uptime guarantee — so set `PUBLIC_URL` for a persistent deployment. Set `WIREBOT_TUNNEL=off` to never open a tunnel; without a tunnel or `PUBLIC_URL`, browser sign-in links and Telegram’s Settings button are unavailable, and Slack/Discord keep their in-chat settings picker. The HTTP app and health endpoint still run locally. The tunnel fallback works with any configured messenger, including Slack-only and Discord-only deployments.

### Configuration reference

Expand Down Expand Up @@ -146,6 +146,7 @@ Voice messages use that same ChatGPT subscription. Wirebot briefly shows a **Tra
| `/status` | Check app-server connectivity and the current Codex account. |
| `/login` | Start Codex's ChatGPT device-code login in a private chat. |
| `/logout` | Sign out through Codex in a private chat. |
| `/web` | Get a one-use link to sign in to the browser app (private chats, admins only). |
| `/config` | Open the authenticated settings Mini App in a private chat. |
| `/reload` | Reload config, MCP servers, and skills through Codex's native app-server APIs. |
| `/restart` | Drain active work and safely restart only the Codex app-server. |
Expand All @@ -163,7 +164,7 @@ Telegram's hosted Bot API only allows bots to download files up to 20 MB and upl

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.

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. The settings Mini App stays Telegram-only because it authenticates through Telegram `initData`.
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.

## Discord connector

Expand Down Expand Up @@ -198,9 +199,17 @@ The service must be running when work becomes due; this is not a cloud scheduler

Wirebot's design rule is to use Codex's native app-server behavior instead of building a custom agent harness. The scheduled-runs engine is the one exception because Codex Desktop already implements scheduling in its host application rather than in Codex CLI. Wirebot mirrors that approach nearly 1:1: the host claims due work, applies foreground priority, persists run and notification state, and asks Codex to execute normal turns. As soon as Codex CLI or app-server provides native cron ownership, Wirebot will switch to it immediately and retire this engine.

## Settings Mini App
## Web app and Telegram Mini App

The Mini App is pinned to the bot's **Settings** menu button when its public URL is available; `/config` remains an equivalent entry point. At startup Wirebot reconciles both Telegram's default button and each allowlisted private chat, so a stale chat-specific command-menu override cannot hide the Mini App. It uses source-owned UI components styled with Tailwind and accepts only signed Telegram `initData` from allowlisted private users. Its tab bar keeps **Settings** first, **Skills** second, and adds a **Schedules** screen for owner-scoped schedule management. The Skills screen lists every enabled skill from Codex's native `skills/list` response. Opening a skill shows its `SKILL.md` instructions and a read-only browser for bundled scripts, references, images, and other files. Skill paths remain confined to that skill's directory, and oversized files are not loaded into the browser.
Open `https://your-wirebot-origin/app` in a normal browser. Settings, Skills, and Schedules also have bookmarkable URLs at `/app/settings`, `/app/skills`, and `/app/schedules`; `/` and the existing `/miniapp` URL work too. Desktop browsers use a left sidebar, while phones use bottom navigation. Browser colors follow the system light/dark preference, independently of Telegram. Mobile layouts support safe areas, dynamic viewport height, readable form controls, and pinch zoom.

For browser sign-in, send `/wirebot web` (or `/wirebot config`) in a private Slack or Discord bot chat; Telegram users can send `/web`. The bot replies with a private, one-use link that expires in 5 minutes. Open it in your preferred browser. Requesting a new link invalidates your previous unused link. After signing in, ordinary URLs work for 12 hours, including across reloads and new tabs. **Sign out** revokes that browser session; restarting Wirebot invalidates all links and sessions.

Browser access follows the connector's bot-admin policy, not Slack workspace or Discord server roles: `SLACK_ADMIN_USER_IDS` and `DISCORD_ADMIN_USER_IDS` restrict access when configured, and otherwise every authorized user is an admin. All Telegram allowlisted users are admins. The server rechecks authorization when issuing links, exchanging them, and using a session (Slack workspace membership uses its existing 10-minute cache). Link secrets live in URL fragments, are removed from the address bar on load, and are exchanged for Secure, HttpOnly, SameSite cookies. Serve the public app over HTTPS and keep login links private. Browser auth uses no Telegram SDK, localStorage, or third-party authentication service.

Schedules retain the signed-in messenger identity: you see schedules owned by that account, and new schedules use the private conversation from which you requested the link. Accounts on different messengers are separate identities.

The Mini App is pinned to the bot's **Settings** menu button when its public URL is available; `/config` remains an equivalent entry point. At startup Wirebot reconciles both Telegram's default button and each allowlisted private chat, so a stale chat-specific command-menu override cannot hide the Mini App. It uses source-owned UI components styled with Tailwind and authenticates Telegram launches using signed `initData` from allowlisted users. Its tab bar keeps **Settings** first, **Skills** second, and adds a **Schedules** screen for owner-scoped schedule management. The Skills screen lists every enabled skill from Codex's native `skills/list` response. Opening a skill shows its `SKILL.md` instructions and a read-only browser for bundled scripts, references, images, and other files. Skill paths remain confined to that skill's directory, and oversized files are not loaded into the browser.

Inside Telegram, nested Mini App screens use the native header back button; ordinary browser
rendering keeps the in-page back controls. Every multiline input can open a focused full-screen
Expand Down
9 changes: 7 additions & 2 deletions docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,20 @@ Wirebot owns one global application command with subcommands:
/wirebot login
/wirebot logout
/wirebot config
/wirebot web
/wirebot reload
/wirebot restart
/wirebot help
```

Conversation commands in a server must run inside a Discord thread because a root channel is not
a stable task boundary. You can also write `!new` or `/new` as a normal direct message, or mention
the bot with that text in a server. `/wirebot config` opens a compact settings picker in a direct
message; the larger web Mini App remains Telegram-authenticated.
the bot with that text in a server. In a direct message, `/wirebot config` or `/wirebot web`
gives bot admins a private browser sign-in link. It expires after 5 minutes and works once;
the browser session lasts 12 hours. Set `PUBLIC_URL` to an HTTPS origin or use the automatic
quick tunnel. Without either, `/wirebot config` keeps the compact in-chat settings picker.
Browser access follows `DISCORD_ADMIN_USER_IDS`, independently of Discord server roles.
Schedules remain owned by the signed-in Discord user and new ones deliver to that private chat.

Approvals and user-input prompts arrive as buttons. Only the user who received a prompt can answer
it, and controls expire after five minutes or disappear when the requesting turn ends. Scheduled
Expand Down
18 changes: 9 additions & 9 deletions docs/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ Wirebot validates and snapshots that file before uploading it with Slack's
`files:write` permission. Local links used only as code references are not
uploaded.

The settings Mini App remains Telegram-only because it authenticates through
Telegram. Everything else — including `/wirebot login` for the ChatGPT sign-in
works from Slack.
The full web app works without Telegram. In a bot DM, `/wirebot config` or
`/wirebot web` gives admins a one-use browser sign-in link (valid for 5 minutes).
`/wirebot login` remains the separate ChatGPT sign-in command.

## 1. Create the Slack app

Expand Down Expand Up @@ -115,10 +115,11 @@ Open it to the whole workspace only if that is acceptable.

Optionally, `SLACK_ADMIN_USER_IDS` (comma-separated member IDs) restricts
instance-wide commands — `/wirebot config`, `login`, `logout`, `reload`, and
`restart` — to the listed users. Unset, every authorized user may
run them. `/wirebot config` opens interactive Codex settings built from Slack
buttons (model, reasoning effort, speed tier, approvals, sandbox, web
search) in the bot DM — the Slack counterpart of the Telegram Mini App.
`restart`, plus browser sign-in with `web` — to the listed users. Unset, every authorized user may
run them. `/wirebot config` opens the web app through a private sign-in link.
Set `PUBLIC_URL` to an HTTPS origin or use the automatic quick tunnel. Without
either, `/wirebot config` keeps the compact in-chat settings picker. Browser
sessions last 12 hours and retain the Slack user identity for schedule ownership.

## 4. Configure Wirebot

Expand All @@ -132,8 +133,7 @@ SLACK_ALLOWED_USER_IDS=U0123ABCDEF,U0456GHIJKL

All three must be set together; leaving them all unset keeps the connector
disabled. Telegram is optional when Slack is configured — with only the Slack
variables set, Wirebot runs Slack-only (the Telegram bot and the settings Mini
App stay off). Restart Wirebot and check the log for
variables set, Wirebot runs Slack-only with the same web app. Restart Wirebot and check the log for
`Slack bot connected through Socket Mode`.

## 5. Talk to it
Expand Down
4 changes: 4 additions & 0 deletions src/channels/discord/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export class DiscordChannel implements MessagingChannel {
);
}

public async isAuthorizedAdmin(principal: ProviderReference): Promise<boolean> {
return (await this.isAuthorized(principal)) && this.isAdmin(principal.id);
}

public async publish(
targetReference: ProviderReference,
message: OutboundMessage,
Expand Down
4 changes: 4 additions & 0 deletions src/channels/slack/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ export class SlackChannel implements MessagingChannel {
return this.isUserAllowed(principal.id);
}

public async isAuthorizedAdmin(principal: ProviderReference): Promise<boolean> {
return (await this.isAuthorized(principal)) && this.isAdmin(principal.id);
}

private isUserAllowed(userId: string): boolean | Promise<boolean> {
if (!this.#allowAllWorkspaceMembers) return this.#allowedUserIds.has(userId);
const cached = this.#membership.get(userId);
Expand Down
4 changes: 4 additions & 0 deletions src/channels/telegram/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ export class TelegramChannel implements MessagingChannel {
return Number.isSafeInteger(userId) && this.#allowedUserIds.has(userId);
}

public async isAuthorizedAdmin(principal: ProviderReference): Promise<boolean> {
return await this.isAuthorized(principal);
}

public async stop(): Promise<void> {
await this.#runner?.stop();
await this.#pendingChoices.declineAll("Request cancelled");
Expand Down
45 changes: 45 additions & 0 deletions src/core/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Account } from "../generated/codex/v2/Account.js";
import type { AccountLoginCompletedNotification } from "../generated/codex/v2/AccountLoginCompletedNotification.js";
import type { GetAccountResponse } from "../generated/codex/v2/GetAccountResponse.js";
import type { LoginAccountResponse } from "../generated/codex/v2/LoginAccountResponse.js";
import type { BrowserAuth } from "../miniapp/browser-auth.js";
import { BridgeError, errorMessage } from "../shared/errors.js";
import type { Logger } from "../shared/logger.js";
import {
Expand Down Expand Up @@ -49,6 +50,11 @@ export const botCommands: readonly {
},
{ command: "login", menuDescription: "Sign in to Codex", help: "sign in to ChatGPT" },
{ command: "logout", menuDescription: "Sign out of Codex", help: "sign out" },
{
command: "web",
menuDescription: "Open Wirebot in a browser",
help: "get a temporary browser sign-in link",
},
{ command: "config", menuDescription: "Open Codex settings", help: "open Codex settings" },
{
command: "reload",
Expand Down Expand Up @@ -77,6 +83,7 @@ export const conversationScopedCommands: ReadonlySet<string> = new Set([
/** Commands that change shared instance state and require provider-admin gating. */
export const instanceAdminCommands: ReadonlySet<string> = new Set([
"config",
"web",
"login",
"logout",
"reload",
Expand Down Expand Up @@ -119,6 +126,7 @@ interface PendingLogin {

export class CodexBridge {
readonly #codex: CodexService;
readonly #browserAuth: BrowserAuth | undefined;
readonly #publicUrl: string | undefined;
readonly #logger: Logger;
readonly #runtimeCommand: CodexRuntimeCommand;
Expand Down Expand Up @@ -158,9 +166,11 @@ export class CodexBridge {
logger: Logger,
runtimeCommand: CodexRuntimeCommand,
scheduledRuns: ScheduledRunsEngine,
browserAuth?: BrowserAuth,
) {
this.#codex = codex;
this.#publicUrl = publicUrl;
this.#browserAuth = browserAuth;
this.#logger = logger;
this.#runtimeCommand = runtimeCommand;
this.#scheduledRuns = scheduledRuns;
Expand Down Expand Up @@ -292,7 +302,14 @@ export class CodexBridge {
`Signed out of Codex. Send ${commandText(message.address.channel, "login")} whenever you want back in.`,
);
return;
case "web":
await this.openBrowserApp(message);
return;
case "config":
if (message.address.channel !== "telegram") {
await this.openBrowserApp(message);
return;
}
if (!(await this.requirePrivateChat(message))) return;
if (this.#publicUrl === undefined) {
await message.responder.sendText(
Expand Down Expand Up @@ -323,6 +340,34 @@ export class CodexBridge {
}
}

private async openBrowserApp(message: InboundMessage): Promise<void> {
if (!(await this.requirePrivateChat(message))) return;
if (this.#publicUrl === undefined || this.#browserAuth === undefined) {
await message.responder.sendText(
"Browser sign-in needs a public URL. Set PUBLIC_URL to your HTTPS origin, or enable WIREBOT_TUNNEL=auto and restart.",
);
return;
}
const deliveryTarget = message.address.deliveryTarget;
if (deliveryTarget === undefined) {
await message.responder.sendText("This messenger does not support browser sign-in yet.");
return;
}
const token = await this.#browserAuth.issue({
owner: messageOwner(message),
conversation: messageConversation(message),
deliveryTarget,
});
// Fragments are omitted from HTTP requests, including preview fetches.
const url = `${this.#publicUrl}/app#login=${token}`;
await message.responder.sendText(
"Sign in to Wirebot in your browser. This private link works once and expires in 5 minutes. Your browser session lasts 12 hours.",
{
button: { label: "Open Wirebot", kind: "url", url },
},
);
}

private async handleSchedules(message: InboundMessage): Promise<void> {
const automations = this.#scheduledRuns.listForConversation(
messageOwner(message),
Expand Down
2 changes: 2 additions & 0 deletions src/core/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ export interface MessagingChannel {
readonly name: string;
/** Re-check a persisted provider principal before unattended work executes. */
isAuthorized(principal: ProviderReference): boolean | Promise<boolean>;
/** Re-check bot-admin access before issuing or using a browser session. Fail closed if absent. */
isAuthorizedAdmin?(principal: ProviderReference): boolean | Promise<boolean>;
start(handler: MessageHandler): Promise<void>;
publish(target: ProviderReference, message: OutboundMessage): Promise<DeliveryReceipt>;
stop(): Promise<void>;
Expand Down
Loading
Loading