diff --git a/Cargo.lock b/Cargo.lock index 7c98b754..11a00e04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -128,12 +178,58 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -493,15 +589,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.37", - "rustls-pki-types", + "rustls 0.23.38", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -696,6 +791,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -851,16 +952,25 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openab" -version = "0.6.6" +version = "0.7.4" dependencies = [ "anyhow", "base64", + "clap", "image", + "libc", "rand 0.8.5", "regex", "reqwest", + "rpassword", "serde", "serde_json", "serenity", @@ -868,6 +978,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "unicode-width", "uuid", ] @@ -986,7 +1097,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.37", + "rustls 0.23.38", "socket2", "thiserror 2.0.18", "tokio", @@ -1003,10 +1114,10 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", "rustc-hash", - "rustls 0.23.37", + "rustls 0.23.38", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -1063,9 +1174,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -1155,6 +1266,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -1169,7 +1281,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.37", + "rustls 0.23.38", "rustls-pki-types", "serde", "serde_json", @@ -1203,6 +1315,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327b72899159dfae8060c51a1f6aebe955245bcd9cc4997eed0f623caea022e4" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1225,9 +1358,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "once_cell", "ring", @@ -1477,6 +1610,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1664,7 +1803,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.37", + "rustls 0.23.38", "tokio", ] @@ -1909,6 +2048,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1946,6 +2091,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.0" @@ -2163,6 +2314,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 3b3b1514..33c1dac0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openab" -version = "0.7.2" +version = "0.7.4" edition = "2021" [dependencies] @@ -15,6 +15,10 @@ uuid = { version = "1", features = ["v4"] } regex = "1" anyhow = "1" rand = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json"] } +clap = { version = "4", features = ["derive"] } +rpassword = "7" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "multipart", "json", "blocking"] } base64 = "0.22" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +unicode-width = "0.2" +libc = "0.2" diff --git a/Dockerfile b/Dockerfile index fdb14e2a..600b4680 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,10 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip && rm -rf /var/lib/apt/lists/* # Install kiro-cli (auto-detect arch, copy binary directly) +ARG KIRO_CLI_VERSION=2.0.0 RUN ARCH=$(dpkg --print-architecture) && \ - if [ "$ARCH" = "arm64" ]; then URL="https://desktop-release.q.us-east-1.amazonaws.com/latest/kirocli-aarch64-linux.zip"; \ - else URL="https://desktop-release.q.us-east-1.amazonaws.com/latest/kirocli-x86_64-linux.zip"; fi && \ + if [ "$ARCH" = "arm64" ]; then URL="https://prod.download.cli.kiro.dev/stable/${KIRO_CLI_VERSION}/kirocli-aarch64-linux.zip"; \ + else URL="https://prod.download.cli.kiro.dev/stable/${KIRO_CLI_VERSION}/kirocli-x86_64-linux.zip"; fi && \ curl --proto '=https' --tlsv1.2 -sSf --retry 3 --retry-delay 5 "$URL" -o /tmp/kirocli.zip && \ unzip /tmp/kirocli.zip -d /tmp && \ cp /tmp/kirocli/bin/* /usr/local/bin/ && \ diff --git a/Dockerfile.claude b/Dockerfile.claude index 2c8b90ab..da12d8bb 100644 --- a/Dockerfile.claude +++ b/Dockerfile.claude @@ -11,7 +11,8 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* # Install claude-agent-acp adapter and Claude Code CLI -RUN npm install -g @agentclientprotocol/claude-agent-acp@0.25.0 @anthropic-ai/claude-code --retry 3 +ARG CLAUDE_CODE_VERSION=2.1.107 +RUN npm install -g @agentclientprotocol/claude-agent-acp@0.25.0 @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} --retry 3 # Install gh CLI RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ diff --git a/Dockerfile.codex b/Dockerfile.codex index b7ab4921..198b8cb0 100644 --- a/Dockerfile.codex +++ b/Dockerfile.codex @@ -11,7 +11,8 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* # Pre-install codex-acp and codex CLI globally -RUN npm install -g @zed-industries/codex-acp@0.9.5 @openai/codex --retry 3 +ARG CODEX_VERSION=0.120.0 +RUN npm install -g @zed-industries/codex-acp@0.9.5 @openai/codex@${CODEX_VERSION} --retry 3 # Install gh CLI RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ diff --git a/Dockerfile.copilot b/Dockerfile.copilot index ca9bcc67..c164a429 100644 --- a/Dockerfile.copilot +++ b/Dockerfile.copilot @@ -11,7 +11,8 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* # Install GitHub Copilot CLI via npm (pinned version) -RUN npm install -g @github/copilot@1 --retry 3 +ARG COPILOT_VERSION=1.0.25 +RUN npm install -g @github/copilot@${COPILOT_VERSION} --retry 3 # Install gh CLI (for auth and token management) RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ diff --git a/Dockerfile.gemini b/Dockerfile.gemini index a5ce9201..d2230547 100644 --- a/Dockerfile.gemini +++ b/Dockerfile.gemini @@ -11,7 +11,8 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* # Install Gemini CLI (native ACP support via --acp) -RUN npm install -g @google/gemini-cli --retry 3 +ARG GEMINI_CLI_VERSION=0.37.2 +RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} --retry 3 # Install gh CLI RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index db6a694f..83157741 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: Discord ↔ ACP coding CLI bridge (Kiro CLI, Claude Code, Codex, Gemini) type: application -version: 0.7.3-beta.56 -appVersion: "920ae7e" +version: 0.7.4-beta.2 +appVersion: "0.7.4-beta.2" diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 194d8c25..cb7dce89 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -24,6 +24,23 @@ data: {{- end }} {{- end }} allowed_users = {{ $cfg.discord.allowedUsers | default list | toJson }} + {{- if $cfg.discord.allowBotMessages }} + {{- if not (has $cfg.discord.allowBotMessages (list "off" "mentions" "all")) }} + {{- fail (printf "agents.%s.discord.allowBotMessages must be one of: off, mentions, all — got: %s" $name $cfg.discord.allowBotMessages) }} + {{- end }} + allow_bot_messages = "{{ $cfg.discord.allowBotMessages }}" + {{- end }} + {{- range $cfg.discord.trustedBotIds }} + {{- if regexMatch "e\\+|E\\+" (toString .) }} + {{- fail (printf "discord.trustedBotIds contains a mangled ID: %s — use --set-string instead of --set for bot IDs" (toString .)) }} + {{- end }} + {{- if not (regexMatch "^[0-9]{17,20}$" (toString .)) }} + {{- fail (printf "discord.trustedBotIds contains an invalid bot ID: %s — must be a 17-20 digit snowflake ID" (toString .)) }} + {{- end }} + {{- end }} + {{- if $cfg.discord.trustedBotIds }} + trusted_bot_ids = {{ $cfg.discord.trustedBotIds | toJson }} + {{- end }} [agent] command = "{{ $cfg.command }}" diff --git a/charts/openab/tests/helm-template-test.sh b/charts/openab/tests/helm-template-test.sh new file mode 100755 index 00000000..d3f74f81 --- /dev/null +++ b/charts/openab/tests/helm-template-test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Helm template tests for openab chart — bot messages config +set -euo pipefail + +CHART_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PASS=0 +FAIL=0 + +pass() { ((PASS++)); echo " PASS: $1"; } +fail() { ((FAIL++)); echo " FAIL: $1"; } + +echo "=== Helm template tests: allowBotMessages & trustedBotIds ===" +echo + +# ---------- Test 1: allowBotMessages = "mentions" renders correctly ---------- +echo "[Test 1] allowBotMessages = mentions renders correctly" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.allowBotMessages=mentions' 2>&1) +if echo "$OUT" | grep -q 'allow_bot_messages = "mentions"'; then + pass "allow_bot_messages = \"mentions\" found in rendered output" +else + fail "allow_bot_messages = \"mentions\" not found in rendered output" + echo "$OUT" +fi + +# ---------- Test 2: allowBotMessages = "all" renders correctly ---------- +echo "[Test 2] allowBotMessages = all renders correctly" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.allowBotMessages=all' 2>&1) +if echo "$OUT" | grep -q 'allow_bot_messages = "all"'; then + pass "allow_bot_messages = \"all\" found in rendered output" +else + fail "allow_bot_messages = \"all\" not found in rendered output" + echo "$OUT" +fi + +# ---------- Test 3: allowBotMessages = "off" renders correctly ---------- +echo "[Test 3] allowBotMessages = off renders correctly" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.allowBotMessages=off' 2>&1) +if echo "$OUT" | grep -q 'allow_bot_messages = "off"'; then + pass "allow_bot_messages = \"off\" found in rendered output" +else + fail "allow_bot_messages = \"off\" not found in rendered output" + echo "$OUT" +fi + +# ---------- Test 4: invalid allowBotMessages value fails ---------- +echo "[Test 4] invalid allowBotMessages value is rejected" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.allowBotMessages=yolo' 2>&1) && RC=0 || RC=$? +if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q 'must be one of: off, mentions, all'; then + pass "invalid value 'yolo' rejected with correct error message" +else + fail "invalid value 'yolo' was not rejected or error message is wrong" + echo "$OUT" +fi + +# ---------- Test 5: trustedBotIds renders correctly ---------- +echo "[Test 5] trustedBotIds renders correctly" +OUT=$(helm template test "$CHART_DIR" \ + --set-string 'agents.kiro.discord.trustedBotIds[0]=123456789012345678' \ + --set-string 'agents.kiro.discord.trustedBotIds[1]=987654321098765432' \ + --set 'agents.kiro.discord.allowBotMessages=mentions' 2>&1) +if echo "$OUT" | grep -q 'trusted_bot_ids = \["123456789012345678","987654321098765432"\]'; then + pass "trustedBotIds rendered as JSON array" +else + fail "trustedBotIds not rendered correctly" + echo "$OUT" +fi + +# ---------- Test 6: mangled trustedBotId (--set not --set-string) fails ---------- +echo "[Test 6] mangled snowflake ID via --set is rejected" +OUT=$(helm template test "$CHART_DIR" \ + --set 'agents.kiro.discord.trustedBotIds[0]=1.234567890123457e+17' 2>&1) && RC=0 || RC=$? +if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q 'mangled ID'; then + pass "mangled snowflake ID rejected with correct error" +else + fail "mangled snowflake ID was not rejected" + echo "$OUT" +fi + +# ---------- Test 7: default allowBotMessages="off" does not omit the field ---------- +echo "[Test 7] default values render allow_bot_messages" +OUT=$(helm template test "$CHART_DIR" 2>&1) +if echo "$OUT" | grep -q 'allow_bot_messages = "off"'; then + pass "default allow_bot_messages = \"off\" rendered" +else + fail "default allow_bot_messages = \"off\" not found in rendered output" + echo "$OUT" +fi + +echo +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 0e4f6e7a..4cb6bacd 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -1,7 +1,7 @@ image: repository: ghcr.io/openabdev/openab # tag defaults to .Chart.AppVersion - tag: "920ae7e" + tag: "" pullPolicy: IfNotPresent podSecurityContext: @@ -29,6 +29,11 @@ agents: # allowedChannels: # - "YOUR_CHANNEL_ID" # allowedUsers: [] + # # allowBotMessages: "off" (default) | "mentions" | "all" + # # recommended for multi-agent collaboration + # allowBotMessages: "off" + # # trustedBotIds: [] # empty = any bot (mode permitting) + # trustedBotIds: [] # workingDir: /home/agent # env: {} # envFrom: [] @@ -60,6 +65,11 @@ agents: - "YOUR_CHANNEL_ID" # ⚠️ Use --set-string for user IDs to avoid float64 precision loss allowedUsers: [] # empty = allow all users (default) + # allowBotMessages: "off" (default) | "mentions" | "all" + # recommended for multi-agent collaboration + allowBotMessages: "off" + # trustedBotIds: [] # empty = any bot (mode permitting); set to restrict + trustedBotIds: [] workingDir: /home/agent env: {} envFrom: [] diff --git a/config.toml.example b/config.toml.example index 6b377e5f..60db88f8 100644 --- a/config.toml.example +++ b/config.toml.example @@ -2,6 +2,9 @@ bot_token = "${DISCORD_BOT_TOKEN}" allowed_channels = ["1234567890"] # allowed_users = [""] # empty or omitted = allow all users +# allow_bot_messages = "off" # "off" (default) | "mentions" | "all" + # "mentions" is recommended for multi-agent collaboration +# trusted_bot_ids = [] # empty = any bot (mode permitting); set to restrict [agent] command = "kiro-cli" diff --git a/docs/multi-agent.md b/docs/multi-agent.md index c28c2f30..f228c346 100644 --- a/docs/multi-agent.md +++ b/docs/multi-agent.md @@ -51,3 +51,77 @@ See individual agent docs for authentication steps: - [Claude Code](claude-code.md) - [Codex](codex.md) - [Gemini](gemini.md) + +## Bot-to-Bot Communication + +By default, each agent ignores messages from other bots. To enable multi-agent collaboration in the same channel (e.g. a code review bot handing off to a deploy bot), configure `allow_bot_messages` in each agent's `config.toml`: + +```toml +[discord] +allow_bot_messages = "mentions" # recommended +``` + +### Modes + +| Value | Behavior | Loop risk | +|---|---|---| +| `"off"` (default) | Ignore all bot messages | None | +| `"mentions"` | Only respond to bot messages that @mention this bot | Very low — bots must explicitly @mention each other | +| `"all"` | Respond to all bot messages | Mitigated by turn cap (10 consecutive bot messages) | + +### Which mode should I use? + +**`"mentions"` is recommended for most setups.** It enables collaboration while acting as a natural loop breaker — Bot A only processes Bot B's message if Bot B explicitly @mentions Bot A. Two bots won't accidentally ping-pong. + +Use `"all"` only when bots need to react to each other's messages without explicit mentions (e.g. monitoring bots). A hard cap of 10 consecutive bot-to-bot turns prevents infinite loops. + +### Example: Code Review → Deploy handoff + +``` +┌──────────────────────────────────────────────────────────┐ +│ Discord Channel #dev │ +│ │ +│ 👤 User: "Review this PR and deploy if it looks good" │ +│ │ │ +│ ▼ │ +│ 🤖 Kiro (allow_bot_messages = "off"): │ +│ "LGTM — tests pass, no security issues. │ +│ @DeployBot please deploy to staging." │ +│ │ │ +│ ▼ │ +│ 🤖 Deploy Bot (allow_bot_messages = "mentions"): │ +│ "Deploying to staging... ✅ Done." │ +└──────────────────────────────────────────────────────────┘ +``` + +Note: the review bot doesn't need `allow_bot_messages` enabled — only the bot that needs to *receive* bot messages does. + +### Helm values + +```bash +helm install openab openab/openab \ + --set agents.kiro.discord.botToken="$KIRO_BOT_TOKEN" \ + --set agents.kiro.discord.allowBotMessages="off" \ + --set agents.deploy.discord.botToken="$DEPLOY_BOT_TOKEN" \ + --set agents.deploy.discord.allowBotMessages="mentions" +``` + +### Safety + +- The bot's own messages are **always** ignored, regardless of setting +- `"mentions"` mode is a natural loop breaker — no rate limiter needed +- `"all"` mode has a hard cap of 10 consecutive bot-to-bot turns per channel +- Channel and user allowlists still apply to bot messages +- `trusted_bot_ids` further restricts which bots are allowed through + +### Restricting to specific bots + +If you only want to accept messages from specific bots (e.g. your own deploy bot), add their Discord user IDs: + +```toml +[discord] +allow_bot_messages = "mentions" +trusted_bot_ids = ["123456789012345678"] # only this bot's messages pass through +``` + +When `trusted_bot_ids` is empty (default), any bot can pass through (subject to the mode check). When set, only listed bots are accepted — all others are silently ignored. diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 83efd50d..03f55712 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -107,11 +107,14 @@ impl ContentBlock { pub struct AcpConnection { _proc: Child, + /// PID of the direct child, used as the process group ID for cleanup. + child_pgid: Option, stdin: Arc>, next_id: AtomicU64, pending: Arc>>>, notify_tx: Arc>>>, pub acp_session_id: Option, + pub supports_load_session: bool, pub last_active: Instant, pub session_reset: bool, _reader_handle: JoinHandle<()>, @@ -131,14 +134,27 @@ impl AcpConnection { .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) - .current_dir(working_dir) - .kill_on_drop(true); + .current_dir(working_dir); + // Create a new process group so we can kill the entire tree. + // SAFETY: setpgid is async-signal-safe (POSIX.1-2008) and called + // before exec. Return value checked — failure means the child won't + // have its own process group, so kill(-pgid) would be unsafe. + unsafe { + cmd.pre_exec(|| { + if libc::setpgid(0, 0) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } for (k, v) in env { cmd.env(k, expand_env(v)); } let mut proc = cmd .spawn() .map_err(|e| anyhow!("failed to spawn {command}: {e}"))?; + let child_pgid = proc.id() + .and_then(|pid| i32::try_from(pid).ok()); let stdout = proc.stdout.take().ok_or_else(|| anyhow!("no stdout"))?; let stdin = proc.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; @@ -245,11 +261,13 @@ impl AcpConnection { Ok(Self { _proc: proc, + child_pgid, stdin, next_id: AtomicU64::new(1), pending, notify_tx, acp_session_id: None, + supports_load_session: false, last_active: Instant::now(), session_reset: false, _reader_handle: reader_handle, @@ -303,12 +321,18 @@ impl AcpConnection { ) .await?; - let agent_name = resp.result.as_ref() + let result = resp.result.as_ref(); + let agent_name = result .and_then(|r| r.get("agentInfo")) .and_then(|a| a.get("name")) .and_then(|n| n.as_str()) .unwrap_or("unknown"); - info!(agent = agent_name, "initialized"); + self.supports_load_session = result + .and_then(|r| r.get("agentCapabilities")) + .and_then(|c| c.get("loadSession")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + info!(agent = agent_name, load_session = self.supports_load_session, "initialized"); Ok(()) } @@ -382,6 +406,47 @@ impl AcpConnection { pub fn alive(&self) -> bool { !self._reader_handle.is_finished() } + + /// Resume a previous session by ID. Returns Ok(()) if the agent accepted + /// the load, or an error if it failed (caller should fall back to session/new). + pub async fn session_load(&mut self, session_id: &str, cwd: &str) -> Result<()> { + let resp = self + .send_request( + "session/load", + Some(json!({"sessionId": session_id, "cwd": cwd, "mcpServers": []})), + ) + .await?; + // Accept any non-error response as success + if resp.error.is_some() { + return Err(anyhow!("session/load rejected")); + } + info!(session_id, "session loaded"); + self.acp_session_id = Some(session_id.to_string()); + Ok(()) + } + + /// Kill the entire process group: SIGTERM → SIGKILL. + /// Uses std::thread (not tokio::spawn) so SIGKILL fires even during + /// runtime shutdown or panic unwinding. + fn kill_process_group(&mut self) { + let pgid = match self.child_pgid { + Some(pid) if pid > 0 => pid, + _ => return, + }; + // Stage 1: SIGTERM the process group + unsafe { libc::kill(-pgid, libc::SIGTERM); } + // Stage 2: SIGKILL after brief grace (std::thread survives runtime shutdown) + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(1500)); + unsafe { libc::kill(-pgid, libc::SIGKILL); } + }); + } +} + +impl Drop for AcpConnection { + fn drop(&mut self) { + self.kill_process_group(); + } } #[cfg(test)] diff --git a/src/acp/pool.rs b/src/acp/pool.rs index a2c8a06c..cff159b1 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -6,8 +6,18 @@ use tokio::sync::RwLock; use tokio::time::Instant; use tracing::{info, warn}; +/// Combined state protected by a single lock to prevent deadlocks. +/// Lock ordering: always acquire `state` before any operation on either map. +struct PoolState { + /// Active connections: thread_key → AcpConnection. + active: HashMap, + /// Suspended sessions: thread_key → ACP sessionId. + /// Saved on eviction so sessions can be resumed via `session/load`. + suspended: HashMap, +} + pub struct SessionPool { - connections: RwLock>, + state: RwLock, config: AgentConfig, max_sessions: usize, } @@ -15,7 +25,10 @@ pub struct SessionPool { impl SessionPool { pub fn new(config: AgentConfig, max_sessions: usize) -> Self { Self { - connections: RwLock::new(HashMap::new()), + state: RwLock::new(PoolState { + active: HashMap::new(), + suspended: HashMap::new(), + }), config, max_sessions, } @@ -24,8 +37,8 @@ impl SessionPool { pub async fn get_or_create(&self, thread_id: &str) -> Result<()> { // Check if alive connection exists { - let conns = self.connections.read().await; - if let Some(conn) = conns.get(thread_id) { + let state = self.state.read().await; + if let Some(conn) = state.active.get(thread_id) { if conn.alive() { return Ok(()); } @@ -33,19 +46,29 @@ impl SessionPool { } // Need to create or rebuild - let mut conns = self.connections.write().await; + let mut state = self.state.write().await; // Double-check after acquiring write lock - if let Some(conn) = conns.get(thread_id) { + if let Some(conn) = state.active.get(thread_id) { if conn.alive() { return Ok(()); } warn!(thread_id, "stale connection, rebuilding"); - conns.remove(thread_id); + suspend_entry(&mut state, thread_id); } - if conns.len() >= self.max_sessions { - return Err(anyhow!("pool exhausted ({} sessions)", self.max_sessions)); + if state.active.len() >= self.max_sessions { + // LRU evict: suspend the oldest idle session to make room + let oldest = state.active + .iter() + .min_by_key(|(_, c)| c.last_active) + .map(|(k, _)| k.clone()); + if let Some(key) = oldest { + info!(evicted = %key, "pool full, suspending oldest idle session"); + suspend_entry(&mut state, &key); + } else { + return Err(anyhow!("pool exhausted ({} sessions)", self.max_sessions)); + } } let mut conn = AcpConnection::spawn( @@ -57,14 +80,32 @@ impl SessionPool { .await?; conn.initialize().await?; - conn.session_new(&self.config.working_dir).await?; - let is_rebuild = conns.contains_key(thread_id); - if is_rebuild { - conn.session_reset = true; + // Try to resume a suspended session via session/load + let saved_session_id = state.suspended.remove(thread_id); + let mut resumed = false; + if let Some(ref sid) = saved_session_id { + if conn.supports_load_session { + match conn.session_load(sid, &self.config.working_dir).await { + Ok(()) => { + info!(thread_id, session_id = %sid, "session resumed via session/load"); + resumed = true; + } + Err(e) => { + warn!(thread_id, session_id = %sid, error = %e, "session/load failed, creating new session"); + } + } + } + } + + if !resumed { + conn.session_new(&self.config.working_dir).await?; + if saved_session_id.is_some() { + conn.session_reset = true; + } } - conns.insert(thread_id.to_string(), conn); + state.active.insert(thread_id.to_string(), conn); Ok(()) } @@ -73,8 +114,8 @@ impl SessionPool { where F: FnOnce(&mut AcpConnection) -> std::pin::Pin> + Send + '_>>, { - let mut conns = self.connections.write().await; - let conn = conns + let mut state = self.state.write().await; + let conn = state.active .get_mut(thread_id) .ok_or_else(|| anyhow!("no connection for thread {thread_id}"))?; f(conn).await @@ -82,23 +123,34 @@ impl SessionPool { pub async fn cleanup_idle(&self, ttl_secs: u64) { let cutoff = Instant::now() - std::time::Duration::from_secs(ttl_secs); - let mut conns = self.connections.write().await; - let stale: Vec = conns + let mut state = self.state.write().await; + let stale: Vec = state.active .iter() .filter(|(_, c)| c.last_active < cutoff || !c.alive()) .map(|(k, _)| k.clone()) .collect(); for key in stale { info!(thread_id = %key, "cleaning up idle session"); - conns.remove(&key); - // Child process killed via kill_on_drop when AcpConnection drops + suspend_entry(&mut state, &key); } } pub async fn shutdown(&self) { - let mut conns = self.connections.write().await; - let count = conns.len(); - conns.clear(); // kill_on_drop handles process cleanup + let mut state = self.state.write().await; + let count = state.active.len(); + state.active.clear(); // Drop impl kills process groups info!(count, "pool shutdown complete"); } } + +/// Suspend a connection: save its sessionId to the suspended map and remove +/// from active. The connection is dropped, triggering process group kill. +fn suspend_entry(state: &mut PoolState, thread_id: &str) { + if let Some(conn) = state.active.remove(thread_id) { + if let Some(sid) = &conn.acp_session_id { + info!(thread_id, session_id = %sid, "suspending session"); + state.suspended.insert(thread_id.to_string(), sid.clone()); + } + // conn dropped here → Drop impl kills process group + } +} diff --git a/src/config.rs b/src/config.rs index c4ed3d30..658e00b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,34 @@ use serde::Deserialize; use std::collections::HashMap; use std::path::Path; +/// Controls whether the bot processes messages from other Discord bots. +/// +/// Inspired by Hermes Agent's `DISCORD_ALLOW_BOTS` 3-value design: +/// - `Off` (default): ignore all bot messages (safe default, no behavior change) +/// - `Mentions`: only process bot messages that @mention this bot (natural loop breaker) +/// - `All`: process all bot messages (capped at `MAX_CONSECUTIVE_BOT_TURNS`) +/// +/// The bot's own messages are always ignored regardless of this setting. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AllowBots { + #[default] + Off, + Mentions, + All, +} + +impl<'de> Deserialize<'de> for AllowBots { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "off" | "none" | "false" => Ok(Self::Off), + "mentions" => Ok(Self::Mentions), + "all" | "true" => Ok(Self::All), + other => Err(serde::de::Error::unknown_variant(other, &["off", "mentions", "all"])), + } + } +} + #[derive(Debug, Deserialize)] pub struct Config { pub discord: DiscordConfig, @@ -48,6 +76,15 @@ pub struct DiscordConfig { pub allowed_channels: Vec, #[serde(default)] pub allowed_users: Vec, + #[serde(default)] + pub allow_bot_messages: AllowBots, + /// When non-empty, only bot messages from these IDs pass the bot gate. + /// Combines with `allow_bot_messages`: the mode check runs first, then + /// the allowlist filters further. Empty = allow any bot (mode permitting). + /// Only relevant when `allow_bot_messages` is `"mentions"` or `"all"`; + /// ignored when `"off"` since all bot messages are rejected before this check. + #[serde(default)] + pub trusted_bot_ids: Vec, } #[derive(Debug, Deserialize)] @@ -117,7 +154,7 @@ pub struct ReactionTiming { fn default_working_dir() -> String { "/tmp".into() } fn default_max_sessions() -> usize { 10 } -fn default_ttl_hours() -> u64 { 24 } +fn default_ttl_hours() -> u64 { 4 } fn default_true() -> bool { true } fn emoji_queued() -> String { "👀".into() } diff --git a/src/discord.rs b/src/discord.rs index e267064e..7677bc47 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,5 +1,5 @@ use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool}; -use crate::config::{ReactionsConfig, SttConfig}; +use crate::config::{AllowBots, ReactionsConfig, SttConfig}; use crate::error_display::{format_coded_error, format_user_error}; use crate::format; use crate::reactions::StatusReactionController; @@ -18,6 +18,15 @@ use std::sync::Arc; use tokio::sync::watch; use tracing::{debug, error, info}; +/// Hard cap on consecutive bot messages (from any other bot) in a +/// channel or thread. When this many recent messages are all from +/// bots other than ourselves, we stop responding to prevent runaway +/// loops between multiple bots in "all" mode. +/// +/// Note: must be ≤ 255 because Serenity's `GetMessages::limit()` takes `u8`. +/// Inspired by OpenClaw's `session.agentToAgent.maxPingPongTurns`. +const MAX_CONSECUTIVE_BOT_TURNS: u8 = 10; + /// Reusable HTTP client for downloading Discord attachments. /// Built once with a 30s timeout and rustls TLS (no native-tls deps). static HTTP_CLIENT: LazyLock = LazyLock::new(|| { @@ -33,17 +42,20 @@ pub struct Handler { pub allowed_users: HashSet, pub reactions_config: ReactionsConfig, pub stt_config: SttConfig, + pub allow_bot_messages: AllowBots, + pub trusted_bot_ids: HashSet, } #[async_trait] impl EventHandler for Handler { async fn message(&self, ctx: Context, msg: Message) { - if msg.author.bot { + let bot_id = ctx.cache.current_user().id; + + // Always ignore own messages + if msg.author.id == bot_id { return; } - let bot_id = ctx.cache.current_user().id; - let channel_id = msg.channel_id.get(); let in_allowed_channel = self.allowed_channels.is_empty() || self.allowed_channels.contains(&channel_id); @@ -52,6 +64,71 @@ impl EventHandler for Handler { || msg.content.contains(&format!("<@{}>", bot_id)) || msg.mention_roles.iter().any(|r| msg.content.contains(&format!("<@&{}>", r))); + // Bot message gating — runs after self-ignore but before channel/user + // allowlist checks. This ordering is intentional: channel checks below + // apply uniformly to both human and bot messages, so a bot mention in + // a non-allowed channel is still rejected by the channel check. + if msg.author.bot { + match self.allow_bot_messages { + AllowBots::Off => return, + AllowBots::Mentions => if !is_mentioned { return; }, + AllowBots::All => { + // Safety net: count consecutive messages from any bot + // (excluding ourselves) in recent history. If all recent + // messages are from other bots, we've likely entered a + // loop. This counts *all* other-bot messages, not just + // one specific bot — so 3 bots taking turns still hits + // the cap (which is intentionally conservative). + // + // Try cache first to avoid an API call on every bot + // message. Fall back to API on cache miss. If both fail, + // reject the message (fail-closed) to avoid unbounded + // loops during Discord API outages. + let cap = MAX_CONSECUTIVE_BOT_TURNS as usize; + let history = ctx.cache.channel_messages(msg.channel_id) + .map(|msgs| { + let mut recent: Vec<_> = msgs.iter() + .filter(|(mid, _)| **mid < msg.id) + .map(|(_, m)| m.clone()) + .collect(); + recent.sort_unstable_by(|a, b| b.id.cmp(&a.id)); // newest first + recent.truncate(cap); + recent + }) + .filter(|msgs| !msgs.is_empty()); + + let recent = if let Some(cached) = history { + cached + } else { + match msg.channel_id + .messages(&ctx.http, serenity::builder::GetMessages::new().before(msg.id).limit(MAX_CONSECUTIVE_BOT_TURNS)) + .await + { + Ok(msgs) => msgs, + Err(e) => { + tracing::warn!(channel_id = %msg.channel_id, error = %e, "failed to fetch history for bot turn cap, rejecting (fail-closed)"); + return; + } + } + }; + + let consecutive_bot = recent.iter() + .take_while(|m| m.author.bot && m.author.id != bot_id) + .count(); + if consecutive_bot >= cap { + tracing::warn!(channel_id = %msg.channel_id, cap, "bot turn cap reached, ignoring"); + return; + } + }, + } + + // If trusted_bot_ids is set, only allow bots on the list + if !self.trusted_bot_ids.is_empty() && !self.trusted_bot_ids.contains(&msg.author.id.get()) { + tracing::debug!(bot_id = %msg.author.id, "bot not in trusted_bot_ids, ignoring"); + return; + } + } + let in_thread = if !in_allowed_channel { match msg.channel_id.to_channel(&ctx.http).await { Ok(serenity::model::channel::Channel::Guild(gc)) => { diff --git a/src/main.rs b/src/main.rs index 225bf236..59330ab3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,14 +4,39 @@ mod discord; mod error_display; mod format; mod reactions; +mod setup; mod stt; +use clap::Parser; use serenity::prelude::*; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use tracing::info; +#[derive(Parser)] +#[command(name = "openab")] +#[command(about = "Discord bot that manages ACP agent sessions", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(clap::Subcommand)] +enum Commands { + /// Run the bot (default) + Run { + /// Config file path (default: config.toml) + config: Option, + }, + /// Launch the interactive setup wizard + Setup { + /// Output file path for generated config (default: config.toml) + #[arg(short, long)] + output: Option, + }, +} + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -21,86 +46,99 @@ async fn main() -> anyhow::Result<()> { ) .init(); - let config_path = std::env::args() - .nth(1) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("config.toml")); - - let mut cfg = config::load_config(&config_path)?; - info!( - agent_cmd = %cfg.agent.command, - pool_max = cfg.pool.max_sessions, - channels = ?cfg.discord.allowed_channels, - users = ?cfg.discord.allowed_users, - reactions = cfg.reactions.enabled, - "config loaded" - ); - - let pool = Arc::new(acp::SessionPool::new(cfg.agent, cfg.pool.max_sessions)); - let ttl_secs = cfg.pool.session_ttl_hours * 3600; - - let allowed_channels = parse_id_set(&cfg.discord.allowed_channels, "allowed_channels")?; - let allowed_users = parse_id_set(&cfg.discord.allowed_users, "allowed_users")?; - info!(channels = allowed_channels.len(), users = allowed_users.len(), "parsed allowlists"); - - // Resolve STT config before constructing handler (auto-detect mutates cfg.stt) - if cfg.stt.enabled { - if cfg.stt.api_key.is_empty() && cfg.stt.base_url.contains("groq.com") { - if let Ok(key) = std::env::var("GROQ_API_KEY") { - if !key.is_empty() { - info!("stt.api_key not set, using GROQ_API_KEY from environment"); - cfg.stt.api_key = key; + let cmd = Cli::parse().command.unwrap_or(Commands::Run { config: None }); + + match cmd { + Commands::Setup { output } => { + setup::run_setup(output.map(PathBuf::from))?; + return Ok(()); + } + Commands::Run { config } => { + let config_path = config + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("config.toml")); + + let mut cfg = config::load_config(&config_path)?; + info!( + agent_cmd = %cfg.agent.command, + pool_max = cfg.pool.max_sessions, + channels = ?cfg.discord.allowed_channels, + users = ?cfg.discord.allowed_users, + reactions = cfg.reactions.enabled, + allow_bot_messages = ?cfg.discord.allow_bot_messages, + "config loaded" + ); + + let pool = Arc::new(acp::SessionPool::new(cfg.agent, cfg.pool.max_sessions)); + let ttl_secs = cfg.pool.session_ttl_hours * 3600; + + let allowed_channels = parse_id_set(&cfg.discord.allowed_channels, "allowed_channels")?; + let allowed_users = parse_id_set(&cfg.discord.allowed_users, "allowed_users")?; + let trusted_bot_ids = parse_id_set(&cfg.discord.trusted_bot_ids, "trusted_bot_ids")?; + info!(channels = allowed_channels.len(), users = allowed_users.len(), trusted_bots = ?trusted_bot_ids, "parsed allowlists"); + + // Resolve STT config before constructing handler (auto-detect mutates cfg.stt) + if cfg.stt.enabled { + if cfg.stt.api_key.is_empty() && cfg.stt.base_url.contains("groq.com") { + if let Ok(key) = std::env::var("GROQ_API_KEY") { + if !key.is_empty() { + info!("stt.api_key not set, using GROQ_API_KEY from environment"); + cfg.stt.api_key = key; + } + } + } + if cfg.stt.api_key.is_empty() { + anyhow::bail!("stt.enabled = true but no API key found — set stt.api_key in config or export GROQ_API_KEY"); } + info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled"); } - } - if cfg.stt.api_key.is_empty() { - anyhow::bail!("stt.enabled = true but no API key found — set stt.api_key in config or export GROQ_API_KEY"); - } - info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled"); - } - let handler = discord::Handler { - pool: pool.clone(), - allowed_channels, - allowed_users, - reactions_config: cfg.reactions, - stt_config: cfg.stt.clone(), - }; - - let intents = GatewayIntents::GUILD_MESSAGES - | GatewayIntents::MESSAGE_CONTENT - | GatewayIntents::GUILDS; - - let mut client = Client::builder(&cfg.discord.bot_token, intents) - .event_handler(handler) - .await?; - - // Spawn cleanup task - let cleanup_pool = pool.clone(); - let cleanup_handle = tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - cleanup_pool.cleanup_idle(ttl_secs).await; + let handler = discord::Handler { + pool: pool.clone(), + allowed_channels, + allowed_users, + reactions_config: cfg.reactions, + stt_config: cfg.stt.clone(), + allow_bot_messages: cfg.discord.allow_bot_messages, + trusted_bot_ids, + }; + + let intents = GatewayIntents::GUILD_MESSAGES + | GatewayIntents::MESSAGE_CONTENT + | GatewayIntents::GUILDS; + + let mut client = Client::builder(&cfg.discord.bot_token, intents) + .event_handler(handler) + .await?; + + // Spawn cleanup task + let cleanup_pool = pool.clone(); + let cleanup_handle = tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + cleanup_pool.cleanup_idle(ttl_secs).await; + } + }); + + // Run bot until SIGINT/SIGTERM + let shard_manager = client.shard_manager.clone(); + let shutdown_pool = pool.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + info!("shutdown signal received"); + shard_manager.shutdown_all().await; + }); + + info!("starting discord bot"); + client.start().await?; + + // Cleanup + cleanup_handle.abort(); + shutdown_pool.shutdown().await; + info!("openab shut down"); + Ok(()) } - }); - - // Run bot until SIGINT/SIGTERM - let shard_manager = client.shard_manager.clone(); - let shutdown_pool = pool.clone(); - tokio::spawn(async move { - tokio::signal::ctrl_c().await.ok(); - info!("shutdown signal received"); - shard_manager.shutdown_all().await; - }); - - info!("starting discord bot"); - client.start().await?; - - // Cleanup - cleanup_handle.abort(); - shutdown_pool.shutdown().await; - info!("openab shut down"); - Ok(()) + } } fn parse_id_set(raw: &[String], label: &str) -> anyhow::Result> { diff --git a/src/setup/config.rs b/src/setup/config.rs new file mode 100644 index 00000000..21d65e7e --- /dev/null +++ b/src/setup/config.rs @@ -0,0 +1,167 @@ +//! Config generation and TOML serialization for the setup wizard. + +/// Mask bot token in config output for preview +pub fn mask_bot_token(config: &str) -> String { + config + .lines() + .map(|line| { + if line.trim_start().starts_with("bot_token") { + "bot_token = \"***\"".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") +} + +#[derive(serde::Serialize)] +pub(crate) struct ConfigToml { + discord: DiscordConfigToml, + agent: AgentConfigToml, + pool: PoolConfigToml, + reactions: ReactionsConfigToml, +} + +#[derive(serde::Serialize)] +struct DiscordConfigToml { + bot_token: String, + allowed_channels: Vec, +} + +#[derive(serde::Serialize)] +struct AgentConfigToml { + command: String, + args: Vec, + working_dir: String, +} + +#[derive(serde::Serialize)] +struct PoolConfigToml { + max_sessions: usize, + session_ttl_hours: u64, +} + +#[derive(serde::Serialize)] +struct ReactionsConfigToml { + enabled: bool, + remove_after_reply: bool, + emojis: EmojisToml, + timing: TimingToml, +} + +#[derive(serde::Serialize)] +struct EmojisToml { + queued: String, + thinking: String, + tool: String, + coding: String, + web: String, + done: String, + error: String, +} + +#[derive(serde::Serialize)] +struct TimingToml { + debounce_ms: u64, + stall_soft_ms: u64, + stall_hard_ms: u64, + done_hold_ms: u64, + error_hold_ms: u64, +} + +pub fn generate_config( + bot_token: &str, + agent_command: &str, + channel_ids: Vec, + working_dir: &str, + max_sessions: usize, + session_ttl_hours: u64, +) -> String { + let config = ConfigToml { + discord: DiscordConfigToml { + bot_token: bot_token.to_string(), + allowed_channels: channel_ids, + }, + agent: { + let (command, args): (&str, Vec) = match agent_command { + "kiro" => ( + "kiro-cli", + vec!["acp".into(), "--trust-all-tools".into()], + ), + "claude" => ("claude-agent-acp", vec![]), + "codex" => ("codex-acp", vec![]), + "gemini" => ("gemini", vec!["--acp".into()]), + other => (other, vec![]), + }; + AgentConfigToml { + command: command.to_string(), + args, + working_dir: working_dir.to_string(), + } + }, + pool: PoolConfigToml { + max_sessions, + session_ttl_hours, + }, + reactions: ReactionsConfigToml { + enabled: true, + remove_after_reply: false, + emojis: EmojisToml { + queued: "👀".into(), + thinking: "🤔".into(), + tool: "🔥".into(), + coding: "👨💻".into(), + web: "⚡".into(), + done: "🆗".into(), + error: "😱".into(), + }, + timing: TimingToml { + debounce_ms: 700, + stall_soft_ms: 10_000, + stall_hard_ms: 30_000, + done_hold_ms: 1_500, + error_hold_ms: 2_500, + }, + }, + }; + toml::to_string_pretty(&config).expect("TOML serialization failed") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_config_contains_sections() { + let config = generate_config( + "my_token", + "claude", + vec!["123".to_string()], + "/home/agent", + 10, + 24, + ); + assert!(config.contains("[discord]")); + assert!(config.contains("[agent]")); + assert!(config.contains("[pool]")); + assert!(config.contains("[reactions]")); + assert!(config.contains("[reactions.emojis]")); + assert!(config.contains("[reactions.timing]")); + } + + #[test] + fn test_generate_config_kiro_working_dir() { + let config = generate_config( + "tok", + "kiro", + vec!["ch".to_string()], + "/home/agent", + 10, + 24, + ); + assert!(config.contains(r#"working_dir = "/home/agent""#)); + assert!(config.contains("acp")); + assert!(config.contains("--trust-all-tools")); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs new file mode 100644 index 00000000..96034f0a --- /dev/null +++ b/src/setup/mod.rs @@ -0,0 +1,12 @@ +//! OpenAB interactive setup wizard. +//! +//! Modules: +//! - `validate` — input validation (bot token, channel ID, agent command) +//! - `config` — TOML config generation and serialization +//! - `wizard` — interactive TUI, Discord API client, and wizard entry point + +mod config; +mod validate; +mod wizard; + +pub use wizard::run_setup; diff --git a/src/setup/validate.rs b/src/setup/validate.rs new file mode 100644 index 00000000..247b1b9a --- /dev/null +++ b/src/setup/validate.rs @@ -0,0 +1,73 @@ +//! Input validation functions for the setup wizard. + +/// Validate bot token format using allowlist (a-zA-Z0-9-./_) +pub fn validate_bot_token(token: &str) -> anyhow::Result<()> { + if token.is_empty() { + anyhow::bail!("Token cannot be empty"); + } + if !token + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/' || c == '*' || c == '=') + { + anyhow::bail!( + "Token must only contain ASCII letters, numbers, dash, period, underscore, slash, or equals" + ); + } + Ok(()) +} + +/// Validate agent command +#[cfg(test)] +pub fn validate_agent_command(cmd: &str) -> anyhow::Result<()> { + let valid = ["kiro", "claude", "codex", "gemini"]; + if !valid.contains(&cmd) { + anyhow::bail!("Agent must be one of: {}", valid.join(", ")); + } + Ok(()) +} + +/// Validate channel ID is numeric +pub fn validate_channel_id(id: &str) -> anyhow::Result<()> { + if id.is_empty() { + anyhow::bail!("Channel ID cannot be empty"); + } + if !id.chars().all(|c| c.is_ascii_digit()) { + anyhow::bail!("Channel ID must be numeric only"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_bot_token_ok() { + assert!(validate_bot_token("simple_token").is_ok()); + assert!(validate_bot_token("token.with-dashes_123").is_ok()); + assert!(validate_bot_token("***/efgh").is_ok()); + } + + #[test] + fn test_validate_bot_token_reject_invalid() { + assert!(validate_bot_token("").is_err()); + assert!(validate_bot_token("token\nnewline").is_err()); + assert!(validate_bot_token("token\ttab").is_err()); + assert!(validate_bot_token("token with space").is_err()); + } + + #[test] + fn test_validate_agent_command() { + for agent in &["kiro", "claude", "codex", "gemini"] { + assert!(validate_agent_command(agent).is_ok()); + } + assert!(validate_agent_command("invalid").is_err()); + } + + #[test] + fn test_validate_channel_id() { + assert!(validate_channel_id("1492329565824094370").is_ok()); + assert!(validate_channel_id("").is_err()); + assert!(validate_channel_id("abc123").is_err()); + } +} diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs new file mode 100644 index 00000000..8a346400 --- /dev/null +++ b/src/setup/wizard.rs @@ -0,0 +1,676 @@ +//! Interactive setup wizard TUI and Discord API client. + +use std::io::{self, IsTerminal, Write}; +use std::path::{Path, PathBuf}; + +use crate::setup::config::{generate_config, mask_bot_token}; +use crate::setup::validate::{validate_bot_token, validate_channel_id}; + +// --------------------------------------------------------------------------- +// Color codes (ANSI) +// --------------------------------------------------------------------------- + +const C: Colors = Colors { + reset: "\x1b[0m", + bold: "\x1b[1m", + cyan: "\x1b[36m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + magenta: "\x1b[35m", +}; + +struct Colors { + reset: &'static str, + bold: &'static str, + cyan: &'static str, + green: &'static str, + red: &'static str, + yellow: &'static str, + magenta: &'static str, +} + +const BORDER: char = '═'; + +macro_rules! cprintln { + ($color:expr, $fmt:expr) => {{ + println!("{}{}{}", $color, $fmt, C.reset); + }}; + ($color:expr, $fmt:expr, $($arg:tt)*) => {{ + println!("{}{}{}", $color, format!($fmt, $($arg)*), C.reset); + }}; +} + +// --------------------------------------------------------------------------- +// Input helpers +// --------------------------------------------------------------------------- + +fn is_interactive() -> bool { + std::io::stdin().is_terminal() && std::io::stdout().is_terminal() +} + +fn prompt(prompt_text: &str) -> String { + print!("{}{}: {}", C.yellow, prompt_text, C.reset); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + input.trim().to_string() +} + +fn prompt_default(prompt_text: &str, default: &str) -> String { + print!("{}{} [{}]: {}", C.yellow, prompt_text, default, C.reset); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim(); + if input.is_empty() { + default.to_string() + } else { + input.to_string() + } +} + +fn prompt_password(prompt_text: &str) -> String { + print!("{}{}: ", C.yellow, prompt_text); + io::stdout().flush().ok(); + rpassword::read_password().unwrap_or_default() +} + +fn prompt_yes_no(prompt_text: &str, default: bool) -> bool { + let default_str = if default { "Y/n" } else { "y/N" }; + loop { + print!("{}{} [{}]: ", C.yellow, prompt_text, default_str,); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim().to_lowercase(); + if input.is_empty() { + return default; + } + match input.as_str() { + "y" | "yes" => return true, + "n" | "no" => return false, + _ => cprintln!(C.red, "Please enter 'y' or 'n'"), + } + } +} + +fn prompt_choice(prompt_text: &str, choices: &[&str]) -> usize { + println!(); + cprintln!(C.cyan, "{}", prompt_text); + for (i, choice) in choices.iter().enumerate() { + println!(" {}. {}", i + 1, choice); + } + print!("{}Select [1-{}]: {}", C.yellow, choices.len(), C.reset); + io::stdout().flush().ok(); + loop { + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + match input.trim().parse::() { + Ok(n) if n >= 1 && n <= choices.len() => return n - 1, + _ => { + print!("{}Select [1-{}]: {}", C.yellow, choices.len(), C.reset); + io::stdout().flush().ok(); + } + } + } +} + +fn prompt_checklist(prompt_text: &str, items: &[&str]) -> Vec { + println!(); + cprintln!(C.cyan, "{}", prompt_text); + for (i, item) in items.iter().enumerate() { + println!(" [{}] {}", i + 1, item); + } + println!(); + print!( + "{}Enter numbers separated by commas (e.g. 1,3,5) or press Enter for all: {}", + C.yellow, C.reset + ); + io::stdout().flush().ok(); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); + let input = input.trim(); + if input.is_empty() { + return (0..items.len()).collect(); + } + input + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .filter(|n| *n >= 1 && *n <= items.len()) + .map(|n| n - 1) + .collect() +} + +// --------------------------------------------------------------------------- +// Box drawing helpers +// --------------------------------------------------------------------------- + +fn print_box(lines: &[&str]) { + let width = lines + .iter() + .map(|l| unicode_width::UnicodeWidthStr::width(&**l)) + .max() + .unwrap_or(60); + let width = width.clamp(60, 76); + println!(); + cprintln!(C.cyan, "{}", "╔".to_string() + &BORDER.to_string().repeat(width + 2) + "╗"); + for line in lines { + let padded = format!(" {: Self { + Self { + token: token.to_string(), + http: reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("static HTTP client must build"), + } + } + + /// Verify token by fetching bot info + fn verify_token(&self) -> anyhow::Result<(String, String)> { + let resp = self + .http + .get("https://discord.com/api/v10/users/@me") + .header("Authorization", format!("Bot {}", self.token)) + .header("User-Agent", "OpenAB setup wizard") + .send()?; + if !resp.status().is_success() { + anyhow::bail!("Token verification failed: HTTP {}", resp.status()); + } + #[derive(serde::Deserialize)] + struct MeResponse { + id: String, + username: String, + } + let me: MeResponse = resp.json()?; + Ok((me.id, me.username)) + } + + /// Fetch guilds the bot is in + fn fetch_guilds(&self) -> anyhow::Result> { + let resp = self + .http + .get("https://discord.com/api/v10/users/@me/guilds") + .header("Authorization", format!("Bot {}", self.token)) + .header("User-Agent", "OpenAB setup wizard") + .send()?; + if !resp.status().is_success() { + anyhow::bail!("Failed to fetch guilds: HTTP {}", resp.status()); + } + #[derive(serde::Deserialize)] + struct Guild { + id: String, + name: String, + } + let guilds: Vec = resp.json()?; + Ok(guilds.into_iter().map(|g| (g.id, g.name)).collect()) + } + + /// Fetch channels in a guild + fn fetch_channels(&self, guild_id: &str) -> anyhow::Result> { + let url = format!("https://discord.com/api/v10/guilds/{}/channels", guild_id); + let resp = self + .http + .get(&url) + .header("Authorization", format!("Bot {}", self.token)) + .header("User-Agent", "OpenAB setup wizard") + .send()?; + if !resp.status().is_success() { + anyhow::bail!("Failed to fetch channels: HTTP {}", resp.status()); + } + #[derive(serde::Deserialize)] + struct Channel { + id: String, + #[serde(rename = "type")] + kind: u8, + name: String, + } + let channels: Vec = resp.json()?; + // type 0 = text channel + Ok(channels + .into_iter() + .filter(|c| c.kind == 0) + .map(|c| (c.id, c.name, guild_id.to_string())) + .collect()) + } +} + +// --------------------------------------------------------------------------- +// Section 1: Discord Bot Setup Guide +// --------------------------------------------------------------------------- + +fn section_discord_guide() { + print_box(&[ + "Discord Bot Setup Guide", + "", + "1. Go to: https://discord.com/developers/applications", + "2. Click 'New Application' -> name it (e.g. OpenAB)", + "3. Bot -> Reset Token -> COPY the token", + "", + "4. Enable Privileged Gateway Intents:", + " - Message Content Intent", + " - Guild Members Intent", + "", + "5. OAuth2 -> URL Generator:", + " - SCOPES: bot", + " - BOT PERMISSIONS:", + " Send Messages | Embed Links | Attach Files", + " Read Message History | Add Reactions", + " Use Slash Commands", + "", + "6. Visit the generated URL -> add bot to your server", + ]); +} + +// --------------------------------------------------------------------------- +// Section 2: Channel Selection +// --------------------------------------------------------------------------- + +fn section_channels(client: &DiscordClient) -> anyhow::Result> { + println!(); + cprintln!(C.bold, "--- Step 2: Allowed Channels ---"); + println!(); + + print!(" Fetching servers... "); + io::stdout().flush().ok(); + let guilds = client.fetch_guilds()?; + cprintln!(C.green, "OK Found {} server(s)", guilds.len()); + println!(); + + if guilds.is_empty() { + cprintln!( + C.yellow, + " No servers found. Enter channel IDs manually." + ); + let input = prompt(" Channel ID(s), comma-separated"); + let ids: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + for id in &ids { + validate_channel_id(id)?; + } + return Ok(ids); + } + + let guild_names: Vec<&str> = guilds.iter().map(|(_, n)| n.as_str()).collect(); + let guild_idx = prompt_choice(" Select server:", &guild_names); + let (guild_id, guild_name) = &guilds[guild_idx]; + + print!(" Fetching channels in '{}'... ", guild_name); + io::stdout().flush().ok(); + let channels = client.fetch_channels(guild_id)?; + cprintln!(C.green, "OK Found {} channel(s)", channels.len()); + println!(); + + if channels.is_empty() { + cprintln!( + C.yellow, + " No text channels found. Enter channel IDs manually." + ); + let input = prompt(" Channel ID(s), comma-separated"); + let ids: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + for id in &ids { + validate_channel_id(id)?; + } + return Ok(ids); + } + + let channel_names: Vec = channels + .iter() + .map(|(_, n, _)| format!("#{}", n)) + .collect(); + let channel_names_refs: Vec<&str> = channel_names + .iter() + .map(|s| s.as_str()) + .collect(); + + let selected = + prompt_checklist(" Select channels (by number):", &channel_names_refs); + let selected_ids: Vec = selected + .iter() + .map(|&i| channels[i].0.clone()) + .collect(); + + println!(); + cprintln!(C.green, " Selected {} channel(s)", selected_ids.len()); + for id in &selected_ids { + if let Some((_, name, _)) = channels.iter().find(|(cid, _, _)| cid == id) { + println!(" * #{}", name); + } else { + println!(" * {}", id); + } + } + println!(); + + Ok(selected_ids) +} + +// --------------------------------------------------------------------------- +// Section 3: Agent Configuration +// --------------------------------------------------------------------------- + +fn section_agent() -> (String, String, bool) { + println!(); + cprintln!(C.bold, "--- Step 3: Agent Configuration ---"); + println!(); + + print_box(&[ + "Agent Installation Guide", + "", + "claude: npm install -g @anthropic-ai/claude-code", + "kiro: npm install -g @koryhutchison/kiro-cli", + "codex: npm install -g openai-codex (requires OpenAI API key)", + "gemini: npm install -g @google/gemini-cli", + "", + "Make sure the agent is in your PATH before continuing.", + ]); + println!(); + + let choices = ["claude", "kiro", "codex", "gemini"]; + let idx = prompt_choice(" Select agent:", &choices); + let agent = choices[idx]; + + let deploy_choices = ["Local (current directory)", "Docker / k8s"]; + let deploy_idx = prompt_choice(" Deployment target:", &deploy_choices); + let is_local = deploy_idx == 0; + let default_dir = match (is_local, agent) { + (true, _) => ".", + (false, "kiro") => "/home/agent", + (false, _) => "/home/node", + }; + + let working_dir = prompt_default(" Working directory", default_dir); + + cprintln!( + C.green, + " Agent: {} | Working dir: {}", + agent, + working_dir + ); + println!(); + + (agent.to_string(), working_dir, is_local) +} + +// --------------------------------------------------------------------------- +// Section 4: Pool Settings +// --------------------------------------------------------------------------- + +fn section_pool() -> (usize, u64) { + println!(); + cprintln!(C.bold, "--- Step 4: Session Pool ---"); + println!(); + + let max_sessions: usize = prompt_default(" Max sessions", "10") + .parse() + .unwrap_or(10); + let ttl_hours: u64 = prompt_default(" Session TTL (hours)", "24") + .parse() + .unwrap_or(24); + + cprintln!( + C.green, + " Max sessions: {} | TTL: {}h", + max_sessions, + ttl_hours + ); + println!(); + + (max_sessions, ttl_hours) +} + +// --------------------------------------------------------------------------- +// Preview & Save +// --------------------------------------------------------------------------- + +fn section_preview_and_save(config_content: &str, output_path: &PathBuf) -> anyhow::Result<()> { + println!(); + cprintln!(C.bold, "--- Preview ---"); + println!(); + println!("{}", mask_bot_token(config_content)); + println!(); + + if output_path.exists() + && !prompt_yes_no(" File exists. Overwrite?", false) + { + println!(" Saving cancelled."); + return Ok(()); + } + + std::fs::write(output_path, config_content)?; + cprintln!(C.green, "OK config.toml saved to {}", output_path.display()); + println!(); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Non-interactive guidance +// --------------------------------------------------------------------------- + +fn print_noninteractive_guide() { + print_box(&[ + "Non-Interactive Mode", + "", + "The interactive wizard requires a terminal.", + "Create config.toml manually, then run:", + "", + " openab run config.toml", + "", + "Config format reference:", + " [discord]", + " bot_token = \"YOUR_BOT_TOKEN\"", + " allowed_channels = [\"CHANNEL_ID\"]", + "", + " [agent]", + " command = \"kiro-cli\"", + " args = [\"acp\", \"--trust-all-tools\"]", + " working_dir = \"/home/agent\"", + "", + " [pool]", + " max_sessions = 10", + " session_ttl_hours = 24", + "", + " [reactions]", + " enabled = true", + " remove_after_reply = false", + " ...", + ]); +} + +// --------------------------------------------------------------------------- +// Next steps printer +// --------------------------------------------------------------------------- + +fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { + println!(); + cprintln!(C.bold, "--- Next Steps ---"); + println!(); + + if is_local { + match agent { + "kiro" => { + cprintln!(C.cyan, " 1. Install kiro-cli (see https://kiro.dev for installer)"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" kiro-cli login --use-device-flow"); + } + "claude" => { + cprintln!(C.cyan, " 1. Install Claude Code + ACP adapter:"); + println!(" npm install -g @anthropic-ai/claude-code @agentclientprotocol/claude-agent-acp"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" claude setup-token"); + } + "codex" => { + cprintln!(C.cyan, " 1. Install Codex CLI + ACP adapter:"); + println!(" npm install -g @openai/codex @zed-industries/codex-acp"); + cprintln!(C.cyan, " 2. Authenticate:"); + println!(" codex login --device-auth"); + } + "gemini" => { + cprintln!(C.cyan, " 1. Install Gemini CLI:"); + println!(" npm install -g @google/gemini-cli"); + cprintln!(C.cyan, " 2. Authenticate via Google OAuth, or set GEMINI_API_KEY in config.toml"); + } + _ => {} + } + + println!(); + cprintln!(C.green, " 3. Run the bot:"); + println!(" cargo run -- run {}", output_path.display()); + } else { + cprintln!( + C.cyan, + " Docker image already bundles the agent CLI and ACP adapter." + ); + println!(); + cprintln!(C.cyan, " 1. Deploy with Helm (or your preferred method):"); + println!(" helm install openab openab/openab \\"); + println!(" --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", agent); + println!(); + cprintln!(C.cyan, " 2. Authenticate inside the pod (first time only):"); + match agent { + "kiro" => println!( + " kubectl exec -it deployment/openab-kiro -- kiro-cli login --use-device-flow" + ), + "claude" => println!( + " kubectl exec -it deployment/openab-claude -- claude setup-token" + ), + "codex" => println!( + " kubectl exec -it deployment/openab-codex -- codex login --device-auth" + ), + "gemini" => println!( + " Set GEMINI_API_KEY via secret, or exec into the pod for OAuth" + ), + _ => {} + } + println!(); + cprintln!(C.green, " See README for full Helm options."); + } + println!(); +} + +// --------------------------------------------------------------------------- +// Main wizard entry point +// --------------------------------------------------------------------------- + +pub fn run_setup(output_path: Option) -> anyhow::Result<()> { + if !is_interactive() { + print_noninteractive_guide(); + return Ok(()); + } + + println!(); + cprintln!( + C.magenta, + "============================================================" + ); + cprintln!( + C.magenta, + " OpenAB Interactive Setup Wizard " + ); + cprintln!( + C.magenta, + "============================================================" + ); + + // Step 1: Discord Guide + Token + section_discord_guide(); + println!(); + let bot_token = prompt_password(" Bot Token (or press Enter to skip)"); + if bot_token.is_empty() { + cprintln!( + C.yellow, + " Skipped. Set bot_token manually in config.toml" + ); + println!(); + cprintln!( + C.green, + " Setup complete! Edit config.toml to add your bot token." + ); + return Ok(()); + } + validate_bot_token(&bot_token)?; + + let client = DiscordClient::new(&bot_token); + print!(" Verifying token with Discord API... "); + io::stdout().flush().ok(); + let (_bot_id, bot_username) = client.verify_token()?; + cprintln!(C.green, "OK Logged in as {}", bot_username); + + // Step 2: Channels + let channel_ids = match section_channels(&client) { + Ok(ids) if !ids.is_empty() => ids, + Ok(_) => { + cprintln!(C.yellow, " No channels selected."); + vec![] + } + Err(e) => { + cprintln!( + C.yellow, + " Channel fetch failed: {}. Enter manually.", + e + ); + let input = prompt(" Channel ID(s), comma-separated"); + let ids: Vec = input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + for id in &ids { + validate_channel_id(id).map_err(|e| anyhow::anyhow!("{}", e))?; + } + ids + } + }; + + // Step 3: Agent + let (agent, working_dir, is_local) = section_agent(); + + // Step 4: Pool + let (max_sessions, ttl_hours) = section_pool(); + + // Generate + let config_content = generate_config( + &bot_token, + &agent, + channel_ids, + &working_dir, + max_sessions, + ttl_hours, + ); + + // Output + let output_path = output_path.unwrap_or_else(|| PathBuf::from("config.toml")); + section_preview_and_save(&config_content, &output_path)?; + + print_next_steps(&agent, &output_path, is_local); + + Ok(()) +}