diff --git a/.gitignore b/.gitignore index 2f9af9a..2b19974 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ state.*.json *.db target/ +data/ noc.service diff --git a/Cargo.lock b/Cargo.lock index 4d6cb88..cf653dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -622,6 +674,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -942,6 +995,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -986,6 +1045,7 @@ name = "noc" version = "0.1.0" dependencies = [ "anyhow", + "axum", "base64 0.22.1", "chrono", "cron", @@ -1224,7 +1284,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.37", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -1261,7 +1321,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.52.0", ] @@ -1636,6 +1696,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2069,6 +2140,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2107,6 +2179,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index df9884d..3ca2c3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] anyhow = "1" +axum = "0.8" base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } cron = "0.16" diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..4f2950c --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,15 @@ +# Suite Ingress — 按需修改域名 +# 复制到 /data/caddy/Caddyfile 后自定义 +# Caddy 自动申请 HTTPS 证书(需要域名解析到本机) + +# Gitea +{$SUITE_DOMAIN:localhost}:80 { + reverse_proxy localhost:3000 +} + +# 静态站点 / 生成的 web app(放到 /data/www// 下) +# 取消注释并改域名即可: +# app1.example.com { +# root * /data/www/app1 +# file_server +# } diff --git a/deploy/Dockerfile b/deploy/Dockerfile index e4a74aa..8f04112 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -10,17 +10,24 @@ RUN curl -fSL "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION -o /usr/local/bin/gitea \ && chmod +x /usr/local/bin/gitea +# install caddy +ARG CADDY_VERSION=2.9.1 +RUN curl -fSL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C /usr/local/bin caddy \ + && chmod +x /usr/local/bin/caddy + # noc binary (pre-built musl static binary) COPY noc /usr/local/bin/noc RUN chmod +x /usr/local/bin/noc COPY tools/ /opt/noc/tools/ COPY config.example.yaml /opt/noc/config.example.yaml +COPY Caddyfile /opt/noc/Caddyfile COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh RUN useradd -m -s /bin/bash noc \ - && mkdir -p /data/gitea /data/noc \ + && mkdir -p /data/gitea /data/noc /data/caddy /data/www \ && chown -R noc:noc /data /opt/noc VOLUME ["/data"] USER noc @@ -28,8 +35,9 @@ USER noc ENV RUST_LOG=noc=info \ NOC_CONFIG=/data/noc/config.yaml \ NOC_STATE=/data/noc/state.json \ - GITEA_WORK_DIR=/data/gitea + GITEA_WORK_DIR=/data/gitea \ + XDG_DATA_HOME=/data/caddy -EXPOSE 3000 +EXPOSE 80 443 ENTRYPOINT ["/entrypoint.sh"] diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index 1ac6812..45c2f71 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -3,16 +3,24 @@ set -euo pipefail GITEA_DATA="/data/gitea" NOC_DATA="/data/noc" +CADDY_DATA="/data/caddy" GITEA_DB="$GITEA_DATA/gitea.db" GITEA_INI="$GITEA_DATA/app.ini" GITEA_TOKEN_FILE="$NOC_DATA/gitea-token" +CADDYFILE="$CADDY_DATA/Caddyfile" GITEA_ADMIN_USER="${GITEA_ADMIN_USER:-noc}" GITEA_ADMIN_PASS="${GITEA_ADMIN_PASS:-noc-admin-changeme}" GITEA_ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-noc@localhost}" GITEA_HTTP_PORT="${GITEA_HTTP_PORT:-3000}" -mkdir -p "$GITEA_DATA" "$NOC_DATA" +mkdir -p "$GITEA_DATA" "$NOC_DATA" "$CADDY_DATA" /data/www + +# ── caddy config ─────────────────────────────────────────────────── +if [ ! -f "$CADDYFILE" ]; then + cp /opt/noc/Caddyfile "$CADDYFILE" + echo "[caddy] created $CADDYFILE" +fi # ── gitea config ──────────────────────────────────────────────────── if [ ! -f "$GITEA_INI" ]; then @@ -39,6 +47,10 @@ EOF echo "[gitea] created $GITEA_INI" fi +# ── start caddy ──────────────────────────────────────────────────── +echo "[suite] starting caddy..." +caddy run --config "$CADDYFILE" --adapter caddyfile & + # ── start gitea in background ────────────────────────────────────── echo "[suite] starting gitea..." gitea web --config "$GITEA_INI" --custom-path "$GITEA_DATA/custom" & diff --git a/doc/suite.md b/doc/suite.md index 14d01f7..25be0c7 100644 --- a/doc/suite.md +++ b/doc/suite.md @@ -77,9 +77,36 @@ AI 在后台安静地干活,做完了才来找你。 | Memory | 跨会话的持久记忆 | | Context | 对话历史、summary、scratch | | Tools | 统一的工具注册表,各界面按需可见 | +| SubAgent | Claude Code (`claude -p`) 作为可调度的执行引擎 | 所有界面的交互最终都流经同一个 LLM 调用路径,共享 persona 和 inner_state——无论 AI 是在回复聊天、review 代码还是自言自语,它都是同一个"人"。 +### SubAgent — Claude Code 作为执行引擎 + +Suite 的 AI Core 通过 OpenAI-compatible API 做对话和决策,但**复杂任务的执行交给 Claude Code**。这是当下 agent 生态的主流模式:一个轻量的调度层 + 重量级的 coding agent 做实际工作。 + +``` +AI Core (决策层) + │ + ├─ 简单任务:直接用 tools(bash、文件操作、API 调用) + │ + └─ 复杂任务:spawn claude -p(subagent) + ├─ 代码编写、重构、debug + ├─ 多文件修改、跨项目操作 + ├─ 调研、分析、生成报告 + └─ 结果异步回传给 AI Core +``` + +**noc 是调度层和人格层,Claude Code 是执行层。** noc 不重复造 coding agent 的轮子,直接站在巨人肩膀上。 + +这意味着 suite 的 VPS 上需要安装 Claude Code CLI。noc 不需要自己实现 coding agent 的能力——它负责理解意图、管理上下文、协调界面,把"脏活"交给 Claude Code。 + +场景举例: +- Chat: "帮我写个脚本分析日志" → spawn claude -p,完成后把结果发回聊天 +- Gitea Bot: PR 来了 → claude -p review 代码,结果写成 comment +- Worker: 定时任务要更新一个 dashboard → claude -p 生成代码,部署到 /data/www/ +- Self: 反思时发现某个 tool 有 bug → 自己 spawn claude -p 去修 + ## 界面之间的联动 界面不是孤立的,它们之间会互相触发: @@ -93,10 +120,20 @@ Self ──"Fam 今天还没动过代码"──→ Chat(主动关心) ## 部署架构 -Suite 是自包含的——不依赖外部服务,自己带齐所有组件: +Suite 跑在一台专属 VPS / EC2 上——一台小机器(2C4G 足够),完整拥有整个环境: ``` -┌─ suite 部署 ─────────────────────────────────┐ +┌─ VPS (suite 专属) ───────────────────────────┐ +│ │ +│ Caddy (ingress) │ +│ ├─ git.example.com → Gitea :3000 │ +│ ├─ app1.example.com → /data/www/app1 │ +│ └─ ...按需扩展 │ +│ │ +│ Gitea (self-hosted, AI 专属) │ +│ ├─ noc 持有 admin token,完全控制 │ +│ ├─ webhook → noc http server │ +│ └─ noc 通过 REST API 读写一切 │ │ │ │ noc (Rust binary) │ │ ├─ telegram loop (Chat) │ @@ -104,25 +141,42 @@ Suite 是自包含的——不依赖外部服务,自己带齐所有组件: │ ├─ life loop (Self) │ │ └─ worker loop (Worker) │ │ │ -│ Gitea (self-hosted, AI 专属) │ -│ ├─ noc 持有 admin token,完全控制 │ -│ ├─ webhook → noc http server │ -│ └─ noc 通过 REST API 读写一切 │ -│ │ │ SQLite (共享状态) │ -│ │ │ LLM backend (外部,OpenAI-compatible) │ │ │ └───────────────────────────────────────────────┘ ``` -Gitea 是 noc 的"专属地盘"——admin token 意味着 noc 可以: +### 为什么是裸机而不是 Docker + +- Caddy 要绑 80/443,容器里搞端口映射反而多一层 +- noc 需要 spawn 子进程、读写磁盘、跑工具脚本,容器限制多 +- 一台机器就是给 suite 独占的,不需要隔离 +- Worker 以后可能跑 CI、生成 web app,直接操作文件系统最自然 + +Docker image 保留用于本地开发和测试。 + +### Caddy 的角色 + +不只是反向代理——是 suite 的**统一入口**: + +- 子域名路由:不同服务用不同子域名 +- 静态站点托管:Worker 生成的 web app 放到 `/data/www//`,加一条路由即可对外 +- 自动 HTTPS:Let's Encrypt 证书自动申请和续期 +- 未来 noc 自己的 HTTP API 也从这里暴露 + +### Gitea 的角色 + +noc 的"专属地盘"——admin token 意味着 noc 可以: - 创建/删除 repo 和 branch - 读写任意 PR、issue、comment - 管理 webhook、CI、用户 - 不用操心权限,想干嘛干嘛 -部署方式:docker-compose 或 systemd 同机编排,一键拉起。 +### 部署方式 + +- 主线:`deploy/setup.sh` 在 VPS 上一键安装 Caddy + Gitea + noc,systemd 管理 +- 开发:`make docker` 构建 all-in-one image,本地测试用 ## 现状 → 目标 @@ -132,4 +186,4 @@ Gitea 是 noc 的"专属地盘"——admin token 意味着 noc 可以: | Gitea Bot | ❌ 不存在 | webhook 接收 + PR review | | Worker | 🟡 SubAgent 雏形 | 独立任务队列 + 结果路由 | | Self | 🟡 life loop + reflect | 更丰富的自主行为 | -| Gitea | ❌ 外部依赖 | 纳入 suite 部署,self-hosted | +| Infra | ✅ Docker all-in-one | VPS setup 脚本 + systemd | diff --git a/src/config.rs b/src/config.rs index 5853462..e9ac511 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,6 +11,40 @@ pub struct Config { pub backend: BackendConfig, #[serde(default)] pub whisper_url: Option, + #[serde(default)] + pub gitea: Option, +} + +#[derive(Deserialize, Clone)] +pub struct GiteaConfig { + pub url: String, + /// Direct token or read from token_file at startup + #[serde(default)] + pub token: String, + #[serde(default)] + pub token_file: Option, + #[serde(default = "default_webhook_port")] + pub webhook_port: u16, + #[serde(default)] + pub webhook_secret: Option, +} + +impl GiteaConfig { + /// Resolve token: if token_file is set and token is empty, read from file + pub fn resolve_token(&mut self) { + if self.token.is_empty() { + if let Some(path) = &self.token_file { + match std::fs::read_to_string(path) { + Ok(t) => self.token = t.trim().to_string(), + Err(e) => tracing::error!("failed to read gitea token_file {path}: {e}"), + } + } + } + } +} + +fn default_webhook_port() -> u16 { + 9800 } fn default_name() -> String { diff --git a/src/gitea.rs b/src/gitea.rs new file mode 100644 index 0000000..df62e4d --- /dev/null +++ b/src/gitea.rs @@ -0,0 +1,379 @@ +use std::sync::Arc; + +use anyhow::Result; +use axum::extract::State as AxumState; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::Json; +use tracing::{error, info, warn}; + +use crate::config::GiteaConfig; + +// ── Gitea API client ─────────────────────────────────────────────── + +#[derive(Clone)] +pub struct GiteaClient { + pub base_url: String, + pub token: String, + http: reqwest::Client, +} + +impl GiteaClient { + pub fn new(config: &GiteaConfig) -> Self { + Self { + base_url: config.url.trim_end_matches('/').to_string(), + token: config.token.clone(), + http: reqwest::Client::new(), + } + } + + pub async fn post_comment( + &self, + owner: &str, + repo: &str, + issue_nr: u64, + body: &str, + ) -> Result<()> { + let url = format!( + "{}/api/v1/repos/{owner}/{repo}/issues/{issue_nr}/comments", + self.base_url + ); + let resp = self + .http + .post(&url) + .header("Authorization", format!("token {}", self.token)) + .json(&serde_json::json!({ "body": body })) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("gitea comment failed: {status} {text}"); + } + Ok(()) + } + + pub async fn get_pr_diff( + &self, + owner: &str, + repo: &str, + pr_nr: u64, + ) -> Result { + let url = format!( + "{}/api/v1/repos/{owner}/{repo}/pulls/{pr_nr}.diff", + self.base_url + ); + let resp = self + .http + .get(&url) + .header("Authorization", format!("token {}", self.token)) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + anyhow::bail!("gitea get diff failed: {status}"); + } + Ok(resp.text().await?) + } + + pub async fn get_issue( + &self, + owner: &str, + repo: &str, + issue_nr: u64, + ) -> Result { + let url = format!( + "{}/api/v1/repos/{owner}/{repo}/issues/{issue_nr}", + self.base_url + ); + let resp = self + .http + .get(&url) + .header("Authorization", format!("token {}", self.token)) + .send() + .await?; + Ok(resp.json().await?) + } +} + +// ── Webhook types ────────────────────────────────────────────────── + +#[derive(serde::Deserialize, Debug)] +struct WebhookPayload { + action: Option, + #[serde(default)] + comment: Option, + #[serde(default)] + issue: Option, + #[serde(default)] + pull_request: Option, + repository: Option, +} + +#[derive(serde::Deserialize, Debug)] +struct Comment { + body: Option, + user: Option, +} + +#[derive(serde::Deserialize, Debug, Clone)] +struct Issue { + number: u64, + title: Option, + body: Option, + #[serde(default)] + pull_request: Option, +} + +#[derive(serde::Deserialize, Debug, Clone)] +struct PullRequest { + number: u64, + title: Option, + body: Option, +} + +#[derive(serde::Deserialize, Debug)] +struct Repository { + full_name: Option, +} + +#[derive(serde::Deserialize, Debug)] +struct User { + login: Option, +} + +// ── Webhook server ───────────────────────────────────────────────── + +#[derive(Clone)] +pub struct WebhookState { + pub gitea: GiteaClient, + pub bot_user: String, +} + +pub async fn start_webhook_server(config: &GiteaConfig, bot_user: String) { + let gitea = GiteaClient::new(config); + let state = Arc::new(WebhookState { + gitea, + bot_user, + }); + + let app = axum::Router::new() + .route("/webhook/gitea", post(handle_webhook)) + .with_state(state); + + let addr = format!("0.0.0.0:{}", config.webhook_port); + info!("gitea webhook server listening on {addr}"); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .unwrap_or_else(|e| panic!("bind {addr}: {e}")); + + if let Err(e) = axum::serve(listener, app).await { + error!("webhook server error: {e}"); + } +} + +async fn handle_webhook( + AxumState(state): AxumState>, + Json(payload): Json, +) -> impl IntoResponse { + let action = payload.action.as_deref().unwrap_or(""); + let repo_full = payload + .repository + .as_ref() + .and_then(|r| r.full_name.as_deref()) + .unwrap_or("unknown"); + + info!(repo = repo_full, action, "webhook received"); + + // We care about: + // 1. issue_comment with @bot mention (works for both issues and PRs) + // 2. issue opened with @bot mention + if action == "created" || action == "opened" { + let mention = format!("@{}", state.bot_user); + + // Check comment body for mention + if let Some(comment) = &payload.comment { + let body = comment.body.as_deref().unwrap_or(""); + let commenter = comment + .user + .as_ref() + .and_then(|u| u.login.as_deref()) + .unwrap_or(""); + + // Don't respond to our own comments + if commenter == state.bot_user { + return StatusCode::OK; + } + + if body.contains(&mention) { + let state = state.clone(); + let repo = repo_full.to_string(); + let issue = payload.issue.clone(); + let pr = payload.pull_request.clone(); + let body = body.to_string(); + + tokio::spawn(async move { + if let Err(e) = handle_mention(&state, &repo, issue, pr, &body).await { + error!(repo, "handle mention: {e:#}"); + } + }); + } + } + // Check issue/PR body for mention on open + else if action == "opened" { + let body = payload + .issue + .as_ref() + .and_then(|i| i.body.as_deref()) + .or(payload.pull_request.as_ref().and_then(|p| p.body.as_deref())) + .unwrap_or(""); + + if body.contains(&mention) { + let state = state.clone(); + let repo = repo_full.to_string(); + let issue = payload.issue.clone(); + let pr = payload.pull_request.clone(); + let body = body.to_string(); + + tokio::spawn(async move { + if let Err(e) = handle_mention(&state, &repo, issue, pr, &body).await { + error!(repo, "handle mention: {e:#}"); + } + }); + } + } + } + + StatusCode::OK +} + +async fn handle_mention( + state: &WebhookState, + repo_full: &str, + issue: Option, + pr: Option, + comment_body: &str, +) -> Result<()> { + let parts: Vec<&str> = repo_full.splitn(2, '/').collect(); + if parts.len() != 2 { + anyhow::bail!("bad repo name: {repo_full}"); + } + let (owner, repo) = (parts[0], parts[1]); + + // Strip the @mention to get the actual request + let mention = format!("@{}", state.bot_user); + let request = comment_body + .replace(&mention, "") + .trim() + .to_string(); + + // Determine issue/PR number + let issue_nr = issue + .as_ref() + .map(|i| i.number) + .or(pr.as_ref().map(|p| p.number)) + .unwrap_or(0); + + if issue_nr == 0 { + return Ok(()); + } + + let is_pr = pr.is_some() + || issue + .as_ref() + .map(|i| i.pull_request.is_some()) + .unwrap_or(false); + + let title = issue + .as_ref() + .and_then(|i| i.title.as_deref()) + .or(pr.as_ref().and_then(|p| p.title.as_deref())) + .unwrap_or(""); + + info!( + repo = repo_full, + issue_nr, + is_pr, + "handling mention: {request}" + ); + + // Build prompt for claude -p + let prompt = if is_pr { + let diff = state.gitea.get_pr_diff(owner, repo, issue_nr).await?; + // Truncate very large diffs + let diff_truncated = if diff.len() > 50_000 { + format!("{}...\n\n(diff truncated, {} bytes total)", &diff[..50_000], diff.len()) + } else { + diff + }; + + if request.is_empty() { + format!( + "Review this pull request.\n\n\ + PR #{issue_nr}: {title}\n\ + Repo: {repo_full}\n\n\ + Diff:\n```\n{diff_truncated}\n```\n\n\ + Give a concise code review. Point out bugs, issues, and suggestions. \ + Be direct and specific. Use markdown." + ) + } else { + format!( + "PR #{issue_nr}: {title}\nRepo: {repo_full}\n\n\ + Diff:\n```\n{diff_truncated}\n```\n\n\ + User request: {request}" + ) + } + } else { + // Issue + let issue_data = state.gitea.get_issue(owner, repo, issue_nr).await?; + let issue_body = issue_data["body"].as_str().unwrap_or(""); + + if request.is_empty() { + format!( + "Analyze this issue and suggest how to address it.\n\n\ + Issue #{issue_nr}: {title}\n\ + Repo: {repo_full}\n\n\ + {issue_body}" + ) + } else { + format!( + "Issue #{issue_nr}: {title}\n\ + Repo: {repo_full}\n\n\ + {issue_body}\n\n\ + User request: {request}" + ) + } + }; + + // Run claude -p + let output = tokio::process::Command::new("claude") + .args(["-p", &prompt]) + .output() + .await; + + let response = match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + if out.status.success() && !stdout.is_empty() { + stdout.to_string() + } else if !stderr.is_empty() { + format!("Error running review:\n```\n{stderr}\n```") + } else { + "(no output)".to_string() + } + } + Err(e) => format!("Failed to run claude: {e}"), + }; + + // Post result as comment + state + .gitea + .post_comment(owner, repo, issue_nr, &response) + .await?; + + info!(repo = repo_full, issue_nr, "posted review comment"); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 6a26f21..a9535a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ mod config; -mod state; -mod tools; -mod stream; mod display; +mod gitea; mod life; +mod state; +mod stream; +mod tools; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -71,8 +72,11 @@ async fn main() { let config_path = std::env::var("NOC_CONFIG").unwrap_or_else(|_| "config.yaml".into()); let raw = std::fs::read_to_string(&config_path) .unwrap_or_else(|e| panic!("read {config_path}: {e}")); - let config: Config = + let mut config: Config = serde_yaml::from_str(&raw).unwrap_or_else(|e| panic!("parse config: {e}")); + if let Some(ref mut gitea) = config.gitea { + gitea.resolve_token(); + } let state_path = std::env::var("NOC_STATE") .map(PathBuf::from) @@ -93,6 +97,16 @@ async fn main() { // start life loop tokio::spawn(life::life_loop(bot.clone(), state.clone(), config.clone())); + // start gitea webhook server + if let Some(gitea_config) = &config.gitea { + let gc = gitea_config.clone(); + // Use the gitea admin username as the bot user for @mention detection + let bot_user = std::env::var("GITEA_ADMIN_USER").unwrap_or_else(|_| "noc".into()); + tokio::spawn(async move { + gitea::start_webhook_server(&gc, bot_user).await; + }); + } + Dispatcher::builder(bot, handler) .dependencies(dptree::deps![state, config, bot_username]) .default_handler(|_| async {}) diff --git a/src/tools.rs b/src/tools.rs index 50eb08d..3503e28 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -200,6 +200,22 @@ pub fn discover_tools() -> serde_json::Value { } } }), + serde_json::json!({ + "type": "function", + "function": { + "name": "call_gitea_api", + "description": "调用 Gitea REST API。以 noc_bot 身份操作,拥有 admin 权限。可管理 repo、issue、PR、comment、webhook 等一切。", + "parameters": { + "type": "object", + "properties": { + "method": {"type": "string", "description": "HTTP method: GET, POST, PATCH, PUT, DELETE"}, + "path": {"type": "string", "description": "API path after /api/v1/, e.g. repos/noc/myrepo/issues"}, + "body": {"type": "object", "description": "Optional JSON body for POST/PATCH/PUT"} + }, + "required": ["method", "path"] + } + } + }), ]; // discover script tools @@ -350,6 +366,46 @@ pub async fn execute_tool( Err(e) => format!("Error: {e}"), } } + "call_gitea_api" => { + let method = args["method"].as_str().unwrap_or("GET").to_uppercase(); + let path = args["path"].as_str().unwrap_or("").trim_start_matches('/'); + let body = args.get("body"); + + let gitea_config = match &config.gitea { + Some(c) => c, + None => return "Gitea not configured".to_string(), + }; + + let url = format!("{}/api/v1/{}", gitea_config.url.trim_end_matches('/'), path); + let client = reqwest::Client::new(); + let mut req = match method.as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PATCH" => client.patch(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + _ => return format!("Unsupported method: {method}"), + }; + req = req.header("Authorization", format!("token {}", gitea_config.token)); + if let Some(b) = body { + req = req.json(b); + } + + match req.send().await { + Ok(resp) => { + let status = resp.status().as_u16(); + let text = resp.text().await.unwrap_or_default(); + // Truncate large responses + let text = if text.len() > 4000 { + format!("{}...(truncated)", &text[..4000]) + } else { + text + }; + format!("HTTP {status}\n{text}") + } + Err(e) => format!("Request failed: {e:#}"), + } + } "gen_voice" => { let text = args["text"].as_str().unwrap_or(""); if text.is_empty() {