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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ TELEGRAM_ALLOWED_USER_IDS=123456789
#PUBLIC_URL=https://codex.example.com
TELEX_TUNNEL=auto

# Release 0.0.35 migrates to Wirebot in Docker on startup. Set off BEFORE the
# first migration to keep running native Telex (also useful for source development).
# After migration, a scheduled shell check replaces the resident telex service.
TELEX_MIGRATION=auto

# Optional runtime paths and listener settings.
TELEX_DATA_DIR=.telex
CODEX_WORKSPACE=.telex/workspace
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check
- name: Verify migration, service handoff, and published Wirebot image
run: sudo env "PATH=$PATH" TMPDIR=/root TELEX_TEST_DOCKER=1 TELEX_TEST_SYSTEMD=1 npm run test:migration
- run: npm run build
- name: Load E2E entry point
run: npm test -- --help
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
test "$GITHUB_REF_NAME" = "v$package_version"
- run: npm ci
- run: npm run check
- name: Verify migration and published Wirebot image
run: sudo env "PATH=$PATH" TMPDIR=/root TELEX_TEST_DOCKER=1 TELEX_TEST_SYSTEMD=1 npm run test:migration
- run: npm run build
- name: Load E2E entry point
run: npm test -- --help
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to Telex are documented in this file.

## Unreleased

## [0.0.35] - 2026-09-06

### Changed

- Migrate 0.0.34 instances to Wirebot's Docker image on startup, preserving the original data
and copying authentication, conversations, memory, schedules, settings, and workspace files.
- Retire the telex service after migration and use a scheduled shell check for Docker image
updates, with no resident updater process. Run update transactions only when needed, retaining
startup checks, data snapshots, rollback, and recovery after interrupted updates.
- Notify Telegram users after the migrated instance is ready; retain native Telex if initial
preparation or startup fails. Set `TELEX_MIGRATION=off` before migration to defer it.

## [0.0.34] - 2026-08-02

### Fixed
Expand Down
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,105 @@
# Telex

Development continues in [Wirebot](https://github.com/sadfun/wirebot). **Telex 0.0.35 is the
migration release for existing 0.0.34 instances.** New installations should use Wirebot directly.

## Automatic migration to Wirebot

The normal Telex release updater installs this release and restarts the existing service. Before
starting a bot, it prepares Docker, pulls `ghcr.io/sadfun/wirebot:latest`, and copies the configured
data and workspace into `~/.local/share/telex-wirebot/<instance-id>/` (or the corresponding
`XDG_DATA_HOME` directory). The original Telex files and configuration remain intact.

The copy includes Codex authentication, sessions and SQLite databases (including WAL files),
memory, skills, configuration, conversation-to-thread mappings and history, schedules, delivery
records, settings, attachments, and workspace files. The copy is mounted inside the container at
the original absolute paths as well as `/data`, preserving stored rollout paths and thread working
directories. The workspace's `.wirebot` is linked to `.telex`, so existing schedule memories and
attachment paths still resolve. No host Docker socket is exposed to the agent.

The default `workspace-write` Codex sandbox setting becomes `danger-full-access` **inside the
container**, matching Wirebot's machine model: ordinary Docker cannot run Codex's nested
bubblewrap sandbox. Approval policy and other Codex settings stay intact. The original host
configuration remains unchanged. Wirebot logs at least at `info` level so the updater can verify
its startup event.

Once Wirebot reports that its connectors and scheduler are ready, and its health endpoint confirms
the existing Codex account, each allowlisted Telegram user receives:

> Telex just got a big update! Your instance is now [Wirebot](https://github.com/sadfun/wirebot),
> the next evolution of telex.
>
> Your login, conversations, memory, and schedules are preserved. Slack and Discord are now
> supported, and updates are automatic. Just keep chatting.

Successful deliveries are recorded per user; failed deliveries retry. A crash between Telegram
accepting a message and saving its receipt can cause that message to repeat.

### Docker and the host service

- Linux: reuse a local Docker daemon, including rootless Docker. If Docker is missing, install
it using a pinned, checksum-verified official Docker installer and enable its system service.
Installation needs root or passwordless sudo; the updater never waits for a password.
- macOS: reuse Docker Desktop or Colima. If necessary, install Docker and Colima through an
existing Homebrew installation and enable Colima's login service. Homebrew or Docker Desktop
must already be available. Linux and macOS on x86-64 and ARM64 are supported.
- Migration installs a **systemd timer and oneshot service** on Linux, or a **launchd job with
`StartInterval`** on macOS, then disables and stops the old telex service. There is no updater
process between checks. A small shell script checks every `TELEX_UPDATE_INTERVAL_HOURS`
(six hours by default), pulls before stopping anything, and exits if the image is unchanged.
Node runs only for the first notification, an image replacement, or recovery; it exits after
that work. Each replacement
retains a stopped-data snapshot and restores both image and data if startup fails. The latest
snapshot is kept at the migrated data directory's `-backup` sibling and rotated on the next
update. Interrupted replacements recover at the next scheduled check or after reboot.
- Docker's `unless-stopped` restart policy keeps Wirebot running after Telex exits. Stopping
the updater disables checks, not the bot: use `docker stop <container-name>` to stop Wirebot,
after stopping its updater. Keep the migration release and Node installed for occasional
update transactions. The standard installer services are retired automatically; if using a
custom process manager, remove its old `telex start` job. Source development can use
`TELEX_MIGRATION=off npm run dev` to run native Telex.

The private instance directory contains `migration.json` (container name, Docker endpoint, data
path and notification credentials), `wirebot.env`, and `update.sh`. Extra environment variables from the launcher's env file are
retained, excluding host-only paths and replaced runtime settings. Wirebot's HTTP port is
published at the original Telex host/port, so an existing `PUBLIC_URL` reverse proxy keeps working.
Slack and Discord are available but require their own connector credentials; migration does not
enable them automatically.

Allow disk space for the migrated data/workspace and another copy for update rollback, in addition
to the Docker image. External symlink targets, host-installed programs, host keychain credentials,
and services bound to the host's localhost need container-compatible configuration. Remote Docker
daemons and paths that conflict with the image's system directories are rejected.

### Deferring migration and recovery

Set `TELEX_MIGRATION=off` in `telex.env` **before migration** to defer it. If preparation or initial
startup fails, the original Telex instance resumes; inspect the telex service logs, fix the
reported prerequisite, and restart that service to retry. Failed copies are retained for inspection.
No success announcement is sent on failure.

After migration, `TELEX_MIGRATION=off` cannot switch back to the stale original state, and
`telex update` / `telex update --rollback` refuse to replace the migration release. For recovery,
stop the updater first, inspect `migration.json`, and back up the **migrated** data before
changing the container or image. The original Telex directory is a pre-migration backup and does
not contain conversations or token refreshes made since migration. Do not run it alongside Wirebot.

For a container named `telex-wirebot-<instance-id>`, the Linux timer and oneshot service are
both named `telex-wirebot-<instance-id>-update`. Use `systemctl --user disable --now
telex-wirebot-<instance-id>-update.timer` and `systemctl --user stop
telex-wirebot-<instance-id>-update.service` before maintenance (omit `--user` for a root system
installation). On macOS, disable and boot out `gui/$(id -u)/com.sadfun.telex-wirebot-<instance-id>-update`
with `launchctl`. Linux logs go to the journal; macOS retains the last check's `update.log` in
the instance directory. Running `/bin/sh <instance-directory>/update.sh` checks immediately.

Run `npm run test:migration` for the isolated migration/rollback regression check. On a machine
with Docker, `TELEX_TEST_DOCKER=1 npm run test:migration` also boots the published image against a
simulated Telegram API and verifies its mounted data, readiness, and snapshot restoration. CI
and release builds also set `TELEX_TEST_SYSTEMD=1` to verify real service retirement and that the
oneshot updater has no remaining process. No real Telegram or ChatGPT credentials are used.

The rest of this README documents native Telex and its pre-migration installation.

Telex is a self-hosted Telegram bridge for OpenAI Codex. Telegram is only the transport: a dedicated [Codex app-server](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md) owns threads, turns, tools, approvals, authentication, and configuration.

Telex supports private conversations, scheduled runs, automatic Telegram voice-message transcription, photos and files in both directions, forwarded and replied-to context, polls and other structured messages, streamed replies and thinking, interactive approvals, guest mentions, persistent Codex threads, and an authenticated settings Mini App. It installs a pinned Codex CLI into isolated application storage, so it never depends on a global Codex installation.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "telex",
"version": "0.0.34",
"version": "0.0.35",
"private": true,
"type": "module",
"description": "Telex — a Telegram bridge for the OpenAI Codex app-server.",
Expand All @@ -25,6 +25,7 @@
"dev": "npm run build && node --env-file-if-exists=.env --watch dist/cli/main.js start",
"start": "node --env-file-if-exists=.env dist/cli/main.js start",
"test": "npm run test:e2e --",
"test:migration": "node --import tsx --test src/update/wirebot.test.ts",
"test:e2e": "node --import tsx src/core/e2e/cli.ts core",
"test:telegram": "node --import tsx src/core/e2e/cli.ts telegram",
"protocol:generate-bindings": "node --import tsx src/cli/main.ts codex check --apply --version $(cat codex.version)",
Expand Down
2 changes: 1 addition & 1 deletion src/automations/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
type ProviderReference,
} from "./types.js";

const storedStateSchema = z.preprocess(
export const storedStateSchema = z.preprocess(
migrateStoredState,
z.strictObject({
version: z.literal(1),
Expand Down
18 changes: 15 additions & 3 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,25 @@ async function main(args: readonly string[]): Promise<number> {
switch (args[0]) {
case "start": {
if (args.length !== 1) throw new Error(usage);
const { runTelex } = await import("../index.js");
const result = await runTelex();
return result.reason === "updated" ? 75 : 0;
const { runWithWirebot } = await import("../update/wirebot.js");
const result = await runWithWirebot(async () => {
const { runTelex } = await import("../index.js");
return await runTelex();
});
return result?.reason === "updated" ? 75 : 0;
}
case "version": {
if (args.length !== 1) throw new Error(usage);
console.log(await readTelexVersion(projectRootFrom(import.meta.url)));
return 0;
}
case "wirebot-update": {
const directory = args[1];
if (args.length !== 2 || directory === undefined) throw new Error(usage);
const { maintainWirebot } = await import("../update/wirebot.js");
await maintainWirebot(directory);
return 0;
}
case "update":
return await updateTelex(parseUpdateArguments(args));
case "codex":
Expand All @@ -58,6 +68,8 @@ async function main(args: readonly string[]): Promise<number> {
}

async function updateTelex(args: UpdateArguments): Promise<number> {
const { assertTelexReleaseUpdatesAllowed } = await import("../update/wirebot.js");
await assertTelexReleaseUpdatesAllowed();
const projectRoot = projectRootFrom(import.meta.url);
const config = loadUpdateConfig();
const currentVersion = await readTelexVersion(projectRoot);
Expand Down
3 changes: 3 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const envSchema = z.object({
.refine((value) => new URL(value).protocol === "https:", "PUBLIC_URL must use HTTPS")
.optional(),
TELEX_TUNNEL: z.enum(["auto", "off"]).default("auto"),
TELEX_MIGRATION: z.enum(["auto", "off"]).default("auto"),
TELEX_DATA_DIR: z.string().min(1).default(".telex"),
CODEX_WORKSPACE: z.string().min(1).default(".telex/workspace"),
HOST: z.string().min(1).default("127.0.0.1"),
Expand All @@ -38,6 +39,7 @@ interface AppConfig {
readonly telegramPollTimeout: number;
readonly publicUrl: string | undefined;
readonly tunnelMode: "auto" | "off";
readonly migrationMode: "auto" | "off";
readonly dataDirectory: string;
readonly workspace: string;
readonly updateMode: UpdateMode;
Expand Down Expand Up @@ -72,6 +74,7 @@ export function loadAppConfig(environment: NodeJS.ProcessEnv = process.env): App
telegramPollTimeout: parsed.TELEGRAM_POLL_TIMEOUT,
publicUrl: parsed.PUBLIC_URL?.replace(/\/$/, ""),
tunnelMode: parsed.TELEX_TUNNEL,
migrationMode: parsed.TELEX_MIGRATION,
dataDirectory: resolve(parsed.TELEX_DATA_DIR),
workspace: resolve(parsed.CODEX_WORKSPACE),
host: parsed.HOST,
Expand Down
2 changes: 1 addition & 1 deletion src/core/conversation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const conversationStateSchema = z.object({
previousThreadIds: z.array(z.string().min(1)).max(10),
});

const storedStateSchema = z.object({
export const storedStateSchema = z.object({
version: z.literal(2),
conversations: z.record(z.string(), conversationStateSchema),
});
Expand Down
2 changes: 1 addition & 1 deletion src/core/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const settingsSchema = z.strictObject({
remoteClientContext: z.boolean(),
});

const storedSettingsSchema = settingsSchema.extend({
export const storedSettingsSchema = settingsSchema.extend({
version: z.literal(1),
});

Expand Down
6 changes: 5 additions & 1 deletion src/shared/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export async function runCommand(
options: Readonly<{
cwd: string;
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
timeout?: number;
}>,
): Promise<CommandResult> {
return await new Promise<CommandResult>((resolve, reject) => {
Expand All @@ -20,13 +22,15 @@ export async function runCommand(
env: options.env ?? process.env,
stdio: ["ignore", "pipe", "pipe"],
shell: false,
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
child.once("error", reject);
child.once("exit", (code, signal) => {
child.once("close", (code, signal) => {
const result = {
stdout: Buffer.concat(stdout).toString("utf8"),
stderr: Buffer.concat(stderr).toString("utf8"),
Expand Down
Loading
Loading