add tool calling, SQLite persistence, group chat, image vision, voice transcription

Major features:
- OpenAI function calling with tool call loop (streaming SSE parsing)
- Built-in tools: spawn_agent (async claude -p), agent_status, kill_agent,
  update_scratch, send_file
- Script-based tool discovery: tools/ dir with --schema convention
- Feishu todo management script (tools/manage_todo)
- SQLite persistence: conversations, messages, config, scratch_area tables
- Sliding window context (100 msgs, slide 50, auto-summarize)
- Conversation summary generation via LLM on window slide
- Group chat support with independent session contexts
- Image understanding: multimodal vision input (base64 to API)
- Voice transcription via faster-whisper Docker service
- Configurable persona stored in DB
- diag command for session diagnostics
- System prompt restructured: persona + tool instructions separated
- RUST_BACKTRACE=1 in service, clippy in deploy pipeline
- .gitignore for config/state/db files
This commit is contained in:
Fam Zheng
2026-04-09 16:38:28 +01:00
parent 84ba209b3f
commit 128f2481c0
9 changed files with 1840 additions and 51 deletions

6
.gitignore vendored
View File

@@ -1,5 +1,5 @@
/target
config.yaml config.yaml
config.hera.yaml config.*.yaml
state.json state.json
noc.service state.*.json
*.db

92
Cargo.lock generated
View File

@@ -2,6 +2,18 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -258,6 +270,18 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.0" version = "2.4.0"
@@ -465,6 +489,15 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -480,6 +513,15 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@@ -886,6 +928,17 @@ version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -983,10 +1036,13 @@ name = "noc"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1",
"chrono", "chrono",
"dptree", "dptree",
"libc",
"pulldown-cmark", "pulldown-cmark",
"reqwest 0.12.28", "reqwest 0.12.28",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
@@ -1300,6 +1356,7 @@ dependencies = [
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util",
"h2 0.4.13", "h2 0.4.13",
"http 1.4.0", "http 1.4.0",
"http-body 1.0.1", "http-body 1.0.1",
@@ -1311,6 +1368,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
@@ -1344,6 +1402,20 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.11.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -2607,6 +2679,26 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.7" version = "0.1.7"

View File

@@ -5,13 +5,16 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
dptree = "0.3" dptree = "0.3"
libc = "0.2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
pulldown-cmark = "0.12" pulldown-cmark = "0.12"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json", "multipart"] }
rusqlite = { version = "0.32", features = ["bundled"] }
teloxide = { version = "0.12", features = ["macros"] } teloxide = { version = "0.12", features = ["macros"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v5"] } uuid = { version = "1", features = ["v5"] }

View File

@@ -2,15 +2,19 @@ REPO := $(shell pwd)
HERA := heradev HERA := heradev
HERA_DIR := noc HERA_DIR := noc
.PHONY: build deploy deploy-hera .PHONY: build test deploy deploy-hera
build: build:
cargo build --release cargo build --release
test:
cargo clippy -- -D warnings
cargo test -- --nocapture
noc.service: noc.service.in noc.service: noc.service.in
sed -e 's|@REPO@|$(REPO)|g' -e 's|@PATH@|$(PATH)|g' $< > $@ sed -e 's|@REPO@|$(REPO)|g' -e 's|@PATH@|$(PATH)|g' $< > $@
deploy: build noc.service deploy: test build noc.service
mkdir -p ~/bin ~/.config/systemd/user mkdir -p ~/bin ~/.config/systemd/user
systemctl --user stop noc 2>/dev/null || true systemctl --user stop noc 2>/dev/null || true
install target/release/noc ~/bin/noc install target/release/noc ~/bin/noc

View File

@@ -1,10 +1,44 @@
# TODO # noc roadmap
- [ ] Streaming responses — edit message as claude output arrives instead of waiting for full completion ## "会呼吸的助手" — 让 noc 活着
- [ ] Markdown formatting — parse claude output and send with TG MarkdownV2
- [ ] Timeout handling — kill claude if it hangs beyond a threshold 核心理念noc 不应该只在收到消息时才被唤醒,而是一个持续运行、有自己节奏的存在。
- [ ] Graceful shutdown on SIGTERM
- [ ] `/reset` command to force new session without waiting for 5am ### 主动行为
- [ ] Rate limiting per chat - [ ] 定时任务 (cron)LLM 可以自己设置提醒、定期检查
- [ ] Voice message support — STT (whisper.cpp) → text → claude - [ ] 事件驱动监控文件变化、git push、CI 状态等,主动通知
- [ ] Video/audio file transcription - [ ] 晨间/晚间报告:每天自动汇总待办、提醒重要事项
- [ ] 情境感知:根据时间、地点、日历自动调整行为
### 记忆与成长
- [ ] 长期记忆 (MEMORY.md):跨 session 的持久化记忆
- [ ] 语义搜索:基于 embedding 的记忆检索
- [ ] 自我反思:定期回顾对话质量,优化自己的行为
### 感知能力
- [x] 图片理解multimodal vision input
- [ ] 语音转录whisper API 转文字
- [ ] 屏幕/截图分析
- [ ] 链接预览/摘要
### 交互体验
- [x] 群组支持:独立上下文
- [x] 流式输出sendMessageDraft + editMessageText
- [x] Markdown 渲染
- [ ] Typing indicator
- [ ] Inline keyboard 交互
- [ ] 语音回复 (TTS)
### 工具生态
- [x] 脚本工具发现 (tools/ + --schema)
- [x] 异步子代理 (spawn_agent)
- [x] 飞书待办管理
- [ ] Web search / fetch
- [ ] 更多脚本工具
- [ ] MCP 协议支持
### 可靠性
- [ ] API 重试策略 (指数退避)
- [ ] 用量追踪
- [ ] Context pruning (只裁工具输出)
- [ ] Model failover

View File

@@ -10,6 +10,7 @@ ExecStart=%h/bin/noc
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
Environment=RUST_LOG=noc=info Environment=RUST_LOG=noc=info
Environment=RUST_BACKTRACE=1
Environment=NOC_CONFIG=@REPO@/config.yaml Environment=NOC_CONFIG=@REPO@/config.yaml
Environment=NOC_STATE=@REPO@/state.json Environment=NOC_STATE=@REPO@/state.json
Environment=PATH=@PATH@ Environment=PATH=@PATH@

File diff suppressed because it is too large Load Diff

361
tests/tool_call.rs Normal file
View File

@@ -0,0 +1,361 @@
//! Integration test: verify tool call round-trip with Ollama's OpenAI-compatible API.
//! Requires Ollama running at OLLAMA_URL (default: http://100.84.7.49:11434).
use serde_json::json;
const OLLAMA_URL: &str = "http://100.84.7.49:11434/v1";
const MODEL: &str = "gemma4:31b";
fn tools() -> serde_json::Value {
json!([{
"type": "function",
"function": {
"name": "calculator",
"description": "Calculate a math expression",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Math expression to evaluate"}
},
"required": ["expression"]
}
}
}])
}
/// Test non-streaming tool call round-trip
#[tokio::test]
async fn test_tool_call_roundtrip_non_streaming() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
// Round 1: ask the model to use the calculator
let body = json!({
"model": MODEL,
"messages": [
{"role": "user", "content": "What is 2+2? Use the calculator tool."}
],
"tools": tools(),
});
let resp = client.post(&url).json(&body).send().await.unwrap();
assert!(resp.status().is_success(), "Round 1 failed: {}", resp.status());
let result: serde_json::Value = resp.json().await.unwrap();
let choice = &result["choices"][0];
assert_eq!(
choice["finish_reason"].as_str().unwrap(),
"tool_calls",
"Expected tool_calls finish_reason, got: {choice}"
);
let tool_calls = choice["message"]["tool_calls"].as_array().unwrap();
assert!(!tool_calls.is_empty(), "No tool calls returned");
let tc = &tool_calls[0];
let call_id = tc["id"].as_str().unwrap();
let func_name = tc["function"]["name"].as_str().unwrap();
assert_eq!(func_name, "calculator");
// Round 2: send tool result back
let body2 = json!({
"model": MODEL,
"messages": [
{"role": "user", "content": "What is 2+2? Use the calculator tool."},
{
"role": "assistant",
"content": "",
"tool_calls": [{
"id": call_id,
"type": "function",
"function": {
"name": func_name,
"arguments": tc["function"]["arguments"].as_str().unwrap()
}
}]
},
{
"role": "tool",
"tool_call_id": call_id,
"content": "4"
}
],
"tools": tools(),
});
let resp2 = client.post(&url).json(&body2).send().await.unwrap();
let status2 = resp2.status();
let body2_text = resp2.text().await.unwrap();
assert!(
status2.is_success(),
"Round 2 failed ({status2}): {body2_text}"
);
let result2: serde_json::Value = serde_json::from_str(&body2_text).unwrap();
let content = result2["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
assert!(!content.is_empty(), "Expected content in round 2 response");
println!("Round 2 response: {content}");
}
/// Test tool call with conversation history (simulates real scenario)
#[tokio::test]
async fn test_tool_call_with_history() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
// Simulate real message history with system prompt
let body = json!({
"model": MODEL,
"stream": true,
"messages": [
{"role": "system", "content": "你是一个AI助手。你可以使用提供的工具来完成任务。当需要执行命令、运行代码或启动复杂子任务时直接调用对应的工具不要只是描述你会怎么做。"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "What is 3+4? Use the calculator."}
],
"tools": tools(),
});
// Round 1: expect tool call
let mut resp = client.post(&url).json(&body).send().await.unwrap();
assert!(resp.status().is_success(), "Round 1 failed: {}", resp.status());
let mut buffer = String::new();
let mut tc_id = String::new();
let mut tc_name = String::new();
let mut tc_args = String::new();
let mut has_tc = false;
while let Some(chunk) = resp.chunk().await.unwrap() {
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 1..].to_string();
if let Some(data) = line.trim().strip_prefix("data: ") {
if data.trim() == "[DONE]" { break; }
if let Ok(j) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(arr) = j["choices"][0]["delta"]["tool_calls"].as_array() {
has_tc = true;
for tc in arr {
if let Some(id) = tc["id"].as_str() { tc_id = id.into(); }
if let Some(n) = tc["function"]["name"].as_str() { tc_name = n.into(); }
if let Some(a) = tc["function"]["arguments"].as_str() { tc_args.push_str(a); }
}
}
}
}
}
}
assert!(has_tc, "Expected tool call, got content only");
println!("Tool: {tc_name}({tc_args}) id={tc_id}");
// Round 2: tool result → expect content
let body2 = json!({
"model": MODEL,
"stream": true,
"messages": [
{"role": "system", "content": "你是一个AI助手。"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Hello!"},
{"role": "user", "content": "What is 3+4? Use the calculator."},
{"role": "assistant", "content": "", "tool_calls": [{"id": tc_id, "type": "function", "function": {"name": tc_name, "arguments": tc_args}}]},
{"role": "tool", "tool_call_id": tc_id, "content": "7"}
],
"tools": tools(),
});
let resp2 = client.post(&url).json(&body2).send().await.unwrap();
let status = resp2.status();
if !status.is_success() {
let err = resp2.text().await.unwrap();
panic!("Round 2 failed ({status}): {err}");
}
let mut resp2 = client.post(&url).json(&body2).send().await.unwrap();
let mut content = String::new();
let mut buf2 = String::new();
while let Some(chunk) = resp2.chunk().await.unwrap() {
buf2.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buf2.find('\n') {
let line = buf2[..pos].to_string();
buf2 = buf2[pos + 1..].to_string();
if let Some(data) = line.trim().strip_prefix("data: ") {
if data.trim() == "[DONE]" { break; }
if let Ok(j) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(c) = j["choices"][0]["delta"]["content"].as_str() {
content.push_str(c);
}
}
}
}
}
println!("Final response: {content}");
assert!(!content.is_empty(), "Expected non-empty content in round 2");
}
/// Test multimodal image input
#[tokio::test]
async fn test_image_multimodal() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
// 2x2 red PNG generated by PIL
let b64 = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP8z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg==";
let body = json!({
"model": MODEL,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "What color is this image? Reply with just the color name."},
{"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{b64}")}}
]
}],
});
let resp = client.post(&url).json(&body).send().await.unwrap();
let status = resp.status();
let text = resp.text().await.unwrap();
assert!(status.is_success(), "Multimodal request failed ({status}): {text}");
let result: serde_json::Value = serde_json::from_str(&text).unwrap();
let content = result["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
println!("Image description: {content}");
assert!(!content.is_empty(), "Expected non-empty response for image");
}
/// Test streaming tool call round-trip (matches our actual code path)
#[tokio::test]
async fn test_tool_call_roundtrip_streaming() {
let client = reqwest::Client::new();
let url = format!("{OLLAMA_URL}/chat/completions");
// Round 1: streaming, get tool calls
let body = json!({
"model": MODEL,
"stream": true,
"messages": [
{"role": "user", "content": "What is 7*6? Use the calculator tool."}
],
"tools": tools(),
});
let mut resp = client.post(&url).json(&body).send().await.unwrap();
assert!(resp.status().is_success(), "Round 1 streaming failed");
// Parse SSE to extract tool calls
let mut buffer = String::new();
let mut tool_call_id = String::new();
let mut tool_call_name = String::new();
let mut tool_call_args = String::new();
let mut has_tool_calls = false;
while let Some(chunk) = resp.chunk().await.unwrap() {
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 1..].to_string();
let trimmed = line.trim();
if let Some(data) = trimmed.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
break;
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) {
let delta = &json["choices"][0]["delta"];
if let Some(tc_arr) = delta["tool_calls"].as_array() {
has_tool_calls = true;
for tc in tc_arr {
if let Some(id) = tc["id"].as_str() {
tool_call_id = id.to_string();
}
if let Some(name) = tc["function"]["name"].as_str() {
tool_call_name = name.to_string();
}
if let Some(args) = tc["function"]["arguments"].as_str() {
tool_call_args.push_str(args);
}
}
}
}
}
}
}
assert!(has_tool_calls, "No tool calls in streaming response");
assert_eq!(tool_call_name, "calculator");
println!("Tool call: {tool_call_name}({tool_call_args}) id={tool_call_id}");
// Round 2: send tool result, streaming
let body2 = json!({
"model": MODEL,
"stream": true,
"messages": [
{"role": "user", "content": "What is 7*6? Use the calculator tool."},
{
"role": "assistant",
"content": "",
"tool_calls": [{
"id": tool_call_id,
"type": "function",
"function": {
"name": tool_call_name,
"arguments": tool_call_args
}
}]
},
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": "42"
}
],
"tools": tools(),
});
let resp2 = client.post(&url).json(&body2).send().await.unwrap();
let status2 = resp2.status();
if !status2.is_success() {
let err = resp2.text().await.unwrap();
panic!("Round 2 streaming failed ({status2}): {err}");
}
// Collect content from streaming response
let mut resp2 = client
.post(&url)
.json(&body2)
.send()
.await
.unwrap();
let mut content = String::new();
let mut buffer2 = String::new();
while let Some(chunk) = resp2.chunk().await.unwrap() {
buffer2.push_str(&String::from_utf8_lossy(&chunk));
while let Some(pos) = buffer2.find('\n') {
let line = buffer2[..pos].to_string();
buffer2 = buffer2[pos + 1..].to_string();
let trimmed = line.trim();
if let Some(data) = trimmed.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
break;
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(c) = json["choices"][0]["delta"]["content"].as_str() {
content.push_str(c);
}
}
}
}
}
assert!(!content.is_empty(), "Expected content in round 2 streaming");
println!("Round 2 streaming content: {content}");
}

187
tools/manage_todo Executable file
View File

@@ -0,0 +1,187 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["requests"]
# ///
"""Feishu Bitable todo manager.
Usage:
./fam-todo.py list-undone List open todos
./fam-todo.py list-done List completed todos
./fam-todo.py add <title> Add a new todo
./fam-todo.py mark-done <record_id> Mark as done
./fam-todo.py mark-undone <record_id> Mark as undone
./fam-todo.py --schema Print tool schema JSON
"""
import json
import sys
import requests
APP_ID = "cli_a7f042e93d385013"
APP_SECRET = "ht4FCjQ8JJ65ZPUWlff6ldFBmaP0mxqY"
APP_TOKEN = "SSoGbmGFoazJkUs7bbfcaSG8n7f"
TABLE_ID = "tblIA2biceDpvr35"
BASE_URL = "https://open.feishu.cn/open-apis"
ACTIONS = ["list-undone", "list-done", "add", "mark-done", "mark-undone"]
SCHEMA = {
"name": "fam_todo",
"description": "管理 Fam 的飞书待办事项表格。",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ACTIONS,
"description": "操作类型",
},
"title": {
"type": "string",
"description": "待办标题 (add 时必填)",
},
"record_id": {
"type": "string",
"description": "记录ID (mark-done/mark-undone 时必填)",
},
},
"required": ["action"],
},
}
def get_token():
r = requests.post(
f"{BASE_URL}/auth/v3/tenant_access_token/internal/",
json={"app_id": APP_ID, "app_secret": APP_SECRET},
)
r.raise_for_status()
return r.json()["tenant_access_token"]
def headers():
return {"Authorization": f"Bearer {get_token()}", "Content-Type": "application/json"}
def api(method, path, **kwargs):
url = f"{BASE_URL}/bitable/v1/apps/{APP_TOKEN}/tables/{TABLE_ID}{path}"
r = requests.request(method, url, headers=headers(), **kwargs)
r.raise_for_status()
return r.json()
def format_field(v):
if isinstance(v, list):
return "".join(
seg.get("text", str(seg)) if isinstance(seg, dict) else str(seg)
for seg in v
)
return str(v)
def list_records(done_filter):
"""List records. done_filter: True=done only, False=undone only."""
data = api("GET", "/records", params={"page_size": 500})
items = data.get("data", {}).get("items", [])
if not items:
return "No records found."
lines = []
for item in items:
fields = item.get("fields", {})
is_done = bool(fields.get("Done"))
if is_done != done_filter:
continue
rid = item["record_id"]
title = format_field(fields.get("Item", ""))
priority = fields.get("Priority", "")
notes = format_field(fields.get("Notes", ""))
parts = [f"[{rid}] {title}"]
if priority:
parts.append(f" P: {priority}")
if notes:
preview = notes[:80].replace("\n", " ")
parts.append(f" Note: {preview}")
lines.append("\n".join(parts))
if not lines:
label = "completed" if done_filter else "open"
return f"No {label} todos."
return "\n".join(lines)
def add_record(title):
data = api("POST", "/records", json={"fields": {"Item": title}})
rid = data.get("data", {}).get("record", {}).get("record_id", "?")
return f"Added [{rid}]: {title}"
def mark_done(record_id):
api("PUT", f"/records/{record_id}", json={"fields": {"Done": True}})
return f"Marked [{record_id}] as done"
def mark_undone(record_id):
api("PUT", f"/records/{record_id}", json={"fields": {"Done": False}})
return f"Marked [{record_id}] as undone"
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("--help", "-h"):
print(__doc__.strip())
sys.exit(0)
if sys.argv[1] == "--schema":
print(json.dumps(SCHEMA, ensure_ascii=False))
sys.exit(0)
arg = sys.argv[1]
if not arg.startswith("{"):
args = {"action": arg}
if len(sys.argv) > 2:
args["title"] = " ".join(sys.argv[2:])
args["record_id"] = sys.argv[2] # also set record_id for mark-*
else:
try:
args = json.loads(arg)
except json.JSONDecodeError as e:
print(f"Invalid JSON: {e}")
sys.exit(1)
action = args.get("action", "")
try:
if action == "list-undone":
print(list_records(done_filter=False))
elif action == "list-done":
print(list_records(done_filter=True))
elif action == "add":
title = args.get("title", "")
if not title:
print("Error: title is required")
sys.exit(1)
print(add_record(title))
elif action == "mark-done":
rid = args.get("record_id", "")
if not rid:
print("Error: record_id is required")
sys.exit(1)
print(mark_done(rid))
elif action == "mark-undone":
rid = args.get("record_id", "")
if not rid:
print("Error: record_id is required")
sys.exit(1)
print(mark_undone(rid))
else:
print(f"Unknown action: {action}. Valid: {', '.join(ACTIONS)}")
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()