add Gitea Bot interface: webhook server, API tool, Caddy ingress

- Add src/gitea.rs: axum webhook server on :9800, handles @mention in
  issues and PRs, spawns claude -p for review, posts result as comment
- Add call_gitea_api tool: LLM can directly call Gitea REST API with
  pre-configured admin token (noc_bot identity)
- Add Caddy to Docker image as ingress layer (subdomain/path routing)
- Config: add gitea section with token_file support for auto-provisioned token
- Update suite.md: VPS-first deployment, SubAgent architecture, Caddy role
This commit is contained in:
Fam Zheng
2026-04-10 16:30:05 +00:00
parent 035d9b9be2
commit dbd729ecb8
11 changed files with 668 additions and 21 deletions

1
.gitignore vendored
View File

@@ -5,4 +5,5 @@ state.*.json
*.db *.db
target/ target/
data/
noc.service noc.service

77
Cargo.lock generated
View File

@@ -63,6 +63,58 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 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]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@@ -622,6 +674,7 @@ dependencies = [
"http 1.4.0", "http 1.4.0",
"http-body 1.0.1", "http-body 1.0.1",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@@ -942,6 +995,12 @@ dependencies = [
"regex-automata", "regex-automata",
] ]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@@ -986,6 +1045,7 @@ name = "noc"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"cron", "cron",
@@ -1224,7 +1284,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls 0.23.37", "rustls 0.23.37",
"socket2 0.5.10", "socket2 0.6.3",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -1261,7 +1321,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.5.10", "socket2 0.6.3",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
@@ -1636,6 +1696,17 @@ dependencies = [
"zmij", "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]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -2069,6 +2140,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -2107,6 +2179,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",

View File

@@ -5,6 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
axum = "0.8"
base64 = "0.22" base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
cron = "0.16" cron = "0.16"

15
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
# Suite Ingress — 按需修改域名
# 复制到 /data/caddy/Caddyfile 后自定义
# Caddy 自动申请 HTTPS 证书(需要域名解析到本机)
# Gitea
{$SUITE_DOMAIN:localhost}:80 {
reverse_proxy localhost:3000
}
# 静态站点 / 生成的 web app放到 /data/www/<name>/ 下)
# 取消注释并改域名即可:
# app1.example.com {
# root * /data/www/app1
# file_server
# }

View File

@@ -10,17 +10,24 @@ RUN curl -fSL "https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION
-o /usr/local/bin/gitea \ -o /usr/local/bin/gitea \
&& chmod +x /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) # noc binary (pre-built musl static binary)
COPY noc /usr/local/bin/noc COPY noc /usr/local/bin/noc
RUN chmod +x /usr/local/bin/noc RUN chmod +x /usr/local/bin/noc
COPY tools/ /opt/noc/tools/ COPY tools/ /opt/noc/tools/
COPY config.example.yaml /opt/noc/config.example.yaml COPY config.example.yaml /opt/noc/config.example.yaml
COPY Caddyfile /opt/noc/Caddyfile
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN useradd -m -s /bin/bash noc \ 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 && chown -R noc:noc /data /opt/noc
VOLUME ["/data"] VOLUME ["/data"]
USER noc USER noc
@@ -28,8 +35,9 @@ USER noc
ENV RUST_LOG=noc=info \ ENV RUST_LOG=noc=info \
NOC_CONFIG=/data/noc/config.yaml \ NOC_CONFIG=/data/noc/config.yaml \
NOC_STATE=/data/noc/state.json \ 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"] ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -3,16 +3,24 @@ set -euo pipefail
GITEA_DATA="/data/gitea" GITEA_DATA="/data/gitea"
NOC_DATA="/data/noc" NOC_DATA="/data/noc"
CADDY_DATA="/data/caddy"
GITEA_DB="$GITEA_DATA/gitea.db" GITEA_DB="$GITEA_DATA/gitea.db"
GITEA_INI="$GITEA_DATA/app.ini" GITEA_INI="$GITEA_DATA/app.ini"
GITEA_TOKEN_FILE="$NOC_DATA/gitea-token" GITEA_TOKEN_FILE="$NOC_DATA/gitea-token"
CADDYFILE="$CADDY_DATA/Caddyfile"
GITEA_ADMIN_USER="${GITEA_ADMIN_USER:-noc}" GITEA_ADMIN_USER="${GITEA_ADMIN_USER:-noc}"
GITEA_ADMIN_PASS="${GITEA_ADMIN_PASS:-noc-admin-changeme}" GITEA_ADMIN_PASS="${GITEA_ADMIN_PASS:-noc-admin-changeme}"
GITEA_ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-noc@localhost}" GITEA_ADMIN_EMAIL="${GITEA_ADMIN_EMAIL:-noc@localhost}"
GITEA_HTTP_PORT="${GITEA_HTTP_PORT:-3000}" 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 ──────────────────────────────────────────────────── # ── gitea config ────────────────────────────────────────────────────
if [ ! -f "$GITEA_INI" ]; then if [ ! -f "$GITEA_INI" ]; then
@@ -39,6 +47,10 @@ EOF
echo "[gitea] created $GITEA_INI" echo "[gitea] created $GITEA_INI"
fi fi
# ── start caddy ────────────────────────────────────────────────────
echo "[suite] starting caddy..."
caddy run --config "$CADDYFILE" --adapter caddyfile &
# ── start gitea in background ────────────────────────────────────── # ── start gitea in background ──────────────────────────────────────
echo "[suite] starting gitea..." echo "[suite] starting gitea..."
gitea web --config "$GITEA_INI" --custom-path "$GITEA_DATA/custom" & gitea web --config "$GITEA_INI" --custom-path "$GITEA_DATA/custom" &

View File

@@ -77,9 +77,36 @@ AI 在后台安静地干活,做完了才来找你。
| Memory | 跨会话的持久记忆 | | Memory | 跨会话的持久记忆 |
| Context | 对话历史、summary、scratch | | Context | 对话历史、summary、scratch |
| Tools | 统一的工具注册表,各界面按需可见 | | Tools | 统一的工具注册表,各界面按需可见 |
| SubAgent | Claude Code (`claude -p`) 作为可调度的执行引擎 |
所有界面的交互最终都流经同一个 LLM 调用路径,共享 persona 和 inner_state——无论 AI 是在回复聊天、review 代码还是自言自语,它都是同一个"人"。 所有界面的交互最终都流经同一个 LLM 调用路径,共享 persona 和 inner_state——无论 AI 是在回复聊天、review 代码还是自言自语,它都是同一个"人"。
### SubAgent — Claude Code 作为执行引擎
Suite 的 AI Core 通过 OpenAI-compatible API 做对话和决策,但**复杂任务的执行交给 Claude Code**。这是当下 agent 生态的主流模式:一个轻量的调度层 + 重量级的 coding agent 做实际工作。
```
AI Core (决策层)
├─ 简单任务:直接用 toolsbash、文件操作、API 调用)
└─ 复杂任务spawn claude -psubagent
├─ 代码编写、重构、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) │ │ noc (Rust binary) │
│ ├─ telegram loop (Chat) │ │ ├─ telegram loop (Chat) │
@@ -104,25 +141,42 @@ Suite 是自包含的——不依赖外部服务,自己带齐所有组件:
│ ├─ life loop (Self) │ │ ├─ life loop (Self) │
│ └─ worker loop (Worker) │ │ └─ worker loop (Worker) │
│ │ │ │
│ Gitea (self-hosted, AI 专属) │
│ ├─ noc 持有 admin token完全控制 │
│ ├─ webhook → noc http server │
│ └─ noc 通过 REST API 读写一切 │
│ │
│ SQLite (共享状态) │ │ SQLite (共享状态) │
│ │
│ LLM backend (外部OpenAI-compatible) │ │ 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/<name>/`,加一条路由即可对外
- 自动 HTTPSLet's Encrypt 证书自动申请和续期
- 未来 noc 自己的 HTTP API 也从这里暴露
### Gitea 的角色
noc 的"专属地盘"——admin token 意味着 noc 可以:
- 创建/删除 repo 和 branch - 创建/删除 repo 和 branch
- 读写任意 PR、issue、comment - 读写任意 PR、issue、comment
- 管理 webhook、CI、用户 - 管理 webhook、CI、用户
- 不用操心权限,想干嘛干嘛 - 不用操心权限,想干嘛干嘛
部署方式docker-compose 或 systemd 同机编排,一键拉起。 ### 部署方式
- 主线:`deploy/setup.sh` 在 VPS 上一键安装 Caddy + Gitea + nocsystemd 管理
- 开发:`make docker` 构建 all-in-one image本地测试用
## 现状 → 目标 ## 现状 → 目标
@@ -132,4 +186,4 @@ Gitea 是 noc 的"专属地盘"——admin token 意味着 noc 可以:
| Gitea Bot | ❌ 不存在 | webhook 接收 + PR review | | Gitea Bot | ❌ 不存在 | webhook 接收 + PR review |
| Worker | 🟡 SubAgent 雏形 | 独立任务队列 + 结果路由 | | Worker | 🟡 SubAgent 雏形 | 独立任务队列 + 结果路由 |
| Self | 🟡 life loop + reflect | 更丰富的自主行为 | | Self | 🟡 life loop + reflect | 更丰富的自主行为 |
| Gitea | ❌ 外部依赖 | 纳入 suite 部署self-hosted | | Infra | ✅ Docker all-in-one | VPS setup 脚本 + systemd |

View File

@@ -11,6 +11,40 @@ pub struct Config {
pub backend: BackendConfig, pub backend: BackendConfig,
#[serde(default)] #[serde(default)]
pub whisper_url: Option<String>, pub whisper_url: Option<String>,
#[serde(default)]
pub gitea: Option<GiteaConfig>,
}
#[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<String>,
#[serde(default = "default_webhook_port")]
pub webhook_port: u16,
#[serde(default)]
pub webhook_secret: Option<String>,
}
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 { fn default_name() -> String {

379
src/gitea.rs Normal file
View File

@@ -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<String> {
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<serde_json::Value> {
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<String>,
#[serde(default)]
comment: Option<Comment>,
#[serde(default)]
issue: Option<Issue>,
#[serde(default)]
pull_request: Option<PullRequest>,
repository: Option<Repository>,
}
#[derive(serde::Deserialize, Debug)]
struct Comment {
body: Option<String>,
user: Option<User>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct Issue {
number: u64,
title: Option<String>,
body: Option<String>,
#[serde(default)]
pull_request: Option<serde_json::Value>,
}
#[derive(serde::Deserialize, Debug, Clone)]
struct PullRequest {
number: u64,
title: Option<String>,
body: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct Repository {
full_name: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
struct User {
login: Option<String>,
}
// ── 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<Arc<WebhookState>>,
Json(payload): Json<WebhookPayload>,
) -> 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<Issue>,
pr: Option<PullRequest>,
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(())
}

View File

@@ -1,9 +1,10 @@
mod config; mod config;
mod state;
mod tools;
mod stream;
mod display; mod display;
mod gitea;
mod life; mod life;
mod state;
mod stream;
mod tools;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::{Path, PathBuf}; 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 config_path = std::env::var("NOC_CONFIG").unwrap_or_else(|_| "config.yaml".into());
let raw = std::fs::read_to_string(&config_path) let raw = std::fs::read_to_string(&config_path)
.unwrap_or_else(|e| panic!("read {config_path}: {e}")); .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}")); 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") let state_path = std::env::var("NOC_STATE")
.map(PathBuf::from) .map(PathBuf::from)
@@ -93,6 +97,16 @@ async fn main() {
// start life loop // start life loop
tokio::spawn(life::life_loop(bot.clone(), state.clone(), config.clone())); 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) Dispatcher::builder(bot, handler)
.dependencies(dptree::deps![state, config, bot_username]) .dependencies(dptree::deps![state, config, bot_username])
.default_handler(|_| async {}) .default_handler(|_| async {})

View File

@@ -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 // discover script tools
@@ -350,6 +366,46 @@ pub async fn execute_tool(
Err(e) => format!("Error: {e}"), 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" => { "gen_voice" => {
let text = args["text"].as_str().unwrap_or(""); let text = args["text"].as_str().unwrap_or("");
if text.is_empty() { if text.is_empty() {