diff --git a/.github/workflows/upstream-release-watch.yml b/.github/workflows/upstream-release-watch.yml new file mode 100644 index 00000000..644255c1 --- /dev/null +++ b/.github/workflows/upstream-release-watch.yml @@ -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], + }); diff --git a/README.md b/README.md index 1bf9d053..c6601751 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- Claude Code, Codex CLI, and Gemini CLI as your coding assistant — on Telegram and Matrix.
+ Claude Code, Codex CLI, and Gemini CLI as your coding assistant — on Telegram, Matrix, and Slack.
Uses only official CLIs. Nothing spoofed, nothing proxied. Multi-transport, automation, and sub-agents in one runtime.

@@ -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/`. @@ -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) @@ -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) @@ -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 @@ -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: @@ -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 diff --git a/config.example.json b/config.example.json index 7e2c1db8..ac4b05d1 100644 --- a/config.example.json +++ b/config.example.json @@ -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, diff --git a/docs/installation.md b/docs/installation.md index 1cd2313b..c19c5cc4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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 @@ -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 +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 diff --git a/docs/modules/messenger.md b/docs/modules/messenger.md index 184281f5..93da7c27 100644 --- a/docs/modules/messenger.md +++ b/docs/modules/messenger.md @@ -3,7 +3,7 @@ Transport abstraction layer: protocols, capabilities, registry, and multi-transport adapter. Everything in this package is transport-agnostic. Concrete transports live in sub-packages -(`messenger/telegram/`, `messenger/matrix/`). +(`messenger/telegram/`, `messenger/matrix/`, `messenger/slack/`). For transport-specific details see [bot.md](bot.md) (Telegram) and [matrix.md](matrix.md) (Matrix). @@ -45,7 +45,7 @@ Required surface: | `on_task_question(...)` | async | Deliver background task question | | `file_roots(paths)` | method | Allowed root directories for file sends | -Both `TelegramBot` and `MatrixBot` implement this protocol. +`TelegramBot`, `MatrixBot`, and `SlackBot` implement this protocol. ## MessengerCapabilities @@ -71,6 +71,7 @@ Two presets are shipped: |---|---| | `TELEGRAM_CAPABILITIES` | inline buttons, message editing, threads, streaming edit, seen indicator, 4096 char limit | | `MATRIX_CAPABILITIES` | reactions (no inline buttons), no message editing, no threads, seen indicator, 40000 char limit | +| `SLACK_CAPABILITIES` | threads, message editing, file send, 40000 char limit | Orchestrator and delivery code queries capabilities at runtime to decide between streaming-edit vs. segment-based streaming, inline @@ -97,6 +98,7 @@ names to lazy-import factory functions: _TRANSPORT_FACTORIES: dict[str, _Factory] = { "telegram": _create_telegram, "matrix": _create_matrix, + "slack": _create_slack, } ``` diff --git a/ductor_bot/__main__.py b/ductor_bot/__main__.py index 5fa343de..ddf93bc8 100644 --- a/ductor_bot/__main__.py +++ b/ductor_bot/__main__.py @@ -70,7 +70,7 @@ def _is_configured() -> bool: except (json.JSONDecodeError, OSError): return False - transports = data.get("transports", []) + transports = _normalized_transport_list(data) if not transports: transports = [data.get("transport", "telegram")] for t in transports: @@ -93,9 +93,33 @@ def _is_configured_matrix(data: dict[str, object]) -> bool: return bool(mx.get("homeserver")) and bool(mx.get("user_id")) +def _is_configured_slack(data: dict[str, object]) -> bool: + slack = data.get("slack", {}) + if not isinstance(slack, dict): + return False + has_tokens = bool(slack.get("bot_token")) and bool(slack.get("app_token")) + has_targets = bool(slack.get("allowed_users")) or bool(slack.get("allowed_channels")) + return has_tokens and has_targets + + +def _normalized_transport_list(data: dict[str, object]) -> list[str]: + """Return normalized transport list from raw config data.""" + raw_transports = data.get("transports", []) + transports = list(raw_transports) if isinstance(raw_transports, list) else [] + primary = data.get("transport", "telegram") + if not isinstance(primary, str) or not primary: + primary = "telegram" + if not transports: + return [primary] + if transports[0] != primary: + return [primary] + return [t for t in transports if isinstance(t, str)] + + _IS_CONFIGURED_CHECKS: dict[str, Callable[[dict[str, object]], bool]] = { "telegram": _is_configured_telegram, "matrix": _is_configured_matrix, + "slack": _is_configured_slack, } @@ -139,6 +163,13 @@ def load_config() -> AgentConfig: if user_data.get("gemini_api_key") is None: user_data["gemini_api_key"] = DEFAULT_EMPTY_GEMINI_API_KEY normalized_existing = True + normalized_transports = _normalized_transport_list(user_data) + if user_data.get("transports") != normalized_transports: + user_data["transports"] = normalized_transports + normalized_existing = True + if user_data.get("transport") != normalized_transports[0]: + user_data["transport"] = normalized_transports[0] + normalized_existing = True defaults = AgentConfig().model_dump(mode="json") defaults["gemini_api_key"] = DEFAULT_EMPTY_GEMINI_API_KEY @@ -247,9 +278,20 @@ def _validate_matrix_config(config: AgentConfig) -> None: sys.exit(1) +def _validate_slack_config(config: AgentConfig) -> None: + """Validate Slack transport requirements.""" + slack = config.slack + has_tokens = bool(slack.bot_token) and bool(slack.app_token) + has_targets = bool(slack.allowed_users) or bool(slack.allowed_channels) + if not has_tokens or not has_targets: + _console.print(t_rich("config.incomplete")) + sys.exit(1) + + _TRANSPORT_VALIDATORS: dict[str, Callable[[AgentConfig], None]] = { "telegram": _validate_telegram_config, "matrix": _validate_matrix_config, + "slack": _validate_slack_config, } diff --git a/ductor_bot/api/server.py b/ductor_bot/api/server.py index 01908f5f..0e67ac71 100644 --- a/ductor_bot/api/server.py +++ b/ductor_bot/api/server.py @@ -43,6 +43,7 @@ from ductor_bot.api.crypto import E2ESession from ductor_bot.bus.lock_pool import LockPool +from ductor_bot.cli.stream_events import ToolUseEvent from ductor_bot.files.image_processor import process_image from ductor_bot.files.prompt import MediaInfo, build_media_prompt from ductor_bot.files.storage import prepare_destination, sanitize_filename @@ -135,10 +136,10 @@ async def on_text(self, delta: str) -> None: if not await self.channel.send({"type": "text_delta", "data": delta}): self.disconnected = True - async def on_tool(self, name: str) -> None: + async def on_tool(self, tool: ToolUseEvent | str) -> None: if self.disconnected: return - name = normalize_tool_name(name) + name = normalize_tool_name(str(getattr(tool, "tool_name", tool))) if not await self.channel.send({"type": "tool_activity", "data": name}): self.disconnected = True diff --git a/ductor_bot/background/models.py b/ductor_bot/background/models.py index c8d5f5ae..b1b56dbf 100644 --- a/ductor_bot/background/models.py +++ b/ductor_bot/background/models.py @@ -14,6 +14,7 @@ class BackgroundSubmit: prompt: str message_id: int thread_id: int | None + transport: str = "tg" session_name: str = "" resume_session_id: str = "" provider_override: str = "" @@ -29,6 +30,7 @@ class BackgroundTask: prompt: str message_id: int thread_id: int | None + transport: str provider: str model: str submitted_at: float @@ -45,6 +47,7 @@ class BackgroundResult: chat_id: int message_id: int thread_id: int | None + transport: str prompt_preview: str result_text: str status: str diff --git a/ductor_bot/background/observer.py b/ductor_bot/background/observer.py index 33a8cf2f..6c727cde 100644 --- a/ductor_bot/background/observer.py +++ b/ductor_bot/background/observer.py @@ -68,6 +68,7 @@ def submit( prompt=sub.prompt, message_id=sub.message_id, thread_id=sub.thread_id, + transport=sub.transport, provider=sub.provider_override if has_session_override else exec_config.provider, model=sub.model_override if has_session_override else exec_config.model, submitted_at=time.monotonic(), @@ -140,6 +141,7 @@ async def _run_oneshot(self, bg_task: BackgroundTask, exec_config: TaskExecution chat_id=bg_task.chat_id, message_id=bg_task.message_id, thread_id=bg_task.thread_id, + transport=bg_task.transport, prompt_preview=bg_task.prompt[:60], result_text=result.result_text, status="error:cli_not_found" if result.execution is None else result.status, @@ -157,6 +159,7 @@ async def _run_oneshot(self, bg_task: BackgroundTask, exec_config: TaskExecution chat_id=bg_task.chat_id, message_id=bg_task.message_id, thread_id=bg_task.thread_id, + transport=bg_task.transport, prompt_preview=bg_task.prompt[:60], result_text="", status="aborted", @@ -176,6 +179,7 @@ async def _run_oneshot(self, bg_task: BackgroundTask, exec_config: TaskExecution chat_id=bg_task.chat_id, message_id=bg_task.message_id, thread_id=bg_task.thread_id, + transport=bg_task.transport, prompt_preview=bg_task.prompt[:60], result_text=t("tasks.internal_error"), status="error:internal", @@ -218,6 +222,7 @@ async def _run_with_session(self, bg_task: BackgroundTask) -> None: chat_id=bg_task.chat_id, message_id=bg_task.message_id, thread_id=bg_task.thread_id, + transport=bg_task.transport, prompt_preview=bg_task.prompt[:60], result_text=response.result or "", status=status, @@ -237,6 +242,7 @@ async def _run_with_session(self, bg_task: BackgroundTask) -> None: chat_id=bg_task.chat_id, message_id=bg_task.message_id, thread_id=bg_task.thread_id, + transport=bg_task.transport, prompt_preview=bg_task.prompt[:60], result_text="", status="aborted", @@ -259,6 +265,7 @@ async def _run_with_session(self, bg_task: BackgroundTask) -> None: chat_id=bg_task.chat_id, message_id=bg_task.message_id, thread_id=bg_task.thread_id, + transport=bg_task.transport, prompt_preview=bg_task.prompt[:60], result_text=t("tasks.internal_error"), status="error:internal", diff --git a/ductor_bot/bus/adapters.py b/ductor_bot/bus/adapters.py index 2e518171..176d1d97 100644 --- a/ductor_bot/bus/adapters.py +++ b/ductor_bot/bus/adapters.py @@ -27,6 +27,8 @@ def from_background_result(result: BackgroundResult) -> Envelope: return Envelope( origin=Origin.BACKGROUND, chat_id=result.chat_id, + topic_id=result.thread_id, + transport=result.transport, prompt_preview=result.prompt_preview, result_text=result.result_text, status=result.status, diff --git a/ductor_bot/cli/codex_events.py b/ductor_bot/cli/codex_events.py index 79411722..24c45352 100644 --- a/ductor_bot/cli/codex_events.py +++ b/ductor_bot/cli/codex_events.py @@ -18,6 +18,18 @@ logger = logging.getLogger(__name__) +def _tool_parameters(item: dict[str, Any]) -> dict[str, Any] | None: + for key in ("arguments", "parameters", "input"): + value = item.get(key) + if isinstance(value, dict): + return value + if isinstance(item.get("command"), str): + return {"command": item["command"]} + if isinstance(item.get("path"), str): + return {"path": item["path"]} + return None + + def parse_codex_jsonl(raw: str) -> tuple[str, str | None, dict[str, Any] | None]: """Parse Codex JSONL output into (result_text, thread_id, usage).""" lines = raw.strip().splitlines() @@ -241,9 +253,27 @@ def _parse_tool_item(item: dict[str, Any], item_type: str, event_type: str) -> l return [] if item_type == "mcp_tool_call": name = item.get("name") or item.get("tool_name") or "MCP" - return [ToolUseEvent(type="assistant", tool_name=str(name))] + return [ + ToolUseEvent( + type="assistant", + tool_name=str(name), + tool_id=item.get("id"), + parameters=_tool_parameters(item), + ) + ] tool_name = _CODEX_ITEM_TOOL_MAP.get(item_type) - return [ToolUseEvent(type="assistant", tool_name=tool_name)] if tool_name else [] + return ( + [ + ToolUseEvent( + type="assistant", + tool_name=tool_name, + tool_id=item.get("id"), + parameters=_tool_parameters(item), + ) + ] + if tool_name + else [] + ) class CodexThinkingFilter: diff --git a/ductor_bot/cli/init_wizard.py b/ductor_bot/cli/init_wizard.py index 8359073b..5753db40 100644 --- a/ductor_bot/cli/init_wizard.py +++ b/ductor_bot/cli/init_wizard.py @@ -44,6 +44,10 @@ def _load_banner() -> str: _TOKEN_PATTERN = re.compile(r"^\d{8,}:[A-Za-z0-9_-]{30,}$") _MATRIX_USER_RE = re.compile(r"^@[a-z0-9._=/+-]+:[a-z0-9.-]+$", re.IGNORECASE) +_SLACK_BOT_TOKEN_RE = re.compile(r"^xoxb-[A-Za-z0-9-]+$") +_SLACK_APP_TOKEN_RE = re.compile(r"^xapp-[A-Za-z0-9-]+$") +_SLACK_CHANNEL_RE = re.compile(r"^[CG][A-Z0-9]{8,}$") +_SLACK_USER_RE = re.compile(r"^U[A-Z0-9]{8,}$") _TIMEZONES: list[str] = [ # Europe @@ -180,7 +184,7 @@ def _show_disclaimer(console: Console) -> None: def _ask_transport(console: Console) -> str: - """Prompt for the messaging transport (Telegram or Matrix).""" + """Prompt for the messaging transport.""" console.print( Panel( t_rich("wizard.transport.body"), @@ -192,11 +196,16 @@ def _ask_transport(console: Console) -> str: selected: str | None = questionary.select( t_rich("wizard.transport.prompt"), - choices=["Telegram", "Matrix"], + choices=["Telegram", "Matrix", "Slack"], ).ask() if selected is None: _abort() - return "matrix" if selected == "Matrix" else "telegram" + transport_by_label = { + "Telegram": "telegram", + "Matrix": "matrix", + "Slack": "slack", + } + return transport_by_label[selected] # --------------------------------------------------------------------------- @@ -341,6 +350,113 @@ def _ask_matrix_allowed_users(console: Console) -> list[str]: console.print(t_rich("wizard.matrix.allowed_users.error")) +# --------------------------------------------------------------------------- +# Slack setup +# --------------------------------------------------------------------------- + + +def _ask_slack_bot_token(console: Console) -> str: + """Prompt for the Slack bot token.""" + console.print( + Panel( + t_rich("wizard.slack.bot_token.body"), + title=t_rich("wizard.slack.bot_token.title"), + border_style="blue", + padding=(1, 2), + ) + ) + + while True: + token: str | None = questionary.password(t_rich("wizard.slack.bot_token.prompt")).ask() + if token is None: + _abort() + token = token.strip() + if _SLACK_BOT_TOKEN_RE.match(token): + return token + console.print(t_rich("wizard.slack.bot_token.error")) + + +def _ask_slack_app_token(console: Console) -> str: + """Prompt for the Slack app token.""" + console.print( + Panel( + t_rich("wizard.slack.app_token.body"), + title=t_rich("wizard.slack.app_token.title"), + border_style="blue", + padding=(1, 2), + ) + ) + + while True: + token: str | None = questionary.password(t_rich("wizard.slack.app_token.prompt")).ask() + if token is None: + _abort() + token = token.strip() + if _SLACK_APP_TOKEN_RE.match(token): + return token + console.print(t_rich("wizard.slack.app_token.error")) + + +def _parse_slack_ids(raw: str, *, pattern: re.Pattern[str]) -> list[str]: + """Parse comma/space-separated Slack IDs and validate them.""" + values = [part.strip() for part in re.split(r"[\s,]+", raw) if part.strip()] + if not values: + return [] + if any(pattern.match(value) is None for value in values): + raise ValueError(raw) + return values + + +def _ask_slack_allowed_channels(console: Console) -> list[str]: + """Prompt for allowed Slack channel IDs.""" + console.print( + Panel( + t_rich("wizard.slack.allowed_channels.body"), + title=t_rich("wizard.slack.allowed_channels.title"), + border_style="blue", + padding=(1, 2), + ) + ) + + while True: + raw: str | None = questionary.text(t_rich("wizard.slack.allowed_channels.prompt")).ask() + if raw is None: + _abort() + raw = raw.strip() + if not raw: + return [] + try: + return _parse_slack_ids(raw, pattern=_SLACK_CHANNEL_RE) + except ValueError: + console.print(t_rich("wizard.slack.allowed_channels.error")) + + +def _ask_slack_allowed_users(console: Console) -> list[str]: + """Prompt for allowed Slack user IDs.""" + console.print( + Panel( + t_rich("wizard.slack.allowed_users.body"), + title=t_rich("wizard.slack.allowed_users.title"), + border_style="blue", + padding=(1, 2), + ) + ) + + while True: + raw: str | None = questionary.text(t_rich("wizard.slack.allowed_users.prompt")).ask() + if raw is None: + _abort() + raw = raw.strip() + try: + values = _parse_slack_ids(raw, pattern=_SLACK_USER_RE) + except ValueError: + console.print(t_rich("wizard.slack.allowed_users.error")) + continue + if values: + return values + console.print(t_rich("wizard.slack.allowed_users.error")) + + # --------------------------------------------------------------------------- # Common steps # --------------------------------------------------------------------------- @@ -561,6 +677,11 @@ class _WizardConfig(TypedDict, total=False): matrix_user_id: str matrix_password: str matrix_allowed_users: list[str] | None + # Slack + slack_bot_token: str + slack_app_token: str + slack_allowed_channels: list[str] | None + slack_allowed_users: list[str] | None def _load_existing_config(config_path: Path) -> dict[str, object]: @@ -581,10 +702,11 @@ def _load_existing_config(config_path: Path) -> dict[str, object]: def _apply_transport_config(merged: dict[str, object], cfg: _WizardConfig) -> None: """Write transport-specific keys into *merged*.""" - if cfg.get("transport", "telegram") == "telegram": + transport = cfg.get("transport", "telegram") + if transport == "telegram": merged["telegram_token"] = cfg.get("telegram_token", "") merged["allowed_user_ids"] = cfg.get("allowed_user_ids") or [] - else: # matrix + elif transport == "matrix": matrix_section = merged.get("matrix") if not isinstance(matrix_section, dict): matrix_section = {} @@ -594,6 +716,15 @@ def _apply_transport_config(merged: dict[str, object], cfg: _WizardConfig) -> No matrix_section["password"] = cfg.get("matrix_password", "") matrix_section["allowed_users"] = cfg.get("matrix_allowed_users") or [] matrix_section["store_path"] = "matrix_store" + else: # slack + slack_section = merged.get("slack") + if not isinstance(slack_section, dict): + slack_section = {} + merged["slack"] = slack_section + slack_section["bot_token"] = cfg.get("slack_bot_token", "") + slack_section["app_token"] = cfg.get("slack_app_token", "") + slack_section["allowed_channels"] = cfg.get("slack_allowed_channels") or [] + slack_section["allowed_users"] = cfg.get("slack_allowed_users") or [] def _write_config(cfg: _WizardConfig) -> Path: @@ -613,6 +744,7 @@ def _write_config(cfg: _WizardConfig) -> Path: merged["gemini_api_key"] = DEFAULT_EMPTY_GEMINI_API_KEY merged["transport"] = cfg.get("transport", "telegram") + merged["transports"] = [merged["transport"]] merged["user_timezone"] = cfg.get("user_timezone", "UTC") raw_docker = merged.get("docker") if isinstance(raw_docker, dict): @@ -635,6 +767,50 @@ def _write_config(cfg: _WizardConfig) -> Path: return config_path +def _collect_transport_config(console: Console, transport: str) -> _WizardConfig: + """Collect transport-specific onboarding answers.""" + if transport == "telegram": + telegram_token = _ask_telegram_token(console) + console.print() + allowed_user_ids = _ask_user_id(console) + console.print() + return _WizardConfig( + telegram_token=telegram_token, + allowed_user_ids=allowed_user_ids, + ) + + if transport == "matrix": + matrix_homeserver = _ask_matrix_homeserver(console) + console.print() + matrix_user_id = _ask_matrix_user_id(console) + console.print() + matrix_password = _ask_matrix_password(console) + console.print() + matrix_allowed_users = _ask_matrix_allowed_users(console) + console.print() + return _WizardConfig( + matrix_homeserver=matrix_homeserver, + matrix_user_id=matrix_user_id, + matrix_password=matrix_password, + matrix_allowed_users=matrix_allowed_users, + ) + + slack_bot_token = _ask_slack_bot_token(console) + console.print() + slack_app_token = _ask_slack_app_token(console) + console.print() + slack_allowed_channels = _ask_slack_allowed_channels(console) + console.print() + slack_allowed_users = _ask_slack_allowed_users(console) + console.print() + return _WizardConfig( + slack_bot_token=slack_bot_token, + slack_app_token=slack_app_token, + slack_allowed_channels=slack_allowed_channels, + slack_allowed_users=slack_allowed_users, + ) + + # --------------------------------------------------------------------------- # Onboarding flow # --------------------------------------------------------------------------- @@ -654,29 +830,7 @@ def run_onboarding() -> bool: transport = _ask_transport(console) console.print() - - # Transport-specific credentials - telegram_token = "" - allowed_user_ids: list[int] = [] - matrix_homeserver = "" - matrix_user_id = "" - matrix_password = "" - matrix_allowed_users: list[str] = [] - - if transport == "telegram": - telegram_token = _ask_telegram_token(console) - console.print() - allowed_user_ids = _ask_user_id(console) - console.print() - else: # matrix - matrix_homeserver = _ask_matrix_homeserver(console) - console.print() - matrix_user_id = _ask_matrix_user_id(console) - console.print() - matrix_password = _ask_matrix_password(console) - console.print() - matrix_allowed_users = _ask_matrix_allowed_users(console) - console.print() + transport_config = _collect_transport_config(console, transport) docker_enabled = _ask_docker(console) console.print() @@ -695,12 +849,7 @@ def run_onboarding() -> bool: user_timezone=timezone, docker_enabled=docker_enabled, docker_extras=docker_extras, - telegram_token=telegram_token, - allowed_user_ids=allowed_user_ids, - matrix_homeserver=matrix_homeserver, - matrix_user_id=matrix_user_id, - matrix_password=matrix_password, - matrix_allowed_users=matrix_allowed_users, + **transport_config, ) ) diff --git a/ductor_bot/cli/service.py b/ductor_bot/cli/service.py index d6a4c0e9..f66daa27 100644 --- a/ductor_bot/cli/service.py +++ b/ductor_bot/cli/service.py @@ -33,6 +33,8 @@ logger = logging.getLogger(__name__) +_ToolCallback = Callable[[ToolUseEvent], Awaitable[None]] + class _StreamCallbacks: """Dispatch stream events to the appropriate callbacks.""" @@ -40,17 +42,19 @@ class _StreamCallbacks: def __init__( self, on_text: Callable[[str], Awaitable[None]] | None, - on_tool: Callable[[str], Awaitable[None]] | None, + on_thinking: Callable[[str], Awaitable[None]] | None, + on_tool: _ToolCallback | None, on_status: Callable[[str | None], Awaitable[None]] | None, on_compact_boundary: Callable[[], Awaitable[None]] | None = None, ) -> None: self._on_text = on_text + self._on_thinking = on_thinking self._on_tool = on_tool self._on_status = on_status self._on_compact_boundary = on_compact_boundary self.init_session_id: str | None = None - async def dispatch(self, event: StreamEvent) -> tuple[str, ResultEvent | None]: + async def dispatch(self, event: StreamEvent) -> tuple[str, ResultEvent | None]: # noqa: C901 """Handle one event. Returns (accumulated_text_chunk, result_or_none).""" if isinstance(event, SystemInitEvent) and event.session_id: self.init_session_id = event.session_id @@ -59,10 +63,13 @@ async def dispatch(self, event: StreamEvent) -> tuple[str, ResultEvent | None]: if self._on_text is not None: await self._on_text(event.text) return event.text, None - if isinstance(event, ThinkingEvent) and self._on_status is not None: - await self._on_status("thinking") + if isinstance(event, ThinkingEvent): + if event.text and self._on_thinking is not None: + await self._on_thinking(event.text) + if self._on_status is not None: + await self._on_status("thinking") elif isinstance(event, ToolUseEvent) and self._on_tool is not None: - await self._on_tool(event.tool_name) + await self._on_tool(event) elif isinstance(event, SystemStatusEvent) and self._on_status is not None: await self._on_status(event.status) elif isinstance(event, CompactBoundaryEvent): @@ -179,11 +186,12 @@ async def execute(self, request: AgentRequest) -> AgentResponse: self._log_call(request, agent_resp, elapsed_ms) return agent_resp - async def execute_streaming( + async def execute_streaming( # noqa: PLR0913 self, request: AgentRequest, on_text_delta: Callable[[str], Awaitable[None]] | None = None, - on_tool_activity: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, + on_tool_activity: _ToolCallback | None = None, on_system_status: Callable[[str | None], Awaitable[None]] | None = None, on_compact_boundary: Callable[[], Awaitable[None]] | None = None, ) -> AgentResponse: @@ -200,7 +208,11 @@ async def execute_streaming( stream_error = False callbacks = _StreamCallbacks( - on_text_delta, on_tool_activity, on_system_status, on_compact_boundary + on_text_delta, + on_thinking_delta, + on_tool_activity, + on_system_status, + on_compact_boundary, ) try: diff --git a/ductor_bot/cli/stream_events.py b/ductor_bot/cli/stream_events.py index a2738407..9b19e2f0 100644 --- a/ductor_bot/cli/stream_events.py +++ b/ductor_bot/cli/stream_events.py @@ -11,6 +11,10 @@ logger = logging.getLogger(__name__) +def _as_dict(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + class StreamEvent(BaseModel): """Base event from the Claude CLI stream-json output.""" @@ -178,7 +182,14 @@ def _parse_assistant_content(data: dict[str, Any]) -> list[StreamEvent]: elif block_type == "tool_use": name = block.get("name", "") if name: - events.append(ToolUseEvent(type="assistant", tool_name=name)) + events.append( + ToolUseEvent( + type="assistant", + tool_name=name, + tool_id=block.get("id"), + parameters=_as_dict(block.get("input") or block.get("parameters")), + ) + ) elif block_type == "thinking": events.append( diff --git a/ductor_bot/config.py b/ductor_bot/config.py index f09d9f8c..2a0c4e94 100644 --- a/ductor_bot/config.py +++ b/ductor_bot/config.py @@ -123,6 +123,7 @@ class HeartbeatTarget(BaseModel): """ enabled: bool = True + transport: str = "tg" chat_id: int | None = None topic_id: int | None = None prompt: str | None = None @@ -240,6 +241,15 @@ class MatrixConfig(BaseModel): store_path: str = "matrix_store" # relative to ductor_home +class SlackConfig(BaseModel): + """Slack Socket Mode settings.""" + + bot_token: str = "" + app_token: str = "" + allowed_channels: list[str] = Field(default_factory=list) + allowed_users: list[str] = Field(default_factory=list) + + class TasksConfig(BaseModel): """Settings for background task delegation.""" @@ -427,13 +437,14 @@ class AgentConfig(BaseModel): update_check: bool = True group_mention_only: bool = False interagent_port: int = 8799 - transport: str = "telegram" # "telegram" | "matrix" + transport: str = "telegram" # "telegram" | "matrix" | "slack" transports: list[str] = Field(default_factory=list) telegram_token: str = "" allowed_user_ids: list[int] = Field(default_factory=list) allowed_group_ids: list[int] = Field(default_factory=list) allowed_channel_ids: list[int] = Field(default_factory=list) matrix: MatrixConfig = Field(default_factory=MatrixConfig) + slack: SlackConfig = Field(default_factory=SlackConfig) @field_validator("gemini_api_key", mode="before") @classmethod diff --git a/ductor_bot/heartbeat/observer.py b/ductor_bot/heartbeat/observer.py index e2230fdc..07faf213 100644 --- a/ductor_bot/heartbeat/observer.py +++ b/ductor_bot/heartbeat/observer.py @@ -18,11 +18,11 @@ logger = logging.getLogger(__name__) -# Callback signature: (chat_id, alert_text, topic_id) -HeartbeatResultCallback = Callable[[int, str, int | None], Awaitable[None]] +# Callback signature: (chat_id, alert_text, topic_id, transport) +HeartbeatResultCallback = Callable[[int, str, int | None, str], Awaitable[None]] -# Handler signature: (chat_id, topic_id, prompt_override, ack_token_override) -HeartbeatHandler = Callable[[int, int | None, str | None, str | None], Awaitable[str | None]] +# Handler signature: (chat_id, topic_id, prompt_override, ack_token_override, transport) +HeartbeatHandler = Callable[[int, int | None, str | None, str | None, str], Awaitable[str | None]] # Validator signature: (chat_id) -> is_accessible ChatValidator = Callable[[int], Awaitable[bool]] @@ -48,7 +48,7 @@ def __init__(self, config: AgentConfig) -> None: self._chat_validator: ChatValidator | None = None self._valid_targets: dict[int, float] = {} self._target_last_run: dict[tuple[int, int | None], float] = {} - self._target_tasks: dict[tuple[int | None, int | None], asyncio.Task[None]] = {} + self._target_tasks: dict[tuple[str, int | None, int | None], asyncio.Task[None]] = {} @property def _hb(self) -> HeartbeatConfig: @@ -111,7 +111,7 @@ def _start_target_loops(self) -> None: interval = target.interval_minutes or self._hb.interval_minutes if interval == self._hb.interval_minutes and target.interval_minutes is None: continue # No custom interval → runs with global tick - key = (target.chat_id, target.topic_id) + key = (target.transport, target.chat_id, target.topic_id) task = asyncio.create_task(self._target_loop(target, interval)) task.add_done_callback(lambda _: None) self._target_tasks[key] = task @@ -140,6 +140,7 @@ async def _target_loop(self, target: HeartbeatTarget, interval_minutes: int) -> await self._run_for_chat( target.chat_id, target.topic_id, + transport=target.transport, prompt=prompt, ack_token=ack_token, quiet_start=quiet_start, @@ -269,7 +270,7 @@ async def _tick_group_targets(self) -> None: if not target.enabled or target.chat_id is None: continue # Targets with custom intervals run in their own loop - if (target.chat_id, target.topic_id) in self._target_tasks: + if (target.transport, target.chat_id, target.topic_id) in self._target_tasks: continue if not await self._validate_target(target.chat_id): continue @@ -278,6 +279,7 @@ async def _tick_group_targets(self) -> None: await self._run_for_chat( target.chat_id, target.topic_id, + transport=target.transport, prompt=prompt, ack_token=ack_token, quiet_start=quiet_start, @@ -306,6 +308,7 @@ async def _run_for_chat( # noqa: PLR0913 chat_id: int, topic_id: int | None = None, *, + transport: str = "tg", prompt: str | None = None, ack_token: str | None = None, quiet_start: int | None = None, @@ -325,7 +328,9 @@ async def _run_for_chat( # noqa: PLR0913 return try: - alert_text = await self._handle_heartbeat(chat_id, topic_id, prompt, ack_token) + alert_text = await self._handle_heartbeat( + chat_id, topic_id, prompt, ack_token, transport + ) except asyncio.CancelledError: raise except Exception: @@ -337,7 +342,7 @@ async def _run_for_chat( # noqa: PLR0913 if self._on_result: try: - await self._on_result(chat_id, alert_text, topic_id) + await self._on_result(chat_id, alert_text, topic_id, transport) except asyncio.CancelledError: raise except Exception: diff --git a/ductor_bot/i18n/de/chat.toml b/ductor_bot/i18n/de/chat.toml index b115645e..7ba9aff9 100644 --- a/ductor_bot/i18n/de/chat.toml +++ b/ductor_bot/i18n/de/chat.toml @@ -305,6 +305,7 @@ header = "**ductor.dev**" version = "Version: `{version}`" telegram_description = "KI-Coding-Agents (Claude, Codex, Gemini) auf Telegram.\nBenannte Sessions, persistentes Gedächtnis, Cron-Jobs, Webhooks, Live-Streaming." matrix_description = "KI-Coding-Agents (Claude, Codex, Gemini) auf Matrix.\nBenannte Sessions, persistentes Gedächtnis, Cron-Jobs, Webhooks, Live-Streaming." +slack_description = "KI-Coding-Agents (Claude, Codex, Gemini) auf Slack.\nBenannte Sessions, persistentes Gedächtnis, Cron-Jobs, Webhooks, Live-Streaming." # -- Hilfe (Kategorien) ------------------------------------------------------- @@ -317,6 +318,7 @@ cat_browse = "Dateien & Info" cat_maintenance = "Wartung" footer = "Schick einfach eine Nachricht, um loszulegen." matrix_footer = "Nutze `!` oder `/` als Prefix. Schick eine Nachricht zum Starten." +slack_footer = "Nutze Befehle wie `help` oder `/help`. Schick eine andere Nachricht zum Starten." # -- Multi-Agent --------------------------------------------------------------- diff --git a/ductor_bot/i18n/de/wizard.toml b/ductor_bot/i18n/de/wizard.toml index 20cd1443..0910139c 100644 --- a/ductor_bot/i18n/de/wizard.toml +++ b/ductor_bot/i18n/de/wizard.toml @@ -39,7 +39,8 @@ title = "[bold]Messaging-Transport[/bold]" body = """\ [bold]Wähle, wie Nutzer mit dem Bot kommunizieren:[/bold]\n [bold cyan]Telegram[/bold cyan] — Benötigt einen Bot-Token von @BotFather - [bold cyan]Matrix[/bold cyan] — Benötigt ein Matrix-Konto auf einem Homeserver (z.B. Element)""" + [bold cyan]Matrix[/bold cyan] — Benötigt ein Matrix-Konto auf einem Homeserver (z.B. Element) + [bold cyan]Slack[/bold cyan] — Requires Slack bot/app tokens and the Slack extra""" prompt = "Transport wählen:" [telegram.token] @@ -103,6 +104,44 @@ body = """\ prompt = "Deine Matrix User ID:" error = "[red]Ungültiges Format. Erwartet: @user:domain (z.B. @nik:matrix.org)[/red]" +[slack.bot_token] +title = "[bold]Slack Bot Token[/bold]" +body = """\ +[bold]Enter your Slack bot token.[/bold]\n + Copy the [bold cyan]Bot User OAuth Token[/bold cyan] from your Slack app.\n +[dim]Format: xoxb-...[/dim]""" +prompt = "Bot token:" +error = "[red]Invalid bot token. Expected a token starting with xoxb-[/red]" + +[slack.app_token] +title = "[bold]Slack App Token[/bold]" +body = """\ +[bold]Enter your Slack app-level token.[/bold]\n + Enable Socket Mode in your Slack app and create an app token with + the [bold cyan]connections:write[/bold cyan] scope.\n +[dim]Format: xapp-...[/dim]""" +prompt = "App token:" +error = "[red]Invalid app token. Expected a token starting with xapp-[/red]" + +[slack.allowed_channels] +title = "[bold]Allowed Channels[/bold]" +body = """\ +[bold]Which Slack channels should the bot accept messages from?[/bold]\n + Enter one or more channel IDs separated by commas or spaces.\n + Leave blank to allow any channel the bot is invited into.\n +[dim]Examples: C0123456789, G0123456789[/dim]""" +prompt = "Allowed channel IDs (optional):" +error = "[red]Invalid Slack channel ID. Use C... or G... IDs separated by commas/spaces.[/red]" + +[slack.allowed_users] +title = "[bold]Allowed Users[/bold]" +body = """\ +[bold]Which Slack users should be allowed to talk to the bot?[/bold]\n + Enter one or more Slack user IDs separated by commas or spaces.\n +[dim]Example: U0123456789[/dim]""" +prompt = "Allowed user IDs:" +error = "[red]Invalid Slack user ID. Use one or more U... IDs separated by commas/spaces.[/red]" + [docker] title = "[bold]Docker Sandboxing[/bold]" found_body = """\ diff --git a/ductor_bot/i18n/en/chat.toml b/ductor_bot/i18n/en/chat.toml index 547104c6..a05c66f7 100644 --- a/ductor_bot/i18n/en/chat.toml +++ b/ductor_bot/i18n/en/chat.toml @@ -306,6 +306,7 @@ header = "**ductor.dev**" version = "Version: `{version}`" telegram_description = "AI coding agents (Claude, Codex, Gemini) on Telegram.\nNamed sessions, persistent memory, cron jobs, webhooks, live streaming." matrix_description = "AI coding agents (Claude, Codex, Gemini) on Matrix.\nNamed sessions, persistent memory, cron jobs, webhooks, live streaming." +slack_description = "AI coding agents (Claude, Codex, Gemini) on Slack.\nNamed sessions, persistent memory, cron jobs, webhooks, live streaming." # -- Help (categories) -------------------------------------------------------- @@ -318,6 +319,7 @@ cat_browse = "Browse & Info" cat_maintenance = "Maintenance" footer = "Send any message to start working with your agent." matrix_footer = "Use `!` or `/` prefix. Send any message to start." +slack_footer = "Use `help` or `/help`. Send any other message to start." # -- Multi-agent --------------------------------------------------------------- diff --git a/ductor_bot/i18n/en/wizard.toml b/ductor_bot/i18n/en/wizard.toml index 2a6b8454..653480bf 100644 --- a/ductor_bot/i18n/en/wizard.toml +++ b/ductor_bot/i18n/en/wizard.toml @@ -39,7 +39,8 @@ title = "[bold]Messaging Transport[/bold]" body = """\ [bold]Choose how users will talk to the bot:[/bold]\n [bold cyan]Telegram[/bold cyan] — Requires a bot token from @BotFather - [bold cyan]Matrix[/bold cyan] — Requires a Matrix account on a homeserver (e.g. Element)""" + [bold cyan]Matrix[/bold cyan] — Requires a Matrix account on a homeserver (e.g. Element) + [bold cyan]Slack[/bold cyan] — Requires Slack bot/app tokens and the Slack extra""" prompt = "Select transport:" [telegram.token] @@ -103,6 +104,44 @@ body = """\ prompt = "Your Matrix user ID:" error = "[red]Invalid format. Expected: @user:domain (e.g. @nik:matrix.org)[/red]" +[slack.bot_token] +title = "[bold]Slack Bot Token[/bold]" +body = """\ +[bold]Enter your Slack bot token.[/bold]\n + Copy the [bold cyan]Bot User OAuth Token[/bold cyan] from your Slack app.\n +[dim]Format: xoxb-...[/dim]""" +prompt = "Bot token:" +error = "[red]Invalid bot token. Expected a token starting with xoxb-[/red]" + +[slack.app_token] +title = "[bold]Slack App Token[/bold]" +body = """\ +[bold]Enter your Slack app-level token.[/bold]\n + Enable Socket Mode in your Slack app and create an app token with + the [bold cyan]connections:write[/bold cyan] scope.\n +[dim]Format: xapp-...[/dim]""" +prompt = "App token:" +error = "[red]Invalid app token. Expected a token starting with xapp-[/red]" + +[slack.allowed_channels] +title = "[bold]Allowed Channels[/bold]" +body = """\ +[bold]Which Slack channels should the bot accept messages from?[/bold]\n + Enter one or more channel IDs separated by commas or spaces.\n + Leave blank to allow any channel the bot is invited into.\n +[dim]Examples: C0123456789, G0123456789[/dim]""" +prompt = "Allowed channel IDs (optional):" +error = "[red]Invalid Slack channel ID. Use C... or G... IDs separated by commas/spaces.[/red]" + +[slack.allowed_users] +title = "[bold]Allowed Users[/bold]" +body = """\ +[bold]Which Slack users should be allowed to talk to the bot?[/bold]\n + Enter one or more Slack user IDs separated by commas or spaces.\n +[dim]Example: U0123456789[/dim]""" +prompt = "Allowed user IDs:" +error = "[red]Invalid Slack user ID. Use one or more U... IDs separated by commas/spaces.[/red]" + [docker] title = "[bold]Docker Sandboxing[/bold]" found_body = """\ @@ -124,7 +163,7 @@ col_size = "Size" prompt = "Select extras (Space to toggle, Enter to confirm):" hint = """\ [dim]These packages are optional and increase image build time. -You can change this later with [cyan]ductor docker extras-add / extras-remove[/cyan].[/dim]""" +You can change this later with [cyan]ductor-slack docker extras-add / extras-remove[/cyan].[/dim]""" auto_deps = "[dim]Auto-added dependencies: {names}[/dim]" [timezone] @@ -139,7 +178,7 @@ error = "[red]Unknown timezone: {tz}[/red]" [service] title = "[bold]Background Service[/bold]" body = """\ -[bold]Run ductor as a background service?[/bold]\n +[bold]Run ductor-slack as a background service?[/bold]\n This creates a {mechanism} that:\n - Starts ductor on {trigger} - Restarts automatically on crash diff --git a/ductor_bot/i18n/es/chat.toml b/ductor_bot/i18n/es/chat.toml index 03b3d38f..c0a9e6be 100644 --- a/ductor_bot/i18n/es/chat.toml +++ b/ductor_bot/i18n/es/chat.toml @@ -305,6 +305,7 @@ header = "**ductor.dev**" version = "Versión: `{version}`" telegram_description = "Agentes de código IA (Claude, Codex, Gemini) en Telegram.\nSesiones con nombre, memoria persistente, tareas cron, webhooks, streaming en vivo." matrix_description = "Agentes de código IA (Claude, Codex, Gemini) en Matrix.\nSesiones con nombre, memoria persistente, tareas cron, webhooks, streaming en vivo." +slack_description = "Agentes de código IA (Claude, Codex, Gemini) en Slack.\nSesiones con nombre, memoria persistente, tareas cron, webhooks, streaming en vivo." # -- Ayuda (categorías) ------------------------------------------------------- @@ -317,6 +318,7 @@ cat_browse = "Archivos e info" cat_maintenance = "Mantenimiento" footer = "Envía cualquier mensaje para empezar a trabajar con tu agente." matrix_footer = "Usa el prefijo `!` o `/`. Envía cualquier mensaje para empezar." +slack_footer = "Usa comandos como `help` o `/help`. Envía cualquier otro mensaje para empezar." # -- Multi-agente -------------------------------------------------------------- diff --git a/ductor_bot/i18n/es/wizard.toml b/ductor_bot/i18n/es/wizard.toml index d18c90b0..7b166323 100644 --- a/ductor_bot/i18n/es/wizard.toml +++ b/ductor_bot/i18n/es/wizard.toml @@ -39,7 +39,8 @@ title = "[bold]Transporte de mensajería[/bold]" body = """\ [bold]Elige cómo los usuarios hablarán con el bot:[/bold]\n [bold cyan]Telegram[/bold cyan] — Requiere un token de bot de @BotFather - [bold cyan]Matrix[/bold cyan] — Requiere una cuenta Matrix en un homeserver (p. ej. Element)""" + [bold cyan]Matrix[/bold cyan] — Requiere una cuenta Matrix en un homeserver (p. ej. Element) + [bold cyan]Slack[/bold cyan] — Requires Slack bot/app tokens and the Slack extra""" prompt = "Selecciona transporte:" [telegram.token] @@ -103,6 +104,44 @@ body = """\ prompt = "Tu ID de usuario de Matrix:" error = "[red]Formato inválido. Esperado: @user:dominio (p. ej. @nik:matrix.org)[/red]" +[slack.bot_token] +title = "[bold]Slack Bot Token[/bold]" +body = """\ +[bold]Enter your Slack bot token.[/bold]\n + Copy the [bold cyan]Bot User OAuth Token[/bold cyan] from your Slack app.\n +[dim]Format: xoxb-...[/dim]""" +prompt = "Bot token:" +error = "[red]Invalid bot token. Expected a token starting with xoxb-[/red]" + +[slack.app_token] +title = "[bold]Slack App Token[/bold]" +body = """\ +[bold]Enter your Slack app-level token.[/bold]\n + Enable Socket Mode in your Slack app and create an app token with + the [bold cyan]connections:write[/bold cyan] scope.\n +[dim]Format: xapp-...[/dim]""" +prompt = "App token:" +error = "[red]Invalid app token. Expected a token starting with xapp-[/red]" + +[slack.allowed_channels] +title = "[bold]Allowed Channels[/bold]" +body = """\ +[bold]Which Slack channels should the bot accept messages from?[/bold]\n + Enter one or more channel IDs separated by commas or spaces.\n + Leave blank to allow any channel the bot is invited into.\n +[dim]Examples: C0123456789, G0123456789[/dim]""" +prompt = "Allowed channel IDs (optional):" +error = "[red]Invalid Slack channel ID. Use C... or G... IDs separated by commas/spaces.[/red]" + +[slack.allowed_users] +title = "[bold]Allowed Users[/bold]" +body = """\ +[bold]Which Slack users should be allowed to talk to the bot?[/bold]\n + Enter one or more Slack user IDs separated by commas or spaces.\n +[dim]Example: U0123456789[/dim]""" +prompt = "Allowed user IDs:" +error = "[red]Invalid Slack user ID. Use one or more U... IDs separated by commas/spaces.[/red]" + [docker] title = "[bold]Docker Sandboxing[/bold]" found_body = """\ diff --git a/ductor_bot/i18n/fr/chat.toml b/ductor_bot/i18n/fr/chat.toml index 0fbf32a7..ff3095d0 100644 --- a/ductor_bot/i18n/fr/chat.toml +++ b/ductor_bot/i18n/fr/chat.toml @@ -305,6 +305,7 @@ header = "**ductor.dev**" version = "Version : `{version}`" telegram_description = "Agents de code IA (Claude, Codex, Gemini) sur Telegram.\nSessions nommées, mémoire persistante, cron jobs, webhooks, streaming en direct." matrix_description = "Agents de code IA (Claude, Codex, Gemini) sur Matrix.\nSessions nommées, mémoire persistante, cron jobs, webhooks, streaming en direct." +slack_description = "Agents de code IA (Claude, Codex, Gemini) sur Slack.\nSessions nommées, mémoire persistante, cron jobs, webhooks, streaming en direct." # -- Aide (catégories) -------------------------------------------------------- @@ -317,6 +318,7 @@ cat_browse = "Fichiers & Info" cat_maintenance = "Maintenance" footer = "Envoie un message pour commencer à travailler avec ton agent." matrix_footer = "Utilise le préfixe `!` ou `/`. Envoie un message pour démarrer." +slack_footer = "Utilise des commandes comme `help` ou `/help`. Envoie un autre message pour démarrer." # -- Multi-agent --------------------------------------------------------------- diff --git a/ductor_bot/i18n/fr/wizard.toml b/ductor_bot/i18n/fr/wizard.toml index 14b8c96d..593aa123 100644 --- a/ductor_bot/i18n/fr/wizard.toml +++ b/ductor_bot/i18n/fr/wizard.toml @@ -39,7 +39,8 @@ title = "[bold]Transport de messagerie[/bold]" body = """\ [bold]Choisis comment les utilisateurs communiqueront avec le bot :[/bold]\n [bold cyan]Telegram[/bold cyan] — Nécessite un token de bot de @BotFather - [bold cyan]Matrix[/bold cyan] — Nécessite un compte Matrix sur un homeserver (par ex. Element)""" + [bold cyan]Matrix[/bold cyan] — Nécessite un compte Matrix sur un homeserver (par ex. Element) + [bold cyan]Slack[/bold cyan] — Requires Slack bot/app tokens and the Slack extra""" prompt = "Choisis le transport :" [telegram.token] @@ -103,6 +104,44 @@ body = """\ prompt = "Ton ID utilisateur Matrix :" error = "[red]Format invalide. Attendu : @user:domain (par ex. @nik:matrix.org)[/red]" +[slack.bot_token] +title = "[bold]Slack Bot Token[/bold]" +body = """\ +[bold]Enter your Slack bot token.[/bold]\n + Copy the [bold cyan]Bot User OAuth Token[/bold cyan] from your Slack app.\n +[dim]Format: xoxb-...[/dim]""" +prompt = "Bot token:" +error = "[red]Invalid bot token. Expected a token starting with xoxb-[/red]" + +[slack.app_token] +title = "[bold]Slack App Token[/bold]" +body = """\ +[bold]Enter your Slack app-level token.[/bold]\n + Enable Socket Mode in your Slack app and create an app token with + the [bold cyan]connections:write[/bold cyan] scope.\n +[dim]Format: xapp-...[/dim]""" +prompt = "App token:" +error = "[red]Invalid app token. Expected a token starting with xapp-[/red]" + +[slack.allowed_channels] +title = "[bold]Allowed Channels[/bold]" +body = """\ +[bold]Which Slack channels should the bot accept messages from?[/bold]\n + Enter one or more channel IDs separated by commas or spaces.\n + Leave blank to allow any channel the bot is invited into.\n +[dim]Examples: C0123456789, G0123456789[/dim]""" +prompt = "Allowed channel IDs (optional):" +error = "[red]Invalid Slack channel ID. Use C... or G... IDs separated by commas/spaces.[/red]" + +[slack.allowed_users] +title = "[bold]Allowed Users[/bold]" +body = """\ +[bold]Which Slack users should be allowed to talk to the bot?[/bold]\n + Enter one or more Slack user IDs separated by commas or spaces.\n +[dim]Example: U0123456789[/dim]""" +prompt = "Allowed user IDs:" +error = "[red]Invalid Slack user ID. Use one or more U... IDs separated by commas/spaces.[/red]" + [docker] title = "[bold]Sandboxing Docker[/bold]" found_body = """\ diff --git a/ductor_bot/i18n/id/chat.toml b/ductor_bot/i18n/id/chat.toml index 207a79dd..55a5fde3 100644 --- a/ductor_bot/i18n/id/chat.toml +++ b/ductor_bot/i18n/id/chat.toml @@ -304,6 +304,7 @@ header = "**ductor.dev**" version = "Versi: `{version}`" telegram_description = "Agen AI coding (Claude, Codex, Gemini) di Telegram.\nSesi bernama, memori persisten, cron job, webhook, streaming langsung." matrix_description = "Agen AI coding (Claude, Codex, Gemini) di Matrix.\nSesi bernama, memori persisten, cron job, webhook, streaming langsung." +slack_description = "Agen AI coding (Claude, Codex, Gemini) di Slack.\nSesi bernama, memori persisten, cron job, webhook, streaming langsung." # -- Help (categories) -------------------------------------------------------- @@ -316,6 +317,7 @@ cat_browse = "Jelajah & Info" cat_maintenance = "Pemeliharaan" footer = "Kirim pesan apa pun untuk mulai bekerja dengan agen Anda." matrix_footer = "Gunakan awalan `!` atau `/`. Kirim pesan apa pun untuk memulai." +slack_footer = "Gunakan perintah seperti `help` atau `/help`. Kirim pesan lain untuk memulai." # -- Multi-agent --------------------------------------------------------------- diff --git a/ductor_bot/i18n/id/wizard.toml b/ductor_bot/i18n/id/wizard.toml index b9ea7039..a0196617 100644 --- a/ductor_bot/i18n/id/wizard.toml +++ b/ductor_bot/i18n/id/wizard.toml @@ -39,7 +39,8 @@ title = "[bold]Transport Pesan[/bold]" body = """\ [bold]Pilih cara pengguna berkomunikasi dengan bot:[/bold]\n [bold cyan]Telegram[/bold cyan] — Memerlukan token bot dari @BotFather - [bold cyan]Matrix[/bold cyan] — Memerlukan akun Matrix di homeserver (mis. Element)""" + [bold cyan]Matrix[/bold cyan] — Memerlukan akun Matrix di homeserver (mis. Element) + [bold cyan]Slack[/bold cyan] — Requires Slack bot/app tokens and the Slack extra""" prompt = "Pilih transport:" [telegram.token] @@ -103,6 +104,44 @@ body = """\ prompt = "ID pengguna Matrix Anda:" error = "[red]Format tidak valid. Diharapkan: @user:domain (mis. @nik:matrix.org)[/red]" +[slack.bot_token] +title = "[bold]Slack Bot Token[/bold]" +body = """\ +[bold]Enter your Slack bot token.[/bold]\n + Copy the [bold cyan]Bot User OAuth Token[/bold cyan] from your Slack app.\n +[dim]Format: xoxb-...[/dim]""" +prompt = "Bot token:" +error = "[red]Invalid bot token. Expected a token starting with xoxb-[/red]" + +[slack.app_token] +title = "[bold]Slack App Token[/bold]" +body = """\ +[bold]Enter your Slack app-level token.[/bold]\n + Enable Socket Mode in your Slack app and create an app token with + the [bold cyan]connections:write[/bold cyan] scope.\n +[dim]Format: xapp-...[/dim]""" +prompt = "App token:" +error = "[red]Invalid app token. Expected a token starting with xapp-[/red]" + +[slack.allowed_channels] +title = "[bold]Allowed Channels[/bold]" +body = """\ +[bold]Which Slack channels should the bot accept messages from?[/bold]\n + Enter one or more channel IDs separated by commas or spaces.\n + Leave blank to allow any channel the bot is invited into.\n +[dim]Examples: C0123456789, G0123456789[/dim]""" +prompt = "Allowed channel IDs (optional):" +error = "[red]Invalid Slack channel ID. Use C... or G... IDs separated by commas/spaces.[/red]" + +[slack.allowed_users] +title = "[bold]Allowed Users[/bold]" +body = """\ +[bold]Which Slack users should be allowed to talk to the bot?[/bold]\n + Enter one or more Slack user IDs separated by commas or spaces.\n +[dim]Example: U0123456789[/dim]""" +prompt = "Allowed user IDs:" +error = "[red]Invalid Slack user ID. Use one or more U... IDs separated by commas/spaces.[/red]" + [docker] title = "[bold]Docker Sandboxing[/bold]" found_body = """\ diff --git a/ductor_bot/i18n/nl/chat.toml b/ductor_bot/i18n/nl/chat.toml index dbf0988b..1d321ff1 100644 --- a/ductor_bot/i18n/nl/chat.toml +++ b/ductor_bot/i18n/nl/chat.toml @@ -305,6 +305,7 @@ header = "**ductor.dev**" version = "Versie: `{version}`" telegram_description = "AI-coding-agents (Claude, Codex, Gemini) op Telegram.\nBenoemde sessies, persistent geheugen, cronjobs, webhooks, live streaming." matrix_description = "AI-coding-agents (Claude, Codex, Gemini) op Matrix.\nBenoemde sessies, persistent geheugen, cronjobs, webhooks, live streaming." +slack_description = "AI-coding-agents (Claude, Codex, Gemini) op Slack.\nBenoemde sessies, persistent geheugen, cronjobs, webhooks, live streaming." # -- Help (categorieën) ----------------------------------------------------------- @@ -317,6 +318,7 @@ cat_browse = "Bestanden & Info" cat_maintenance = "Onderhoud" footer = "Stuur een bericht om met je agent aan de slag te gaan." matrix_footer = "Gebruik `!` of `/` als prefix. Stuur een bericht om te beginnen." +slack_footer = "Gebruik opdrachten zoals `help` of `/help`. Stuur een ander bericht om te beginnen." # -- Multi-agent ------------------------------------------------------------------- diff --git a/ductor_bot/i18n/nl/wizard.toml b/ductor_bot/i18n/nl/wizard.toml index 2ef6984e..3c240f98 100644 --- a/ductor_bot/i18n/nl/wizard.toml +++ b/ductor_bot/i18n/nl/wizard.toml @@ -39,7 +39,8 @@ title = "[bold]Berichtentransport[/bold]" body = """\ [bold]Kies hoe gebruikers met de bot communiceren:[/bold]\n [bold cyan]Telegram[/bold cyan] — Vereist een bot-token van @BotFather - [bold cyan]Matrix[/bold cyan] — Vereist een Matrix-account op een homeserver (bijv. Element)""" + [bold cyan]Matrix[/bold cyan] — Vereist een Matrix-account op een homeserver (bijv. Element) + [bold cyan]Slack[/bold cyan] — Requires Slack bot/app tokens and the Slack extra""" prompt = "Selecteer transport:" [telegram.token] @@ -103,6 +104,44 @@ body = """\ prompt = "Je Matrix user ID:" error = "[red]Ongeldig formaat. Verwacht: @user:domein (bijv. @nik:matrix.org)[/red]" +[slack.bot_token] +title = "[bold]Slack Bot Token[/bold]" +body = """\ +[bold]Enter your Slack bot token.[/bold]\n + Copy the [bold cyan]Bot User OAuth Token[/bold cyan] from your Slack app.\n +[dim]Format: xoxb-...[/dim]""" +prompt = "Bot token:" +error = "[red]Invalid bot token. Expected a token starting with xoxb-[/red]" + +[slack.app_token] +title = "[bold]Slack App Token[/bold]" +body = """\ +[bold]Enter your Slack app-level token.[/bold]\n + Enable Socket Mode in your Slack app and create an app token with + the [bold cyan]connections:write[/bold cyan] scope.\n +[dim]Format: xapp-...[/dim]""" +prompt = "App token:" +error = "[red]Invalid app token. Expected a token starting with xapp-[/red]" + +[slack.allowed_channels] +title = "[bold]Allowed Channels[/bold]" +body = """\ +[bold]Which Slack channels should the bot accept messages from?[/bold]\n + Enter one or more channel IDs separated by commas or spaces.\n + Leave blank to allow any channel the bot is invited into.\n +[dim]Examples: C0123456789, G0123456789[/dim]""" +prompt = "Allowed channel IDs (optional):" +error = "[red]Invalid Slack channel ID. Use C... or G... IDs separated by commas/spaces.[/red]" + +[slack.allowed_users] +title = "[bold]Allowed Users[/bold]" +body = """\ +[bold]Which Slack users should be allowed to talk to the bot?[/bold]\n + Enter one or more Slack user IDs separated by commas or spaces.\n +[dim]Example: U0123456789[/dim]""" +prompt = "Allowed user IDs:" +error = "[red]Invalid Slack user ID. Use one or more U... IDs separated by commas/spaces.[/red]" + [docker] title = "[bold]Docker Sandboxing[/bold]" found_body = """\ diff --git a/ductor_bot/i18n/pt/chat.toml b/ductor_bot/i18n/pt/chat.toml index bcbe66f1..94c8aaaa 100644 --- a/ductor_bot/i18n/pt/chat.toml +++ b/ductor_bot/i18n/pt/chat.toml @@ -305,6 +305,7 @@ header = "**ductor.dev**" version = "Versão: `{version}`" telegram_description = "Agentes de código IA (Claude, Codex, Gemini) no Telegram.\nSessões nomeadas, memória persistente, cron jobs, webhooks, streaming ao vivo." matrix_description = "Agentes de código IA (Claude, Codex, Gemini) no Matrix.\nSessões nomeadas, memória persistente, cron jobs, webhooks, streaming ao vivo." +slack_description = "Agentes de código IA (Claude, Codex, Gemini) no Slack.\nSessões nomeadas, memória persistente, cron jobs, webhooks, streaming ao vivo." # -- Ajuda (categorias) ------------------------------------------------------- @@ -317,6 +318,7 @@ cat_browse = "Arquivos & Info" cat_maintenance = "Manutenção" footer = "Envie qualquer mensagem para começar a trabalhar com seu agente." matrix_footer = "Use o prefixo `!` ou `/`. Envie qualquer mensagem para começar." +slack_footer = "Use comandos como `help` ou `/help`. Envie qualquer outra mensagem para começar." # -- Multi-agent --------------------------------------------------------------- diff --git a/ductor_bot/i18n/pt/wizard.toml b/ductor_bot/i18n/pt/wizard.toml index 97912365..414a222d 100644 --- a/ductor_bot/i18n/pt/wizard.toml +++ b/ductor_bot/i18n/pt/wizard.toml @@ -39,7 +39,8 @@ title = "[bold]Transporte de Mensagens[/bold]" body = """\ [bold]Escolha como os usuários vão se comunicar com o bot:[/bold]\n [bold cyan]Telegram[/bold cyan] — Requer um token de bot do @BotFather - [bold cyan]Matrix[/bold cyan] — Requer uma conta Matrix em um homeserver (ex.: Element)""" + [bold cyan]Matrix[/bold cyan] — Requer uma conta Matrix em um homeserver (ex.: Element) + [bold cyan]Slack[/bold cyan] — Requires Slack bot/app tokens and the Slack extra""" prompt = "Selecione o transporte:" [telegram.token] @@ -103,6 +104,44 @@ body = """\ prompt = "Seu ID de usuário Matrix:" error = "[red]Formato inválido. Esperado: @user:domínio (ex.: @nik:matrix.org)[/red]" +[slack.bot_token] +title = "[bold]Slack Bot Token[/bold]" +body = """\ +[bold]Enter your Slack bot token.[/bold]\n + Copy the [bold cyan]Bot User OAuth Token[/bold cyan] from your Slack app.\n +[dim]Format: xoxb-...[/dim]""" +prompt = "Bot token:" +error = "[red]Invalid bot token. Expected a token starting with xoxb-[/red]" + +[slack.app_token] +title = "[bold]Slack App Token[/bold]" +body = """\ +[bold]Enter your Slack app-level token.[/bold]\n + Enable Socket Mode in your Slack app and create an app token with + the [bold cyan]connections:write[/bold cyan] scope.\n +[dim]Format: xapp-...[/dim]""" +prompt = "App token:" +error = "[red]Invalid app token. Expected a token starting with xapp-[/red]" + +[slack.allowed_channels] +title = "[bold]Allowed Channels[/bold]" +body = """\ +[bold]Which Slack channels should the bot accept messages from?[/bold]\n + Enter one or more channel IDs separated by commas or spaces.\n + Leave blank to allow any channel the bot is invited into.\n +[dim]Examples: C0123456789, G0123456789[/dim]""" +prompt = "Allowed channel IDs (optional):" +error = "[red]Invalid Slack channel ID. Use C... or G... IDs separated by commas/spaces.[/red]" + +[slack.allowed_users] +title = "[bold]Allowed Users[/bold]" +body = """\ +[bold]Which Slack users should be allowed to talk to the bot?[/bold]\n + Enter one or more Slack user IDs separated by commas or spaces.\n +[dim]Example: U0123456789[/dim]""" +prompt = "Allowed user IDs:" +error = "[red]Invalid Slack user ID. Use one or more U... IDs separated by commas/spaces.[/red]" + [docker] title = "[bold]Docker Sandboxing[/bold]" found_body = """\ diff --git a/ductor_bot/i18n/ru/chat.toml b/ductor_bot/i18n/ru/chat.toml index e062b089..25da1e81 100644 --- a/ductor_bot/i18n/ru/chat.toml +++ b/ductor_bot/i18n/ru/chat.toml @@ -305,6 +305,7 @@ header = "**ductor.dev**" version = "Версия: `{version}`" telegram_description = "ИИ-агенты для кода (Claude, Codex, Gemini) в Telegram.\nИменованные сессии, постоянная память, cron-задачи, вебхуки, стриминг." matrix_description = "ИИ-агенты для кода (Claude, Codex, Gemini) в Matrix.\nИменованные сессии, постоянная память, cron-задачи, вебхуки, стриминг." +slack_description = "ИИ-агенты для кода (Claude, Codex, Gemini) в Slack.\nИменованные сессии, постоянная память, cron-задачи, вебхуки, стриминг." # -- Помощь (категории) -------------------------------------------------------- @@ -317,6 +318,7 @@ cat_browse = "Файлы и инфо" cat_maintenance = "Обслуживание" footer = "Отправь любое сообщение, чтобы начать работу с агентом." matrix_footer = "Используй префикс `!` или `/`. Отправь сообщение, чтобы начать." +slack_footer = "Используй команды вроде `help` или `/help`. Отправь любое другое сообщение, чтобы начать." # -- Multi-Agent --------------------------------------------------------------- diff --git a/ductor_bot/i18n/ru/wizard.toml b/ductor_bot/i18n/ru/wizard.toml index 367bfa9c..60cb50d4 100644 --- a/ductor_bot/i18n/ru/wizard.toml +++ b/ductor_bot/i18n/ru/wizard.toml @@ -39,7 +39,8 @@ title = "[bold]Транспорт сообщений[/bold]" body = """\ [bold]Выбери, как пользователи будут общаться с ботом:[/bold]\n [bold cyan]Telegram[/bold cyan] — Нужен токен от @BotFather - [bold cyan]Matrix[/bold cyan] — Нужен аккаунт Matrix на homeserver (напр. Element)""" + [bold cyan]Matrix[/bold cyan] — Нужен аккаунт Matrix на homeserver (напр. Element) + [bold cyan]Slack[/bold cyan] — Requires Slack bot/app tokens and the Slack extra""" prompt = "Выбери транспорт:" [telegram.token] @@ -103,6 +104,44 @@ body = """\ prompt = "Твой Matrix User ID:" error = "[red]Неверный формат. Ожидается: @user:domain (напр. @nik:matrix.org)[/red]" +[slack.bot_token] +title = "[bold]Slack Bot Token[/bold]" +body = """\ +[bold]Enter your Slack bot token.[/bold]\n + Copy the [bold cyan]Bot User OAuth Token[/bold cyan] from your Slack app.\n +[dim]Format: xoxb-...[/dim]""" +prompt = "Bot token:" +error = "[red]Invalid bot token. Expected a token starting with xoxb-[/red]" + +[slack.app_token] +title = "[bold]Slack App Token[/bold]" +body = """\ +[bold]Enter your Slack app-level token.[/bold]\n + Enable Socket Mode in your Slack app and create an app token with + the [bold cyan]connections:write[/bold cyan] scope.\n +[dim]Format: xapp-...[/dim]""" +prompt = "App token:" +error = "[red]Invalid app token. Expected a token starting with xapp-[/red]" + +[slack.allowed_channels] +title = "[bold]Allowed Channels[/bold]" +body = """\ +[bold]Which Slack channels should the bot accept messages from?[/bold]\n + Enter one or more channel IDs separated by commas or spaces.\n + Leave blank to allow any channel the bot is invited into.\n +[dim]Examples: C0123456789, G0123456789[/dim]""" +prompt = "Allowed channel IDs (optional):" +error = "[red]Invalid Slack channel ID. Use C... or G... IDs separated by commas/spaces.[/red]" + +[slack.allowed_users] +title = "[bold]Allowed Users[/bold]" +body = """\ +[bold]Which Slack users should be allowed to talk to the bot?[/bold]\n + Enter one or more Slack user IDs separated by commas or spaces.\n +[dim]Example: U0123456789[/dim]""" +prompt = "Allowed user IDs:" +error = "[red]Invalid Slack user ID. Use one or more U... IDs separated by commas/spaces.[/red]" + [docker] title = "[bold]Docker-песочница[/bold]" found_body = """\ diff --git a/ductor_bot/messenger/capabilities.py b/ductor_bot/messenger/capabilities.py index 4c9711bc..29b03e4b 100644 --- a/ductor_bot/messenger/capabilities.py +++ b/ductor_bot/messenger/capabilities.py @@ -46,3 +46,16 @@ class MessengerCapabilities: supports_seen_indicator=True, max_message_length=40000, ) + +SLACK_CAPABILITIES = MessengerCapabilities( + name="slack", + supports_inline_buttons=False, + supports_reactions=True, + supports_message_editing=True, + supports_threads=True, + supports_typing_indicator=False, + supports_file_send=True, + supports_streaming_edit=False, + supports_seen_indicator=False, + max_message_length=40000, +) diff --git a/ductor_bot/messenger/matrix/bot.py b/ductor_bot/messenger/matrix/bot.py index d741c149..d53c4af7 100644 --- a/ductor_bot/messenger/matrix/bot.py +++ b/ductor_bot/messenger/matrix/bot.py @@ -1206,12 +1206,16 @@ async def on_async_interagent_result(self, result: AsyncInterAgentResult) -> Non text = result.result_text or f"Inter-agent result from {result.recipient}" await self._notification_service.notify_all(text) return - await self._bus.submit(from_interagent_result(result, chat_id)) + env = from_interagent_result(result, chat_id) + env.transport = "mx" + await self._bus.submit(env) async def on_task_result(self, result: TaskResult) -> None: from ductor_bot.bus.adapters import from_task_result - await self._bus.submit(from_task_result(result)) + env = from_task_result(result) + env.transport = "mx" + await self._bus.submit(env) async def on_task_question( self, @@ -1225,7 +1229,9 @@ async def on_task_question( if not chat_id: chat_id = self._default_chat_id() - await self._bus.submit(from_task_question(task_id, question, prompt_preview, chat_id)) + env = from_task_question(task_id, question, prompt_preview, chat_id, topic_id=thread_id) + env.transport = "mx" + await self._bus.submit(env) def _default_chat_id(self) -> int: """Default delivery target: first allowed room, or last active room.""" diff --git a/ductor_bot/messenger/matrix/streaming.py b/ductor_bot/messenger/matrix/streaming.py index 653e0523..bb4b27df 100644 --- a/ductor_bot/messenger/matrix/streaming.py +++ b/ductor_bot/messenger/matrix/streaming.py @@ -57,9 +57,9 @@ async def on_delta(self, delta: str) -> None: """Append text to the current segment buffer.""" self._buffer += delta - async def on_tool(self, tool_name: str) -> None: + async def on_tool(self, tool: object) -> None: """Flush the buffer on tool activity and log the segment.""" - tool_name = normalize_tool_name(tool_name) + tool_name = normalize_tool_name(str(getattr(tool, "tool_name", tool))) self._segment_count += 1 logger.info( "Matrix streaming: tool=%s segment=%d buf_len=%d", diff --git a/ductor_bot/messenger/registry.py b/ductor_bot/messenger/registry.py index fe03b05c..a7d21ca8 100644 --- a/ductor_bot/messenger/registry.py +++ b/ductor_bot/messenger/registry.py @@ -95,7 +95,20 @@ def _create_matrix( return MatrixBot(config, agent_name=agent_name, bus=bus, lock_pool=lock_pool) +def _create_slack( + config: AgentConfig, + *, + agent_name: str, + bus: MessageBus | None, + lock_pool: LockPool | None, +) -> BotProtocol: + from ductor_bot.messenger.slack.bot import SlackBot + + return SlackBot(config, agent_name=agent_name, bus=bus, lock_pool=lock_pool) + + _TRANSPORT_FACTORIES: dict[str, _Factory] = { "telegram": _create_telegram, "matrix": _create_matrix, + "slack": _create_slack, } diff --git a/ductor_bot/messenger/slack/__init__.py b/ductor_bot/messenger/slack/__init__.py new file mode 100644 index 00000000..f9b27200 --- /dev/null +++ b/ductor_bot/messenger/slack/__init__.py @@ -0,0 +1,5 @@ +"""Slack messenger transport.""" + +from ductor_bot.messenger.slack.bot import SlackBot + +__all__ = ["SlackBot"] diff --git a/ductor_bot/messenger/slack/bot.py b/ductor_bot/messenger/slack/bot.py new file mode 100644 index 00000000..641b19fc --- /dev/null +++ b/ductor_bot/messenger/slack/bot.py @@ -0,0 +1,1041 @@ +"""Slack transport bot using Socket Mode.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ductor_bot.bus.bus import MessageBus +from ductor_bot.bus.lock_pool import LockPool +from ductor_bot.commands import BOT_COMMANDS, MULTIAGENT_SUB_COMMANDS +from ductor_bot.config import AgentConfig +from ductor_bot.files.allowed_roots import resolve_allowed_roots +from ductor_bot.i18n import t +from ductor_bot.infra.version import get_current_version +from ductor_bot.messenger.commands import classify_command +from ductor_bot.messenger.notifications import NotificationService +from ductor_bot.messenger.slack.id_map import SlackIdMap +from ductor_bot.messenger.slack.sender import SlackSendOpts, send_rich +from ductor_bot.session.key import SessionKey +from ductor_bot.text.response_format import SEP, fmt + +if TYPE_CHECKING: + from ductor_bot.infra.updater import UpdateObserver + from ductor_bot.multiagent.bus import AsyncInterAgentResult + from ductor_bot.orchestrator.core import Orchestrator + from ductor_bot.tasks.models import TaskResult + from ductor_bot.workspace.paths import DuctorPaths + +_SlackSocketModeHandler: Any +_SlackAsyncApp: Any + +try: + from slack_bolt.adapter.socket_mode.async_handler import ( + AsyncSocketModeHandler as _SlackSocketModeHandler, + ) + from slack_bolt.async_app import AsyncApp as _SlackAsyncApp + + _SLACK_AVAILABLE = True +except ImportError: # pragma: no cover - import fallback + _SLACK_AVAILABLE = False + _SlackSocketModeHandler = object + _SlackAsyncApp = object + +logger = logging.getLogger(__name__) + +_DEFAULT_MENTIONED_THREAD_TTL_SECONDS = 3600.0 +_DEFAULT_MENTIONED_THREAD_MAX_SIZE = 200 +_DEFAULT_THREAD_CONTEXT_CACHE_MAX_SIZE = 200 +_MESSAGE_COMMANDS_WITH_ARGS = frozenset( + {"agent_restart", "agent_start", "agent_stop", "model", "session", "showfiles"} +) + + +@dataclass(slots=True) +class _ThreadContextCache: + """Cache entry for fetched Slack thread context.""" + + content: str + fetched_at: float = field(default_factory=time.monotonic) + message_count: int = 0 + + +def _restart_marker_path(ductor_home: str) -> Path: + """Return the restart marker path.""" + return Path(ductor_home).expanduser() / "restart-requested" + + +def _slack_ts_is_at_or_after(candidate_ts: str, current_ts: str) -> bool: + """Return whether *candidate_ts* is the current Slack message or later.""" + try: + return Decimal(candidate_ts) >= Decimal(current_ts) + except (InvalidOperation, ValueError): + return candidate_ts >= current_ts + + +class SlackNotificationService: + """Notification service implementation for Slack.""" + + def __init__(self, bot: SlackBot) -> None: + self._bot = bot + + async def notify(self, chat_id: int, text: str) -> None: + channel_id = self._bot.id_map.int_to_channel(chat_id) + if channel_id: + await self._bot._send_rich(channel_id, text) + return + logger.warning("notify: cannot resolve chat_id=%d to Slack channel, falling back", chat_id) + await self.notify_all(text) + + async def notify_all(self, text: str) -> None: + await self._bot.broadcast(text) + + +class SlackBot: + """Slack bot implementing ``BotProtocol``.""" + + def __init__( + self, + config: AgentConfig, + *, + agent_name: str = "main", + bus: MessageBus | None = None, + lock_pool: LockPool | None = None, + ) -> None: + if not _SLACK_AVAILABLE: + raise ImportError( + "slack-bolt is required for Slack transport. " + "Install with: pip install 'ductor[slack]'" + ) from None + + self._config = config + self._agent_name = agent_name + self._store_path = Path(config.ductor_home).expanduser() / "slack_store" + self._store_path.mkdir(parents=True, exist_ok=True) + + self._app: Any = _SlackAsyncApp(token=config.slack.bot_token) + self._socket_handler: Any | None = None + self._socket_task: asyncio.Task[None] | None = None + self._lock_pool = lock_pool or LockPool() + self._bus = bus or MessageBus(lock_pool=self._lock_pool) + self._id_map = SlackIdMap(self._store_path) + + from ductor_bot.messenger.slack.transport import SlackTransport + + self._bus.register_transport(SlackTransport(self)) + self._orchestrator: Orchestrator | None = None + self._startup_hooks: list[Callable[[], Awaitable[None]]] = [] + self._notification_service: NotificationService = SlackNotificationService(self) + self._abort_all_callback: Callable[[], Awaitable[int]] | None = None + self._exit_code = 0 + self._update_observer: UpdateObserver | None = None + self._restart_watcher: asyncio.Task[None] | None = None + self._bot_user_id = "" + self._bot_name = "slack-bot" + self._team_id = "" + self._last_active_channel: str | None = None + self._mentioned_threads: dict[tuple[str, str], float] = {} + self._user_name_cache: dict[str, str] = {} + self._thread_context_cache: dict[str, _ThreadContextCache] = {} + self._MENTIONED_THREAD_TTL = _DEFAULT_MENTIONED_THREAD_TTL_SECONDS + self._MENTIONED_THREAD_MAX_SIZE = _DEFAULT_MENTIONED_THREAD_MAX_SIZE + self._THREAD_CACHE_TTL = 60.0 + self._THREAD_CONTEXT_CACHE_MAX_SIZE = _DEFAULT_THREAD_CONTEXT_CACHE_MAX_SIZE + + self._register_handlers() + + @property + def orchestrator(self) -> Orchestrator | None: + return self._orchestrator + + @property + def config(self) -> AgentConfig: + return self._config + + @property + def notification_service(self) -> NotificationService: + return self._notification_service + + @property + def id_map(self) -> SlackIdMap: + return self._id_map + + @property + def client(self) -> Any: + return self._app.client + + @property + def bot_name(self) -> str: + return self._bot_name + + def register_startup_hook(self, hook: Callable[[], Awaitable[None]]) -> None: + self._startup_hooks.append(hook) + + def set_abort_all_callback(self, callback: Callable[[], Awaitable[int]]) -> None: + self._abort_all_callback = callback + + def file_roots(self, paths: DuctorPaths) -> list[Path] | None: + return resolve_allowed_roots(self._config.file_access, paths.workspace) + + async def run(self) -> int: + auth = await self.client.auth_test() + self._bot_user_id = str(auth.get("user_id", "")) + self._bot_name = str(auth.get("user", "slack-bot")) + self._team_id = str(auth.get("team_id", "")) + + from ductor_bot.messenger.slack.startup import run_slack_startup + + await run_slack_startup(self) + + self._restart_watcher = asyncio.create_task(self._watch_restart_marker()) + self._socket_handler = _SlackSocketModeHandler(self._app, self._config.slack.app_token) + handler: Any = self._socket_handler + self._socket_task = asyncio.create_task(handler.start_async()) + try: + await self._socket_task + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Slack Socket Mode exited with error, requesting restart") + from ductor_bot.infra.restart import EXIT_RESTART + + self._exit_code = EXIT_RESTART + return self._exit_code + + async def shutdown(self) -> None: + if self._restart_watcher: + self._restart_watcher.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._restart_watcher + + if self._socket_handler: + handler: Any = self._socket_handler + with contextlib.suppress(Exception): + await handler.close_async() + if self._socket_task and not self._socket_task.done(): + self._socket_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._socket_task + + if self._update_observer: + await self._update_observer.stop() + if self._orchestrator: + await self._orchestrator.shutdown() + + logger.info("SlackBot shut down") + + def _register_handlers(self) -> None: + self._app.event("message")(self._handle_message_event) + self._app.event("app_mention")(self._handle_mention_event) + + async def _handle_message_event(self, event: dict[str, Any], _say: object) -> None: + await self._on_message(event) + + async def _handle_mention_event(self, event: dict[str, Any], _say: object) -> None: + await self._on_message(event) + + async def _on_message(self, event: dict[str, Any]) -> None: + subtype = str(event.get("subtype", "") or "") + user_id = str(event.get("user", "") or "") + channel_id = str(event.get("channel", "") or "") + text = str(event.get("text", "") or "").strip() + if ( + subtype in {"bot_message", "message_changed", "message_deleted"} + or event.get("bot_id") + or not user_id + or not channel_id + or not text + ): + return + + is_dm = str(event.get("channel_type", "") or "") == "im" + if not self._is_authorized(channel_id, user_id, is_dm=is_dm): + return + + thread_ts = str(event.get("thread_ts", "") or "") + ts = str(event.get("ts", "") or "") + reply_thread_ts = thread_ts or (ts if not is_dm else "") + is_thread_reply = bool(reply_thread_ts and reply_thread_ts != ts) + has_thread_session = bool( + reply_thread_ts + and await self._has_active_session_for_thread(channel_id, reply_thread_ts) + ) + if not is_dm and self._config.group_mention_only: + is_mentioned = bool(self._bot_user_id and f"<@{self._bot_user_id}>" in text) + in_mentioned_thread = bool( + is_thread_reply + and reply_thread_ts + and self._has_recent_mentioned_thread(channel_id, reply_thread_ts) + ) + if not is_mentioned and not in_mentioned_thread and not has_thread_session: + return + text = self._strip_mention(text) + if is_mentioned and reply_thread_ts: + self._mark_mentioned_thread(channel_id, reply_thread_ts) + + chat_id = self._id_map.channel_to_int(channel_id) + topic_id = ( + self._id_map.thread_to_int(channel_id, reply_thread_ts) if reply_thread_ts else None + ) + key = SessionKey.for_transport("sl", chat_id, topic_id) + self._last_active_channel = channel_id + + command_text = self._normalize_command_text(text) + if command_text is not None: + await self._handle_command( + command_text, + channel_id, + key, + reply_thread_ts or None, + stream_thread_ts=reply_thread_ts or ts, + recipient_user_id=user_id, + ) + return + + if is_thread_reply and reply_thread_ts and not has_thread_session: + thread_context = await self._fetch_thread_context( + channel_id=channel_id, + thread_ts=reply_thread_ts, + current_ts=ts, + ) + if thread_context: + text = thread_context + text + + await self._dispatch_with_lock( + key, + text, + channel_id, + reply_thread_ts or None, + stream_thread_ts=reply_thread_ts or ts, + recipient_user_id=user_id, + ) + + async def _handle_command( # noqa: PLR0913 + self, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + *, + stream_thread_ts: str | None = None, + recipient_user_id: str | None = None, + ) -> None: + cmd = text.split(maxsplit=1)[0].lower().lstrip("/") + handler = self._COMMAND_DISPATCH.get(cmd) + if handler is not None: + if cmd in self._IMMEDIATE_COMMANDS: + await handler(self, text=text, channel_id=channel_id, key=key, thread_ts=thread_ts) + else: + await self._run_handler_with_lock( + handler, + text=text, + channel_id=channel_id, + key=key, + thread_ts=thread_ts, + ) + elif classify_command(cmd) in ("orchestrator", "multiagent"): + await self._cmd_orchestrator( + text=text, channel_id=channel_id, key=key, thread_ts=thread_ts + ) + else: + await self._dispatch_with_lock( + key, + text, + channel_id, + thread_ts, + stream_thread_ts=stream_thread_ts, + recipient_user_id=recipient_user_id, + ) + + async def _dispatch_with_lock( # noqa: PLR0913 + self, + key: SessionKey, + text: str, + channel_id: str, + thread_ts: str | None, + *, + stream_thread_ts: str | None = None, + recipient_user_id: str | None = None, + ) -> None: + lock = self._lock_pool.get(key.lock_key) + async with lock: + await self._dispatch_message( + key, + text, + channel_id, + thread_ts, + stream_thread_ts=stream_thread_ts, + recipient_user_id=recipient_user_id, + ) + + async def _run_handler_with_lock( + self, + handler: Callable[..., Awaitable[None]], + **kwargs: object, + ) -> None: + key = kwargs["key"] + assert isinstance(key, SessionKey) + lock = self._lock_pool.get(key.lock_key) + async with lock: + await handler(self, **kwargs) + + async def _dispatch_message( # noqa: PLR0913 + self, + key: SessionKey, + text: str, + channel_id: str, + thread_ts: str | None, + *, + stream_thread_ts: str | None = None, + recipient_user_id: str | None = None, + ) -> None: + if self._config.streaming.enabled: + await self._run_streaming( + key, + text, + channel_id, + stream_thread_ts or thread_ts, + recipient_user_id=recipient_user_id, + ) + return + await self._run_non_streaming(key, text, channel_id, thread_ts) + + async def _run_streaming( + self, + key: SessionKey, + text: str, + channel_id: str, + thread_ts: str | None, + *, + recipient_user_id: str | None = None, + ) -> None: + orch = self._orchestrator + if orch is None: + return + if thread_ts is None: + await self._run_non_streaming(key, text, channel_id, thread_ts) + return + + from ductor_bot.messenger.slack.streaming import SlackStreamEditor + + editor = SlackStreamEditor( + self.client, + channel_id, + thread_ts=thread_ts, + recipient_user_id=recipient_user_id if not channel_id.startswith("D") else None, + recipient_team_id=self._team_id or None if not channel_id.startswith("D") else None, + edit_interval_seconds=self._config.streaming.edit_interval_seconds, + ) + result = await orch.handle_message_streaming( + key, + text, + on_text_delta=editor.on_delta, + on_thinking_delta=editor.on_thinking, + on_tool_activity=editor.on_tool, + on_system_status=editor.on_system, + ) + self._maybe_append_footer(result) + await editor.finalize(result.text) + + async def _run_non_streaming( + self, + key: SessionKey, + text: str, + channel_id: str, + thread_ts: str | None, + ) -> None: + orch = self._orchestrator + if orch is None: + return + result = await orch.handle_message(key, text) + self._maybe_append_footer(result) + if result.text: + await self._send_rich(channel_id, result.text, thread_ts=thread_ts) + + async def _cmd_stop( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + del text + orch = self._orchestrator + if orch: + killed = await orch.abort(key.chat_id) + msg = t("abort_all.done", count=killed) if killed else t("abort_all.nothing") + else: + msg = t("abort_all.nothing") + await self._send_rich(channel_id, msg, thread_ts=thread_ts) + + async def _cmd_interrupt( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + del text + orch = self._orchestrator + if orch: + interrupted = orch.interrupt(key.chat_id) + msg = t("interrupt.done", count=interrupted) if interrupted else t("interrupt.nothing") + await self._send_rich(channel_id, msg, thread_ts=thread_ts) + + async def _cmd_stop_all( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + del text, key + orch = self._orchestrator + killed = await orch.abort_all() if orch else 0 + if self._abort_all_callback: + killed += await self._abort_all_callback() + msg = t("abort_all.done", count=killed) if killed else t("abort_all.nothing") + await self._send_rich(channel_id, msg, thread_ts=thread_ts) + + async def _cmd_restart( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + del text, key + from ductor_bot.infra.restart import EXIT_RESTART, write_restart_marker + + marker = _restart_marker_path(self._config.ductor_home) + write_restart_marker(marker_path=marker) + await self._send_rich( + channel_id, + fmt(t("startup.restart_header"), SEP, t("startup.restart_body")), + thread_ts=thread_ts, + ) + self._exit_code = EXIT_RESTART + if self._socket_task and not self._socket_task.done(): + self._socket_task.cancel() + + async def _cmd_new( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + del text + orch = self._orchestrator + if orch: + result = await orch.handle_message(key, "/new") + if result and result.text: + await self._send_rich(channel_id, result.text, thread_ts=thread_ts) + + async def _cmd_help( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + del text, key + await self._send_rich(channel_id, self._build_help_text(), thread_ts=thread_ts) + + async def _cmd_info( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + del text, key + text_out = fmt( + t("info.header"), + t("info.version", version=get_current_version()), + SEP, + t("info.slack_description"), + ) + await self._send_rich(channel_id, text_out, thread_ts=thread_ts) + + async def _cmd_agent_commands( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + del text, key + lines = [ + "Slack sub-agents use the same multi-agent runtime.", + "", + "`/agents` — list all agents and their status", + "`/agent_start ` — start a sub-agent", + "`/agent_stop ` — stop a sub-agent", + "`/agent_restart ` — restart a sub-agent", + ] + await self._send_rich( + channel_id, + fmt(t("agents.system_header"), SEP, "\n".join(lines)), + thread_ts=thread_ts, + ) + + async def _cmd_showfiles( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + del key + orch = self._orchestrator + if not orch: + return + from ductor_bot.messenger.matrix.file_browser import format_file_listing + + parts = text.split(None, 1) + subdir = parts[1].strip() if len(parts) > 1 else "" + listing = await asyncio.to_thread(format_file_listing, orch.paths, subdir) + await self._send_rich(channel_id, listing, thread_ts=thread_ts) + + async def _cmd_session( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + parts = text.split(None, 1) + if len(parts) < 2 or not parts[1].strip(): + await self._send_rich( + channel_id, + fmt( + t("session_help.header"), + SEP, + "`/session ` — start a background session\n" + "`/sessions` — list running sessions\n" + "`/stop` — stop the active run", + ), + thread_ts=thread_ts, + ) + return + await self._dispatch_message(key, text, channel_id, thread_ts) + + async def _cmd_orchestrator( + self, + *, + text: str, + channel_id: str, + key: SessionKey, + thread_ts: str | None, + ) -> None: + orch = self._orchestrator + if not orch: + return + result = await orch.handle_message(key, text) + if result and result.text: + await self._send_rich(channel_id, result.text, thread_ts=thread_ts) + + _COMMAND_DISPATCH: dict[str, Callable[..., Awaitable[None]]] = { + "stop": _cmd_stop, + "stop_all": _cmd_stop_all, + "interrupt": _cmd_interrupt, + "restart": _cmd_restart, + "new": _cmd_new, + "help": _cmd_help, + "start": _cmd_help, + "info": _cmd_info, + "agent_commands": _cmd_agent_commands, + "showfiles": _cmd_showfiles, + "session": _cmd_session, + } + + _IMMEDIATE_COMMANDS: frozenset[str] = frozenset( + { + "stop", + "stop_all", + "interrupt", + "restart", + "help", + "start", + "info", + "agent_commands", + "showfiles", + } + ) + + def _build_help_text(self) -> str: + cmd_desc = {**dict(BOT_COMMANDS), **dict(MULTIAGENT_SUB_COMMANDS)} + + def _line(command: str) -> str: + description = cmd_desc.get(command, "") + return f"`/{command}` — {description}" if description else f"`/{command}`" + + return fmt( + t("help.header"), + SEP, + f"**{t('help.cat_daily')}**\n{_line('new')}\n{_line('stop')}\n{_line('stop_all')}\n" + f"{_line('model')}\n{_line('status')}\n{_line('memory')}", + f"**{t('help.cat_automation')}**\n{_line('session')}\n{_line('tasks')}\n{_line('cron')}", + f"**{t('help.cat_multiagent')}**\n{_line('agent_commands')}\n{_line('agents')}\n" + f"{_line('agent_start')}\n{_line('agent_stop')}\n{_line('agent_restart')}", + f"**{t('help.cat_browse')}**\n{_line('showfiles')}\n{_line('info')}\n{_line('help')}", + f"**{t('help.cat_maintenance')}**\n{_line('diagnose')}\n{_line('upgrade')}\n{_line('restart')}", + SEP, + t("help.slack_footer"), + ) + + def _is_authorized(self, channel_id: str, user_id: str, *, is_dm: bool) -> bool: + slack = self._config.slack + channel_ok = is_dm or not slack.allowed_channels or channel_id in slack.allowed_channels + if self._config.group_mention_only and not is_dm: + return channel_ok + user_ok = not slack.allowed_users or user_id in slack.allowed_users + return channel_ok and user_ok + + def _is_message_addressed(self, channel_id: str, thread_ts: str, text: str) -> bool: + if self._bot_user_id and f"<@{self._bot_user_id}>" in text: + return True + return self._has_recent_mentioned_thread(channel_id, thread_ts) + + def _normalize_command_text(self, text: str) -> str | None: + stripped = text.strip() + if not stripped: + return None + parts = stripped.split(None, 1) + raw_cmd = parts[0] + cmd = raw_cmd.lower().lstrip("/") + if classify_command(cmd) == "unknown": + return None + has_args = len(parts) > 1 and bool(parts[1].strip()) + if not raw_cmd.startswith("/") and has_args and cmd not in _MESSAGE_COMMANDS_WITH_ARGS: + return None + suffix = f" {parts[1].strip()}" if has_args else "" + return f"/{cmd}{suffix}" + + def _prune_mentioned_threads(self, now: float) -> None: + if self._MENTIONED_THREAD_TTL > 0: + cutoff = now - self._MENTIONED_THREAD_TTL + expired = [key for key, seen_at in self._mentioned_threads.items() if seen_at < cutoff] + for key in expired: + del self._mentioned_threads[key] + max_size = max(1, self._MENTIONED_THREAD_MAX_SIZE) + while len(self._mentioned_threads) > max_size: + oldest = next(iter(self._mentioned_threads)) + del self._mentioned_threads[oldest] + + def _mark_mentioned_thread(self, channel_id: str, thread_ts: str) -> None: + now = time.monotonic() + key = (channel_id, thread_ts) + self._mentioned_threads.pop(key, None) + self._mentioned_threads[key] = now + self._prune_mentioned_threads(now) + + def _has_recent_mentioned_thread(self, channel_id: str, thread_ts: str) -> bool: + if not thread_ts: + return False + now = time.monotonic() + self._prune_mentioned_threads(now) + key = (channel_id, thread_ts) + seen_at = self._mentioned_threads.get(key) + if seen_at is None: + return False + if self._MENTIONED_THREAD_TTL > 0 and now - seen_at >= self._MENTIONED_THREAD_TTL: + del self._mentioned_threads[key] + return False + return True + + async def _has_active_session_for_thread(self, channel_id: str, thread_ts: str) -> bool: + """Return whether this Slack thread already has a fresh persisted session.""" + orch = self._orchestrator + if orch is None: + return False + chat_id = self._id_map.channel_to_int(channel_id) + topic_id = self._id_map.thread_to_int(channel_id, thread_ts) + sessions = await orch._sessions.list_active_for_chat(chat_id) + for session in sessions: + if session.topic_id == topic_id and bool(session.session_id): + return True + return False + + async def _resolve_user_name(self, user_id: str, *, channel_id: str) -> str: + """Resolve a Slack user ID to a display name with a small in-memory cache.""" + if not user_id: + return "unknown" + cached = self._user_name_cache.get(user_id) + if cached: + return cached + try: + response = await self.client.users_info(user=user_id) + user = response.get("user", {}) if isinstance(response, dict) else {} + profile = user.get("profile", {}) if isinstance(user, dict) else {} + name = ( + profile.get("display_name") + or profile.get("real_name") + or user.get("real_name") + or user.get("name") + or user_id + ) + except Exception: + logger.debug( + "Failed to resolve Slack user name in channel %s", channel_id, exc_info=True + ) + name = user_id + resolved = str(name).strip() or user_id + self._user_name_cache[user_id] = resolved + return resolved + + async def _fetch_thread_context( + self, + *, + channel_id: str, + thread_ts: str, + current_ts: str, + limit: int = 30, + ) -> str: + """Fetch earlier Slack thread messages for the first message in a fresh session.""" + cache_key = f"{channel_id}:{thread_ts}" + now = time.monotonic() + self._prune_thread_context_cache(now) + cached = self._thread_context_cache.get(cache_key) + if cached: + return cached.content + + try: + response = await self.client.conversations_replies( + channel=channel_id, + ts=thread_ts, + limit=limit + 1, + inclusive=True, + ) + except Exception: + logger.warning( + "Failed to fetch Slack thread context for %s/%s", + channel_id, + thread_ts, + exc_info=True, + ) + return "" + + messages = response.get("messages", []) if isinstance(response, dict) else [] + if not isinstance(messages, list) or not messages: + return "" + + context_parts = await self._build_thread_context_parts( + messages=messages, + channel_id=channel_id, + thread_ts=thread_ts, + current_ts=current_ts, + ) + return self._cache_thread_context( + cache_key=cache_key, + content_parts=context_parts, + fetched_at=now, + ) + + async def _build_thread_context_parts( + self, + *, + messages: list[object], + channel_id: str, + thread_ts: str, + current_ts: str, + ) -> list[str]: + """Build normalized thread-history lines from Slack reply payloads.""" + context_parts: list[str] = [] + for msg in messages: + if not isinstance(msg, dict): + continue + msg_ts = str(msg.get("ts", "") or "") + if not msg_ts or _slack_ts_is_at_or_after(msg_ts, current_ts): + continue + if msg.get("bot_id") or msg.get("subtype") == "bot_message": + continue + msg_text = str(msg.get("text", "") or "").strip() + if not msg_text: + continue + if self._bot_user_id: + msg_text = msg_text.replace(f"<@{self._bot_user_id}>", "").strip() + if not msg_text: + continue + msg_user = str(msg.get("user", "") or "") + user_name = await self._resolve_user_name(msg_user, channel_id=channel_id) + prefix = "[thread parent] " if msg_ts == thread_ts else "" + context_parts.append(f"{prefix}{user_name}: {msg_text}") + return context_parts + + def _cache_thread_context( + self, + *, + cache_key: str, + content_parts: list[str], + fetched_at: float, + ) -> str: + """Persist thread-context cache entry and return the formatted content.""" + self._prune_thread_context_cache(fetched_at) + if not content_parts: + self._thread_context_cache.pop(cache_key, None) + self._thread_context_cache[cache_key] = _ThreadContextCache( + content="", + fetched_at=fetched_at, + ) + self._prune_thread_context_cache(fetched_at) + return "" + + content = ( + "[Thread context — prior messages in this thread (not yet in conversation history):]\n" + + "\n".join(content_parts) + + "\n[End of thread context]\n\n" + ) + self._thread_context_cache.pop(cache_key, None) + self._thread_context_cache[cache_key] = _ThreadContextCache( + content=content, + fetched_at=fetched_at, + message_count=len(content_parts), + ) + self._prune_thread_context_cache(fetched_at) + return content + + def _prune_thread_context_cache(self, now: float) -> None: + if self._THREAD_CACHE_TTL > 0: + cutoff = now - self._THREAD_CACHE_TTL + expired = [ + key + for key, entry in self._thread_context_cache.items() + if entry.fetched_at < cutoff + ] + for key in expired: + del self._thread_context_cache[key] + max_size = max(1, self._THREAD_CONTEXT_CACHE_MAX_SIZE) + while len(self._thread_context_cache) > max_size: + oldest = next(iter(self._thread_context_cache)) + del self._thread_context_cache[oldest] + + def _strip_mention(self, text: str) -> str: + if not self._bot_user_id: + return text + return text.replace(f"<@{self._bot_user_id}>", "").strip() + + def _maybe_append_footer(self, result: object) -> None: + from ductor_bot.orchestrator.registry import OrchestratorResult + + if not isinstance(result, OrchestratorResult): + return + if not self._config.scene.technical_footer or not result.model_name: + return + from ductor_bot.text.response_format import format_technical_footer + + footer = format_technical_footer( + result.model_name, + result.total_tokens, + result.input_tokens, + result.cost_usd, + result.duration_ms, + ) + result.text += footer + + async def _send_rich( + self, + channel_id: str, + text: str, + *, + thread_ts: str | None = None, + ) -> str | None: + return await send_rich(self.client, channel_id, text, SlackSendOpts(thread_ts=thread_ts)) + + def _broadcast_channels(self) -> list[str]: + channels = list(self._config.slack.allowed_channels) + if not channels and self._last_active_channel: + channels = [self._last_active_channel] + return channels + + async def broadcast(self, text: str) -> None: + channels = self._broadcast_channels() + if not channels: + logger.warning("Slack broadcast: no channels available, message lost: %s", text[:80]) + return + for channel_id in channels: + await self._send_rich(channel_id, text) + + async def notify_startup(self, text: str) -> None: + await self._notification_service.notify_all(text) + + async def notify_upgrade(self, text: str) -> None: + await self._notification_service.notify_all(text) + + async def on_async_interagent_result(self, result: AsyncInterAgentResult) -> None: + from ductor_bot.bus.adapters import from_interagent_result + + chat_id = self._default_chat_id() + if not chat_id: + logger.warning( + "No chat_id for async interagent result (task=%s) — delivering to all channels", + result.task_id, + ) + text = result.result_text or f"Inter-agent result from {result.recipient}" + await self._notification_service.notify_all(text) + return + env = from_interagent_result(result, chat_id) + env.transport = "sl" + await self._bus.submit(env) + + async def on_task_result(self, result: TaskResult) -> None: + from ductor_bot.bus.adapters import from_task_result + + env = from_task_result(result) + env.transport = "sl" + await self._bus.submit(env) + + async def on_task_question( + self, + task_id: str, + question: str, + prompt_preview: str, + chat_id: int, + thread_id: int | None = None, + ) -> None: + from ductor_bot.bus.adapters import from_task_question + + if not chat_id: + chat_id = self._default_chat_id() + env = from_task_question(task_id, question, prompt_preview, chat_id, topic_id=thread_id) + env.transport = "sl" + await self._bus.submit(env) + + def _default_chat_id(self) -> int: + if self._config.slack.allowed_channels: + return self._id_map.channel_to_int(self._config.slack.allowed_channels[0]) + if self._last_active_channel: + return self._id_map.channel_to_int(self._last_active_channel) + logger.warning("No default chat_id: no allowed_channels and no active channel yet") + return 0 + + async def _watch_restart_marker(self) -> None: + from ductor_bot.infra.restart import EXIT_RESTART + + marker = _restart_marker_path(self._config.ductor_home) + while True: + await asyncio.sleep(2) + if marker.exists(): + logger.info("Restart marker detected") + self._exit_code = EXIT_RESTART + if self._socket_task and not self._socket_task.done(): + self._socket_task.cancel() + break diff --git a/ductor_bot/messenger/slack/id_map.py b/ductor_bot/messenger/slack/id_map.py new file mode 100644 index 00000000..3450186b --- /dev/null +++ b/ductor_bot/messenger/slack/id_map.py @@ -0,0 +1,95 @@ +"""Persistent mapping between Slack channel/thread IDs and internal ints.""" + +from __future__ import annotations + +import hashlib +import json +import logging +from collections.abc import Mapping +from pathlib import Path + +from ductor_bot.infra.atomic_io import atomic_text_save + +logger = logging.getLogger(__name__) + + +class SlackIdMap: + """Bidirectional channel/thread mapping for Slack conversations.""" + + def __init__(self, store_path: Path) -> None: + self._channel_to_int: dict[str, int] = {} + self._int_to_channel: dict[int, str] = {} + self._thread_to_int: dict[str, int] = {} + self._int_to_thread: dict[int, tuple[str, str]] = {} + self._path = store_path / "slack_id_map.json" + self._load() + + def channel_to_int(self, channel_id: str) -> int: + """Get or create a deterministic int for a Slack channel ID.""" + if channel_id in self._channel_to_int: + return self._channel_to_int[channel_id] + int_id = self._allocate(channel_id, existing=self._int_to_channel) + self._channel_to_int[channel_id] = int_id + self._int_to_channel[int_id] = channel_id + self._save() + return int_id + + def int_to_channel(self, chat_id: int) -> str | None: + """Resolve an internal chat ID back to a Slack channel ID.""" + return self._int_to_channel.get(chat_id) + + def thread_to_int(self, channel_id: str, thread_ts: str) -> int: + """Get or create a deterministic int for a Slack thread.""" + key = self._thread_key(channel_id, thread_ts) + if key in self._thread_to_int: + return self._thread_to_int[key] + int_id = self._allocate(key, existing=dict(self._int_to_thread)) + self._thread_to_int[key] = int_id + self._int_to_thread[int_id] = (channel_id, thread_ts) + self._save() + return int_id + + def int_to_thread(self, topic_id: int) -> tuple[str, str] | None: + """Resolve an internal topic ID back to ``(channel_id, thread_ts)``.""" + return self._int_to_thread.get(topic_id) + + @staticmethod + def _thread_key(channel_id: str, thread_ts: str) -> str: + return f"{channel_id}:{thread_ts}" + + @staticmethod + def _allocate(key: str, *, existing: Mapping[int, object]) -> int: + int_id = int.from_bytes(hashlib.sha256(key.encode()).digest()[:8], "big") + while int_id in existing: + int_id = int.from_bytes(hashlib.sha256(f"{key}:{int_id}".encode()).digest()[:8], "big") + return int_id + + def _load(self) -> None: + if not self._path.exists(): + return + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + channels = data.get("channels", {}) + threads = data.get("threads", {}) + if isinstance(channels, dict): + for channel_id, int_id in channels.items(): + if isinstance(channel_id, str) and isinstance(int_id, int): + self._channel_to_int[channel_id] = int_id + self._int_to_channel[int_id] = channel_id + if isinstance(threads, dict): + for key, int_id in threads.items(): + if not isinstance(key, str) or not isinstance(int_id, int): + continue + channel_id, _, thread_ts = key.partition(":") + if channel_id and thread_ts: + self._thread_to_int[key] = int_id + self._int_to_thread[int_id] = (channel_id, thread_ts) + except (json.JSONDecodeError, OSError): + logger.warning("Failed to load slack_id_map.json, starting fresh") + + def _save(self) -> None: + payload = { + "channels": self._channel_to_int, + "threads": self._thread_to_int, + } + atomic_text_save(self._path, json.dumps(payload, indent=2, sort_keys=True)) diff --git a/ductor_bot/messenger/slack/sender.py b/ductor_bot/messenger/slack/sender.py new file mode 100644 index 00000000..e734b04d --- /dev/null +++ b/ductor_bot/messenger/slack/sender.py @@ -0,0 +1,134 @@ +"""Slack message sender.""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from ductor_bot.files.tags import path_from_file_tag +from ductor_bot.messenger.send_opts import BaseSendOpts + +if TYPE_CHECKING: + from typing import Any + +logger = logging.getLogger(__name__) + +_MAX_MESSAGE_LENGTH = 39_000 +_FILE_TAG_RE = re.compile(r"") + + +@dataclass(slots=True) +class SlackSendOpts(BaseSendOpts): + """Options for sending Slack messages.""" + + thread_ts: str | None = None + + +async def send_rich( + client: Any, + channel_id: str, + text: str, + opts: SlackSendOpts | None = None, +) -> str | None: + """Send a Slack message, splitting long text and uploading tagged files.""" + opts = opts or SlackSendOpts() + files = _FILE_TAG_RE.findall(text) + cleaned = _FILE_TAG_RE.sub("", text).strip() + last_ts: str | None = None + + for chunk in _split_text(cleaned): + if not chunk: + continue + response = await client.chat_postMessage( + channel=channel_id, + text=_to_slack_mrkdwn(chunk), + mrkdwn=True, + thread_ts=opts.thread_ts, + ) + last_ts = _response_value(response, "ts") + + for file_path_str in files: + file_path = path_from_file_tag(file_path_str) + if not _file_accessible(file_path, opts.allowed_roots): + continue + response = await client.files_upload_v2( + channel=channel_id, + file=str(file_path), + filename=file_path.name, + thread_ts=opts.thread_ts, + ) + last_ts = _response_value(response, "ts") or last_ts + + return last_ts + + +async def update_message( + client: Any, + channel_id: str, + message_ts: str, + text: str, + *, + thread_ts: str | None = None, +) -> None: + """Update an existing Slack message.""" + await client.chat_update( + channel=channel_id, + ts=message_ts, + text=_to_slack_mrkdwn(text), + mrkdwn=True, + thread_ts=thread_ts, + ) + + +def _response_value(response: object, key: str) -> str | None: + if isinstance(response, dict): + value = response.get(key) + return value if isinstance(value, str) else None + data = getattr(response, "data", None) + if isinstance(data, dict): + value = data.get(key) + return value if isinstance(value, str) else None + value = getattr(response, key, None) + return value if isinstance(value, str) else None + + +def _file_accessible(file_path: Path, allowed_roots: Sequence[Path] | None) -> bool: + if not file_path.exists(): + logger.warning("File not found: %s", file_path) + return False + if allowed_roots is not None and not any( + file_path.resolve().is_relative_to(root.resolve()) for root in allowed_roots + ): + logger.warning("File outside allowed roots: %s", file_path) + return False + return True + + +def _split_text(text: str) -> list[str]: + """Split long Slack messages into reasonably sized chunks.""" + if not text: + return [""] + if len(text) <= _MAX_MESSAGE_LENGTH: + return [text] + + chunks: list[str] = [] + remaining = text + while remaining: + if len(remaining) <= _MAX_MESSAGE_LENGTH: + chunks.append(remaining) + break + split_at = remaining.rfind("\n", 0, _MAX_MESSAGE_LENGTH) + if split_at <= 0: + split_at = _MAX_MESSAGE_LENGTH + chunks.append(remaining[:split_at].rstrip()) + remaining = remaining[split_at:].lstrip() + return chunks + + +def _to_slack_mrkdwn(text: str) -> str: + """Best-effort Markdown -> Slack mrkdwn normalization.""" + return re.sub(r"__(.+?)__", r"_\1_", re.sub(r"\*\*(.+?)\*\*", r"*\1*", text)) diff --git a/ductor_bot/messenger/slack/startup.py b/ductor_bot/messenger/slack/startup.py new file mode 100644 index 00000000..42be3531 --- /dev/null +++ b/ductor_bot/messenger/slack/startup.py @@ -0,0 +1,122 @@ +"""Slack-specific startup sequence.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from typing import TYPE_CHECKING + +from ductor_bot.i18n import t +from ductor_bot.infra.restart import consume_restart_marker, consume_restart_sentinel + +if TYPE_CHECKING: + from ductor_bot.messenger.slack.bot import SlackBot + +logger = logging.getLogger(__name__) + + +async def run_slack_startup(bot: SlackBot) -> None: + """Slack startup mirrors Matrix startup with Slack-specific logging.""" + primary = bot._orchestrator is None + + if primary: + from ductor_bot.orchestrator.core import Orchestrator + + bot._orchestrator = await Orchestrator.create(bot._config, agent_name=bot._agent_name) + bot._orchestrator.wire_observers_to_bus(bot._bus) + + sentinel = await _handle_restart_sentinel(bot) + restart_reason = _consume_restart_marker(bot) + if restart_reason and sentinel is None: + await bot.notify_startup(f"**Bot restarted** ({restart_reason})") + + await _handle_startup_lifecycle(bot, sentinel) + await _handle_recovery(bot) + + try: + from ductor_bot.infra.install import is_upgradeable + from ductor_bot.infra.updater import UpdateObserver + from ductor_bot.infra.version import VersionInfo + + if is_upgradeable() and bot._config.update_check and bot._agent_name == "main": + + async def _on_update(info: VersionInfo) -> None: + await bot.notify_upgrade( + f"**Update available:** `{info.latest}`\nUse `/upgrade` to update." + ) + + bot._update_observer = UpdateObserver(notify=_on_update) + bot._update_observer.start() + except ImportError: + pass + + logger.info("Slack bot online: %s", bot.bot_name) + + for hook in bot._startup_hooks: + await hook() + + +async def _handle_restart_sentinel(bot: SlackBot) -> dict[str, object] | None: + if bot._orchestrator is None: + return None + sentinel_path = bot._orchestrator.paths.ductor_home / "restart-sentinel.json" + sentinel = await asyncio.to_thread(consume_restart_sentinel, sentinel_path=sentinel_path) + if sentinel: + chat_id = int(sentinel.get("chat_id", 0)) + msg = str(sentinel.get("message", t("startup.restart_default"))) + if chat_id: + await bot.notification_service.notify(chat_id, msg) + return sentinel + + +def _consume_restart_marker(bot: SlackBot) -> str: + paths_obj = bot._orchestrator.paths if bot._orchestrator else None + if paths_obj is None: + return "" + marker_path = paths_obj.ductor_home / "restart-requested" + if consume_restart_marker(marker_path=marker_path): + return "restart marker" + return "" + + +async def _handle_startup_lifecycle(bot: SlackBot, sentinel: dict[str, object] | None) -> None: + from ductor_bot.infra.startup_state import detect_startup_kind, save_startup_state + from ductor_bot.text.response_format import startup_notification_text + + if bot._orchestrator is None: + return + startup_state_path = bot._orchestrator.paths.startup_state_path + startup_info = await asyncio.to_thread(detect_startup_kind, startup_state_path) + await asyncio.to_thread(save_startup_state, startup_state_path, startup_info) + if sentinel is None and startup_info.kind.value != "service_restart": + note = startup_notification_text(startup_info.kind.value) + if note: + await bot.notify_startup(note) + + +async def _handle_recovery(bot: SlackBot) -> None: + from ductor_bot.infra.recovery import RecoveryPlanner + from ductor_bot.text.response_format import recovery_notification_text + + orch = bot._orchestrator + if orch is None: + return + planner = RecoveryPlanner( + inflight=orch.inflight_tracker, + named_sessions=orch.named_sessions.pop_recovered_running(), + max_age_seconds=bot._config.timeouts.normal * 2, + ) + for action in planner.plan(): + note = recovery_notification_text(action.kind, action.prompt_preview, action.session_name) + await bot.notification_service.notify(action.chat_id, note) + if action.kind == "named_session" and action.session_name: + with contextlib.suppress(Exception): + orch.submit_named_followup_bg( + action.chat_id, + action.session_name, + action.prompt_preview, + message_id=0, + thread_id=None, + ) + orch.inflight_tracker.clear() diff --git a/ductor_bot/messenger/slack/streaming.py b/ductor_bot/messenger/slack/streaming.py new file mode 100644 index 00000000..7c8af856 --- /dev/null +++ b/ductor_bot/messenger/slack/streaming.py @@ -0,0 +1,411 @@ +"""Native chat stream support for Slack with graceful fallback.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from ductor_bot.cli.stream_events import ToolUseEvent +from ductor_bot.messenger.slack.sender import SlackSendOpts, send_rich +from ductor_bot.text.response_format import normalize_tool_name + +logger = logging.getLogger(__name__) + +_STREAM_BUFFER_SIZE = 64 +_MAX_TOOL_HISTORY = 8 +_PLAN_TITLE = "Working on your request" +_ANALYZE_TASK_ID = "analyze" +_ANALYZE_TASK_TITLE = "Understand request" +_TOOLS_TASK_ID = "tools" +_TOOLS_TASK_TITLE = "Use tools if needed" +_RESPONSE_TASK_ID = "respond" +_RESPONSE_TASK_TITLE = "Draft response" +_NO_TOOLS_DETAIL = "No tools needed" +_DETAIL_LIMIT = 280 +_SYSTEM_LABELS: dict[str, str] = { + "thinking": "Thinking", + "compacting": "Compacting", + "recovering": "Recovering", + "timeout_warning": "Timeout approaching", + "timeout_extended": "Timeout extended", +} + + +@dataclass(slots=True) +class _PlanTaskState: + id: str + title: str + status: str = "pending" + details: str = "" + + +@dataclass(slots=True) +class _ToolActivity: + label: str + target: str = "" + + +class SlackStreamEditor: + """Render Slack output through the native chat streaming APIs.""" + + def __init__( # noqa: PLR0913 + self, + client: Any, + channel_id: str, + *, + thread_ts: str, + recipient_user_id: str | None = None, + recipient_team_id: str | None = None, + edit_interval_seconds: float = 1.0, + ) -> None: + del edit_interval_seconds + self._client = client + self._channel_id = channel_id + self._thread_ts = thread_ts + self._recipient_user_id = recipient_user_id + self._recipient_team_id = recipient_team_id + self._stream: Any | None = None + self._native_failed = False + self._thinking_parts: list[str] = [] + self._answer_parts: list[str] = [] + self._status: str | None = None + self._thinking_started = False + self._answer_started = False + self._tool_history: list[_ToolActivity] = [] + self._used_tools = False + self._plan_started = False + self._plan_tasks = { + _ANALYZE_TASK_ID: _PlanTaskState(_ANALYZE_TASK_ID, _ANALYZE_TASK_TITLE), + _TOOLS_TASK_ID: _PlanTaskState(_TOOLS_TASK_ID, _TOOLS_TASK_TITLE), + _RESPONSE_TASK_ID: _PlanTaskState(_RESPONSE_TASK_ID, _RESPONSE_TASK_TITLE), + } + + async def on_thinking(self, text: str) -> None: + """Append streamed reasoning text.""" + if not text.strip(): + return + self._thinking_parts.append(text) + self._status = "thinking" + await self._ensure_plan_started( + phase=_ANALYZE_TASK_ID, + analysis_detail=self._thinking_detail(text), + ) + prefix = "💭 *Thinking*\n" if not self._thinking_started else "" + self._thinking_started = True + await self._append_markdown(prefix + text) + + async def on_delta(self, text: str) -> None: + """Append assistant answer text.""" + if not text: + return + await self._ensure_plan_started(phase=_RESPONSE_TASK_ID) + plan_updates: list[dict[str, object]] = [] + analyze_chunk = self._set_task( + _ANALYZE_TASK_ID, + status="complete", + details=self._analysis_detail(), + ) + if analyze_chunk is not None: + plan_updates.append(analyze_chunk) + tools_chunk = self._set_task( + _TOOLS_TASK_ID, + status="complete", + details=self._tool_details() if self._used_tools else _NO_TOOLS_DETAIL, + ) + if tools_chunk is not None: + plan_updates.append(tools_chunk) + response_chunk = self._set_task(_RESPONSE_TASK_ID, status="in_progress") + if response_chunk is not None: + plan_updates.append(response_chunk) + await self._append_chunks(plan_updates) + self._answer_parts.append(text) + prefix = "" + if not self._answer_started: + prefix = "\n\n" if self._thinking_started else "" + self._answer_started = True + await self._append_markdown(prefix + text) + + async def on_tool(self, tool: ToolUseEvent | str) -> None: + """Show tool activity using Slack's plan UI.""" + activity = self._tool_activity(tool) + if activity.target: + self._thinking_parts.append(f"\n[TOOL: {activity.label}: {activity.target}]\n") + else: + self._thinking_parts.append(f"\n[TOOL: {activity.label}]\n") + self._tool_history.append(activity) + self._tool_history = self._tool_history[-_MAX_TOOL_HISTORY:] + self._used_tools = True + await self._ensure_plan_started(phase=_TOOLS_TASK_ID) + chunks: list[dict[str, object]] = [] + analyze_chunk = self._set_task( + _ANALYZE_TASK_ID, + status="complete", + details=self._analysis_detail(), + ) + if analyze_chunk is not None: + chunks.append(analyze_chunk) + tools_chunk = self._set_task( + _TOOLS_TASK_ID, + status="in_progress", + details=self._tool_details(), + ) + if tools_chunk is not None: + chunks.append(tools_chunk) + await self._append_chunks(chunks) + + async def on_system(self, status: str | None) -> None: + """Track transient system status.""" + self._status = status + if status is None or status == "thinking": + return + label = _SYSTEM_LABELS.get(status) + if label: + await self._append_markdown(f"\n\n_{label}_") + + async def finalize(self, final_text: str | None) -> None: + """Finalize the stream, falling back to a single rich message if needed.""" + if final_text and not "".join(self._answer_parts).strip(): + self._answer_parts = [final_text] + if self._native_failed: + rendered = self._render_fallback(final_text) + if rendered: + await send_rich( + self._client, + self._channel_id, + rendered, + SlackSendOpts(thread_ts=self._thread_ts), + ) + return + + stream = await self._ensure_stream() + stop_text = None + if final_text and not self._answer_started: + stop_text = final_text + stop_chunks = self._final_plan_chunks(final_text) + await stream.stop(markdown_text=stop_text, chunks=stop_chunks) + + async def _append_markdown(self, text: str) -> None: + if not text or self._native_failed: + return + try: + stream = await self._ensure_stream() + await stream.append(markdown_text=text) + except Exception as exc: + self._mark_native_failure(exc) + + async def _append_chunks(self, chunks: list[dict[str, object]]) -> None: + if not chunks or self._native_failed: + return + try: + stream = await self._ensure_stream() + await stream.append(chunks=chunks) + except Exception as exc: + self._mark_native_failure(exc) + + async def _ensure_stream(self) -> Any: + if self._stream is not None: + return self._stream + kwargs: dict[str, object] = { + "channel": self._channel_id, + "thread_ts": self._thread_ts, + "task_display_mode": "plan", + "buffer_size": _STREAM_BUFFER_SIZE, + } + if self._recipient_team_id is not None: + kwargs["recipient_team_id"] = self._recipient_team_id + if self._recipient_user_id is not None: + kwargs["recipient_user_id"] = self._recipient_user_id + self._stream = await self._client.chat_stream(**kwargs) + return self._stream + + def _mark_native_failure(self, exc: Exception) -> None: + if self._native_failed: + return + self._native_failed = True + logger.warning("Slack native stream failed; falling back to plain reply: %r", exc) + + async def _ensure_plan_started( + self, + *, + phase: str, + analysis_detail: str = "", + ) -> None: + if self._plan_started: + return + chunks: list[dict[str, object]] = [{"type": "plan_update", "title": _PLAN_TITLE}] + if phase == _ANALYZE_TASK_ID: + self._plan_tasks[_ANALYZE_TASK_ID].status = "in_progress" + self._plan_tasks[_ANALYZE_TASK_ID].details = analysis_detail + elif phase == _TOOLS_TASK_ID: + self._plan_tasks[_ANALYZE_TASK_ID].status = "complete" + self._plan_tasks[_ANALYZE_TASK_ID].details = self._analysis_detail() + self._plan_tasks[_TOOLS_TASK_ID].status = "in_progress" + self._plan_tasks[_TOOLS_TASK_ID].details = self._tool_details() + elif phase == _RESPONSE_TASK_ID: + self._plan_tasks[_ANALYZE_TASK_ID].status = "complete" + self._plan_tasks[_ANALYZE_TASK_ID].details = self._analysis_detail() + self._plan_tasks[_TOOLS_TASK_ID].status = "complete" + self._plan_tasks[_TOOLS_TASK_ID].details = ( + self._tool_details() if self._used_tools else _NO_TOOLS_DETAIL + ) + self._plan_tasks[_RESPONSE_TASK_ID].status = "in_progress" + chunks.extend( + self._task_chunk(self._plan_tasks[task_id]) + for task_id in (_ANALYZE_TASK_ID, _TOOLS_TASK_ID, _RESPONSE_TASK_ID) + ) + self._plan_started = True + await self._append_chunks(chunks) + + def _set_task( + self, + task_id: str, + *, + status: str | None = None, + details: str | None = None, + ) -> dict[str, object] | None: + task = self._plan_tasks[task_id] + next_status = status or task.status + next_details = task.details if details is None else self._limit_detail(details) + if task.status == next_status and task.details == next_details: + return None + task.status = next_status + task.details = next_details + return self._task_chunk(task) + + def _task_chunk(self, task: _PlanTaskState) -> dict[str, object]: + chunk: dict[str, object] = { + "type": "task_update", + "id": task.id, + "title": task.title, + "status": task.status, + } + if task.details: + chunk["details"] = task.details + return chunk + + def _analysis_detail(self) -> str: + for part in self._thinking_parts: + detail = self._thinking_detail(part) + if detail: + return detail + return "Understanding the request" + + def _thinking_detail(self, text: str) -> str: + cleaned = " ".join(text.split()) + return self._limit_detail(cleaned) + + def _tool_activity(self, tool: ToolUseEvent | str) -> _ToolActivity: + label = normalize_tool_name(str(getattr(tool, "tool_name", tool))) + parameters = getattr(tool, "parameters", None) + return _ToolActivity(label=label, target=self._tool_target(parameters)) + + def _tool_target(self, parameters: dict[str, Any] | None) -> str: + if not parameters: + return "" + return self._limit_detail( + self._string_param(parameters, ("url", "uri"), transform=self._compact_url) + or self._string_param(parameters, ("query", "q", "search", "pattern")) + or self._string_param( + parameters, ("path", "file_path", "file", "filepath", "directory", "dir") + ) + or self._string_param(parameters, ("cmd", "command")) + or self._list_param(parameters, ("urls", "paths")) + ) + + def _string_param( + self, + parameters: dict[str, Any], + keys: tuple[str, ...], + *, + transform: Any = None, + ) -> str: + for key in keys: + value = parameters.get(key) + if isinstance(value, str) and value.strip(): + cleaned = " ".join(value.split()) + return transform(cleaned) if callable(transform) else cleaned + return "" + + def _list_param(self, parameters: dict[str, Any], keys: tuple[str, ...]) -> str: + for key in keys: + value = parameters.get(key) + if isinstance(value, list) and value: + first = value[0] + if isinstance(first, str) and first.strip(): + suffix = f" (+{len(value) - 1} more)" if len(value) > 1 else "" + return f"{first.strip()}{suffix}" + return "" + + def _compact_url(self, url: str) -> str: + compact = url.replace("https://", "").replace("http://", "") + return compact.rstrip("/") + + def _tool_details(self) -> str: + collapsed: list[tuple[_ToolActivity, int]] = [] + for activity in self._tool_history: + if collapsed and collapsed[-1][0] == activity: + previous, count = collapsed[-1] + collapsed[-1] = (previous, count + 1) + else: + collapsed.append((activity, 1)) + lines = [ + self._tool_detail_line(activity, count) + for activity, count in collapsed[-_MAX_TOOL_HISTORY:] + ] + details = "\n".join(lines) + return self._limit_detail(details) + + def _tool_detail_line(self, activity: _ToolActivity, count: int) -> str: + label = f"{activity.label} x{count}" if count > 1 else activity.label + if activity.target: + return f"- {label}: {activity.target}" + return f"- {label}" + + def _final_plan_chunks(self, final_text: str | None) -> list[dict[str, object]] | None: + if not self._plan_started: + return None + chunks: list[dict[str, object]] = [] + analyze_chunk = self._set_task( + _ANALYZE_TASK_ID, + status="complete", + details=self._analysis_detail(), + ) + if analyze_chunk is not None: + chunks.append(analyze_chunk) + tools_chunk = self._set_task( + _TOOLS_TASK_ID, + status="complete", + details=self._tool_details() if self._used_tools else _NO_TOOLS_DETAIL, + ) + if tools_chunk is not None: + chunks.append(tools_chunk) + if self._answer_started or final_text: + response_chunk = self._set_task(_RESPONSE_TASK_ID, status="complete") + if response_chunk is not None: + chunks.append(response_chunk) + return chunks or None + + def _limit_detail(self, text: str) -> str: + if len(text) <= _DETAIL_LIMIT: + return text + return text[: _DETAIL_LIMIT - 1].rstrip() + "…" + + def _render_fallback(self, final_text: str | None) -> str: + sections: list[str] = [] + thinking = "".join(self._thinking_parts).strip() + answer = "".join(self._answer_parts).strip() or (final_text or "").strip() + + if thinking: + sections.append(f"💭 *Thinking*\n{thinking}") + elif self._status is not None: + label = _SYSTEM_LABELS.get(self._status or "") + if label: + sections.append(f"💭 *{label}*") + + if answer: + sections.append(answer) + + if not sections: + sections.append("…") + return "\n\n".join(sections).strip() diff --git a/ductor_bot/messenger/slack/transport.py b/ductor_bot/messenger/slack/transport.py new file mode 100644 index 00000000..4572af31 --- /dev/null +++ b/ductor_bot/messenger/slack/transport.py @@ -0,0 +1,231 @@ +"""Slack delivery adapter for the MessageBus.""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +from ductor_bot.bus.cron_sanitize import sanitize_cron_result_text +from ductor_bot.bus.envelope import Envelope, Origin +from ductor_bot.messenger.slack.sender import SlackSendOpts +from ductor_bot.messenger.slack.sender import send_rich as slack_send_rich +from ductor_bot.text.response_format import SEP, fmt + +if TYPE_CHECKING: + from ductor_bot.messenger.slack.bot import SlackBot + +logger = logging.getLogger(__name__) + + +class SlackTransport: + """Implements the transport adapter protocol for Slack delivery.""" + + def __init__(self, bot: SlackBot) -> None: + self._bot = bot + + @property + def transport_name(self) -> str: + return "sl" + + async def deliver(self, envelope: Envelope) -> None: + handler = _HANDLERS.get(envelope.origin) + if handler is None: + logger.warning("No handler for origin=%s", envelope.origin.value) + return + await handler(self, envelope) + + async def deliver_broadcast(self, envelope: Envelope) -> None: + handler = _BROADCAST_HANDLERS.get(envelope.origin) + if handler is None: + logger.warning("No broadcast handler for origin=%s", envelope.origin.value) + return + await handler(self, envelope) + + def _resolve_target(self, env: Envelope) -> tuple[str | None, str | None]: + channel_id = self._bot.id_map.int_to_channel(env.chat_id) + if channel_id is None: + return None, None + if env.topic_id is None: + return channel_id, None + thread = self._bot.id_map.int_to_thread(env.topic_id) + if thread is None: + return channel_id, None + thread_channel, thread_ts = thread + if thread_channel != channel_id: + logger.warning("Slack topic mapping mismatch for chat_id=%s topic_id=%s", env.chat_id, env.topic_id) + return channel_id, None + return channel_id, thread_ts + + def _opts(self, env: Envelope) -> SlackSendOpts: + channel_id, thread_ts = self._resolve_target(env) + del channel_id + orch = self._bot.orchestrator + roots = self._bot.file_roots(orch.paths) if orch else None + return SlackSendOpts(allowed_roots=roots, thread_ts=thread_ts) + + async def _deliver_background(self, env: Envelope) -> None: + channel_id, _thread_ts = self._resolve_target(env) + if not channel_id: + return + elapsed = f"{env.elapsed_seconds:.0f}s" + if env.session_name: + if env.status == "aborted": + text = fmt(f"**[{env.session_name}] Cancelled**", SEP, f"_{env.prompt_preview}_") + elif env.is_error: + body = env.result_text[:2000] if env.result_text else "_No output._" + text = fmt(f"**[{env.session_name}] Failed** ({elapsed})", SEP, body) + else: + text = fmt( + f"**[{env.session_name}] Complete** ({elapsed})", + SEP, + env.result_text or "_No output._", + ) + else: + task_id = env.metadata.get("task_id", "?") + if env.status == "aborted": + text = fmt( + "**Background Task Cancelled**", + SEP, + f"Task `{task_id}` was cancelled.\nPrompt: _{env.prompt_preview}_", + ) + elif env.is_error: + text = fmt( + f"**Background Task Failed** ({elapsed})", + SEP, + f"Task `{task_id}` failed ({env.status}).\nPrompt: _{env.prompt_preview}_\n\n" + + (env.result_text[:2000] if env.result_text else "_No output._"), + ) + else: + text = fmt( + f"**Background Task Complete** ({elapsed})", + SEP, + env.result_text or "_No output._", + ) + await slack_send_rich(self._bot.client, channel_id, text, self._opts(env)) + + async def _deliver_heartbeat(self, env: Envelope) -> None: + channel_id, _thread_ts = self._resolve_target(env) + if channel_id and env.result_text: + await slack_send_rich(self._bot.client, channel_id, env.result_text, self._opts(env)) + + async def _deliver_interagent(self, env: Envelope) -> None: + channel_id, _thread_ts = self._resolve_target(env) + if not channel_id: + return + if env.is_error: + session_info = f"\nSession: `{env.session_name}`" if env.session_name else "" + text = ( + f"**Inter-Agent Request Failed**\n\n" + f"Agent: `{env.metadata.get('recipient', '?')}`{session_info}\n" + f"Error: {env.metadata.get('error', 'unknown')}\n" + f"Request: _{env.prompt_preview}_" + ) + await slack_send_rich(self._bot.client, channel_id, text, self._opts(env)) + return + + notice = env.metadata.get("provider_switch_notice", "") + if notice: + await slack_send_rich( + self._bot.client, + channel_id, + f"**Provider Switch Detected**\n\n{notice}", + self._opts(env), + ) + if env.result_text: + await slack_send_rich(self._bot.client, channel_id, env.result_text, self._opts(env)) + + async def _deliver_task_result(self, env: Envelope) -> None: + channel_id, _thread_ts = self._resolve_target(env) + if not channel_id: + return + name = env.metadata.get("name", env.metadata.get("task_id", "?")) + note = "" + if env.status == "done": + duration = f"{env.elapsed_seconds:.0f}s" + target = f"{env.provider}/{env.model}" if env.provider else "" + detail = f"{duration}, {target}" if target else duration + note = f"**Task `{name}` completed** ({detail})" + elif env.status == "cancelled": + note = f"**Task `{name}` cancelled**" + elif env.status == "failed": + note = f"**Task `{name}` failed**\nReason: {env.metadata.get('error', 'unknown')}" + + if note: + await slack_send_rich(self._bot.client, channel_id, note, self._opts(env)) + if env.needs_injection and env.result_text: + await slack_send_rich(self._bot.client, channel_id, env.result_text, self._opts(env)) + + async def _deliver_task_question(self, env: Envelope) -> None: + channel_id, _thread_ts = self._resolve_target(env) + if not channel_id: + return + task_id = env.metadata.get("task_id", "?") + note = f"**Task `{task_id}` has a question:**\n{env.prompt}" + await slack_send_rich(self._bot.client, channel_id, note, self._opts(env)) + if env.result_text: + await slack_send_rich(self._bot.client, channel_id, env.result_text, self._opts(env)) + + async def _deliver_webhook_wake(self, env: Envelope) -> None: + channel_id, _thread_ts = self._resolve_target(env) + if channel_id and env.result_text: + await slack_send_rich(self._bot.client, channel_id, env.result_text, self._opts(env)) + + async def _deliver_cron(self, env: Envelope) -> None: + channel_id, _thread_ts = self._resolve_target(env) + if not channel_id: + logger.warning( + "Slack cron unicast: cannot resolve chat_id=%d, falling back to broadcast", + env.chat_id, + ) + await self._broadcast_cron(env) + return + title = env.metadata.get("title", "?") + clean_result = sanitize_cron_result_text(env.result_text) + if env.result_text and not clean_result and env.status == "success": + return + text = ( + f"**TASK: {title}**\n\n{clean_result}" + if clean_result + else f"**TASK: {title}**\n\n_{env.status}_" + ) + await slack_send_rich(self._bot.client, channel_id, text, self._opts(env)) + + async def _broadcast_cron(self, env: Envelope) -> None: + title = env.metadata.get("title", "?") + clean_result = sanitize_cron_result_text(env.result_text) + if env.result_text and not clean_result and env.status == "success": + return + text = ( + f"**TASK: {title}**\n\n{clean_result}" + if clean_result + else f"**TASK: {title}**\n\n_{env.status}_" + ) + await self._bot.broadcast(text) + + async def _broadcast_webhook_cron(self, env: Envelope) -> None: + title = env.metadata.get("hook_title", "?") + text = ( + f"**WEBHOOK (CRON TASK): {title}**\n\n{env.result_text}" + if env.result_text + else f"**WEBHOOK (CRON TASK): {title}**\n\n_{env.status}_" + ) + await self._bot.broadcast(text) + + +_Handler = Callable[[SlackTransport, Envelope], Awaitable[None]] + +_HANDLERS: dict[Origin, _Handler] = { + Origin.BACKGROUND: SlackTransport._deliver_background, + Origin.CRON: SlackTransport._deliver_cron, + Origin.HEARTBEAT: SlackTransport._deliver_heartbeat, + Origin.INTERAGENT: SlackTransport._deliver_interagent, + Origin.TASK_RESULT: SlackTransport._deliver_task_result, + Origin.TASK_QUESTION: SlackTransport._deliver_task_question, + Origin.WEBHOOK_WAKE: SlackTransport._deliver_webhook_wake, +} + +_BROADCAST_HANDLERS: dict[Origin, _Handler] = { + Origin.CRON: SlackTransport._broadcast_cron, + Origin.WEBHOOK_CRON: SlackTransport._broadcast_webhook_cron, +} diff --git a/ductor_bot/messenger/telegram/message_dispatch.py b/ductor_bot/messenger/telegram/message_dispatch.py index 2d6708c7..2e97d44e 100644 --- a/ductor_bot/messenger/telegram/message_dispatch.py +++ b/ductor_bot/messenger/telegram/message_dispatch.py @@ -253,7 +253,8 @@ async def run_streaming_message( async def on_text(delta: str) -> None: await coalescer.feed(delta) - async def on_tool(tool_name: str) -> None: + async def on_tool(tool: object) -> None: + tool_name = str(getattr(tool, "tool_name", tool)) await tracker.set_tool(tool_name) await coalescer.flush(force=True) await editor.append_tool(tool_name) diff --git a/ductor_bot/orchestrator/core.py b/ductor_bot/orchestrator/core.py index 95c702e2..8114c2a0 100644 --- a/ductor_bot/orchestrator/core.py +++ b/ductor_bot/orchestrator/core.py @@ -14,6 +14,7 @@ ) from ductor_bot.cli.process_registry import ProcessRegistry from ductor_bot.cli.service import CLIService, CLIServiceConfig +from ductor_bot.cli.stream_events import ToolUseEvent from ductor_bot.config import AgentConfig from ductor_bot.cron.manager import CronManager from ductor_bot.errors import ( @@ -77,6 +78,7 @@ _TextCallback = Callable[[str], Awaitable[None]] +_ToolCallback = Callable[[ToolUseEvent], Awaitable[None]] _SystemStatusCallback = Callable[[str | None], Awaitable[None]] @@ -86,6 +88,7 @@ class NamedSessionRequest: message_id: int thread_id: int | None + transport: str = "tg" provider_override: str | None = None model_override: str | None = None @@ -99,13 +102,15 @@ class _MessageDispatch: cmd: str streaming: bool = False on_text_delta: _TextCallback | None = None - on_tool_activity: _TextCallback | None = None + on_thinking_delta: _TextCallback | None = None + on_tool_activity: _ToolCallback | None = None on_system_status: _SystemStatusCallback | None = None def streaming_callbacks(self) -> StreamingCallbacks: """Bundle the streaming callbacks into a StreamingCallbacks instance.""" return StreamingCallbacks( on_text_delta=self.on_text_delta, + on_thinking_delta=self.on_thinking_delta, on_tool_activity=self.on_tool_activity, on_system_status=self.on_system_status, ) @@ -162,9 +167,10 @@ async def _heartbeat_handler( topic_id: int | None = None, prompt: str | None = None, ack_token: str | None = None, + transport: str = "tg", ) -> str | None: return await self.handle_heartbeat( - SessionKey(chat_id=chat_id, topic_id=topic_id), + SessionKey.for_transport(transport, chat_id, topic_id), prompt=prompt, ack_token=ack_token, ) @@ -296,13 +302,14 @@ async def handle_message(self, key: SessionKey, text: str) -> OrchestratorResult dispatch = _MessageDispatch(key=key, text=text, cmd=text.strip().lower()) return await self._handle_message_impl(dispatch) - async def handle_message_streaming( + async def handle_message_streaming( # noqa: PLR0913 self, key: SessionKey, text: str, *, on_text_delta: _TextCallback | None = None, - on_tool_activity: _TextCallback | None = None, + on_thinking_delta: _TextCallback | None = None, + on_tool_activity: _ToolCallback | None = None, on_system_status: _SystemStatusCallback | None = None, ) -> OrchestratorResult: """Main entry point with streaming support.""" @@ -312,6 +319,7 @@ async def handle_message_streaming( cmd=text.strip().lower(), streaming=True, on_text_delta=on_text_delta, + on_thinking_delta=on_thinking_delta, on_tool_activity=on_tool_activity, on_system_status=on_system_status, ) @@ -527,13 +535,20 @@ def submit_named_session( request.provider_override ) - ns = self._named_sessions.create(chat_id, provider_name, model_name, prompt) + ns = self._named_sessions.create( + chat_id, + provider_name, + model_name, + prompt, + key=SessionKey.for_transport(request.transport, chat_id, request.thread_id), + ) exec_config = resolve_cli_config(self._config, self._observers.codex_cache) sub = BackgroundSubmit( chat_id=chat_id, prompt=prompt, message_id=request.message_id, thread_id=request.thread_id, + transport=request.transport, session_name=ns.name, provider_override=provider_name, model_override=model_name, @@ -567,13 +582,20 @@ def submit_named_followup_bg( msg = f"Session '{session_name}' is still processing" raise ValueError(msg) - self._named_sessions.mark_running(chat_id, session_name, prompt) + self._named_sessions.mark_running( + chat_id, + session_name, + prompt, + transport=ns.transport, + topic_id=thread_id, + ) exec_config = resolve_cli_config(self._config, self._observers.codex_cache) sub = BackgroundSubmit( chat_id=chat_id, prompt=prompt, message_id=message_id, - thread_id=thread_id, + thread_id=thread_id if thread_id is not None else ns.topic_id, + transport=ns.transport, session_name=session_name, resume_session_id=ns.session_id, provider_override=ns.provider, diff --git a/ductor_bot/orchestrator/flows.py b/ductor_bot/orchestrator/flows.py index 5fea1444..ef699adf 100644 --- a/ductor_bot/orchestrator/flows.py +++ b/ductor_bot/orchestrator/flows.py @@ -10,6 +10,7 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING +from ductor_bot.cli.stream_events import ToolUseEvent from ductor_bot.cli.timeout_controller import TimeoutConfig as TCConfig from ductor_bot.cli.timeout_controller import TimeoutController from ductor_bot.cli.types import AgentRequest, AgentResponse @@ -34,7 +35,8 @@ class StreamingCallbacks: """Bundle of optional streaming callbacks passed through flow functions.""" on_text_delta: Callable[[str], Awaitable[None]] | None = field(default=None) - on_tool_activity: Callable[[str], Awaitable[None]] | None = field(default=None) + on_thinking_delta: Callable[[str], Awaitable[None]] | None = field(default=None) + on_tool_activity: Callable[[ToolUseEvent], Awaitable[None]] | None = field(default=None) on_system_status: Callable[[str | None], Awaitable[None]] | None = field(default=None) @@ -345,6 +347,7 @@ async def _recover_session( response = await orch._cli_service.execute_streaming( request, on_text_delta=cb.on_text_delta, + on_thinking_delta=cb.on_thinking_delta, on_tool_activity=cb.on_tool_activity, on_system_status=cb.on_system_status, ) @@ -494,6 +497,7 @@ async def _on_compact() -> None: response = await orch._cli_service.execute_streaming( request, on_text_delta=cb.on_text_delta, + on_thinking_delta=cb.on_thinking_delta, on_tool_activity=cb.on_tool_activity, on_system_status=cb.on_system_status, on_compact_boundary=_on_compact if orch._memory_flusher is not None else None, @@ -664,7 +668,13 @@ async def named_session_flow( return OrchestratorResult(text=t("session.still_running", name=session_name)) tag = f"**[{session_name} | {ns.provider}]**\n" - orch._named_sessions.mark_running(key.chat_id, session_name, text) + orch._named_sessions.mark_running( + key.chat_id, + session_name, + text, + transport=key.transport, + topic_id=key.topic_id, + ) request = AgentRequest( prompt=text, model_override=ns.model, @@ -710,7 +720,13 @@ async def named_session_streaming( cb = cbs or StreamingCallbacks() tag = f"**[{session_name} | {ns.provider}]**\n" - orch._named_sessions.mark_running(key.chat_id, session_name, text) + orch._named_sessions.mark_running( + key.chat_id, + session_name, + text, + transport=key.transport, + topic_id=key.topic_id, + ) request = AgentRequest( prompt=text, model_override=ns.model, @@ -736,6 +752,7 @@ async def _tagged_text_delta(chunk: str) -> None: response = await orch._cli_service.execute_streaming( request, on_text_delta=_tagged_text_delta, + on_thinking_delta=cb.on_thinking_delta, on_tool_activity=cb.on_tool_activity, on_system_status=cb.on_system_status, ) diff --git a/ductor_bot/orchestrator/observers.py b/ductor_bot/orchestrator/observers.py index 1f1c19c9..732ef8d2 100644 --- a/ductor_bot/orchestrator/observers.py +++ b/ductor_bot/orchestrator/observers.py @@ -207,8 +207,13 @@ async def _on_cron( # noqa: PLR0913 self.cron.set_result_handler(_on_cron) - async def _on_heartbeat(chat_id: int, text: str, topic_id: int | None = None) -> None: - await bus.submit(from_heartbeat(chat_id, text, topic_id)) + async def _on_heartbeat( + chat_id: int, + text: str, + topic_id: int | None = None, + transport: str = "tg", + ) -> None: + await bus.submit(from_heartbeat(chat_id, text, topic_id, transport=transport)) self.heartbeat.set_result_handler(_on_heartbeat) diff --git a/ductor_bot/session/named.py b/ductor_bot/session/named.py index 88b964b1..991c119c 100644 --- a/ductor_bot/session/named.py +++ b/ductor_bot/session/named.py @@ -11,6 +11,7 @@ from typing import Any from ductor_bot.infra.json_store import atomic_json_save, load_json +from ductor_bot.session.key import SessionKey logger = logging.getLogger(__name__) @@ -143,6 +144,7 @@ class NamedSession: message_count: int = 0 last_prompt: str = "" transport: str = "tg" + topic_id: int | None = None def _session_from_dict(data: dict[str, Any]) -> NamedSession: @@ -150,6 +152,7 @@ def _session_from_dict(data: dict[str, Any]) -> NamedSession: return NamedSession( name=str(data.get("name", "")), chat_id=int(data.get("chat_id", 0)), + topic_id=int(data["topic_id"]) if data.get("topic_id") is not None else None, provider=str(data.get("provider", "")), model=str(data.get("model", "")), session_id=str(data.get("session_id", "")), @@ -192,6 +195,7 @@ def _load(self) -> None: self._recovered_running[(ns.chat_id, ns.name)] = NamedSession( name=ns.name, chat_id=ns.chat_id, + topic_id=ns.topic_id, provider=ns.provider, model=ns.model, session_id=ns.session_id, @@ -200,6 +204,7 @@ def _load(self) -> None: created_at=ns.created_at, message_count=ns.message_count, last_prompt=ns.last_prompt, + transport=ns.transport, ) ns.status = "idle" self._sessions[(ns.chat_id, ns.name)] = ns @@ -216,6 +221,7 @@ def create( provider: str, model: str, prompt_preview: str, + key: SessionKey | None = None, ) -> NamedSession: """Create a new named session. Raises ValueError if limit exceeded.""" active = self.active_names(chat_id) @@ -224,15 +230,18 @@ def create( raise ValueError(msg) name = generate_name(active) + session_key = key or SessionKey.telegram(chat_id) session = NamedSession( name=name, - chat_id=chat_id, + chat_id=session_key.chat_id, provider=provider, model=model, session_id="", prompt_preview=prompt_preview[:60], status="running", created_at=time.time(), + transport=session_key.transport, + topic_id=session_key.topic_id, ) self._sessions[(chat_id, name)] = session self._persist() @@ -304,19 +313,34 @@ def add(self, session: NamedSession) -> None: self._sessions[(session.chat_id, session.name)] = session self._persist() - def mark_running(self, chat_id: int, name: str, prompt: str) -> None: + def mark_running( + self, + chat_id: int, + name: str, + prompt: str, + *, + transport: str | None = None, + topic_id: int | None = None, + ) -> None: """Mark a session as running and store the prompt for recovery.""" ns = self._sessions.get((chat_id, name)) if ns is None: return ns.status = "running" ns.last_prompt = prompt[:4000] + if transport: + ns.transport = transport + if topic_id is not None: + ns.topic_id = topic_id self._persist() - def pop_recovered_running(self, chat_id: int | None = None) -> list[NamedSession]: + def pop_recovered_running( + self, chat_id: int | None = None, *, transport: str | None = None + ) -> list[NamedSession]: """Return sessions that were running at last shutdown, then clear them. If *chat_id* is given, only return sessions for that chat. + If *transport* is given, only return sessions for that transport. Excludes inter-agent sessions (``ia-`` prefix). """ results: list[NamedSession] = [] @@ -324,6 +348,8 @@ def pop_recovered_running(self, chat_id: int | None = None) -> list[NamedSession for key, ns in self._recovered_running.items(): if chat_id is not None and ns.chat_id != chat_id: continue + if transport is not None and ns.transport != transport: + continue if ns.name.startswith("ia-"): continue results.append(ns) diff --git a/ductor_bot/text/response_format.py b/ductor_bot/text/response_format.py index 16b82510..8714afd8 100644 --- a/ductor_bot/text/response_format.py +++ b/ductor_bot/text/response_format.py @@ -7,11 +7,20 @@ SEP = "\u2500\u2500\u2500" _SHELL_TOOLS = frozenset({"bash", "powershell", "cmd", "sh", "zsh", "shell"}) +_TOOL_LABELS = { + "toolsearch": "Search", + "searchtool": "Search", + "webfetch": "Web fetch", + "websearch": "Web search", +} def normalize_tool_name(name: str) -> str: """Normalize shell-related tool names to 'Shell' for display.""" - return "Shell" if name.lower() in _SHELL_TOOLS else name + lower = name.lower() + if lower in _SHELL_TOOLS: + return "Shell" + return _TOOL_LABELS.get(lower, name) def fmt(*blocks: str) -> str: diff --git a/ductor_bot/workspace/init.py b/ductor_bot/workspace/init.py index a0209f68..f6e96d0f 100644 --- a/ductor_bot/workspace/init.py +++ b/ductor_bot/workspace/init.py @@ -428,9 +428,25 @@ def init_workspace(paths: DuctorPaths) -> None: Do not place button markers inside code blocks. """ +_TRANSPORT_SLACK = """ + +--- + +## Messenger Rules + +- Replies are Slack messages. +- Slack command keywords work as normal messages or slash-prefixed messages + (for example `help` or `/help`). +- In channels, keep long conversations inside the existing thread when one exists. +- To send files, use ``. +- Save generated deliverables in `output_to_user/`. +- Do not suggest GUI-only actions like opening Finder or clicking app-only menus. +""" + _TRANSPORT_RULES: dict[str, str] = { "telegram": _TRANSPORT_TELEGRAM, "matrix": _TRANSPORT_MATRIX, + "slack": _TRANSPORT_SLACK, } # --------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 2f29ca9e..8be38e57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,11 @@ api = [ matrix = [ "matrix-nio>=0.25.0", ] -dev = ["ductor[test,lint,api,matrix]"] +slack = [ + "slack-bolt>=1.23.0,<2.0.0", + "slack-sdk>=3.37.0,<4.0.0", +] +dev = ["ductor[test,lint,api,matrix,slack]"] [project.urls] Homepage = "https://ductor.dev" @@ -197,6 +201,10 @@ ignore_missing_imports = true module = ["nio.*"] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["slack_bolt.*", "slack_sdk.*"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = ["PIL.*"] ignore_missing_imports = true diff --git a/tests/api/test_server_e2e.py b/tests/api/test_server_e2e.py index 0dfcab0d..8fd25b39 100644 --- a/tests/api/test_server_e2e.py +++ b/tests/api/test_server_e2e.py @@ -24,6 +24,7 @@ from ductor_bot.api.crypto import E2ESession from ductor_bot.api.server import ApiServer +from ductor_bot.cli.stream_events import ToolUseEvent from ductor_bot.config import ApiConfig # --------------------------------------------------------------------------- @@ -272,7 +273,13 @@ async def fake_handler( on_system_status: Any, ) -> SimpleNamespace: await on_system_status("Thinking") - await on_tool_activity("Reading file") + await on_tool_activity( + ToolUseEvent( + type="assistant", + tool_name="WebFetch", + parameters={"url": "https://slack.dev/slack-thinking-steps-ai-agents/"}, + ) + ) await on_text_delta("chunk1") await on_text_delta("chunk2") return SimpleNamespace(text="chunk1chunk2", stream_fallback=False) @@ -300,6 +307,8 @@ async def fake_handler( assert "tool_activity" in types assert "text_delta" in types assert types[-1] == "result" + tool_events = [e["data"] for e in events if e["type"] == "tool_activity"] + assert tool_events == ["Web fetch"] deltas = [e["data"] for e in events if e["type"] == "text_delta"] assert deltas == ["chunk1", "chunk2"] diff --git a/tests/background/test_observer.py b/tests/background/test_observer.py index 38c90ffb..2fd4a619 100644 --- a/tests/background/test_observer.py +++ b/tests/background/test_observer.py @@ -18,8 +18,21 @@ from ductor_bot.workspace.paths import DuctorPaths -def _sub(chat_id: int = 123, prompt: str = "", message_id: int = 1) -> BackgroundSubmit: - return BackgroundSubmit(chat_id=chat_id, prompt=prompt, message_id=message_id, thread_id=None) +def _sub( + chat_id: int = 123, + prompt: str = "", + message_id: int = 1, + *, + thread_id: int | None = None, + transport: str = "tg", +) -> BackgroundSubmit: + return BackgroundSubmit( + chat_id=chat_id, + prompt=prompt, + message_id=message_id, + thread_id=thread_id, + transport=transport, + ) def _make_paths(tmp_path: Path) -> DuctorPaths: @@ -159,6 +172,21 @@ async def test_success_delivers_result(self, observer: BackgroundObserver) -> No assert bg_result.chat_id == 123 assert bg_result.message_id == 42 assert bg_result.prompt_preview == "say hello" + assert bg_result.transport == "tg" + + async def test_preserves_transport_and_thread_target(self, observer: BackgroundObserver) -> None: + config = _make_exec_config() + handler = AsyncMock() + observer.set_result_handler(handler) + + result = _success_task_result("Hello thread") + with patch("ductor_bot.background.observer.run_oneshot_task", return_value=result): + observer.submit(_sub(prompt="say hello", thread_id=77, transport="sl"), config) + await asyncio.sleep(0.05) + + bg_result: BackgroundResult = handler.call_args[0][0] + assert bg_result.thread_id == 77 + assert bg_result.transport == "sl" async def test_cli_not_found(self, observer: BackgroundObserver) -> None: config = _make_exec_config() diff --git a/tests/bus/test_adapters.py b/tests/bus/test_adapters.py index d2594a6e..8c5889fa 100644 --- a/tests/bus/test_adapters.py +++ b/tests/bus/test_adapters.py @@ -26,6 +26,7 @@ class _FakeBackgroundResult: chat_id: int = 100 message_id: int = 42 thread_id: int | None = None + transport: str = "tg" prompt_preview: str = "do something" result_text: str = "done" status: str = "success" @@ -87,6 +88,8 @@ def test_from_background_result() -> None: env = from_background_result(_FakeBackgroundResult()) assert env.origin == Origin.BACKGROUND assert env.chat_id == 100 + assert env.topic_id is None + assert env.transport == "tg" assert env.delivery == DeliveryMode.UNICAST assert env.lock_mode == LockMode.NONE assert not env.needs_injection @@ -101,6 +104,13 @@ def test_from_background_result_error() -> None: assert env.is_error +def test_from_background_result_preserves_transport_and_thread_target() -> None: + env = from_background_result(_FakeBackgroundResult(thread_id=9, transport="sl")) + assert env.topic_id == 9 + assert env.thread_id == 9 + assert env.transport == "sl" + + def test_from_cron_result() -> None: env = from_cron_result("Daily Report", "all good", "success") assert env.origin == Origin.CRON @@ -143,6 +153,13 @@ def test_from_heartbeat_with_topic_id() -> None: assert env.result_text == "group alert" +def test_from_heartbeat_preserves_transport() -> None: + env = from_heartbeat(200, "alert text", topic_id=7, transport="sl") + assert env.chat_id == 200 + assert env.topic_id == 7 + assert env.transport == "sl" + + def test_from_webhook_cron_result() -> None: env = from_webhook_cron_result(_FakeWebhookResult()) assert env.origin == Origin.WEBHOOK_CRON diff --git a/tests/bus/test_observers_wire.py b/tests/bus/test_observers_wire.py index f3c76a68..a8edd302 100644 --- a/tests/bus/test_observers_wire.py +++ b/tests/bus/test_observers_wire.py @@ -165,17 +165,18 @@ async def test_heartbeat_callback_submits_to_bus(self) -> None: assert env.chat_id == 99 assert env.topic_id is None assert env.result_text == "Alert text" + assert env.transport == "tg" async def test_heartbeat_callback_submits_topic_id_to_bus(self) -> None: mgr = _make_observers() bus = MessageBus() transport = AsyncMock() - transport.transport_name = "tg" + transport.transport_name = "sl" bus.register_transport(transport) mgr.wire_to_bus(bus) handler = mgr.heartbeat.set_result_handler.call_args[0][0] - await handler(-1001, "Group alert", 42) + await handler(-1001, "Group alert", 42, "sl") transport.deliver.assert_awaited_once() env = transport.deliver.call_args[0][0] @@ -183,13 +184,14 @@ async def test_heartbeat_callback_submits_topic_id_to_bus(self) -> None: assert env.chat_id == -1001 assert env.topic_id == 42 assert env.result_text == "Group alert" + assert env.transport == "sl" async def test_cron_callback_submits_to_bus(self) -> None: mgr = _make_observers() mgr.cron = MagicMock() bus = MessageBus() transport = AsyncMock() - transport.transport_name = "tg" + transport.transport_name = "sl" bus.register_transport(transport) mgr.wire_to_bus(bus) @@ -206,7 +208,7 @@ async def test_background_callback_submits_to_bus(self) -> None: mgr.background = MagicMock() bus = MessageBus() transport = AsyncMock() - transport.transport_name = "tg" + transport.transport_name = "sl" bus.register_transport(transport) mgr.wire_to_bus(bus) @@ -218,7 +220,8 @@ async def test_background_callback_submits_to_bus(self) -> None: bg_result.result_text = "done" bg_result.status = "success" bg_result.message_id = 1 - bg_result.thread_id = None + bg_result.thread_id = 77 + bg_result.transport = "sl" bg_result.elapsed_seconds = 5.0 bg_result.provider = "claude" bg_result.model = "sonnet" @@ -231,3 +234,5 @@ async def test_background_callback_submits_to_bus(self) -> None: env = transport.deliver.call_args[0][0] assert env.origin == Origin.BACKGROUND assert env.chat_id == 42 + assert env.topic_id == 77 + assert env.transport == "sl" diff --git a/tests/bus/test_transport_routing.py b/tests/bus/test_transport_routing.py index f1978708..434390ea 100644 --- a/tests/bus/test_transport_routing.py +++ b/tests/bus/test_transport_routing.py @@ -227,6 +227,16 @@ async def test_matrix_transport_name(self) -> None: t = MatrixTransport(bot) assert t.transport_name == "mx" + async def test_slack_transport_name(self) -> None: + """SlackTransport.transport_name returns 'sl'.""" + from unittest.mock import MagicMock + + from ductor_bot.messenger.slack.transport import SlackTransport + + bot = MagicMock() + t = SlackTransport(bot) + assert t.transport_name == "sl" + # -- Adapter transport parameter -- diff --git a/tests/cli/test_codex_events.py b/tests/cli/test_codex_events.py index f4e5b093..2f8a96fa 100644 --- a/tests/cli/test_codex_events.py +++ b/tests/cli/test_codex_events.py @@ -189,13 +189,20 @@ def test_stream_mcp_tool_call() -> None: line = json.dumps( { "type": "item.started", - "item": {"type": "mcp_tool_call", "name": "search_docs"}, + "item": { + "type": "mcp_tool_call", + "name": "search_docs", + "id": "mcp-1", + "arguments": {"query": "slack plan block"}, + }, } ) events = parse_codex_stream_event(line) assert len(events) == 1 assert isinstance(events[0], ToolUseEvent) assert events[0].tool_name == "search_docs" + assert events[0].tool_id == "mcp-1" + assert events[0].parameters == {"query": "slack plan block"} def test_stream_unknown_type_returns_empty() -> None: diff --git a/tests/cli/test_init_wizard.py b/tests/cli/test_init_wizard.py index 09bc585d..cb77adf0 100644 --- a/tests/cli/test_init_wizard.py +++ b/tests/cli/test_init_wizard.py @@ -11,6 +11,7 @@ from ductor_bot.cli.auth import AuthResult, AuthStatus from ductor_bot.cli.init_wizard import ( + _ask_transport, _check_clis, _WizardConfig, _write_config, @@ -79,6 +80,54 @@ def test_write_config_normalizes_existing_null_gemini_api_key(tmp_path: Path) -> assert data["gemini_api_key"] == "null" +def test_write_config_writes_slack_section(tmp_path: Path) -> None: + paths = _make_paths(tmp_path) + paths.config_path.parent.mkdir(parents=True, exist_ok=True) + + with ( + patch("ductor_bot.cli.init_wizard.resolve_paths", return_value=paths), + patch("ductor_bot.cli.init_wizard.init_workspace"), + ): + _write_config( + _WizardConfig( + transport="slack", + slack_bot_token="xoxb-test-bot-token", + slack_app_token="xapp-test-app-token", + slack_allowed_channels=["C0123456789", "G0123456789"], + slack_allowed_users=["U0123456789"], + user_timezone="UTC", + docker_enabled=False, + ) + ) + + data = json.loads(paths.config_path.read_text(encoding="utf-8")) + assert data["transport"] == "slack" + assert data["transports"] == ["slack"] + assert data["slack"]["bot_token"] == "xoxb-test-bot-token" + assert data["slack"]["app_token"] == "xapp-test-app-token" + assert data["slack"]["allowed_channels"] == ["C0123456789", "G0123456789"] + assert data["slack"]["allowed_users"] == ["U0123456789"] + + +def test_ask_transport_offers_slack() -> None: + captured: dict[str, object] = {} + + class _FakePrompt: + def ask(self) -> str: + return "Slack" + + def _fake_select(prompt: str, *, choices: list[str]) -> _FakePrompt: + captured["prompt"] = prompt + captured["choices"] = choices + return _FakePrompt() + + console = Console(record=True, width=120) + with patch("ductor_bot.cli.init_wizard.questionary.select", side_effect=_fake_select): + assert _ask_transport(console) == "slack" + + assert captured["choices"] == ["Telegram", "Matrix", "Slack"] + + def test_run_onboarding_returns_false_when_service_install_fails(tmp_path: Path) -> None: paths = _make_paths(tmp_path) @@ -119,6 +168,46 @@ def test_run_onboarding_returns_true_when_service_install_succeeds(tmp_path: Pat assert run_onboarding() is True +def test_run_onboarding_collects_slack_config(tmp_path: Path) -> None: + paths = _make_paths(tmp_path) + + with ( + patch("ductor_bot.cli.init_wizard._show_banner"), + patch("ductor_bot.cli.init_wizard._check_clis"), + patch("ductor_bot.cli.init_wizard._show_disclaimer"), + patch("ductor_bot.cli.init_wizard._ask_transport", return_value="slack"), + patch( + "ductor_bot.cli.init_wizard._ask_slack_bot_token", + return_value="xoxb-test-bot-token", + ), + patch( + "ductor_bot.cli.init_wizard._ask_slack_app_token", + return_value="xapp-test-app-token", + ), + patch( + "ductor_bot.cli.init_wizard._ask_slack_allowed_channels", + return_value=["C0123456789"], + ), + patch( + "ductor_bot.cli.init_wizard._ask_slack_allowed_users", + return_value=["U0123456789"], + ), + patch("ductor_bot.cli.init_wizard._ask_docker", return_value=False), + patch("ductor_bot.cli.init_wizard._ask_timezone", return_value="UTC"), + patch("ductor_bot.cli.init_wizard.resolve_paths", return_value=paths), + patch("ductor_bot.cli.init_wizard._offer_service_install", return_value=False), + patch("ductor_bot.cli.init_wizard._write_config", return_value=paths.config_path) as write_config, + ): + assert run_onboarding() is False + + submitted = write_config.call_args.args[0] + assert submitted["transport"] == "slack" + assert submitted["slack_bot_token"] == "xoxb-test-bot-token" + assert submitted["slack_app_token"] == "xapp-test-app-token" + assert submitted["slack_allowed_channels"] == ["C0123456789"] + assert submitted["slack_allowed_users"] == ["U0123456789"] + + # --- Regression tests for non-fatal CLI auth-check failures (#109 / P1-BUG-01) --- diff --git a/tests/cli/test_service.py b/tests/cli/test_service.py index 5c607ddb..e2d60e6e 100644 --- a/tests/cli/test_service.py +++ b/tests/cli/test_service.py @@ -8,7 +8,7 @@ from ductor_bot.cli.process_registry import ProcessRegistry from ductor_bot.cli.service import CLIService, CLIServiceConfig -from ductor_bot.cli.stream_events import StreamEvent +from ductor_bot.cli.stream_events import StreamEvent, ToolUseEvent from ductor_bot.cli.types import AgentRequest, CLIResponse from ductor_bot.config import ModelRegistry @@ -70,9 +70,10 @@ async def test_execute_error_response() -> None: async def test_execute_streaming_success() -> None: svc = _make_service() - from ductor_bot.cli.stream_events import AssistantTextDelta, ResultEvent + from ductor_bot.cli.stream_events import AssistantTextDelta, ResultEvent, ThinkingEvent async def fake_stream(*_args: Any, **_kwargs: Any) -> AsyncGenerator[StreamEvent, None]: + yield ThinkingEvent(type="assistant", text="considering") yield AssistantTextDelta(type="assistant", text="Hello ") yield AssistantTextDelta(type="assistant", text="world!") yield ResultEvent( @@ -84,10 +85,14 @@ async def fake_stream(*_args: Any, **_kwargs: Any) -> AsyncGenerator[StreamEvent ) deltas: list[str] = [] + thinking: list[str] = [] async def on_delta(text: str) -> None: deltas.append(text) + async def on_thinking(text: str) -> None: + thinking.append(text) + with patch("ductor_bot.cli.service.create_cli") as mock_create: mock_cli = MagicMock() mock_cli.send_streaming = fake_stream @@ -96,10 +101,12 @@ async def on_delta(text: str) -> None: resp = await svc.execute_streaming( AgentRequest(prompt="hello", chat_id=1), on_text_delta=on_delta, + on_thinking_delta=on_thinking, ) assert resp.result == "Hello world!" assert resp.session_id == "sess-1" + assert thinking == ["considering"] assert deltas == ["Hello ", "world!"] @@ -146,6 +153,7 @@ async def on_status(status: str | None) -> None: cbs = _StreamCallbacks( on_text=None, + on_thinking=None, on_tool=None, on_status=on_status, on_compact_boundary=on_boundary, @@ -158,3 +166,56 @@ async def on_status(status: str | None) -> None: assert text == "" assert result is None assert order == ["boundary", "status:None"] + + +async def test_stream_callbacks_dispatches_thinking_text() -> None: + from ductor_bot.cli.service import _StreamCallbacks + from ductor_bot.cli.stream_events import ThinkingEvent + + seen: list[str] = [] + statuses: list[str | None] = [] + + async def on_thinking(text: str) -> None: + seen.append(text) + + async def on_status(status: str | None) -> None: + statuses.append(status) + + cbs = _StreamCallbacks( + on_text=None, + on_thinking=on_thinking, + on_tool=None, + on_status=on_status, + ) + text, result = await cbs.dispatch(ThinkingEvent(type="assistant", text="step 1")) + + assert text == "" + assert result is None + assert seen == ["step 1"] + assert statuses == ["thinking"] + + +async def test_stream_callbacks_dispatch_tool_event() -> None: + from ductor_bot.cli.service import _StreamCallbacks + + seen: list[ToolUseEvent] = [] + + async def on_tool(event: ToolUseEvent) -> None: + seen.append(event) + + cbs = _StreamCallbacks( + on_text=None, + on_thinking=None, + on_tool=on_tool, + on_status=None, + ) + event = ToolUseEvent( + type="assistant", + tool_name="WebFetch", + parameters={"url": "https://slack.dev/slack-thinking-steps-ai-agents/"}, + ) + text, result = await cbs.dispatch(event) + + assert text == "" + assert result is None + assert seen == [event] diff --git a/tests/cli/test_stream_events.py b/tests/cli/test_stream_events.py index 1d17862f..ffec70b2 100644 --- a/tests/cli/test_stream_events.py +++ b/tests/cli/test_stream_events.py @@ -71,12 +71,18 @@ def test_parse_assistant_text() -> None: def test_parse_assistant_tool_use() -> None: data = { "type": "assistant", - "message": {"content": [{"type": "tool_use", "name": "Read"}]}, + "message": { + "content": [ + {"type": "tool_use", "name": "Read", "id": "tool-1", "input": {"path": "a.txt"}} + ] + }, } events = parse_stream_line(json.dumps(data)) assert len(events) == 1 assert isinstance(events[0], ToolUseEvent) assert events[0].tool_name == "Read" + assert events[0].tool_id == "tool-1" + assert events[0].parameters == {"path": "a.txt"} def test_parse_assistant_thinking() -> None: diff --git a/tests/heartbeat/test_heartbeat_targets.py b/tests/heartbeat/test_heartbeat_targets.py index 85183b61..eafdea71 100644 --- a/tests/heartbeat/test_heartbeat_targets.py +++ b/tests/heartbeat/test_heartbeat_targets.py @@ -22,6 +22,7 @@ def test_target_with_own_prompt(self) -> None: def test_target_falls_back_to_none(self) -> None: target = HeartbeatTarget(chat_id=123) + assert target.transport == "tg" assert target.prompt is None assert target.ack_token is None assert target.interval_minutes is None @@ -189,7 +190,12 @@ async def test_target_not_in_quiet_hours_runs(self) -> None: class TestPerTargetPromptAckInTick: async def test_tick_passes_target_prompt_and_ack(self) -> None: """Group target with per-target prompt/ack should pass them to handler.""" - target = HeartbeatTarget(chat_id=-1001, prompt="Check servers", ack_token="SERVER_OK") + target = HeartbeatTarget( + chat_id=-1001, + transport="sl", + prompt="Check servers", + ack_token="SERVER_OK", + ) config = AgentConfig( heartbeat=HeartbeatConfig( enabled=True, @@ -206,7 +212,7 @@ async def test_tick_passes_target_prompt_and_ack(self) -> None: with time_machine.travel(datetime(2026, 1, 15, 14, 0, tzinfo=UTC)): await obs._tick() - handler.assert_awaited_once_with(-1001, None, "Check servers", "SERVER_OK") + handler.assert_awaited_once_with(-1001, None, "Check servers", "SERVER_OK", "sl") async def test_tick_passes_none_for_default_user_targets(self) -> None: """User targets (allowed_user_ids) use None prompt/ack (global fallback).""" @@ -225,7 +231,17 @@ async def test_tick_passes_none_for_default_user_targets(self) -> None: with time_machine.travel(datetime(2026, 1, 15, 14, 0, tzinfo=UTC)): await obs._tick() - handler.assert_awaited_once_with(100, None, None, None) + handler.assert_awaited_once_with(100, None, None, None, "tg") + + async def test_run_for_chat_passes_transport_to_result_handler(self) -> None: + obs = _make_observer(targets=[]) + obs.set_heartbeat_handler(AsyncMock(return_value="alert")) + result_handler = AsyncMock() + obs.set_result_handler(result_handler) + + await obs._run_for_chat(-1001, topic_id=7, transport="sl") + + result_handler.assert_awaited_once_with(-1001, "alert", 7, "sl") # --------------------------------------------------------------------------- @@ -255,7 +271,7 @@ async def test_invalid_target_is_skipped(self) -> None: with time_machine.travel(datetime(2026, 1, 15, 14, 0, tzinfo=UTC)): await obs._tick() - handler.assert_awaited_once_with(100, None, None, None) + handler.assert_awaited_once_with(100, None, None, None, "tg") async def test_cache_expires_after_one_hour(self) -> None: validator = AsyncMock(return_value=True) @@ -320,7 +336,7 @@ async def test_target_with_custom_interval_gets_own_loop(self) -> None: obs.set_heartbeat_handler(handler) obs._start_target_loops() - assert (-1001, None) in obs._target_tasks + assert ("tg", -1001, None) in obs._target_tasks with time_machine.travel(datetime(2026, 1, 15, 14, 0, tzinfo=UTC)): await obs._tick() diff --git a/tests/heartbeat/test_observer.py b/tests/heartbeat/test_observer.py index abfba26d..5a5864eb 100644 --- a/tests/heartbeat/test_observer.py +++ b/tests/heartbeat/test_observer.py @@ -130,8 +130,8 @@ async def test_tick_calls_handler_for_each_user(self) -> None: await obs._tick() assert handler.call_count == 2 - handler.assert_any_await(100, None, None, None) - handler.assert_any_await(200, None, None, None) + handler.assert_any_await(100, None, None, None, "tg") + handler.assert_any_await(200, None, None, None, "tg") async def test_tick_skips_busy_chat(self) -> None: config = _make_config() @@ -143,7 +143,7 @@ async def test_tick_skips_busy_chat(self) -> None: with time_machine.travel(datetime(2026, 1, 15, 14, 0, tzinfo=UTC)): await obs._tick() - handler.assert_awaited_once_with(200, None, None, None) + handler.assert_awaited_once_with(200, None, None, None, "tg") async def test_tick_delivers_alert(self) -> None: config = _make_config() @@ -156,8 +156,8 @@ async def test_tick_delivers_alert(self) -> None: await obs._tick() assert result_handler.call_count == 2 - result_handler.assert_any_await(100, "Hey, check this out!", None) - result_handler.assert_any_await(200, "Hey, check this out!", None) + result_handler.assert_any_await(100, "Hey, check this out!", None, "tg") + result_handler.assert_any_await(200, "Hey, check this out!", None, "tg") async def test_tick_suppresses_none_result(self) -> None: config = _make_config() @@ -255,10 +255,10 @@ async def test_tick_iterates_group_targets(self) -> None: await obs._tick() assert handler.call_count == 3 - handler.assert_any_await(100, None, None, None) + handler.assert_any_await(100, None, None, None, "tg") # Group targets get resolved prompt/ack from global config - handler.assert_any_await(-1001, 42, config.heartbeat.prompt, config.heartbeat.ack_token) - handler.assert_any_await(-1002, None, config.heartbeat.prompt, config.heartbeat.ack_token) + handler.assert_any_await(-1001, 42, config.heartbeat.prompt, config.heartbeat.ack_token, "tg") + handler.assert_any_await(-1002, None, config.heartbeat.prompt, config.heartbeat.ack_token, "tg") async def test_tick_group_target_delivers_alert_with_topic_id(self) -> None: config = AgentConfig( @@ -274,7 +274,7 @@ async def test_tick_group_target_delivers_alert_with_topic_id(self) -> None: with time_machine.travel(datetime(2026, 1, 15, 14, 0, tzinfo=UTC)): await obs._tick() - result_handler.assert_awaited_once_with(-1001, "group alert", 7) + result_handler.assert_awaited_once_with(-1001, "group alert", 7, "tg") async def test_default_group_targets_with_null_chat_id_are_skipped(self) -> None: config = _make_config() @@ -297,7 +297,7 @@ async def test_topic_id_flows_through_run_for_chat(self) -> None: await obs._run_for_chat(-1001, topic_id=42) - result_handler.assert_awaited_once_with(-1001, "alert", 42) + result_handler.assert_awaited_once_with(-1001, "alert", 42, "tg") async def test_run_for_chat_passes_prompt_ack_to_handler(self) -> None: config = _make_config() @@ -307,4 +307,4 @@ async def test_run_for_chat_passes_prompt_ack_to_handler(self) -> None: await obs._run_for_chat(-1001, prompt="Custom", ack_token="OK") - handler.assert_awaited_once_with(-1001, None, "Custom", "OK") + handler.assert_awaited_once_with(-1001, None, "Custom", "OK", "tg") diff --git a/tests/messenger/slack/__init__.py b/tests/messenger/slack/__init__.py new file mode 100644 index 00000000..db0930ef --- /dev/null +++ b/tests/messenger/slack/__init__.py @@ -0,0 +1 @@ +"""Slack messenger tests.""" diff --git a/tests/messenger/slack/test_bot.py b/tests/messenger/slack/test_bot.py new file mode 100644 index 00000000..a06df0ea --- /dev/null +++ b/tests/messenger/slack/test_bot.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from ductor_bot.cli.stream_events import ToolUseEvent +from ductor_bot.config import AgentConfig +from ductor_bot.messenger.slack.bot import SlackBot, _ThreadContextCache +from ductor_bot.orchestrator.registry import OrchestratorResult +from ductor_bot.session.manager import SessionData + + +def _make_bot() -> SlackBot: + bot = object.__new__(SlackBot) + bot._config = AgentConfig( + transport="slack", + slack={ + "bot_token": "xoxb-test", + "app_token": "xapp-test", + "allowed_channels": ["C123"], + "allowed_users": ["U123"], + }, + group_mention_only=True, + ) + bot._agent_name = "main" + bot._app = SimpleNamespace(client=AsyncMock()) + bot._lock_pool = MagicMock() + bot._bus = MagicMock() + bot._id_map = MagicMock() + bot._id_map.channel_to_int.return_value = 11 + bot._id_map.thread_to_int.return_value = 22 + bot._orchestrator = MagicMock() + bot._orchestrator._sessions.list_active_for_chat = AsyncMock(return_value=[]) + bot._startup_hooks = [] + bot._bot_user_id = "B123" + bot._bot_name = "ductor" + bot._team_id = "T123" + bot._last_active_channel = None + bot._mentioned_threads = {} + bot._user_name_cache = {} + bot._thread_context_cache = {} + bot._MENTIONED_THREAD_TTL = 3600.0 + bot._MENTIONED_THREAD_MAX_SIZE = 200 + bot._THREAD_CACHE_TTL = 60.0 + bot._THREAD_CONTEXT_CACHE_MAX_SIZE = 200 + bot._dispatch_with_lock = AsyncMock() + bot._handle_command = AsyncMock() + bot._send_rich = AsyncMock() + return bot + + +class TestThreadSessionLookup: + async def test_detects_active_thread_session(self) -> None: + bot = _make_bot() + active = SessionData(chat_id=11, transport="sl", topic_id=22, provider_sessions={}) + active.session_id = "sid-1" + bot._orchestrator._sessions.list_active_for_chat.return_value = [active] + + result = await bot._has_active_session_for_thread("C123", "1710000000.123") + + assert result is True + + +class TestMentionedThreadCache: + def test_prunes_expired_entries(self) -> None: + bot = _make_bot() + now = time.monotonic() + bot._MENTIONED_THREAD_TTL = 10.0 + bot._mentioned_threads = { + ("C123", "old"): now - 20.0, + ("C123", "fresh"): now - 1.0, + } + + with patch("ductor_bot.messenger.slack.bot.time") as mock_time: + mock_time.monotonic.return_value = now + bot._mark_mentioned_thread("C123", "new") + + assert ("C123", "old") not in bot._mentioned_threads + assert ("C123", "fresh") in bot._mentioned_threads + assert ("C123", "new") in bot._mentioned_threads + + def test_enforces_max_size(self) -> None: + bot = _make_bot() + bot._MENTIONED_THREAD_MAX_SIZE = 2 + now = time.monotonic() + + with patch("ductor_bot.messenger.slack.bot.time") as mock_time: + mock_time.monotonic.return_value = now + bot._mark_mentioned_thread("C123", "one") + mock_time.monotonic.return_value = now + 1.0 + bot._mark_mentioned_thread("C123", "two") + mock_time.monotonic.return_value = now + 2.0 + bot._mark_mentioned_thread("C123", "three") + + assert list(bot._mentioned_threads) == [("C123", "two"), ("C123", "three")] + + +class TestThreadContextFetching: + async def test_fetches_prior_thread_messages_once(self) -> None: + bot = _make_bot() + bot._app.client.conversations_replies.return_value = { + "messages": [ + {"ts": "1710000000.100", "user": "U111", "text": "First context"}, + {"ts": "1710000000.123", "user": "U222", "text": "<@B123> Parent message"}, + {"ts": "1710000000.200", "bot_id": "BOT", "text": "Bot output"}, + {"ts": "1710000000.300", "user": "U333", "text": "Current message"}, + {"ts": "1710000000.301", "user": "U444", "text": "Future message"}, + ] + } + bot._resolve_user_name = AsyncMock(side_effect=["Alice", "Bob"]) + + content = await bot._fetch_thread_context( + channel_id="C123", + thread_ts="1710000000.123", + current_ts="1710000000.300", + ) + + assert "Alice: First context" in content + assert "[thread parent] Bob: Parent message" in content + assert "Current message" not in content + assert "Future message" not in content + assert "Bot output" not in content + + again = await bot._fetch_thread_context( + channel_id="C123", + thread_ts="1710000000.123", + current_ts="1710000000.300", + ) + assert again == content + bot._app.client.conversations_replies.assert_awaited_once() + + def test_prunes_expired_cached_context_entries(self) -> None: + bot = _make_bot() + now = time.monotonic() + bot._THREAD_CACHE_TTL = 10.0 + bot._thread_context_cache = { + "expired": _ThreadContextCache(content="old", fetched_at=now - 20.0), + "fresh": _ThreadContextCache(content="keep", fetched_at=now - 1.0), + } + + bot._cache_thread_context(cache_key="new", content_parts=["Alice: hi"], fetched_at=now) + + assert "expired" not in bot._thread_context_cache + assert "fresh" in bot._thread_context_cache + assert "new" in bot._thread_context_cache + + def test_enforces_thread_context_cache_max_size(self) -> None: + bot = _make_bot() + bot._THREAD_CONTEXT_CACHE_MAX_SIZE = 2 + now = time.monotonic() + + bot._cache_thread_context(cache_key="one", content_parts=["a"], fetched_at=now) + bot._cache_thread_context(cache_key="two", content_parts=["b"], fetched_at=now + 1.0) + bot._cache_thread_context(cache_key="three", content_parts=["c"], fetched_at=now + 2.0) + + assert list(bot._thread_context_cache) == ["two", "three"] + + +class TestCommandPresentation: + async def test_info_uses_slack_i18n_description(self) -> None: + bot = _make_bot() + + await bot._cmd_info(text="/info", channel_id="C123", key=MagicMock(), thread_ts=None) + + sent_text = bot._send_rich.await_args.args[1] + assert "Slack" in sent_text + assert "Matrix" not in sent_text + + def test_help_uses_slack_footer(self) -> None: + bot = _make_bot() + + help_text = bot._build_help_text() + + assert "`help` or `/help`" in help_text + assert "`!`" not in help_text + + +class TestMessageRouting: + async def test_app_mention_event_routes_like_message(self) -> None: + bot = _make_bot() + bot._on_message = AsyncMock() + + event = { + "user": "U123", + "channel": "C123", + "channel_type": "channel", + "ts": "1710000000.456", + "text": "<@B123> status", + } + + await bot._handle_mention_event(event, object()) + + bot._on_message.assert_awaited_once_with(event) + + async def test_backfills_first_thread_reply_after_mention(self) -> None: + bot = _make_bot() + bot._fetch_thread_context = AsyncMock(return_value="[ctx]\n") + + await bot._on_message( + { + "user": "U123", + "channel": "C123", + "channel_type": "channel", + "thread_ts": "1710000000.123", + "ts": "1710000000.456", + "text": "<@B123> help here", + } + ) + + bot._dispatch_with_lock.assert_awaited_once() + assert bot._dispatch_with_lock.await_args.args[1] == "[ctx]\nhelp here" + assert ("C123", "1710000000.123") in bot._mentioned_threads + + async def test_existing_thread_session_routes_without_mention(self) -> None: + bot = _make_bot() + active = SessionData(chat_id=11, transport="sl", topic_id=22, provider_sessions={}) + active.session_id = "sid-1" + bot._orchestrator._sessions.list_active_for_chat.return_value = [active] + bot._fetch_thread_context = AsyncMock() + + await bot._on_message( + { + "user": "U123", + "channel": "C123", + "channel_type": "channel", + "thread_ts": "1710000000.123", + "ts": "1710000000.456", + "text": "follow-up without mention", + } + ) + + bot._dispatch_with_lock.assert_awaited_once() + bot._fetch_thread_context.assert_not_awaited() + + async def test_routes_bare_message_command_without_leading_slash(self) -> None: + bot = _make_bot() + + await bot._on_message( + { + "user": "U123", + "channel": "C123", + "channel_type": "im", + "ts": "1710000000.456", + "text": "status", + } + ) + + bot._handle_command.assert_awaited_once() + assert bot._handle_command.await_args.args[0] == "/status" + bot._dispatch_with_lock.assert_not_awaited() + + async def test_routes_bare_message_command_with_arguments_when_supported(self) -> None: + bot = _make_bot() + + await bot._on_message( + { + "user": "U123", + "channel": "C123", + "channel_type": "im", + "ts": "1710000000.456", + "text": "model gpt-5.4", + } + ) + + bot._handle_command.assert_awaited_once() + assert bot._handle_command.await_args.args[0] == "/model gpt-5.4" + + async def test_non_command_text_with_extra_words_stays_a_message(self) -> None: + bot = _make_bot() + + await bot._on_message( + { + "user": "U123", + "channel": "C123", + "channel_type": "im", + "ts": "1710000000.456", + "text": "help me debug this", + } + ) + + bot._handle_command.assert_not_awaited() + bot._dispatch_with_lock.assert_awaited_once() + + async def test_routes_stream_context_for_top_level_messages(self) -> None: + bot = _make_bot() + + await bot._on_message( + { + "user": "U123", + "channel": "C123", + "channel_type": "channel", + "ts": "1710000000.456", + "text": "<@B123> hello", + } + ) + + bot._dispatch_with_lock.assert_awaited_once() + assert bot._dispatch_with_lock.await_args.kwargs == { + "stream_thread_ts": "1710000000.456", + "recipient_user_id": "U123", + } + + async def test_run_streaming_updates_single_slack_message(self) -> None: + bot = _make_bot() + bot._config.streaming.edit_interval_seconds = 0.0 + streamer = MagicMock() + streamer.append = AsyncMock() + streamer.stop = AsyncMock() + bot._app.client.chat_stream = AsyncMock(return_value=streamer) + + async def _fake_stream( + key: object, + text: str, + *, + on_text_delta: object = None, + on_thinking_delta: object = None, + on_tool_activity: object = None, + on_system_status: object = None, + ) -> OrchestratorResult: + assert key is not None + assert text == "hello" + assert on_thinking_delta is not None + assert on_text_delta is not None + await on_thinking_delta("step 1") + await on_tool_activity( + ToolUseEvent( + type="assistant", + tool_name="ToolSearch", + parameters={"query": "slack thinking steps ai agents"}, + ) + ) + await on_tool_activity( + ToolUseEvent( + type="assistant", + tool_name="WebFetch", + parameters={"url": "https://slack.dev/slack-thinking-steps-ai-agents/"}, + ) + ) + await on_text_delta("final") + await on_system_status(None) + return OrchestratorResult(text="final") + + bot._orchestrator.handle_message_streaming = _fake_stream + + await bot._run_streaming( + MagicMock(), + "hello", + "C123", + "1710000000.123", + recipient_user_id="U123", + ) + + bot._app.client.chat_stream.assert_awaited_once_with( + channel="C123", + thread_ts="1710000000.123", + recipient_team_id="T123", + recipient_user_id="U123", + task_display_mode="plan", + buffer_size=64, + ) + assert any( + "💭 *Thinking*" in call.kwargs.get("markdown_text", "") + for call in streamer.append.await_args_list + ) + chunk_batches = [ + call.kwargs["chunks"] + for call in streamer.append.await_args_list + if call.kwargs.get("chunks") + ] + assert chunk_batches[0][0] == {"type": "plan_update", "title": "Working on your request"} + assert [chunk["id"] for chunk in chunk_batches[0][1:]] == ["analyze", "tools", "respond"] + assert chunk_batches[0][1]["status"] == "in_progress" + assert chunk_batches[0][2]["status"] == "pending" + assert chunk_batches[0][3]["status"] == "pending" + assert chunk_batches[1] == [ + { + "type": "task_update", + "id": "analyze", + "title": "Understand request", + "status": "complete", + "details": "step 1", + }, + { + "type": "task_update", + "id": "tools", + "title": "Use tools if needed", + "status": "in_progress", + "details": "- Search: slack thinking steps ai agents", + }, + ] + assert chunk_batches[2] == [ + { + "type": "task_update", + "id": "tools", + "title": "Use tools if needed", + "status": "in_progress", + "details": ( + "- Search: slack thinking steps ai agents\n" + "- Web fetch: slack.dev/slack-thinking-steps-ai-agents" + ), + } + ] + assert chunk_batches[3] == [ + { + "type": "task_update", + "id": "tools", + "title": "Use tools if needed", + "status": "complete", + "details": ( + "- Search: slack thinking steps ai agents\n" + "- Web fetch: slack.dev/slack-thinking-steps-ai-agents" + ), + }, + { + "type": "task_update", + "id": "respond", + "title": "Draft response", + "status": "in_progress", + }, + ] + streamer.stop.assert_awaited_once() + assert streamer.stop.await_args.kwargs["chunks"] == [ + { + "type": "task_update", + "id": "respond", + "title": "Draft response", + "status": "complete", + } + ] + + async def test_run_streaming_in_dm_omits_recipient_context(self) -> None: + bot = _make_bot() + streamer = MagicMock() + streamer.append = AsyncMock() + streamer.stop = AsyncMock() + bot._app.client.chat_stream = AsyncMock(return_value=streamer) + bot._orchestrator.handle_message_streaming = AsyncMock( + return_value=OrchestratorResult(text="ok") + ) + + await bot._run_streaming( + MagicMock(), + "hello", + "D123", + "1710000000.123", + recipient_user_id="U123", + ) + + bot._app.client.chat_stream.assert_awaited_once_with( + channel="D123", + thread_ts="1710000000.123", + task_display_mode="plan", + buffer_size=64, + ) + + async def test_run_streaming_falls_back_when_native_streaming_fails(self) -> None: + bot = _make_bot() + streamer = MagicMock() + streamer.append = AsyncMock(side_effect=RuntimeError("boom")) + streamer.stop = AsyncMock() + bot._app.client.chat_stream = AsyncMock(return_value=streamer) + + async def _fake_stream( + key: object, + text: str, + *, + on_text_delta: object = None, + on_thinking_delta: object = None, + on_tool_activity: object = None, + on_system_status: object = None, + ) -> OrchestratorResult: + assert key is not None + assert text == "hello" + assert on_thinking_delta is not None + assert on_text_delta is not None + await on_thinking_delta("step 1") + await on_tool_activity( + ToolUseEvent(type="assistant", tool_name="bash", parameters={"cmd": "pwd"}) + ) + await on_text_delta("final") + await on_system_status(None) + return OrchestratorResult(text="final") + + bot._orchestrator.handle_message_streaming = _fake_stream + + with patch( + "ductor_bot.messenger.slack.streaming.send_rich", + new_callable=AsyncMock, + ) as mock_send_rich: + await bot._run_streaming( + MagicMock(), + "hello", + "C123", + "1710000000.123", + recipient_user_id="U123", + ) + + mock_send_rich.assert_awaited_once() + sent_text = mock_send_rich.await_args.args[2] + assert "💭 *Thinking*" in sent_text + assert "final" in sent_text + streamer.stop.assert_not_awaited() diff --git a/tests/messenger/slack/test_id_map.py b/tests/messenger/slack/test_id_map.py new file mode 100644 index 00000000..cb7385ba --- /dev/null +++ b/tests/messenger/slack/test_id_map.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from ductor_bot.messenger.slack.id_map import SlackIdMap + + +def test_channel_and_thread_mapping_round_trip(tmp_path) -> None: + id_map = SlackIdMap(tmp_path) + + chat_id = id_map.channel_to_int("C123") + topic_id = id_map.thread_to_int("C123", "1710000000.123") + + assert id_map.int_to_channel(chat_id) == "C123" + assert id_map.int_to_thread(topic_id) == ("C123", "1710000000.123") diff --git a/tests/messenger/slack/test_sender.py b/tests/messenger/slack/test_sender.py new file mode 100644 index 00000000..1ef92079 --- /dev/null +++ b/tests/messenger/slack/test_sender.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock + +from ductor_bot.messenger.slack.sender import SlackSendOpts, _split_text, send_rich + + +class TestSendRich: + async def test_sends_text_message(self) -> None: + client = AsyncMock() + client.chat_postMessage.return_value = {"ts": "1.0"} + + result = await send_rich(client, "C123", "Hello **world**") + + assert result == "1.0" + client.chat_postMessage.assert_awaited_once() + assert client.chat_postMessage.call_args.kwargs["channel"] == "C123" + assert client.chat_postMessage.call_args.kwargs["text"] == "Hello *world*" + + async def test_sends_tagged_file(self, tmp_path: Path) -> None: + client = AsyncMock() + client.chat_postMessage.return_value = {"ts": "1.0"} + client.files_upload_v2.return_value = {"ok": True} + file_path = tmp_path / "out.txt" + file_path.write_text("hello", encoding="utf-8") + + await send_rich( + client, + "C123", + f"See this ", + SlackSendOpts(allowed_roots=[tmp_path]), + ) + + client.files_upload_v2.assert_awaited_once() + assert client.files_upload_v2.call_args.kwargs["channel"] == "C123" + + +def test_split_text_splits_long_messages() -> None: + chunks = _split_text("x" * 40_500) + assert len(chunks) == 2 diff --git a/tests/messenger/slack/test_transport.py b/tests/messenger/slack/test_transport.py new file mode 100644 index 00000000..a5063cf3 --- /dev/null +++ b/tests/messenger/slack/test_transport.py @@ -0,0 +1,82 @@ +"""Tests for SlackTransport delivery handlers.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from ductor_bot.bus.envelope import Envelope, Origin +from ductor_bot.messenger.slack.transport import SlackTransport + + +def _make_transport() -> tuple[SlackTransport, MagicMock]: + bot = MagicMock() + bot.client = MagicMock() + bot.orchestrator = MagicMock() + bot.orchestrator.paths = MagicMock() + bot.file_roots.return_value = [Path("/tmp/roots")] + bot.broadcast = AsyncMock() + bot.id_map.int_to_channel.return_value = "C123" + bot.id_map.int_to_thread.return_value = ("C123", "1710000000.123") + transport = SlackTransport(bot) + return transport, bot + + +def _env(**kwargs: object) -> Envelope: + defaults: dict[str, object] = {"origin": Origin.CRON, "chat_id": 42} + defaults.update(kwargs) + return Envelope(**defaults) # type: ignore[arg-type] + + +class TestTransportName: + def test_slack_transport_name(self) -> None: + transport, _bot = _make_transport() + assert transport.transport_name == "sl" + + +class TestCronBroadcast: + async def test_broadcasts_with_result_text(self) -> None: + transport, bot = _make_transport() + env = _env(origin=Origin.CRON, result_text="All good", status="success", metadata={"title": "Backup"}) + + await transport.deliver_broadcast(env) + + bot.broadcast.assert_awaited_once() + text = bot.broadcast.call_args[0][0] + assert "**TASK: Backup**" in text + assert "All good" in text + + +class TestTaskQuestionDelivery: + async def test_delivers_task_question_into_thread(self) -> None: + transport, _bot = _make_transport() + env = _env(origin=Origin.TASK_QUESTION, prompt="Need approval", metadata={"task_id": "t1"}, topic_id=9) + + with patch("ductor_bot.messenger.slack.transport.slack_send_rich", new_callable=AsyncMock) as mock_send: + await transport.deliver(env) + + mock_send.assert_awaited_once() + assert mock_send.call_args.args[1] == "C123" + assert "Task `t1` has a question" in mock_send.call_args.args[2] + + +class TestBackgroundDelivery: + async def test_delivers_background_result_into_thread(self) -> None: + transport, _bot = _make_transport() + env = _env( + origin=Origin.BACKGROUND, + result_text="Done", + status="success", + elapsed_seconds=2.0, + topic_id=9, + metadata={"task_id": "bg1"}, + ) + + with patch( + "ductor_bot.messenger.slack.transport.slack_send_rich", new_callable=AsyncMock + ) as mock_send: + await transport.deliver(env) + + mock_send.assert_awaited_once() + opts = mock_send.call_args.args[3] + assert opts.thread_ts == "1710000000.123" diff --git a/tests/messenger/test_registry.py b/tests/messenger/test_registry.py index 3bc99ebf..bb349145 100644 --- a/tests/messenger/test_registry.py +++ b/tests/messenger/test_registry.py @@ -34,3 +34,12 @@ def test_matrix_transport(self) -> None: with patch("ductor_bot.messenger.matrix.bot.MatrixBot", return_value=fake_bot): bot = create_bot(config, agent_name="test") assert bot is fake_bot + + def test_slack_transport(self) -> None: + config = MagicMock() + config.transport = "slack" + config.is_multi_transport = False + fake_bot = MagicMock() + with patch("ductor_bot.messenger.slack.bot.SlackBot", return_value=fake_bot): + bot = create_bot(config, agent_name="test") + assert bot is fake_bot diff --git a/tests/orchestrator/test_core.py b/tests/orchestrator/test_core.py index 3001b867..c5b43401 100644 --- a/tests/orchestrator/test_core.py +++ b/tests/orchestrator/test_core.py @@ -11,7 +11,7 @@ from ductor_bot.cli.types import AgentResponse from ductor_bot.config import AgentConfig from ductor_bot.errors import CLIError, CronError, SessionError, StreamError, WorkspaceError -from ductor_bot.orchestrator.core import Orchestrator +from ductor_bot.orchestrator.core import NamedSessionRequest, Orchestrator from ductor_bot.session.key import SessionKey from ductor_bot.workspace.paths import DuctorPaths @@ -458,6 +458,55 @@ async def test_handle_heartbeat_returns_none_on_ack(orch: Orchestrator) -> None: assert result is None +async def test_submit_named_session_persists_transport_and_topic(orch: Orchestrator) -> None: + orch._observers.background = MagicMock() + orch._observers.background.submit.return_value = "task-1" + + with patch( + "ductor_bot.cli.param_resolver.resolve_cli_config", + new=MagicMock(return_value=MagicMock()), + ): + task_id, session_name = orch.submit_named_session( + 42, + "hello", + NamedSessionRequest(message_id=7, thread_id=99, transport="sl"), + ) + + assert task_id == "task-1" + session = orch.named_sessions.get(42, session_name) + assert session is not None + assert session.transport == "sl" + assert session.topic_id == 99 + sub = orch._observers.background.submit.call_args.args[0] + assert sub.transport == "sl" + assert sub.thread_id == 99 + + +async def test_submit_named_followup_bg_reuses_saved_transport_and_topic(orch: Orchestrator) -> None: + orch._observers.background = MagicMock() + orch._observers.background.submit.return_value = "task-2" + session = orch.named_sessions.create( + 42, + "claude", + "opus", + "hello", + key=SessionKey.for_transport("sl", 42, 88), + ) + session.status = "idle" + session.session_id = "sid-1" + + with patch( + "ductor_bot.cli.param_resolver.resolve_cli_config", + new=MagicMock(return_value=MagicMock()), + ): + task_id = orch.submit_named_followup_bg(42, session.name, "follow up", message_id=7, thread_id=None) + + assert task_id == "task-2" + sub = orch._observers.background.submit.call_args.args[0] + assert sub.transport == "sl" + assert sub.thread_id == 88 + + # --------------------------------------------------------------------------- # wire_observers_to_bus # --------------------------------------------------------------------------- diff --git a/tests/session/test_named_recovery.py b/tests/session/test_named_recovery.py index 4d98f7e9..f1f927c3 100644 --- a/tests/session/test_named_recovery.py +++ b/tests/session/test_named_recovery.py @@ -5,6 +5,7 @@ import time from pathlib import Path +from ductor_bot.session.key import SessionKey from ductor_bot.session.named import NamedSessionRegistry @@ -15,17 +16,27 @@ def _make_registry(tmp_path: Path) -> NamedSessionRegistry: class TestLastPrompt: def test_created_session_has_empty_last_prompt(self, tmp_path: Path) -> None: reg = _make_registry(tmp_path) - ns = reg.create(chat_id=1, provider="claude", model="opus", prompt_preview="hello") + ns = reg.create( + chat_id=1, + provider="claude", + model="opus", + prompt_preview="hello", + key=SessionKey.for_transport("sl", 1, 77), + ) assert ns.last_prompt == "" + assert ns.transport == "sl" + assert ns.topic_id == 77 def test_mark_running_stores_prompt(self, tmp_path: Path) -> None: reg = _make_registry(tmp_path) ns = reg.create(chat_id=1, provider="claude", model="opus", prompt_preview="hello") - reg.mark_running(1, ns.name, "full prompt text here") + reg.mark_running(1, ns.name, "full prompt text here", transport="sl", topic_id=42) updated = reg.get(1, ns.name) assert updated is not None assert updated.status == "running" assert updated.last_prompt == "full prompt text here" + assert updated.transport == "sl" + assert updated.topic_id == 42 def test_mark_running_truncates_at_4000(self, tmp_path: Path) -> None: reg = _make_registry(tmp_path) @@ -49,6 +60,8 @@ def _persist_running_session( name: str = "boldowl", chat_id: int = 42, last_prompt: str = "do stuff", + topic_id: int | None = 77, + transport: str = "sl", ) -> Path: """Write a JSON file with a running session for reload testing.""" import json @@ -59,6 +72,7 @@ def _persist_running_session( { "name": name, "chat_id": chat_id, + "topic_id": topic_id, "provider": "claude", "model": "opus", "session_id": "sid-123", @@ -67,6 +81,7 @@ def _persist_running_session( "created_at": time.time(), "message_count": 3, "last_prompt": last_prompt, + "transport": transport, }, ], } @@ -88,6 +103,8 @@ def test_recovered_running_populated(self, tmp_path: Path) -> None: assert recovered[0].name == "boldowl" assert recovered[0].status == "idle" assert recovered[0].last_prompt == "do stuff" + assert recovered[0].transport == "sl" + assert recovered[0].topic_id == 77 def test_pop_clears_recovered(self, tmp_path: Path) -> None: path = self._persist_running_session(tmp_path) @@ -103,6 +120,12 @@ def test_pop_filtered_by_chat_id(self, tmp_path: Path) -> None: assert len(reg.pop_recovered_running(chat_id=99)) == 0 assert len(reg.pop_recovered_running(chat_id=42)) == 1 + def test_pop_filtered_by_transport(self, tmp_path: Path) -> None: + path = self._persist_running_session(tmp_path, transport="sl") + reg = NamedSessionRegistry(path) + assert len(reg.pop_recovered_running(transport="tg")) == 0 + assert len(reg.pop_recovered_running(transport="sl")) == 1 + def test_ia_sessions_excluded(self, tmp_path: Path) -> None: path = self._persist_running_session(tmp_path, name="ia-sub1") reg = NamedSessionRegistry(path) diff --git a/tests/test_config.py b/tests/test_config.py index fbb1b784..f616f1cb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -153,6 +153,13 @@ def test_transport_matrix_backward_compat() -> None: assert cfg.transport == "matrix" +def test_transport_slack_backward_compat() -> None: + """transport='slack' with empty transports normalizes correctly.""" + cfg = AgentConfig(transport="slack") + assert cfg.transports == ["slack"] + assert cfg.transport == "slack" + + def test_transports_multi_sets_primary_transport() -> None: """Explicit multi-transport sets ``transport`` to first entry.""" cfg = AgentConfig(transports=["telegram", "matrix"]) diff --git a/tests/test_main.py b/tests/test_main.py index dc0c88d8..289b00ba 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -241,6 +241,51 @@ def test_unconfigured_with_corrupt_json(self, tmp_path: Path) -> None: with patch("ductor_bot.__main__.resolve_paths", return_value=paths): assert _is_configured() is False + def test_configured_with_slack(self, tmp_path: Path) -> None: + from ductor_bot.__main__ import _is_configured + + paths = _make_paths(tmp_path) + _write_config( + paths, + { + "transport": "slack", + "transports": ["slack"], + "slack": { + "bot_token": "xoxb-token", + "app_token": "xapp-token", + "allowed_users": ["U0123456789"], + "allowed_channels": [], + }, + }, + ) + with patch("ductor_bot.__main__.resolve_paths", return_value=paths): + assert _is_configured() is True + + def test_configured_with_stale_transports_is_normalized(self, tmp_path: Path) -> None: + from ductor_bot.__main__ import _is_configured, load_config + + paths = _make_paths(tmp_path) + _write_config( + paths, + { + "transport": "slack", + "transports": ["telegram"], + "telegram_token": "", + "allowed_user_ids": [], + "slack": { + "bot_token": "xoxb-token", + "app_token": "xapp-token", + "allowed_users": ["U0123456789"], + "allowed_channels": [], + }, + }, + ) + with patch("ductor_bot.__main__.resolve_paths", return_value=paths): + assert _is_configured() is True + config = load_config() + assert config.transport == "slack" + assert config.transports == ["slack"] + class TestIsConfiguredMultiTransport: def test_configured_multi_transport_both_valid(self, tmp_path: Path) -> None: diff --git a/tests/workspace/test_init.py b/tests/workspace/test_init.py index 5a357be8..50ae0446 100644 --- a/tests/workspace/test_init.py +++ b/tests/workspace/test_init.py @@ -412,6 +412,15 @@ def test_inject_host_notice(tmp_path: Path) -> None: assert "NO SANDBOX" in content +def test_inject_slack_rules_describe_message_commands(tmp_path: Path) -> None: + paths = _make_paths(tmp_path) + init_workspace(paths) + inject_runtime_environment(paths, docker_container="", transport="slack") + content = (paths.workspace / "CLAUDE.md").read_text() + assert "`help` or `/help`" in content + assert "Prefer `/` commands" not in content + + def test_inject_no_duplicate(tmp_path: Path) -> None: """Calling inject twice does not duplicate the section.""" paths = _make_paths(tmp_path)