Skip to content
Draft
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
81 changes: 81 additions & 0 deletions .github/workflows/upstream-release-watch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Watch upstream releases

on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:

permissions:
contents: read
issues: write

jobs:
watch:
runs-on: ubuntu-latest
steps:
- name: Check latest upstream release
uses: actions/github-script@v7
with:
script: |
const upstream = { owner: 'PleasePrompto', repo: 'ductor' };
const labelName = 'upstream-release';
const labelColor = '1d76db';

try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: labelName,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: labelName,
color: labelColor,
description: 'Tracks upstream PleasePrompto/ductor releases',
});
}

const { data: release } = await github.rest.repos.getLatestRelease(upstream);
const title = `Track upstream ductor release ${release.tag_name}`;

const search = await github.rest.search.issuesAndPullRequests({
q: `repo:${context.repo.owner}/${context.repo.repo} is:issue in:title "${title}"`,
per_page: 10,
});

if (search.data.items.some((item) => item.title === title)) {
core.info(`Issue already exists for ${release.tag_name}`);
return;
}

const publishedAt = release.published_at ?? release.created_at ?? 'unknown';
const notes = (release.body ?? '').trim();
const body = [
`Upstream published a new release: **${release.tag_name}**.`,
'',
`- Published: ${publishedAt}`,
`- URL: ${release.html_url}`,
'',
'## Suggested follow-up',
'',
'1. Review the upstream changelog and commits.',
'2. Decide which changes need to be ported into this fork.',
'3. Track any fork-specific conflicts before merging or cherry-picking.',
'',
'## Upstream release notes',
'',
notes || '_No release notes provided._',
].join('\n');

await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: [labelName],
});
76 changes: 70 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
</p>

<p align="center">
<strong>Claude Code, Codex CLI, and Gemini CLI as your coding assistant — on Telegram and Matrix.</strong><br>
<strong>Claude Code, Codex CLI, and Gemini CLI as your coding assistant — on Telegram, Matrix, and Slack.</strong><br>
Uses only official CLIs. Nothing spoofed, nothing proxied. Multi-transport, automation, and sub-agents in one runtime.
</p>

Expand All @@ -23,7 +23,7 @@

---

If you want to control Claude Code, Google's Gemini CLI, or OpenAI's Codex CLI via Telegram or Matrix, build automations, or manage multiple agents easily — ductor is the right tool for you. The messaging layer is modular: Telegram and Matrix ship today, and new transports plug into the same transport-agnostic core.
If you want to control Claude Code, Google's Gemini CLI, or OpenAI's Codex CLI via Telegram, Matrix, or Slack, build automations, or manage multiple agents easily — ductor is the right tool for you. The messaging layer is modular and transports plug into the same transport-agnostic core.

ductor runs on your machine and sends simple console commands as if you were typing them yourself, so you can use your active subscriptions (Claude Max, etc.) directly. No API proxying, no SDK patching, no spoofed headers. Just the official CLIs, executed as subprocesses, with all state kept in plain JSON and Markdown under `~/.ductor/`.

Expand All @@ -39,14 +39,16 @@ pipx install ductor # or: uv tool install ductor
ductor
```

The onboarding wizard handles CLI checks, transport setup (Telegram or Matrix), timezone, optional Docker, and optional background service install.
The onboarding wizard handles CLI checks, transport setup, timezone, optional Docker, and optional background service install.

**Requirements:** Python 3.11+, at least one CLI installed (`claude`, `codex`, or `gemini`), and either:

- a Telegram Bot Token from [@BotFather](https://t.me/BotFather), or
- a Matrix account on a homeserver (homeserver URL, user ID, password/access token)
- a Matrix account on a homeserver (homeserver URL, user ID, password/access token), or
- a Slack bot token + Socket Mode app token (plus the Slack app scopes/events listed in [`docs/installation.md#slack-setup`](docs/installation.md#slack-setup))

For Matrix support: `ductor install matrix` — see [Matrix setup guide](docs/matrix-setup.md).
For Slack support: `pip install "ductor[slack]"`, then follow [`docs/installation.md#slack-setup`](docs/installation.md#slack-setup) and configure `slack.bot_token` + `slack.app_token`.

Detailed setup: [`docs/installation.md`](docs/installation.md)

Expand Down Expand Up @@ -201,7 +203,7 @@ Main chat: "Ask codex-agent to write tests for the API"

## Features

- **Multi-transport** — run Telegram and Matrix simultaneously, or pick one
- **Multi-transport** — run Telegram, Matrix, and Slack simultaneously, or pick any one
- **Multi-language** — UI in English, Deutsch, Nederlands, Français, Русский, Español, Português
- **Real-time streaming** — live message edits (Telegram) or segment-based output (Matrix)
- **Provider switching** — `/model` to change provider/model (never blocks, even during active processes)
Expand All @@ -228,13 +230,15 @@ Telegram is the primary transport — full feature set, battle-tested, zero extr
|---|---|---|---|---|
| **Telegram** | primary | Live message edits | Inline keyboards | `pip install ductor` |
| **Matrix** | supported | Segment-based (new messages) | Emoji reactions | `ductor install matrix` |
| **Slack** | supported | Non-streaming | Native threads | `pip install "ductor[slack]"` |

Both transports can run **in parallel** on the same agent:

```json
{"transport": "telegram"}
{"transport": "matrix"}
{"transports": ["telegram", "matrix"]}
{"transport": "slack"}
{"transports": ["telegram", "slack"]}
```

### Modular transport architecture
Expand Down Expand Up @@ -292,6 +296,64 @@ Matrix auth uses room and user allowlists in the `matrix` config block:

The bot logs in with password on first start, then persists `access_token` and `device_id` for subsequent runs. E2EE is supported via `matrix-nio[e2e]`.

### Slack

Slack runs through **Socket Mode**, so ductor does not need a public webhook URL.

Create a Slack app, then configure these permissions before installing it to your workspace.

**Bot token scopes**

| Scope | Why ductor needs it |
|---|---|
| `chat:write` | send replies as the bot |
| `app_mentions:read` | detect `@bot` in channels |
| `channels:history` | read public-channel messages and thread history |
| `channels:read` | resolve public channel metadata |
| `groups:history` | read private-channel messages and thread history |
| `im:history` | read direct messages |
| `im:read` | access DM metadata |
| `im:write` | open/manage DMs |
| `users:read` | resolve user display names for thread backfill/context |
| `files:read` | download attached files |
| `files:write` | upload generated files |

**Optional bot token scope**

| Scope | When to add it |
|---|---|
| `groups:read` | if you want private-channel metadata lookups beyond history access |

**App-level token scope**

| Scope | Why ductor needs it |
|---|---|
| `connections:write` | required for Socket Mode (`xapp-...`) |

**Event subscriptions**

| Event | Required | Purpose |
|---|---|---|
| `message.im` | yes | direct messages |
| `message.channels` | yes | public-channel messages |
| `message.groups` | recommended | private-channel messages |
| `app_mention` | yes | mention handling in channels |

Also enable **App Home → Messages Tab** so users can DM the bot, then **Install App to Workspace** and copy:

- **Bot User OAuth Token** → `slack.bot_token` (`xoxb-...`)
- **App-Level Token** → `slack.app_token` (`xapp-...`)

If you change scopes or subscribed events later, **reinstall the Slack app** so the new permissions take effect.

ductor's Slack allowlist lives in the `slack` config block:

- **`allowed_users`** — Slack member IDs allowed to use the bot
- **`allowed_channels`** — Slack channel IDs where the bot may respond
- **`group_mention_only`** — when `true`, channel conversations start on `@bot` and continue in the activated thread

After setup, invite the app into each target channel. Full step-by-step setup is in [`docs/installation.md#slack-setup`](docs/installation.md#slack-setup).

## Language

ductor's UI (commands, status messages, onboarding) is available in multiple languages:
Expand Down Expand Up @@ -340,6 +402,8 @@ This is **hot-reloadable** — change the language without restarting the bot.

`/new` is intentionally a factory reset for the current `SessionKey`: it clears the bucket tied to the configured default model/provider for that chat or topic, not whichever provider you last switched to temporarily via `/model`.

On Slack, these same commands also work as normal message commands (for example `help`, `status`, or `model`) even though ductor does not register native Slack slash commands.

## Common CLI commands

```bash
Expand Down
6 changes: 6 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
"allowed_users": ["@you:matrix.org"],
"store_path": "matrix_store",
},
"slack": {
"bot_token": "xoxb-your-slack-bot-token",
"app_token": "xapp-your-slack-app-token",
"allowed_channels": ["C0123456789"],
"allowed_users": ["U0123456789"],
},
"streaming": {
"enabled": true,
"min_chars": 200,
Expand Down
120 changes: 116 additions & 4 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
- Codex CLI: `npm install -g @openai/codex && codex auth`
- Gemini CLI: `npm install -g @google/gemini-cli` and authenticate in `gemini`
4. One of these messaging transports:
- **Telegram**: Bot token from [@BotFather](https://t.me/BotFather) + user ID from [@userinfobot](https://t.me/userinfobot)
- **Matrix**: install Matrix support first (`ductor install matrix` or `pip install \"ductor[matrix]\"`), then provide homeserver URL, user ID, and password/access token
- **Telegram**: Bot token from [@BotFather](https://t.me/BotFather) + user ID from [@userinfobot](https://t.me/userinfobot)
- **Matrix**: install Matrix support first (`ductor install matrix` or `pip install \"ductor[matrix]\"`), then provide homeserver URL, user ID, and password/access token
- **Slack**: install Slack support first (`pip install "ductor[slack]"`), then create a Slack app with Socket Mode, the bot/app scopes below, and provide bot/app tokens plus Slack member/channel IDs for the allowlist
5. Docker optional (recommended for sandboxing)

## Install
Expand Down Expand Up @@ -45,19 +46,130 @@ ductor
On first run, onboarding does:

- checks Claude/Codex/Gemini auth status,
- asks which transport to use (Telegram or Matrix),
- asks which transport to use (Telegram, Matrix, or Slack),
- collects transport credentials,
- asks timezone,
- offers Docker sandboxing (with optional AI/ML package selection),
- offers service install,
- writes config and seeds `~/.ductor/`.

Multiple transports can run in parallel (e.g. Telegram + Matrix
Multiple transports can run in parallel (e.g. Telegram + Slack
simultaneously). After initial setup, configure the `transports` array
in `config.json`. See [config.md](config.md) for details.

If service install succeeds, onboarding returns without starting foreground bot.

## Slack setup

ductor's Slack transport follows the same modern pattern Hermes uses: **Slack Bolt + Socket Mode**. That means no public webhook URL is needed.

### 1. Install the Slack extra

```bash
pip install "ductor[slack]"
```

### 2. Create a Slack app

1. Go to <https://api.slack.com/apps>
2. Click **Create New App**
3. Choose **From scratch**
4. Pick a name and workspace

### 3. Add bot token scopes

In **OAuth & Permissions → Scopes → Bot Token Scopes**, add:

| Scope | Required | Purpose |
|---|---|---|
| `chat:write` | yes | send bot replies |
| `app_mentions:read` | yes | detect `@bot` in channels |
| `channels:history` | yes | read public-channel messages and thread history |
| `channels:read` | yes | resolve public channel metadata |
| `groups:history` | recommended | read private-channel messages and thread history |
| `im:history` | yes | read DMs |
| `im:read` | yes | access DM metadata |
| `im:write` | yes | open/manage DMs |
| `users:read` | yes | resolve Slack user names |
| `files:read` | yes | download attached files |
| `files:write` | yes | upload files back to Slack |
| `groups:read` | optional | resolve private-channel metadata |

Without `channels:history` / `message.channels`, the bot will work in DMs but not in public channels. Without `groups:history` / `message.groups`, it will not work in private channels.

### 4. Enable Socket Mode

In **Settings → Socket Mode**:

1. Turn Socket Mode on
2. Create an app-level token
3. Grant it the `connections:write` scope
4. Copy the resulting `xapp-...` token

This token goes into `slack.app_token`.

### 5. Subscribe to Slack events

In **Event Subscriptions → Subscribe to bot events**, add:

| Event | Required | Purpose |
|---|---|---|
| `message.im` | yes | direct messages |
| `message.channels` | yes | public-channel messages |
| `message.groups` | recommended | private-channel messages |
| `app_mention` | yes | mention handling in channels |

### 6. Enable direct messages

In **App Home**:

1. Turn on **Messages Tab**
2. Enable **Allow users to send Slash commands and messages from the messages tab**

Without this, users cannot DM the bot even if the tokens and scopes are correct.

ductor does not register native Slack slash commands. Instead, its command keywords work in Slack as normal messages (for example `help`, `status`, or `model`) and also accept a leading `/`.

### 7. Install or reinstall the app to the workspace

In **Install App**, click **Install to Workspace** and authorize the app. Copy the **Bot User OAuth Token** (`xoxb-...`) into `slack.bot_token`.

If you change scopes or event subscriptions later, reinstall the app so Slack applies the new permissions.

### 8. Collect Slack IDs for the allowlist

- **User IDs** (`U...`) go into `slack.allowed_users`
- **Channel IDs** (`C...` / `G...`) go into `slack.allowed_channels`

You can get them from Slack's profile/channel details UI.

### 9. Configure ductor

```json
{
"transport": "slack",
"group_mention_only": true,
"slack": {
"bot_token": "xoxb-your-slack-bot-token",
"app_token": "xapp-your-slack-app-token",
"allowed_channels": ["C0123456789"],
"allowed_users": ["U0123456789"]
}
}
```

Then invite the app into each target channel:

```text
/invite @your-bot-name
```

Behavior summary:

- **DMs**: the bot responds to every allowed user message
- **Channels**: with `group_mention_only=true`, a channel conversation starts from a top-level `@bot` mention or an `@bot` inside an existing thread
- **Activated threads**: once a thread is activated, follow-up replies in that thread continue the same session without another mention

## Platform notes

### Linux
Expand Down
Loading