Compare commits
12 Commits
529a3d0242
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c59673eac | |||
| 1a9102e871 | |||
| 5d9c716a72 | |||
|
|
a2f29838b4 | ||
|
|
7ebc98d8d0 | ||
|
|
a35d1e37c8 | ||
|
|
df31359ec4 | ||
|
|
8ad5b6146b | ||
|
|
d7c1fd7bf8 | ||
|
|
55b3c2c00c | ||
|
|
e3f00e82b9 | ||
|
|
9ec1106f53 |
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -12,7 +12,11 @@ Describe this PR in 2-5 bullets:
|
||||
- Risk label (`risk: low|medium|high`):
|
||||
- Size label (`size: XS|S|M|L|XL`, auto-managed/read-only):
|
||||
- Scope labels (`core|agent|channel|config|cron|daemon|doctor|gateway|health|heartbeat|integration|memory|observability|onboard|provider|runtime|security|service|skillforge|skills|tool|tunnel|docs|dependencies|ci|tests|scripts|dev`, comma-separated):
|
||||
<<<<<<< chore/labeler-spacing-trusted-tier
|
||||
- Module labels (`<module>: <component>`, for example `channel: telegram`, `provider: kimi`, `tool: shell`):
|
||||
=======
|
||||
- Module labels (`<module>:<component>`, for example `channel:telegram`, `provider:kimi`, `tool:shell`):
|
||||
>>>>>>> main
|
||||
- Contributor tier label (`trusted contributor|experienced contributor|principal contributor|distinguished contributor`, auto-managed/read-only; author merged PRs >=5/10/20/50):
|
||||
- If any auto-label is incorrect, note requested correction:
|
||||
|
||||
|
||||
1
.github/workflows/auto-response.yml
vendored
1
.github/workflows/auto-response.yml
vendored
@@ -18,6 +18,7 @@ jobs:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Apply contributor tier label for issue author
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
|
||||
27
.github/workflows/labeler.yml
vendored
27
.github/workflows/labeler.yml
vendored
@@ -325,13 +325,18 @@ jobs:
|
||||
return pattern.test(text);
|
||||
}
|
||||
|
||||
function formatModuleLabel(prefix, segment) {
|
||||
return `${prefix}: ${segment}`;
|
||||
}
|
||||
|
||||
function parseModuleLabel(label) {
|
||||
const separatorIndex = label.indexOf(":");
|
||||
if (separatorIndex <= 0 || separatorIndex >= label.length - 1) return null;
|
||||
return {
|
||||
prefix: label.slice(0, separatorIndex),
|
||||
segment: label.slice(separatorIndex + 1),
|
||||
};
|
||||
if (typeof label !== "string") return null;
|
||||
const match = label.match(/^([^:]+):\s*(.+)$/);
|
||||
if (!match) return null;
|
||||
const prefix = match[1].trim().toLowerCase();
|
||||
const segment = (match[2] || "").trim().toLowerCase();
|
||||
if (!prefix || !segment) return null;
|
||||
return { prefix, segment };
|
||||
}
|
||||
|
||||
function sortByPriority(labels, priorityIndex) {
|
||||
@@ -389,7 +394,7 @@ jobs:
|
||||
for (const [prefix, segments] of segmentsByPrefix) {
|
||||
const hasSpecificSegment = [...segments].some((segment) => segment !== "core");
|
||||
if (hasSpecificSegment) {
|
||||
refined.delete(`${prefix}:core`);
|
||||
refined.delete(formatModuleLabel(prefix, "core"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +423,7 @@ jobs:
|
||||
if (uniqueSegments.length === 0) continue;
|
||||
|
||||
if (uniqueSegments.length === 1) {
|
||||
compactedModuleLabels.add(`${prefix}:${uniqueSegments[0]}`);
|
||||
compactedModuleLabels.add(formatModuleLabel(prefix, uniqueSegments[0]));
|
||||
} else {
|
||||
forcePathPrefixes.add(prefix);
|
||||
}
|
||||
@@ -609,7 +614,7 @@ jobs:
|
||||
segment = normalizeLabelSegment(segment);
|
||||
if (!segment) continue;
|
||||
|
||||
detectedModuleLabels.add(`${rule.prefix}:${segment}`);
|
||||
detectedModuleLabels.add(formatModuleLabel(rule.prefix, segment));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,7 +640,7 @@ jobs:
|
||||
|
||||
for (const keyword of providerKeywordHints) {
|
||||
if (containsKeyword(searchableText, keyword)) {
|
||||
detectedModuleLabels.add(`provider:${keyword}`);
|
||||
detectedModuleLabels.add(formatModuleLabel("provider", keyword));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,7 +666,7 @@ jobs:
|
||||
|
||||
for (const keyword of channelKeywordHints) {
|
||||
if (containsKeyword(searchableText, keyword)) {
|
||||
detectedModuleLabels.add(`channel:${keyword}`);
|
||||
detectedModuleLabels.add(formatModuleLabel("channel", keyword));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -10,6 +10,15 @@ docker-compose.override.yml
|
||||
|
||||
# Environment files (may contain secrets)
|
||||
.env
|
||||
|
||||
# Python virtual environments
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# ESP32 build cache (esp-idf-sys managed)
|
||||
|
||||
.embuild/
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4927,6 +4927,7 @@ dependencies = [
|
||||
"prometheus",
|
||||
"prost",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rppal",
|
||||
"rusqlite",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
[workspace]
|
||||
members = ["."]
|
||||
resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "zeroclaw"
|
||||
version = "0.1.0"
|
||||
@@ -86,6 +90,7 @@ glob = "0.3"
|
||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
||||
futures = "0.3"
|
||||
regex = "1.10"
|
||||
hostname = "0.4.2"
|
||||
lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
|
||||
mail-parser = "0.11.2"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Fast, small, and fully autonomous AI assistant infrastructure — deploy anywhere, swap anything.
|
||||
|
||||
```
|
||||
~3.4MB binary · <10ms startup · 1,017 tests · 22+ providers · 8 traits · Pluggable everything
|
||||
~3.4MB binary · <10ms startup · 1,017 tests · 23+ providers · 8 traits · Pluggable everything
|
||||
```
|
||||
|
||||
### ✨ Features
|
||||
@@ -191,7 +191,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze
|
||||
|
||||
| Subsystem | Trait | Ships with | Extend |
|
||||
|-----------|-------|------------|--------|
|
||||
| **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
|
||||
| **AI Models** | `Provider` | 23+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, Astrai, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
|
||||
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | Any messaging API |
|
||||
| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Lucid bridge (CLI sync + SQLite fallback), Markdown | Any persistence backend |
|
||||
| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), browser (agent-browser / rust-native), composio (optional) | Any capability |
|
||||
|
||||
@@ -27,7 +27,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
||||
### Optional Repository Automation
|
||||
|
||||
- `.github/workflows/labeler.yml` (`PR Labeler`)
|
||||
- Purpose: scope/path labels + size/risk labels + fine-grained module labels (`<module>:<component>`)
|
||||
- Purpose: scope/path labels + size/risk labels + fine-grained module labels (`<module>: <component>`)
|
||||
- Additional behavior: label descriptions are auto-managed as hover tooltips to explain each auto-judgment rule
|
||||
- Additional behavior: provider-related keywords in provider/config/onboard/integration changes are promoted to `provider:*` labels (for example `provider:kimi`, `provider:deepseek`)
|
||||
- Additional behavior: hierarchical de-duplication keeps only the most specific scope labels (for example `tool:composio` suppresses `tool:core` and `tool`)
|
||||
|
||||
@@ -244,7 +244,7 @@ Label discipline:
|
||||
- Path labels identify subsystem ownership quickly.
|
||||
- Size labels drive batching strategy.
|
||||
- Risk labels drive review depth (`risk: low/medium/high`).
|
||||
- Module labels (`<module>:<component>`) improve reviewer routing for integration-specific changes and future newly-added modules.
|
||||
- Module labels (`<module>: <component>`) improve reviewer routing for integration-specific changes and future newly-added modules.
|
||||
- `risk: manual` allows maintainers to preserve a human risk judgment when automation lacks context.
|
||||
- `no-stale` is reserved for accepted-but-blocked work.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Use it to reduce review latency without reducing quality.
|
||||
For every new PR, do a fast intake pass:
|
||||
|
||||
1. Confirm template completeness (`summary`, `validation`, `security`, `rollback`).
|
||||
2. Confirm labels (`size:*`, `risk:*`, scope labels such as `provider`/`channel`/`security`, module-scoped labels such as `channel:*`/`provider:*`/`tool:*`, and contributor tier labels when applicable) are present and plausible.
|
||||
2. Confirm labels (`size:*`, `risk:*`, scope labels such as `provider`/`channel`/`security`, module-scoped labels such as `channel: *`/`provider: *`/`tool: *`, and contributor tier labels when applicable) are present and plausible.
|
||||
3. Confirm CI signal status (`CI Required Gate`).
|
||||
4. Confirm scope is one concern (reject mixed mega-PRs unless justified).
|
||||
5. Confirm privacy/data-hygiene and neutral test wording requirements are satisfied.
|
||||
|
||||
@@ -2,4 +2,10 @@
|
||||
target = "riscv32imc-esp-espidf"
|
||||
|
||||
[target.riscv32imc-esp-espidf]
|
||||
linker = "ldproxy"
|
||||
runner = "espflash flash --monitor"
|
||||
# ESP-IDF 5.x uses 64-bit time_t
|
||||
rustflags = ["-C", "default-linker-libraries", "--cfg", "espidf_time64"]
|
||||
|
||||
[unstable]
|
||||
build-std = ["std", "panic_abort"]
|
||||
|
||||
106
firmware/zeroclaw-esp32/Cargo.lock
generated
106
firmware/zeroclaw-esp32/Cargo.lock
generated
@@ -58,24 +58,22 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.63.0"
|
||||
version = "0.71.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885"
|
||||
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"bitflags 2.11.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"lazy_static",
|
||||
"lazycell",
|
||||
"itertools",
|
||||
"log",
|
||||
"peeking_take_while",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 1.0.109",
|
||||
"which",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -374,14 +372,15 @@ checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01"
|
||||
|
||||
[[package]]
|
||||
name = "embassy-sync"
|
||||
version = "0.5.0"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd938f25c0798db4280fcd8026bf4c2f48789aebf8f77b6e5cf8a7693ba114ec"
|
||||
checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"critical-section",
|
||||
"embedded-io-async",
|
||||
"futures-util",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"heapless",
|
||||
]
|
||||
|
||||
@@ -446,16 +445,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "embedded-svc"
|
||||
version = "0.27.1"
|
||||
version = "0.28.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac6f87e7654f28018340aa55f933803017aefabaa5417820a3b2f808033c7bbc"
|
||||
checksum = "a7770e30ab55cfbf954c00019522490d6ce26a3334bede05a732ba61010e98e0"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
"embedded-io",
|
||||
"embedded-io-async",
|
||||
"enumset",
|
||||
"heapless",
|
||||
"no-std-net",
|
||||
"num_enum",
|
||||
"serde",
|
||||
"strum 0.25.0",
|
||||
@@ -463,9 +461,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "embuild"
|
||||
version = "0.31.4"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4caa4f198bb9152a55c0103efb83fa4edfcbb8625f4c9e94ae8ec8e23827c563"
|
||||
checksum = "e188ad2bbe82afa841ea4a29880651e53ab86815db036b2cb9f8de3ac32dad75"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bindgen",
|
||||
@@ -475,6 +473,7 @@ dependencies = [
|
||||
"globwalk",
|
||||
"home",
|
||||
"log",
|
||||
"regex",
|
||||
"remove_dir_all",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -533,9 +532,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp-idf-hal"
|
||||
version = "0.43.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7adf3fb19a9ca016cbea1ab8a7b852ac69df8fcde4923c23d3b155efbc42a74"
|
||||
version = "0.45.2"
|
||||
source = "git+https://github.com/esp-rs/esp-idf-hal#bc48639bd626c72afc1e25e5d497b5c639161d30"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"embassy-sync",
|
||||
@@ -552,14 +550,12 @@ dependencies = [
|
||||
"heapless",
|
||||
"log",
|
||||
"nb 1.1.0",
|
||||
"num_enum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "esp-idf-svc"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2180642ca122a7fec1ec417a9b1a77aa66aaa067fdf1daae683dd8caba84f26b"
|
||||
version = "0.51.0"
|
||||
source = "git+https://github.com/esp-rs/esp-idf-svc#dee202f146c7681e54eabbf118a216fc0195d203"
|
||||
dependencies = [
|
||||
"embassy-futures",
|
||||
"embedded-hal-async",
|
||||
@@ -567,6 +563,7 @@ dependencies = [
|
||||
"embuild",
|
||||
"enumset",
|
||||
"esp-idf-hal",
|
||||
"futures-io",
|
||||
"heapless",
|
||||
"log",
|
||||
"num_enum",
|
||||
@@ -575,14 +572,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "esp-idf-sys"
|
||||
version = "0.34.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e148f97c04ed3e9181a08bcdc9560a515aad939b0ba7f50a0022e294665e0af"
|
||||
version = "0.36.1"
|
||||
source = "git+https://github.com/esp-rs/esp-idf-sys#64667a38fb8004e1fc3b032488af6857ca3cd849"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bindgen",
|
||||
"build-time",
|
||||
"cargo_metadata",
|
||||
"cmake",
|
||||
"const_format",
|
||||
"embuild",
|
||||
"envy",
|
||||
@@ -649,21 +645,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
]
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
@@ -827,6 +818,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -843,18 +843,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "lazycell"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -945,12 +933,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no-std-net"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bcece43b12349917e096cddfa66107277f123e6c96a5aea78711dc601a47152"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -1007,18 +989,6 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "peeking_take_while"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -1138,9 +1108,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
|
||||
@@ -14,15 +14,21 @@ edition = "2021"
|
||||
license = "MIT"
|
||||
description = "ZeroClaw ESP32 peripheral firmware — GPIO over JSON serial"
|
||||
|
||||
[patch.crates-io]
|
||||
# Use latest esp-rs crates to fix u8/i8 char pointer compatibility with ESP-IDF 5.x
|
||||
esp-idf-sys = { git = "https://github.com/esp-rs/esp-idf-sys" }
|
||||
esp-idf-hal = { git = "https://github.com/esp-rs/esp-idf-hal" }
|
||||
esp-idf-svc = { git = "https://github.com/esp-rs/esp-idf-svc" }
|
||||
|
||||
[dependencies]
|
||||
esp-idf-svc = "0.48"
|
||||
esp-idf-svc = { git = "https://github.com/esp-rs/esp-idf-svc" }
|
||||
log = "0.4"
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[build-dependencies]
|
||||
embuild = "0.31"
|
||||
embuild = { version = "0.33", features = ["espidf"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
Peripheral firmware for ESP32 — speaks the same JSON-over-serial protocol as the STM32 firmware. Flash this to your ESP32, then configure ZeroClaw on the host to connect via serial.
|
||||
|
||||
**New to this?** See [SETUP.md](SETUP.md) for step-by-step commands and troubleshooting.
|
||||
|
||||
## Protocol
|
||||
|
||||
|
||||
- **Request** (host → ESP32): `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}\n`
|
||||
- **Response** (ESP32 → host): `{"id":"1","ok":true,"result":"done"}\n`
|
||||
|
||||
@@ -11,19 +14,44 @@ Commands: `gpio_read`, `gpio_write`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **ESP toolchain** (espup):
|
||||
1. **RISC-V ESP-IDF** (ESP32-C2/C3): Uses nightly Rust with `build-std`.
|
||||
|
||||
**Python**: ESP-IDF requires Python 3.10–3.13 (not 3.14). If you have Python 3.14:
|
||||
```sh
|
||||
brew install python@3.12
|
||||
```
|
||||
|
||||
**virtualenv** (needed by ESP-IDF tools; PEP 668 workaround on macOS):
|
||||
```sh
|
||||
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
|
||||
```
|
||||
|
||||
**Rust tools**:
|
||||
```sh
|
||||
cargo install espflash ldproxy
|
||||
```
|
||||
|
||||
The project's `rust-toolchain.toml` pins nightly + rust-src. `esp-idf-sys` downloads ESP-IDF automatically on first build. Use Python 3.12 for the build:
|
||||
```sh
|
||||
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
|
||||
```
|
||||
|
||||
2. **Xtensa targets** (ESP32, ESP32-S2, ESP32-S3): Use espup instead:
|
||||
```sh
|
||||
cargo install espup espflash
|
||||
espup install
|
||||
source ~/export-esp.sh # or ~/export-esp.fish for Fish
|
||||
source ~/export-esp.sh
|
||||
```
|
||||
|
||||
2. **Target**: ESP32-C3 (RISC-V) by default. Edit `.cargo/config.toml` for other targets (e.g. `xtensa-esp32-espidf` for original ESP32).
|
||||
Then edit `.cargo/config.toml` to change the target (e.g. `xtensa-esp32-espidf`).
|
||||
|
||||
## Build & Flash
|
||||
|
||||
```sh
|
||||
cd firmware/zeroclaw-esp32
|
||||
# Use Python 3.12 (required if you have 3.14)
|
||||
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
|
||||
# Optional: pin MCU (esp32c3 or esp32c2)
|
||||
export MCU=esp32c3
|
||||
cargo build --release
|
||||
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
|
||||
```
|
||||
|
||||
156
firmware/zeroclaw-esp32/SETUP.md
Normal file
156
firmware/zeroclaw-esp32/SETUP.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# ESP32 Firmware Setup Guide
|
||||
|
||||
Step-by-step setup for building the ZeroClaw ESP32 firmware. Follow this if you run into issues.
|
||||
|
||||
## Quick Start (copy-paste)
|
||||
|
||||
```sh
|
||||
# 1. Install Python 3.12 (ESP-IDF needs 3.10–3.13, not 3.14)
|
||||
brew install python@3.12
|
||||
|
||||
# 2. Install virtualenv (PEP 668 workaround on macOS)
|
||||
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
|
||||
|
||||
# 3. Install Rust tools
|
||||
cargo install espflash ldproxy
|
||||
|
||||
# 4. Build
|
||||
cd firmware/zeroclaw-esp32
|
||||
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
|
||||
cargo build --release
|
||||
|
||||
# 5. Flash (connect ESP32 via USB)
|
||||
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Detailed Steps
|
||||
|
||||
### 1. Python
|
||||
|
||||
ESP-IDF requires Python 3.10–3.13. **Python 3.14 is not supported.**
|
||||
|
||||
```sh
|
||||
brew install python@3.12
|
||||
```
|
||||
|
||||
### 2. virtualenv
|
||||
|
||||
ESP-IDF tools need `virtualenv`. On macOS with Homebrew Python, PEP 668 blocks `pip install`; use:
|
||||
|
||||
```sh
|
||||
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
|
||||
```
|
||||
|
||||
### 3. Rust Tools
|
||||
|
||||
```sh
|
||||
cargo install espflash ldproxy
|
||||
```
|
||||
|
||||
- **espflash**: flash and monitor
|
||||
- **ldproxy**: linker for ESP-IDF builds
|
||||
|
||||
### 4. Use Python 3.12 for Builds
|
||||
|
||||
Before every build (or add to `~/.zshrc`):
|
||||
|
||||
```sh
|
||||
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
```sh
|
||||
cd firmware/zeroclaw-esp32
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
First build downloads and compiles ESP-IDF (~5–15 min).
|
||||
|
||||
### 6. Flash
|
||||
|
||||
```sh
|
||||
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No space left on device"
|
||||
|
||||
Free disk space. Common targets:
|
||||
|
||||
```sh
|
||||
# Cargo cache (often 5–20 GB)
|
||||
rm -rf ~/.cargo/registry/cache ~/.cargo/registry/src
|
||||
|
||||
# Unused Rust toolchains
|
||||
rustup toolchain list
|
||||
rustup toolchain uninstall <name>
|
||||
|
||||
# iOS Simulator runtimes (~35 GB)
|
||||
xcrun simctl delete unavailable
|
||||
|
||||
# Temp files
|
||||
rm -rf /var/folders/*/T/cargo-install*
|
||||
```
|
||||
|
||||
### "can't find crate for `core`" / "riscv32imc-esp-espidf target may not be installed"
|
||||
|
||||
This project uses **nightly Rust with build-std**, not espup. Ensure:
|
||||
|
||||
- `rust-toolchain.toml` exists (pins nightly + rust-src)
|
||||
- You are **not** sourcing `~/export-esp.sh` (that's for Xtensa targets)
|
||||
- Run `cargo build` from `firmware/zeroclaw-esp32`
|
||||
|
||||
### "externally-managed-environment" / "No module named 'virtualenv'"
|
||||
|
||||
Install virtualenv with the PEP 668 workaround:
|
||||
|
||||
```sh
|
||||
/opt/homebrew/opt/python@3.12/bin/python3.12 -m pip install virtualenv --break-system-packages
|
||||
```
|
||||
|
||||
### "expected `i64`, found `i32`" (time_t mismatch)
|
||||
|
||||
Already fixed in `.cargo/config.toml` with `espidf_time64` for ESP-IDF 5.x. If you use ESP-IDF 4.4, switch to `espidf_time32`.
|
||||
|
||||
### "expected `*const u8`, found `*const i8`" (esp-idf-svc)
|
||||
|
||||
Already fixed via `[patch.crates-io]` in `Cargo.toml` using esp-rs crates from git. Do not remove the patch.
|
||||
|
||||
### 10,000+ files in `git status`
|
||||
|
||||
The `.embuild/` directory (ESP-IDF cache) has ~100k+ files. It is in `.gitignore`. If you see them, ensure `.gitignore` contains:
|
||||
|
||||
```
|
||||
.embuild/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional: Auto-load Python 3.12
|
||||
|
||||
Add to `~/.zshrc`:
|
||||
|
||||
```sh
|
||||
# ESP32 firmware build
|
||||
export PATH="/opt/homebrew/opt/python@3.12/libexec/bin:$PATH"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Xtensa Targets (ESP32, ESP32-S2, ESP32-S3)
|
||||
|
||||
For non–RISC-V chips, use espup instead:
|
||||
|
||||
```sh
|
||||
cargo install espup espflash
|
||||
espup install
|
||||
source ~/export-esp.sh
|
||||
```
|
||||
|
||||
Then edit `.cargo/config.toml` to use `xtensa-esp32-espidf` (or the correct target).
|
||||
3
firmware/zeroclaw-esp32/rust-toolchain.toml
Normal file
3
firmware/zeroclaw-esp32/rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
components = ["rust-src"]
|
||||
@@ -6,8 +6,9 @@
|
||||
//! Protocol: same as STM32 — see docs/hardware-peripherals-design.md
|
||||
|
||||
use esp_idf_svc::hal::gpio::PinDriver;
|
||||
use esp_idf_svc::hal::prelude::*;
|
||||
use esp_idf_svc::hal::uart::*;
|
||||
use esp_idf_svc::hal::peripherals::Peripherals;
|
||||
use esp_idf_svc::hal::uart::{UartConfig, UartDriver};
|
||||
use esp_idf_svc::hal::units::Hertz;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -36,9 +37,13 @@ fn main() -> anyhow::Result<()> {
|
||||
let peripherals = Peripherals::take()?;
|
||||
let pins = peripherals.pins;
|
||||
|
||||
// Create GPIO output drivers first (they take ownership of pins)
|
||||
let mut gpio2 = PinDriver::output(pins.gpio2)?;
|
||||
let mut gpio13 = PinDriver::output(pins.gpio13)?;
|
||||
|
||||
// UART0: TX=21, RX=20 (ESP32) — ESP32-C3 may use different pins; adjust for your board
|
||||
let config = UartConfig::new().baudrate(Hertz(115_200));
|
||||
let mut uart = UartDriver::new(
|
||||
let uart = UartDriver::new(
|
||||
peripherals.uart0,
|
||||
pins.gpio21,
|
||||
pins.gpio20,
|
||||
@@ -60,7 +65,8 @@ fn main() -> anyhow::Result<()> {
|
||||
if b == b'\n' {
|
||||
if !line.is_empty() {
|
||||
if let Ok(line_str) = std::str::from_utf8(&line) {
|
||||
if let Ok(resp) = handle_request(line_str, &peripherals) {
|
||||
if let Ok(resp) = handle_request(line_str, &mut gpio2, &mut gpio13)
|
||||
{
|
||||
let out = serde_json::to_string(&resp).unwrap_or_default();
|
||||
let _ = uart.write(format!("{}\n", out).as_bytes());
|
||||
}
|
||||
@@ -80,10 +86,15 @@ fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(
|
||||
fn handle_request<G2, G13>(
|
||||
line: &str,
|
||||
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
|
||||
) -> anyhow::Result<Response> {
|
||||
gpio2: &mut PinDriver<'_, G2>,
|
||||
gpio13: &mut PinDriver<'_, G13>,
|
||||
) -> anyhow::Result<Response>
|
||||
where
|
||||
G2: esp_idf_svc::hal::gpio::OutputMode,
|
||||
G13: esp_idf_svc::hal::gpio::OutputMode,
|
||||
{
|
||||
let req: Request = serde_json::from_str(line.trim())?;
|
||||
let id = req.id.clone();
|
||||
|
||||
@@ -98,13 +109,13 @@ fn handle_request(
|
||||
}
|
||||
"gpio_read" => {
|
||||
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
|
||||
let value = gpio_read(peripherals, pin_num)?;
|
||||
let value = gpio_read(pin_num)?;
|
||||
Ok(value.to_string())
|
||||
}
|
||||
"gpio_write" => {
|
||||
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
|
||||
let value = req.args.get("value").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
gpio_write(peripherals, pin_num, value)?;
|
||||
gpio_write(gpio2, gpio13, pin_num, value)?;
|
||||
Ok("done".into())
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("Unknown command: {}", req.cmd)),
|
||||
@@ -126,28 +137,26 @@ fn handle_request(
|
||||
}
|
||||
}
|
||||
|
||||
fn gpio_read(_peripherals: &esp_idf_svc::hal::peripherals::Peripherals, _pin: i32) -> anyhow::Result<u8> {
|
||||
fn gpio_read(_pin: i32) -> anyhow::Result<u8> {
|
||||
// TODO: implement input pin read — requires storing InputPin drivers per pin
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn gpio_write(
|
||||
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
|
||||
fn gpio_write<G2, G13>(
|
||||
gpio2: &mut PinDriver<'_, G2>,
|
||||
gpio13: &mut PinDriver<'_, G13>,
|
||||
pin: i32,
|
||||
value: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
let pins = peripherals.pins;
|
||||
let level = value != 0;
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
G2: esp_idf_svc::hal::gpio::OutputMode,
|
||||
G13: esp_idf_svc::hal::gpio::OutputMode,
|
||||
{
|
||||
let level = esp_idf_svc::hal::gpio::Level::from(value != 0);
|
||||
|
||||
match pin {
|
||||
2 => {
|
||||
let mut out = PinDriver::output(pins.gpio2)?;
|
||||
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
|
||||
}
|
||||
13 => {
|
||||
let mut out = PinDriver::output(pins.gpio13)?;
|
||||
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
|
||||
}
|
||||
2 => gpio2.set_level(level)?,
|
||||
13 => gpio13.set_level(level)?,
|
||||
_ => anyhow::bail!("Pin {} not configured (add to gpio_write)", pin),
|
||||
}
|
||||
Ok(())
|
||||
|
||||
324
scripts/recompute_contributor_tiers.sh
Executable file
324
scripts/recompute_contributor_tiers.sh
Executable file
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Recompute contributor tier labels for historical PRs/issues.
|
||||
|
||||
Usage:
|
||||
./$SCRIPT_NAME [options]
|
||||
|
||||
Options:
|
||||
--repo <owner/repo> Target repository (default: current gh repo)
|
||||
--kind <both|prs|issues>
|
||||
Target objects (default: both)
|
||||
--state <all|open|closed>
|
||||
State filter for listing objects (default: all)
|
||||
--limit <N> Limit processed objects after fetch (default: 0 = no limit)
|
||||
--apply Apply label updates (default is dry-run)
|
||||
--dry-run Preview only (default)
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
./$SCRIPT_NAME --repo zeroclaw-labs/zeroclaw --limit 50
|
||||
./$SCRIPT_NAME --repo zeroclaw-labs/zeroclaw --kind prs --state open --apply
|
||||
USAGE
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "[$SCRIPT_NAME] ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
die "Required command not found: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
urlencode() {
|
||||
jq -nr --arg value "$1" '$value|@uri'
|
||||
}
|
||||
|
||||
select_contributor_tier() {
|
||||
local merged_count="$1"
|
||||
if (( merged_count >= 50 )); then
|
||||
echo "distinguished contributor"
|
||||
elif (( merged_count >= 20 )); then
|
||||
echo "principal contributor"
|
||||
elif (( merged_count >= 10 )); then
|
||||
echo "experienced contributor"
|
||||
elif (( merged_count >= 5 )); then
|
||||
echo "trusted contributor"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
DRY_RUN=1
|
||||
KIND="both"
|
||||
STATE="all"
|
||||
LIMIT=0
|
||||
REPO=""
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--repo)
|
||||
[[ $# -ge 2 ]] || die "Missing value for --repo"
|
||||
REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--kind)
|
||||
[[ $# -ge 2 ]] || die "Missing value for --kind"
|
||||
KIND="$2"
|
||||
shift 2
|
||||
;;
|
||||
--state)
|
||||
[[ $# -ge 2 ]] || die "Missing value for --state"
|
||||
STATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--limit)
|
||||
[[ $# -ge 2 ]] || die "Missing value for --limit"
|
||||
LIMIT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--apply)
|
||||
DRY_RUN=0
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$KIND" in
|
||||
both|prs|issues) ;;
|
||||
*) die "--kind must be one of: both, prs, issues" ;;
|
||||
esac
|
||||
|
||||
case "$STATE" in
|
||||
all|open|closed) ;;
|
||||
*) die "--state must be one of: all, open, closed" ;;
|
||||
esac
|
||||
|
||||
if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then
|
||||
die "--limit must be a non-negative integer"
|
||||
fi
|
||||
|
||||
require_cmd gh
|
||||
require_cmd jq
|
||||
|
||||
if ! gh auth status >/dev/null 2>&1; then
|
||||
die "gh CLI is not authenticated. Run: gh auth login"
|
||||
fi
|
||||
|
||||
if [[ -z "$REPO" ]]; then
|
||||
REPO="$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || true)"
|
||||
[[ -n "$REPO" ]] || die "Unable to infer repo. Pass --repo <owner/repo>."
|
||||
fi
|
||||
|
||||
echo "[$SCRIPT_NAME] Repo: $REPO"
|
||||
echo "[$SCRIPT_NAME] Mode: $([[ "$DRY_RUN" -eq 1 ]] && echo "dry-run" || echo "apply")"
|
||||
echo "[$SCRIPT_NAME] Kind: $KIND | State: $STATE | Limit: $LIMIT"
|
||||
|
||||
TIERS_JSON='["trusted contributor","experienced contributor","principal contributor","distinguished contributor"]'
|
||||
|
||||
TMP_FILES=()
|
||||
cleanup() {
|
||||
if ((${#TMP_FILES[@]} > 0)); then
|
||||
rm -f "${TMP_FILES[@]}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
new_tmp_file() {
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
TMP_FILES+=("$tmp")
|
||||
echo "$tmp"
|
||||
}
|
||||
|
||||
targets_file="$(new_tmp_file)"
|
||||
|
||||
if [[ "$KIND" == "both" || "$KIND" == "prs" ]]; then
|
||||
gh api --paginate "repos/$REPO/pulls?state=$STATE&per_page=100" \
|
||||
--jq '.[] | {
|
||||
kind: "pr",
|
||||
number: .number,
|
||||
author: (.user.login // ""),
|
||||
author_type: (.user.type // ""),
|
||||
labels: [(.labels[]?.name // empty)]
|
||||
}' >> "$targets_file"
|
||||
fi
|
||||
|
||||
if [[ "$KIND" == "both" || "$KIND" == "issues" ]]; then
|
||||
gh api --paginate "repos/$REPO/issues?state=$STATE&per_page=100" \
|
||||
--jq '.[] | select(.pull_request | not) | {
|
||||
kind: "issue",
|
||||
number: .number,
|
||||
author: (.user.login // ""),
|
||||
author_type: (.user.type // ""),
|
||||
labels: [(.labels[]?.name // empty)]
|
||||
}' >> "$targets_file"
|
||||
fi
|
||||
|
||||
if [[ "$LIMIT" -gt 0 ]]; then
|
||||
limited_file="$(new_tmp_file)"
|
||||
head -n "$LIMIT" "$targets_file" > "$limited_file"
|
||||
mv "$limited_file" "$targets_file"
|
||||
fi
|
||||
|
||||
target_count="$(wc -l < "$targets_file" | tr -d ' ')"
|
||||
if [[ "$target_count" -eq 0 ]]; then
|
||||
echo "[$SCRIPT_NAME] No targets found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[$SCRIPT_NAME] Targets fetched: $target_count"
|
||||
|
||||
# Ensure tier labels exist (trusted contributor might be new).
|
||||
label_color=""
|
||||
for probe_label in "experienced contributor" "principal contributor" "distinguished contributor" "trusted contributor"; do
|
||||
encoded_label="$(urlencode "$probe_label")"
|
||||
if color_candidate="$(gh api "repos/$REPO/labels/$encoded_label" --jq '.color' 2>/dev/null || true)"; then
|
||||
if [[ -n "$color_candidate" ]]; then
|
||||
label_color="$(echo "$color_candidate" | tr '[:lower:]' '[:upper:]')"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
[[ -n "$label_color" ]] || label_color="C5D7A2"
|
||||
|
||||
while IFS= read -r tier_label; do
|
||||
[[ -n "$tier_label" ]] || continue
|
||||
encoded_label="$(urlencode "$tier_label")"
|
||||
if gh api "repos/$REPO/labels/$encoded_label" >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
echo "[dry-run] Would create missing label: $tier_label (color=$label_color)"
|
||||
else
|
||||
gh api -X POST "repos/$REPO/labels" \
|
||||
-f name="$tier_label" \
|
||||
-f color="$label_color" >/dev/null
|
||||
echo "[apply] Created missing label: $tier_label"
|
||||
fi
|
||||
done < <(jq -r '.[]' <<<"$TIERS_JSON")
|
||||
|
||||
# Build merged PR count cache by unique human authors.
|
||||
authors_file="$(new_tmp_file)"
|
||||
jq -r 'select(.author != "" and .author_type != "Bot") | .author' "$targets_file" | sort -u > "$authors_file"
|
||||
author_count="$(wc -l < "$authors_file" | tr -d ' ')"
|
||||
echo "[$SCRIPT_NAME] Unique human authors: $author_count"
|
||||
|
||||
author_counts_file="$(new_tmp_file)"
|
||||
while IFS= read -r author; do
|
||||
[[ -n "$author" ]] || continue
|
||||
query="repo:$REPO is:pr is:merged author:$author"
|
||||
merged_count="$(gh api search/issues -f q="$query" -F per_page=1 --jq '.total_count' 2>/dev/null || true)"
|
||||
if ! [[ "$merged_count" =~ ^[0-9]+$ ]]; then
|
||||
merged_count=0
|
||||
fi
|
||||
printf '%s\t%s\n' "$author" "$merged_count" >> "$author_counts_file"
|
||||
done < "$authors_file"
|
||||
|
||||
updated=0
|
||||
unchanged=0
|
||||
skipped=0
|
||||
failed=0
|
||||
|
||||
while IFS= read -r target_json; do
|
||||
[[ -n "$target_json" ]] || continue
|
||||
|
||||
number="$(jq -r '.number' <<<"$target_json")"
|
||||
kind="$(jq -r '.kind' <<<"$target_json")"
|
||||
author="$(jq -r '.author' <<<"$target_json")"
|
||||
author_type="$(jq -r '.author_type' <<<"$target_json")"
|
||||
current_labels_json="$(jq -c '.labels // []' <<<"$target_json")"
|
||||
|
||||
if [[ -z "$author" || "$author_type" == "Bot" ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
merged_count="$(awk -F '\t' -v key="$author" '$1 == key { print $2; exit }' "$author_counts_file")"
|
||||
if ! [[ "$merged_count" =~ ^[0-9]+$ ]]; then
|
||||
merged_count=0
|
||||
fi
|
||||
desired_tier="$(select_contributor_tier "$merged_count")"
|
||||
|
||||
if ! current_tier="$(jq -r --argjson tiers "$TIERS_JSON" '[.[] | select(. as $label | ($tiers | index($label)) != null)][0] // ""' <<<"$current_labels_json" 2>/dev/null)"; then
|
||||
echo "[warn] Skipping ${kind} #${number}: cannot parse current labels JSON" >&2
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! next_labels_json="$(jq -c --arg desired "$desired_tier" --argjson tiers "$TIERS_JSON" '
|
||||
(. // [])
|
||||
| map(select(. as $label | ($tiers | index($label)) == null))
|
||||
| if $desired != "" then . + [$desired] else . end
|
||||
| unique
|
||||
' <<<"$current_labels_json" 2>/dev/null)"; then
|
||||
echo "[warn] Skipping ${kind} #${number}: cannot compute next labels" >&2
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! normalized_current="$(jq -c 'unique | sort' <<<"$current_labels_json" 2>/dev/null)"; then
|
||||
echo "[warn] Skipping ${kind} #${number}: cannot normalize current labels" >&2
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! normalized_next="$(jq -c 'unique | sort' <<<"$next_labels_json" 2>/dev/null)"; then
|
||||
echo "[warn] Skipping ${kind} #${number}: cannot normalize next labels" >&2
|
||||
failed=$((failed + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$normalized_current" == "$normalized_next" ]]; then
|
||||
unchanged=$((unchanged + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
echo "[dry-run] ${kind} #${number} @${author} merged=${merged_count} tier: '${current_tier:-none}' -> '${desired_tier:-none}'"
|
||||
updated=$((updated + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
payload="$(jq -cn --argjson labels "$next_labels_json" '{labels: $labels}')"
|
||||
if gh api -X PUT "repos/$REPO/issues/$number/labels" --input - <<<"$payload" >/dev/null; then
|
||||
echo "[apply] Updated ${kind} #${number} @${author} tier: '${current_tier:-none}' -> '${desired_tier:-none}'"
|
||||
updated=$((updated + 1))
|
||||
else
|
||||
echo "[apply] FAILED ${kind} #${number}" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done < "$targets_file"
|
||||
|
||||
echo ""
|
||||
echo "[$SCRIPT_NAME] Summary"
|
||||
echo " Targets: $target_count"
|
||||
echo " Updated: $updated"
|
||||
echo " Unchanged: $unchanged"
|
||||
echo " Skipped: $skipped"
|
||||
echo " Failed: $failed"
|
||||
|
||||
if [[ "$failed" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -7,14 +7,70 @@ use crate::security::SecurityPolicy;
|
||||
use crate::tools::{self, Tool};
|
||||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::Result;
|
||||
use regex::{Regex, RegexSet};
|
||||
use std::fmt::Write;
|
||||
use std::io::Write as _;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Maximum agentic tool-use iterations per user message to prevent runaway loops.
|
||||
const MAX_TOOL_ITERATIONS: usize = 10;
|
||||
|
||||
static SENSITIVE_KEY_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
|
||||
RegexSet::new([
|
||||
r"(?i)token",
|
||||
r"(?i)api[_-]?key",
|
||||
r"(?i)password",
|
||||
r"(?i)secret",
|
||||
r"(?i)user[_-]?key",
|
||||
r"(?i)bearer",
|
||||
r"(?i)credential",
|
||||
])
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
static SENSITIVE_KV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\.]{8,}))"#).unwrap()
|
||||
});
|
||||
|
||||
/// Scrub credentials from tool output to prevent accidental exfiltration.
|
||||
/// Replaces known credential patterns with a redacted placeholder while preserving
|
||||
/// a small prefix for context.
|
||||
fn scrub_credentials(input: &str) -> String {
|
||||
SENSITIVE_KV_REGEX
|
||||
.replace_all(input, |caps: ®ex::Captures| {
|
||||
let full_match = &caps[0];
|
||||
let key = &caps[1];
|
||||
let val = caps
|
||||
.get(2)
|
||||
.or(caps.get(3))
|
||||
.or(caps.get(4))
|
||||
.map(|m| m.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Preserve first 4 chars for context, then redact
|
||||
let prefix = if val.len() > 4 { &val[..4] } else { "" };
|
||||
|
||||
if full_match.contains(':') {
|
||||
if full_match.contains('"') {
|
||||
format!("\"{}\": \"{}*[REDACTED]\"", key, prefix)
|
||||
} else {
|
||||
format!("{}: {}*[REDACTED]", key, prefix)
|
||||
}
|
||||
} else if full_match.contains('=') {
|
||||
if full_match.contains('"') {
|
||||
format!("{}=\"{}*[REDACTED]\"", key, prefix)
|
||||
} else {
|
||||
format!("{}={}*[REDACTED]", key, prefix)
|
||||
}
|
||||
} else {
|
||||
format!("{}: {}*[REDACTED]", key, prefix)
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Trigger auto-compaction when non-system message count exceeds this threshold.
|
||||
const MAX_HISTORY_MESSAGES: usize = 50;
|
||||
|
||||
@@ -608,7 +664,7 @@ pub(crate) async fn run_tool_call_loop(
|
||||
success: r.success,
|
||||
});
|
||||
if r.success {
|
||||
r.output
|
||||
scrub_credentials(&r.output)
|
||||
} else {
|
||||
format!("Error: {}", r.error.unwrap_or_else(|| r.output))
|
||||
}
|
||||
@@ -1222,6 +1278,25 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scrub_credentials() {
|
||||
let input = "API_KEY=sk-1234567890abcdef; token: 1234567890; password=\"secret123456\"";
|
||||
let scrubbed = scrub_credentials(input);
|
||||
assert!(scrubbed.contains("API_KEY=sk-1*[REDACTED]"));
|
||||
assert!(scrubbed.contains("token: 1234*[REDACTED]"));
|
||||
assert!(scrubbed.contains("password=\"secr*[REDACTED]\""));
|
||||
assert!(!scrubbed.contains("abcdef"));
|
||||
assert!(!scrubbed.contains("secret123456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scrub_credentials_json() {
|
||||
let input = r#"{"api_key": "sk-1234567890", "other": "public"}"#;
|
||||
let scrubbed = scrub_credentials(input);
|
||||
assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
|
||||
assert!(scrubbed.contains("public"));
|
||||
}
|
||||
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ impl Channel for CliChannel {
|
||||
let msg = ChannelMessage {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
sender: "user".to_string(),
|
||||
reply_to: "user".to_string(),
|
||||
reply_target: "user".to_string(),
|
||||
content: line,
|
||||
channel: "cli".to_string(),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
|
||||
@@ -238,7 +238,7 @@ impl Channel for DingTalkChannel {
|
||||
let channel_msg = ChannelMessage {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
sender: sender_id.to_string(),
|
||||
reply_to: chat_id,
|
||||
reply_target: chat_id,
|
||||
content: content.to_string(),
|
||||
channel: "dingtalk".to_string(),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
|
||||
@@ -450,7 +450,7 @@ impl LarkChannel {
|
||||
let channel_msg = ChannelMessage {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
sender: lark_msg.chat_id.clone(),
|
||||
reply_to: lark_msg.chat_id.clone(),
|
||||
reply_target: lark_msg.chat_id.clone(),
|
||||
content: text,
|
||||
channel: "lark".to_string(),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
|
||||
@@ -633,6 +633,7 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
|
||||
Arc::new(TelegramChannel::new(
|
||||
tg.bot_token.clone(),
|
||||
tg.allowed_users.clone(),
|
||||
Some(config.workspace_dir.clone()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
@@ -923,6 +924,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
channels.push(Arc::new(TelegramChannel::new(
|
||||
tg.bot_token.clone(),
|
||||
tg.allowed_users.clone(),
|
||||
Some(config.workspace_dir.clone()),
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ use async_trait::async_trait;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Telegram's maximum message length for text messages
|
||||
const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
|
||||
@@ -180,22 +181,35 @@ fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>)
|
||||
|
||||
/// Telegram channel — long-polls the Bot API for updates
|
||||
pub struct TelegramChannel {
|
||||
workspace_dir: std::path::PathBuf,
|
||||
bot_token: String,
|
||||
allowed_users: Vec<String>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TelegramChannel {
|
||||
pub fn new(bot_token: String, allowed_users: Vec<String>) -> Self {
|
||||
pub fn new(
|
||||
bot_token: String,
|
||||
allowed_users: Vec<String>,
|
||||
workspace_dir: Option<std::path::PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
bot_token,
|
||||
allowed_users,
|
||||
client: reqwest::Client::new(),
|
||||
workspace_dir: workspace_dir.unwrap_or_else(|| std::path::PathBuf::from("/tmp")),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_url(&self, method: &str) -> String {
|
||||
format!("https://api.telegram.org/bot{}/{method}", self.bot_token)
|
||||
format!("https://api.telegram.org/bot{}/{}", self.bot_token, method)
|
||||
}
|
||||
|
||||
fn file_url(&self, file_path: &str) -> String {
|
||||
format!(
|
||||
"https://api.telegram.org/file/bot{}/{}",
|
||||
self.bot_token, file_path
|
||||
)
|
||||
}
|
||||
|
||||
fn is_user_allowed(&self, username: &str) -> bool {
|
||||
@@ -209,6 +223,131 @@ impl TelegramChannel {
|
||||
identities.into_iter().any(|id| self.is_user_allowed(id))
|
||||
}
|
||||
|
||||
/// Download a voice/audio file from Telegram and return the local path
|
||||
async fn download_voice_file(&self, file_id: &str) -> anyhow::Result<std::path::PathBuf> {
|
||||
// Get file path from Telegram
|
||||
let get_file_url = self.api_url("getFile");
|
||||
let body = serde_json::json!({
|
||||
"file_id": file_id
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&get_file_url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram getFile failed: {}", err);
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
let file_path = data
|
||||
.get("result")
|
||||
.and_then(|r| r.get("file_path"))
|
||||
.and_then(|p| p.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No file_path in Telegram response"))?;
|
||||
|
||||
// Download the file
|
||||
let download_url = self.file_url(file_path);
|
||||
let file_resp = self.client.get(&download_url).send().await?;
|
||||
|
||||
if !file_resp.status().is_success() {
|
||||
anyhow::bail!("Failed to download voice file: {}", file_resp.status());
|
||||
}
|
||||
|
||||
// Save to workspace with unique name
|
||||
let file_name = format!("voice_{}.ogg", chrono::Utc::now().format("%Y%m%d_%H%M%S_%f"));
|
||||
let local_path = self.workspace_dir.join(&file_name);
|
||||
let bytes = file_resp.bytes().await?;
|
||||
tokio::fs::write(&local_path, &bytes).await?;
|
||||
|
||||
tracing::info!("Downloaded voice file to: {:?}", local_path);
|
||||
Ok(local_path)
|
||||
}
|
||||
|
||||
/// Transcribe a voice file using faster-whisper
|
||||
async fn transcribe_voice_file(&self, audio_path: &std::path::Path) -> anyhow::Result<String> {
|
||||
let script = r#"
|
||||
import sys
|
||||
import json
|
||||
|
||||
def transcribe(audio_path):
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
model = WhisperModel("base", device="cpu", compute_type="int8")
|
||||
segments, info = model.transcribe(audio_path, beam_size=5)
|
||||
|
||||
text_parts = []
|
||||
for segment in segments:
|
||||
text_parts.append(segment.text.strip())
|
||||
|
||||
result = {
|
||||
"language": info.language,
|
||||
"duration": round(info.duration, 2),
|
||||
"text": " ".join(text_parts)
|
||||
}
|
||||
|
||||
print(json.dumps(result))
|
||||
|
||||
if __name__ == "__main__":
|
||||
transcribe(sys.argv[1])
|
||||
"#;
|
||||
|
||||
let output = Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.arg(audio_path)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(result) => {
|
||||
if result.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||
match serde_json::from_str::<serde_json::Value>(&stdout) {
|
||||
Ok(json) => {
|
||||
let text = json
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let language = json
|
||||
.get("language")
|
||||
.and_then(|l| l.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let duration = json
|
||||
.get("duration")
|
||||
.and_then(|d| d.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
tracing::info!(
|
||||
"Transcribed voice message: {:.1}s, language: {}",
|
||||
duration,
|
||||
language
|
||||
);
|
||||
Ok(text)
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback: return raw stdout
|
||||
Ok(stdout.trim().to_string())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
tracing::warn!("Voice transcription failed: {}", stderr);
|
||||
Err(anyhow::anyhow!("Transcription failed: {}", stderr.trim()))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to run transcription: {}", e);
|
||||
Err(anyhow::anyhow!("Failed to run transcription: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
|
||||
let message = update.get("message")?;
|
||||
|
||||
@@ -240,8 +379,9 @@ impl TelegramChannel {
|
||||
|
||||
if !self.is_any_user_allowed(identities.iter().copied()) {
|
||||
tracing::warn!(
|
||||
"Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \
|
||||
"Telegram: ignoring message from unauthorized user: username={}, user_id={}. \
|
||||
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
|
||||
username,
|
||||
user_id.as_deref().unwrap_or("unknown")
|
||||
);
|
||||
return None;
|
||||
@@ -259,7 +399,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
.unwrap_or(0);
|
||||
|
||||
Some(ChannelMessage {
|
||||
id: format!("telegram_{chat_id}_{message_id}"),
|
||||
id: format!("telegram_{}_{}", chat_id, message_id),
|
||||
sender: sender_identity,
|
||||
reply_target: chat_id,
|
||||
content: text.to_string(),
|
||||
@@ -271,17 +411,80 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse voice/audio messages from Telegram update (returns file_id and metadata)
|
||||
fn parse_voice_message(
|
||||
&self,
|
||||
update: &serde_json::Value,
|
||||
) -> Option<(String, String, String, i64)> {
|
||||
let message = update.get("message")?;
|
||||
|
||||
// Check for voice message
|
||||
let file_id = if let Some(voice) = message.get("voice") {
|
||||
voice.get("file_id").and_then(|v| v.as_str())?
|
||||
} else if let Some(audio) = message.get("audio") {
|
||||
// Also support audio messages
|
||||
audio.get("file_id").and_then(|v| v.as_str())?
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let username = message
|
||||
.get("from")
|
||||
.and_then(|from| from.get("username"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let user_id = message
|
||||
.get("from")
|
||||
.and_then(|from| from.get("id"))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.map(|id| id.to_string());
|
||||
|
||||
let sender_identity = if username == "unknown" {
|
||||
user_id.clone().unwrap_or_else(|| "unknown".to_string())
|
||||
} else {
|
||||
username.clone()
|
||||
};
|
||||
|
||||
let mut identities = vec![username.as_str()];
|
||||
if let Some(id) = user_id.as_deref() {
|
||||
identities.push(id);
|
||||
}
|
||||
|
||||
if !self.is_any_user_allowed(identities.iter().copied()) {
|
||||
tracing::warn!(
|
||||
"Telegram: ignoring voice message from unauthorized user: {}",
|
||||
sender_identity
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let chat_id = message
|
||||
.get("chat")
|
||||
.and_then(|chat| chat.get("id"))
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.map(|id| id.to_string())?;
|
||||
|
||||
let message_id = message
|
||||
.get("message_id")
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
Some((file_id.to_string(), sender_identity, chat_id, message_id))
|
||||
}
|
||||
|
||||
async fn send_text_chunks(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
|
||||
let chunks = split_message_for_telegram(message);
|
||||
|
||||
for (index, chunk) in chunks.iter().enumerate() {
|
||||
let text = if chunks.len() > 1 {
|
||||
if index == 0 {
|
||||
format!("{chunk}\n\n(continues...)")
|
||||
format!("{}\n\n(continues...)", chunk)
|
||||
} else if index == chunks.len() - 1 {
|
||||
format!("(continued)\n\n{chunk}")
|
||||
format!("(continued)\n\n{}", chunk)
|
||||
} else {
|
||||
format!("(continued)\n\n{chunk}\n\n(continues...)")
|
||||
format!("(continued)\n\n{}\n\n(continues...)", chunk)
|
||||
}
|
||||
} else {
|
||||
chunk.to_string()
|
||||
@@ -371,10 +574,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram {method} by URL failed: {err}");
|
||||
anyhow::bail!("Telegram {} by URL failed: {}", method, err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram {method} sent to {chat_id}: {url}");
|
||||
tracing::info!("Telegram {} sent to {}: {}", method, chat_id, url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -407,7 +610,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
let path = Path::new(target);
|
||||
if !path.exists() {
|
||||
anyhow::bail!("Telegram attachment path not found: {target}");
|
||||
anyhow::bail!("Telegram attachment path not found: {}", target);
|
||||
}
|
||||
|
||||
match attachment.kind {
|
||||
@@ -451,10 +654,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram sendDocument failed: {err}");
|
||||
anyhow::bail!("Telegram sendDocument failed: {}", err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
|
||||
tracing::info!("Telegram document sent to {}: {}", chat_id, file_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -485,10 +688,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram sendDocument failed: {err}");
|
||||
anyhow::bail!("Telegram sendDocument failed: {}", err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
|
||||
tracing::info!("Telegram document sent to {}: {}", chat_id, file_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -524,10 +727,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram sendPhoto failed: {err}");
|
||||
anyhow::bail!("Telegram sendPhoto failed: {}", err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
|
||||
tracing::info!("Telegram photo sent to {}: {}", chat_id, file_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -558,10 +761,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram sendPhoto failed: {err}");
|
||||
anyhow::bail!("Telegram sendPhoto failed: {}", err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
|
||||
tracing::info!("Telegram photo sent to {}: {}", chat_id, file_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -597,10 +800,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram sendVideo failed: {err}");
|
||||
anyhow::bail!("Telegram sendVideo failed: {}", err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram video sent to {chat_id}: {file_name}");
|
||||
tracing::info!("Telegram video sent to {}: {}", chat_id, file_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -636,10 +839,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram sendAudio failed: {err}");
|
||||
anyhow::bail!("Telegram sendAudio failed: {}", err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram audio sent to {chat_id}: {file_name}");
|
||||
tracing::info!("Telegram audio sent to {}: {}", chat_id, file_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -675,10 +878,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram sendVoice failed: {err}");
|
||||
anyhow::bail!("Telegram sendVoice failed: {}", err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram voice sent to {chat_id}: {file_name}");
|
||||
tracing::info!("Telegram voice sent to {}: {}", chat_id, file_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -707,10 +910,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram sendDocument by URL failed: {err}");
|
||||
anyhow::bail!("Telegram sendDocument by URL failed: {}", err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram document (URL) sent to {chat_id}: {url}");
|
||||
tracing::info!("Telegram document (URL) sent to {}: {}", chat_id, url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -739,10 +942,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await?;
|
||||
anyhow::bail!("Telegram sendPhoto by URL failed: {err}");
|
||||
anyhow::bail!("Telegram sendPhoto by URL failed: {}", err);
|
||||
}
|
||||
|
||||
tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
|
||||
tracing::info!("Telegram photo (URL) sent to {}: {}", chat_id, url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -826,7 +1029,7 @@ impl Channel for TelegramChannel {
|
||||
let resp = match self.client.post(&url).json(&body).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Telegram poll error: {e}");
|
||||
tracing::warn!("Telegram poll error: {}", e);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
@@ -835,7 +1038,7 @@ impl Channel for TelegramChannel {
|
||||
let data: serde_json::Value = match resp.json().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!("Telegram parse error: {e}");
|
||||
tracing::warn!("Telegram parse error: {}", e);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
@@ -848,24 +1051,81 @@ impl Channel for TelegramChannel {
|
||||
offset = uid + 1;
|
||||
}
|
||||
|
||||
let Some(msg) = self.parse_update_message(update) else {
|
||||
// First, try to parse as text message
|
||||
if let Some(msg) = self.parse_update_message(update) {
|
||||
// Send "typing" indicator immediately when we receive a message
|
||||
let typing_body = serde_json::json!({
|
||||
"chat_id": &msg.reply_target,
|
||||
"action": "typing"
|
||||
});
|
||||
let _ = self
|
||||
.client
|
||||
.post(self.api_url("sendChatAction"))
|
||||
.json(&typing_body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
}
|
||||
|
||||
// Send "typing" indicator immediately when we receive a message
|
||||
let typing_body = serde_json::json!({
|
||||
"chat_id": &msg.reply_target,
|
||||
"action": "typing"
|
||||
});
|
||||
let _ = self
|
||||
.client
|
||||
.post(self.api_url("sendChatAction"))
|
||||
.json(&typing_body)
|
||||
.send()
|
||||
.await; // Ignore errors for typing indicator
|
||||
// Then, try to parse as voice/audio message
|
||||
if let Some((file_id, sender, chat_id, message_id)) =
|
||||
self.parse_voice_message(update)
|
||||
{
|
||||
// Send "typing" indicator
|
||||
let typing_body = serde_json::json!({
|
||||
"chat_id": &chat_id,
|
||||
"action": "typing"
|
||||
});
|
||||
let _ = self
|
||||
.client
|
||||
.post(self.api_url("sendChatAction"))
|
||||
.json(&typing_body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
return Ok(());
|
||||
// Download and transcribe the voice file
|
||||
let transcription = match self.download_voice_file(&file_id).await {
|
||||
Ok(audio_path) => {
|
||||
let result = self.transcribe_voice_file(&audio_path).await;
|
||||
// Clean up the downloaded file
|
||||
let _ = tokio::fs::remove_file(&audio_path).await;
|
||||
result
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to download voice file: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
|
||||
let content = match transcription {
|
||||
Ok(text) if !text.is_empty() => {
|
||||
format!("[Voice message transcription]\n{}", text)
|
||||
}
|
||||
Ok(_) => "[Voice message - empty transcription]".to_string(),
|
||||
Err(e) => {
|
||||
format!("[Voice message - transcription failed: {}]", e)
|
||||
}
|
||||
};
|
||||
|
||||
let msg = ChannelMessage {
|
||||
id: format!("telegram_{}_{}", chat_id, message_id),
|
||||
sender,
|
||||
reply_target: chat_id,
|
||||
content,
|
||||
channel: "telegram".to_string(),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -883,7 +1143,7 @@ impl Channel for TelegramChannel {
|
||||
{
|
||||
Ok(Ok(resp)) => resp.status().is_success(),
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!("Telegram health check failed: {e}");
|
||||
tracing::debug!("Telegram health check failed: {}", e);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -900,41 +1160,50 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn telegram_channel_name() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
assert_eq!(ch.name(), "telegram");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_api_url() {
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
|
||||
assert_eq!(
|
||||
ch.api_url("getMe"),
|
||||
"https://api.telegram.org/bot123:ABC/getMe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_file_url() {
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
|
||||
assert_eq!(
|
||||
ch.file_url("voice/file.ogg"),
|
||||
"https://api.telegram.org/file/bot123:ABC/voice/file.ogg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_user_allowed_wildcard() {
|
||||
let ch = TelegramChannel::new("t".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("t".into(), vec!["*".into()], None);
|
||||
assert!(ch.is_user_allowed("anyone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_user_allowed_specific() {
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()]);
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()], None);
|
||||
assert!(ch.is_user_allowed("alice"));
|
||||
assert!(!ch.is_user_allowed("eve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_user_denied_empty() {
|
||||
let ch = TelegramChannel::new("t".into(), vec![]);
|
||||
let ch = TelegramChannel::new("t".into(), vec![], None);
|
||||
assert!(!ch.is_user_allowed("anyone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_user_exact_match_not_substring() {
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into()]);
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into()], None);
|
||||
assert!(!ch.is_user_allowed("alice_bot"));
|
||||
assert!(!ch.is_user_allowed("alic"));
|
||||
assert!(!ch.is_user_allowed("malice"));
|
||||
@@ -942,13 +1211,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn telegram_user_empty_string_denied() {
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into()]);
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into()], None);
|
||||
assert!(!ch.is_user_allowed(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_user_case_sensitive() {
|
||||
let ch = TelegramChannel::new("t".into(), vec!["Alice".into()]);
|
||||
let ch = TelegramChannel::new("t".into(), vec!["Alice".into()], None);
|
||||
assert!(ch.is_user_allowed("Alice"));
|
||||
assert!(!ch.is_user_allowed("alice"));
|
||||
assert!(!ch.is_user_allowed("ALICE"));
|
||||
@@ -956,7 +1225,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn telegram_wildcard_with_specific_users() {
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()]);
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()], None);
|
||||
assert!(ch.is_user_allowed("alice"));
|
||||
assert!(ch.is_user_allowed("bob"));
|
||||
assert!(ch.is_user_allowed("anyone"));
|
||||
@@ -964,13 +1233,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn telegram_user_allowed_by_numeric_id_identity() {
|
||||
let ch = TelegramChannel::new("t".into(), vec!["123456789".into()]);
|
||||
let ch = TelegramChannel::new("t".into(), vec!["123456789".into()], None);
|
||||
assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telegram_user_denied_when_none_of_identities_match() {
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]);
|
||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()], None);
|
||||
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
|
||||
}
|
||||
|
||||
@@ -1024,7 +1293,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_update_message_uses_chat_id_as_reply_target() {
|
||||
let ch = TelegramChannel::new("token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("token".into(), vec!["*".into()], None);
|
||||
let update = serde_json::json!({
|
||||
"update_id": 1,
|
||||
"message": {
|
||||
@@ -1052,7 +1321,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_update_message_allows_numeric_id_without_username() {
|
||||
let ch = TelegramChannel::new("token".into(), vec!["555".into()]);
|
||||
let ch = TelegramChannel::new("token".into(), vec!["555".into()], None);
|
||||
let update = serde_json::json!({
|
||||
"update_id": 2,
|
||||
"message": {
|
||||
@@ -1075,11 +1344,63 @@ mod tests {
|
||||
assert_eq!(msg.reply_target, "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_message_extracts_file_id() {
|
||||
let ch = TelegramChannel::new("token".into(), vec!["*".into()], None);
|
||||
let update = serde_json::json!({
|
||||
"update_id": 3,
|
||||
"message": {
|
||||
"message_id": 42,
|
||||
"voice": {
|
||||
"file_id": "AwADBAADbXXXXXXXX",
|
||||
"duration": 5
|
||||
},
|
||||
"from": {
|
||||
"id": 555,
|
||||
"username": "alice"
|
||||
},
|
||||
"chat": {
|
||||
"id": 12345
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = ch.parse_voice_message(&update);
|
||||
assert!(result.is_some());
|
||||
let (file_id, sender, chat_id, message_id) = result.unwrap();
|
||||
assert_eq!(file_id, "AwADBAADbXXXXXXXX");
|
||||
assert_eq!(sender, "alice");
|
||||
assert_eq!(chat_id, "12345");
|
||||
assert_eq!(message_id, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_message_returns_none_for_text_message() {
|
||||
let ch = TelegramChannel::new("token".into(), vec!["*".into()], None);
|
||||
let update = serde_json::json!({
|
||||
"update_id": 4,
|
||||
"message": {
|
||||
"message_id": 10,
|
||||
"text": "hello",
|
||||
"from": {
|
||||
"id": 555,
|
||||
"username": "alice"
|
||||
},
|
||||
"chat": {
|
||||
"id": 12345
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = ch.parse_voice_message(&update);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ── File sending API URL tests ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn telegram_api_url_send_document() {
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
|
||||
assert_eq!(
|
||||
ch.api_url("sendDocument"),
|
||||
"https://api.telegram.org/bot123:ABC/sendDocument"
|
||||
@@ -1088,7 +1409,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn telegram_api_url_send_photo() {
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
|
||||
assert_eq!(
|
||||
ch.api_url("sendPhoto"),
|
||||
"https://api.telegram.org/bot123:ABC/sendPhoto"
|
||||
@@ -1097,7 +1418,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn telegram_api_url_send_video() {
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
|
||||
assert_eq!(
|
||||
ch.api_url("sendVideo"),
|
||||
"https://api.telegram.org/bot123:ABC/sendVideo"
|
||||
@@ -1106,7 +1427,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn telegram_api_url_send_audio() {
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
|
||||
assert_eq!(
|
||||
ch.api_url("sendAudio"),
|
||||
"https://api.telegram.org/bot123:ABC/sendAudio"
|
||||
@@ -1115,7 +1436,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn telegram_api_url_send_voice() {
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
|
||||
assert_eq!(
|
||||
ch.api_url("sendVoice"),
|
||||
"https://api.telegram.org/bot123:ABC/sendVoice"
|
||||
@@ -1127,7 +1448,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn telegram_send_document_bytes_builds_correct_form() {
|
||||
// This test verifies the method doesn't panic and handles bytes correctly
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let file_bytes = b"Hello, this is a test file content".to_vec();
|
||||
|
||||
// The actual API call will fail (no real server), but we verify the method exists
|
||||
@@ -1142,13 +1463,14 @@ mod tests {
|
||||
// Error should be network-related, not a code bug
|
||||
assert!(
|
||||
err.contains("error") || err.contains("failed") || err.contains("connect"),
|
||||
"Expected network error, got: {err}"
|
||||
"Expected network error, got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_photo_bytes_builds_correct_form() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
// Minimal valid PNG header bytes
|
||||
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
@@ -1161,7 +1483,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_document_by_url_builds_correct_json() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
|
||||
let result = ch
|
||||
.send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc"))
|
||||
@@ -1172,7 +1494,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_photo_by_url_builds_correct_json() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
|
||||
let result = ch
|
||||
.send_photo_by_url("123456", "https://example.com/image.jpg", None)
|
||||
@@ -1185,7 +1507,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_document_nonexistent_file() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let path = Path::new("/nonexistent/path/to/file.txt");
|
||||
|
||||
let result = ch.send_document("123456", path, None).await;
|
||||
@@ -1195,13 +1517,14 @@ mod tests {
|
||||
// Should fail with file not found error
|
||||
assert!(
|
||||
err.contains("No such file") || err.contains("not found") || err.contains("os error"),
|
||||
"Expected file not found error, got: {err}"
|
||||
"Expected file not found error, got: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_photo_nonexistent_file() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let path = Path::new("/nonexistent/path/to/photo.jpg");
|
||||
|
||||
let result = ch.send_photo("123456", path, None).await;
|
||||
@@ -1211,7 +1534,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_video_nonexistent_file() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let path = Path::new("/nonexistent/path/to/video.mp4");
|
||||
|
||||
let result = ch.send_video("123456", path, None).await;
|
||||
@@ -1221,7 +1544,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_audio_nonexistent_file() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let path = Path::new("/nonexistent/path/to/audio.mp3");
|
||||
|
||||
let result = ch.send_audio("123456", path, None).await;
|
||||
@@ -1231,7 +1554,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_voice_nonexistent_file() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let path = Path::new("/nonexistent/path/to/voice.ogg");
|
||||
|
||||
let result = ch.send_voice("123456", path, None).await;
|
||||
@@ -1319,7 +1642,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_document_bytes_with_caption() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let file_bytes = b"test content".to_vec();
|
||||
|
||||
// With caption
|
||||
@@ -1337,7 +1660,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_photo_bytes_with_caption() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
|
||||
|
||||
// With caption
|
||||
@@ -1362,7 +1685,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_document_bytes_empty_file() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let file_bytes: Vec<u8> = vec![];
|
||||
|
||||
let result = ch
|
||||
@@ -1375,7 +1698,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_document_bytes_empty_filename() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let file_bytes = b"content".to_vec();
|
||||
|
||||
let result = ch.send_document_bytes("123456", file_bytes, "", None).await;
|
||||
@@ -1386,7 +1709,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_send_document_bytes_empty_chat_id() {
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None);
|
||||
let file_bytes = b"content".to_vec();
|
||||
|
||||
let result = ch
|
||||
@@ -1404,7 +1727,7 @@ mod tests {
|
||||
// Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
|
||||
let chat_id = "123456";
|
||||
let message_id = 789;
|
||||
let expected_id = format!("telegram_{chat_id}_{message_id}");
|
||||
let expected_id = format!("telegram_{}_{}", chat_id, message_id);
|
||||
assert_eq!(expected_id, "telegram_123456_789");
|
||||
}
|
||||
|
||||
@@ -1413,8 +1736,8 @@ mod tests {
|
||||
// Same chat_id + same message_id = same ID (prevents duplicates after restart)
|
||||
let chat_id = "123456";
|
||||
let message_id = 789;
|
||||
let id1 = format!("telegram_{chat_id}_{message_id}");
|
||||
let id2 = format!("telegram_{chat_id}_{message_id}");
|
||||
let id1 = format!("telegram_{}_{}", chat_id, message_id);
|
||||
let id2 = format!("telegram_{}_{}", chat_id, message_id);
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
@@ -1422,8 +1745,8 @@ mod tests {
|
||||
fn telegram_message_id_different_message_different_id() {
|
||||
// Different message IDs produce different IDs
|
||||
let chat_id = "123456";
|
||||
let id1 = format!("telegram_{chat_id}_789");
|
||||
let id2 = format!("telegram_{chat_id}_790");
|
||||
let id1 = format!("telegram_{}_789", chat_id);
|
||||
let id2 = format!("telegram_{}_790", chat_id);
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
@@ -1431,8 +1754,8 @@ mod tests {
|
||||
fn telegram_message_id_different_chat_different_id() {
|
||||
// Different chats produce different IDs even with same message_id
|
||||
let message_id = 789;
|
||||
let id1 = format!("telegram_123456_{message_id}");
|
||||
let id2 = format!("telegram_789012_{message_id}");
|
||||
let id1 = format!("telegram_123456_{}", message_id);
|
||||
let id2 = format!("telegram_789012_{}", message_id);
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
@@ -1441,7 +1764,7 @@ mod tests {
|
||||
// Verify format doesn't contain random UUID components
|
||||
let chat_id = "123456";
|
||||
let message_id = 789;
|
||||
let id = format!("telegram_{chat_id}_{message_id}");
|
||||
let id = format!("telegram_{}_{}", chat_id, message_id);
|
||||
assert!(!id.contains('-')); // No UUID dashes
|
||||
assert!(id.starts_with("telegram_"));
|
||||
}
|
||||
@@ -1451,7 +1774,7 @@ mod tests {
|
||||
// Edge case: message_id can be 0 (fallback/missing case)
|
||||
let chat_id = "123456";
|
||||
let message_id = 0;
|
||||
let id = format!("telegram_{chat_id}_{message_id}");
|
||||
let id = format!("telegram_{}_{}", chat_id, message_id);
|
||||
assert_eq!(id, "telegram_123456_0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ pub struct Config {
|
||||
/// Hardware configuration (wizard-driven physical world setup).
|
||||
#[serde(default)]
|
||||
pub hardware: HardwareConfig,
|
||||
|
||||
/// SkillForge auto-discovery configuration.
|
||||
#[serde(default)]
|
||||
pub skillforge: crate::skillforge::SkillForgeConfig,
|
||||
}
|
||||
|
||||
// ── Delegate Agents ──────────────────────────────────────────────
|
||||
@@ -1645,6 +1649,7 @@ impl Default for Config {
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
agents: HashMap::new(),
|
||||
hardware: HardwareConfig::default(),
|
||||
skillforge: crate::skillforge::SkillForgeConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) ->
|
||||
.telegram
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("telegram channel not configured"))?;
|
||||
let channel = TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone());
|
||||
let channel = TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone(), Some(config.workspace_dir.clone()));
|
||||
channel.send(output, target).await?;
|
||||
}
|
||||
"discord" => {
|
||||
|
||||
@@ -709,7 +709,7 @@ async fn handle_whatsapp_message(
|
||||
{
|
||||
Ok(response) => {
|
||||
// Send reply via WhatsApp
|
||||
if let Err(e) = wa.send(&response, &msg.reply_to).await {
|
||||
if let Err(e) = wa.send(&response, &msg.reply_target).await {
|
||||
tracing::error!("Failed to send WhatsApp reply: {e}");
|
||||
}
|
||||
}
|
||||
@@ -718,7 +718,7 @@ async fn handle_whatsapp_message(
|
||||
let _ = wa
|
||||
.send(
|
||||
"Sorry, I couldn't process your message right now.",
|
||||
&msg.reply_to,
|
||||
&msg.reply_target,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ pub mod rag;
|
||||
pub mod runtime;
|
||||
pub mod security;
|
||||
pub mod service;
|
||||
pub mod skillforge;
|
||||
pub mod skills;
|
||||
pub mod tools;
|
||||
pub mod tunnel;
|
||||
|
||||
10
src/main.rs
10
src/main.rs
@@ -209,6 +209,12 @@ enum Commands {
|
||||
skill_command: SkillCommands,
|
||||
},
|
||||
|
||||
/// Auto-discover and integrate skills from GitHub/ClawHub
|
||||
Skillforge {
|
||||
#[command(subcommand)]
|
||||
skillforge_command: skillforge::SkillforgeCommands,
|
||||
},
|
||||
|
||||
/// Migrate data from other agent runtimes
|
||||
Migrate {
|
||||
#[command(subcommand)]
|
||||
@@ -563,6 +569,10 @@ async fn main() -> Result<()> {
|
||||
skills::handle_command(skill_command, &config.workspace_dir)
|
||||
}
|
||||
|
||||
Commands::Skillforge { skillforge_command } => {
|
||||
skillforge::handle_command(skillforge_command, &config).await
|
||||
}
|
||||
|
||||
Commands::Migrate { migrate_command } => {
|
||||
migration::handle_command(migrate_command, &config).await
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ pub fn run_wizard() -> Result<Config> {
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
agents: std::collections::HashMap::new(),
|
||||
hardware: hardware_config,
|
||||
skillforge: crate::skillforge::SkillForgeConfig::default(),
|
||||
};
|
||||
|
||||
println!(
|
||||
@@ -346,6 +347,7 @@ pub fn run_quick_setup(
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
agents: std::collections::HashMap::new(),
|
||||
hardware: crate::config::HardwareConfig::default(),
|
||||
skillforge: crate::skillforge::SkillForgeConfig::default(),
|
||||
};
|
||||
|
||||
config.save()?;
|
||||
|
||||
@@ -894,6 +894,7 @@ mod tests {
|
||||
make_provider("Groq", "https://api.groq.com/openai", None),
|
||||
make_provider("Mistral", "https://api.mistral.ai", None),
|
||||
make_provider("xAI", "https://api.x.ai", None),
|
||||
make_provider("Astrai", "https://as-trai.com/v1", None),
|
||||
];
|
||||
|
||||
for p in providers {
|
||||
|
||||
@@ -135,9 +135,11 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) ->
|
||||
"zai" | "z.ai" => vec!["ZAI_API_KEY"],
|
||||
"nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
|
||||
"synthetic" => vec!["SYNTHETIC_API_KEY"],
|
||||
"nanogpt" | "nano-gpt" => vec!["NANO_GPT_API_KEY"],
|
||||
"opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
|
||||
"vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
|
||||
"cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
|
||||
"astrai" => vec!["ASTRAI_API_KEY"],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
@@ -245,6 +247,12 @@ pub fn create_provider_with_url(
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
))),
|
||||
"nanogpt" | "nano-gpt" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"NanoGPT",
|
||||
"https://nano-gpt.com/api/v1",
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
))),
|
||||
"bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Amazon Bedrock",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
@@ -313,6 +321,11 @@ pub fn create_provider_with_url(
|
||||
),
|
||||
)),
|
||||
|
||||
// ── AI inference routers ─────────────────────────────
|
||||
"astrai" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Astrai", "https://as-trai.com/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
|
||||
// ── Bring Your Own Provider (custom URL) ───────────
|
||||
// Format: "custom:https://your-api.com" or "custom:http://localhost:1234"
|
||||
name if name.starts_with("custom:") => {
|
||||
@@ -651,6 +664,13 @@ mod tests {
|
||||
assert!(create_provider("build.nvidia.com", Some("nvapi-test")).is_ok());
|
||||
}
|
||||
|
||||
// ── AI inference routers ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn factory_astrai() {
|
||||
assert!(create_provider("astrai", Some("sk-astrai-test")).is_ok());
|
||||
}
|
||||
|
||||
// ── Custom / BYOP provider ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -343,6 +343,7 @@ impl SecurityPolicy {
|
||||
/// validates each sub-command against the allowlist
|
||||
/// - Blocks single `&` background chaining (`&&` remains supported)
|
||||
/// - Blocks output redirections (`>`, `>>`) that could write outside workspace
|
||||
/// - Blocks dangerous arguments (e.g. `find -exec`, `git config`)
|
||||
pub fn is_command_allowed(&self, command: &str) -> bool {
|
||||
if self.autonomy == AutonomyLevel::ReadOnly {
|
||||
return false;
|
||||
@@ -398,13 +399,9 @@ impl SecurityPolicy {
|
||||
// Strip leading env var assignments (e.g. FOO=bar cmd)
|
||||
let cmd_part = skip_env_assignments(segment);
|
||||
|
||||
let base_cmd = cmd_part
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
let mut words = cmd_part.split_whitespace();
|
||||
let base_raw = words.next().unwrap_or("");
|
||||
let base_cmd = base_raw.rsplit('/').next().unwrap_or("");
|
||||
|
||||
if base_cmd.is_empty() {
|
||||
continue;
|
||||
@@ -417,6 +414,12 @@ impl SecurityPolicy {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate arguments for the command
|
||||
let args: Vec<String> = words.map(|w| w.to_ascii_lowercase()).collect();
|
||||
if !self.is_args_safe(base_cmd, &args) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// At least one command must be present
|
||||
@@ -428,6 +431,29 @@ impl SecurityPolicy {
|
||||
has_cmd
|
||||
}
|
||||
|
||||
/// Check for dangerous arguments that allow sub-command execution.
|
||||
fn is_args_safe(&self, base: &str, args: &[String]) -> bool {
|
||||
let base = base.to_ascii_lowercase();
|
||||
match base.as_str() {
|
||||
"find" => {
|
||||
// find -exec and find -ok allow arbitrary command execution
|
||||
!args.iter().any(|arg| arg == "-exec" || arg == "-ok")
|
||||
}
|
||||
"git" => {
|
||||
// git config, alias, and -c can be used to set dangerous options
|
||||
// (e.g. git config core.editor "rm -rf /")
|
||||
!args.iter().any(|arg| {
|
||||
arg == "config"
|
||||
|| arg.starts_with("config.")
|
||||
|| arg == "alias"
|
||||
|| arg.starts_with("alias.")
|
||||
|| arg == "-c"
|
||||
})
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a file path is allowed (no path traversal, within workspace)
|
||||
pub fn is_path_allowed(&self, path: &str) -> bool {
|
||||
// Block null bytes (can truncate paths in C-backed syscalls)
|
||||
@@ -996,6 +1022,22 @@ mod tests {
|
||||
assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_argument_injection_blocked() {
|
||||
let p = default_policy();
|
||||
// find -exec is a common bypass
|
||||
assert!(!p.is_command_allowed("find . -exec rm -rf {} +"));
|
||||
assert!(!p.is_command_allowed("find / -ok cat {} \\;"));
|
||||
// git config/alias can execute commands
|
||||
assert!(!p.is_command_allowed("git config core.editor \"rm -rf /\""));
|
||||
assert!(!p.is_command_allowed("git alias.st status"));
|
||||
assert!(!p.is_command_allowed("git -c core.editor=calc.exe commit"));
|
||||
// Legitimate commands should still work
|
||||
assert!(p.is_command_allowed("find . -name '*.txt'"));
|
||||
assert!(p.is_command_allowed("git status"));
|
||||
assert!(p.is_command_allowed("git add ."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_injection_dollar_brace_blocked() {
|
||||
let p = default_policy();
|
||||
|
||||
@@ -16,6 +16,136 @@ use self::evaluate::{EvalResult, Evaluator, Recommendation};
|
||||
use self::integrate::Integrator;
|
||||
use self::scout::{GitHubScout, Scout, ScoutResult, ScoutSource};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Debug, Clone, clap::Subcommand)]
|
||||
pub enum SkillforgeCommands {
|
||||
Run {
|
||||
#[arg(long, default_value = "0.7")]
|
||||
min_score: f64,
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
#[arg(long)]
|
||||
source: Option<String>,
|
||||
},
|
||||
Scout {
|
||||
#[arg(long, default_value = "zeroclaw skill")]
|
||||
query: String,
|
||||
#[arg(long, default_value = "20")]
|
||||
limit: usize,
|
||||
},
|
||||
Status,
|
||||
}
|
||||
|
||||
pub async fn handle_command(command: SkillforgeCommands, config: &Config) -> Result<()> {
|
||||
let _forge_config = config.skillforge.clone();
|
||||
|
||||
match command {
|
||||
SkillforgeCommands::Run { min_score, dry_run, source } => {
|
||||
run_forge(config, min_score, dry_run, source).await
|
||||
}
|
||||
SkillforgeCommands::Scout { query, limit } => {
|
||||
run_scout(config, query, limit).await
|
||||
}
|
||||
SkillforgeCommands::Status => {
|
||||
show_status(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_forge(config: &Config, min_score: f64, dry_run: bool, source: Option<String>) -> Result<()> {
|
||||
println!("🔮 SkillForge Pipeline");
|
||||
println!(" Min Score: {}", min_score);
|
||||
println!(" Dry Run: {}", dry_run);
|
||||
println!(" Source: {}", source.as_deref().unwrap_or("all"));
|
||||
println!();
|
||||
|
||||
let mut forge_config = config.skillforge.clone();
|
||||
forge_config.enabled = true;
|
||||
forge_config.min_score = min_score;
|
||||
forge_config.auto_integrate = !dry_run;
|
||||
|
||||
if let Some(src) = source {
|
||||
forge_config.sources = vec![src];
|
||||
}
|
||||
|
||||
let forge = SkillForge::new(forge_config);
|
||||
let report = forge.forge().await?;
|
||||
|
||||
println!("📊 Results:");
|
||||
println!(" Discovered: {}", report.discovered);
|
||||
println!(" Evaluated: {}", report.evaluated);
|
||||
println!(" Auto-integrated: {}", report.auto_integrated);
|
||||
println!(" Manual review: {}", report.manual_review);
|
||||
println!(" Skipped: {}", report.skipped);
|
||||
println!();
|
||||
|
||||
if !report.results.is_empty() {
|
||||
println!("📋 Top Candidates:");
|
||||
for (i, res) in report.results.iter().take(10).enumerate() {
|
||||
let status = match res.recommendation {
|
||||
Recommendation::Auto => "✅ AUTO",
|
||||
Recommendation::Manual => "⚠️ REVIEW",
|
||||
Recommendation::Skip => "❌ SKIP",
|
||||
};
|
||||
println!(" {}. {} [{:.2}] {}", i + 1, res.candidate.name, res.total_score, status);
|
||||
println!(" {}", res.candidate.description.chars().take(60).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_scout(config: &Config, query: String, limit: usize) -> Result<()> {
|
||||
println!("🔍 SkillForge Scout");
|
||||
println!(" Query: {}", query);
|
||||
println!(" Limit: {}", limit);
|
||||
println!();
|
||||
|
||||
let github_token = config.skillforge.github_token.clone();
|
||||
let scout = GitHubScout::new_with_query(github_token, query.clone());
|
||||
|
||||
let results = scout.discover().await?;
|
||||
let limited: Vec<_> = results.into_iter().take(limit).collect();
|
||||
|
||||
println!("📋 Found {} candidates:", limited.len());
|
||||
for (i, candidate) in limited.iter().enumerate() {
|
||||
println!(" {}. {}", i + 1, candidate.name);
|
||||
println!(" URL: {}", candidate.url);
|
||||
println!(" ⭐ {} stars", candidate.stars);
|
||||
let desc: String = candidate.description.chars().take(60).collect();
|
||||
if !desc.is_empty() {
|
||||
println!(" {}", desc);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_status(config: &Config) -> Result<()> {
|
||||
println!("🔮 SkillForge Status");
|
||||
println!();
|
||||
println!("Configuration:");
|
||||
println!(" Enabled: {}", config.skillforge.enabled);
|
||||
println!(" Auto-integrate: {}", config.skillforge.auto_integrate);
|
||||
println!(" Sources: {:?}", config.skillforge.sources);
|
||||
println!(" Scan interval: {}h", config.skillforge.scan_interval_hours);
|
||||
println!(" Min score: {:.2}", config.skillforge.min_score);
|
||||
println!(" Output directory: {}", config.skillforge.output_dir);
|
||||
println!(" GitHub token: {}", if config.skillforge.github_token.is_some() { "configured" } else { "not set" });
|
||||
|
||||
let skills_dir = std::path::Path::new(&config.skillforge.output_dir);
|
||||
if skills_dir.exists() {
|
||||
let count = std::fs::read_dir(skills_dir)
|
||||
.map(|entries| entries.filter_map(|e| e.ok()).count())
|
||||
.unwrap_or(0);
|
||||
println!();
|
||||
println!("Integrated skills: {}", count);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -103,6 +103,36 @@ impl GitHubScout {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_query(token: Option<String>, query: String) -> Self {
|
||||
use std::time::Duration;
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
"application/vnd.github+json".parse().expect("valid header"),
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
"ZeroClaw-SkillForge/0.1".parse().expect("valid header"),
|
||||
);
|
||||
if let Some(ref t) = token {
|
||||
if let Ok(val) = format!("Bearer {t}").parse() {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, val);
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.default_headers(headers)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("failed to build reqwest client");
|
||||
|
||||
Self {
|
||||
client,
|
||||
queries: vec![query],
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the GitHub search/repositories JSON response.
|
||||
fn parse_items(body: &serde_json::Value) -> Vec<ScoutResult> {
|
||||
let items = match body.get("items").and_then(|v| v.as_array()) {
|
||||
|
||||
@@ -749,4 +749,54 @@ mod tests {
|
||||
let _ = HttpRequestTool::redact_headers_for_display(&headers);
|
||||
assert_eq!(headers[0].1, "Bearer real-token");
|
||||
}
|
||||
|
||||
// ── SSRF: alternate IP notation bypass defense-in-depth ─────────
|
||||
//
|
||||
// Rust's IpAddr::parse() rejects non-standard notations (octal, hex,
|
||||
// decimal integer, zero-padded). These tests document that property
|
||||
// so regressions are caught if the parsing strategy ever changes.
|
||||
|
||||
#[test]
|
||||
fn ssrf_octal_loopback_not_parsed_as_ip() {
|
||||
// 0177.0.0.1 is octal for 127.0.0.1 in some languages, but
|
||||
// Rust's IpAddr rejects it — it falls through as a hostname.
|
||||
assert!(!is_private_or_local_host("0177.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_hex_loopback_not_parsed_as_ip() {
|
||||
// 0x7f000001 is hex for 127.0.0.1 in some languages.
|
||||
assert!(!is_private_or_local_host("0x7f000001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_decimal_loopback_not_parsed_as_ip() {
|
||||
// 2130706433 is decimal for 127.0.0.1 in some languages.
|
||||
assert!(!is_private_or_local_host("2130706433"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_zero_padded_loopback_not_parsed_as_ip() {
|
||||
// 127.000.000.001 uses zero-padded octets.
|
||||
assert!(!is_private_or_local_host("127.000.000.001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_alternate_notations_rejected_by_validate_url() {
|
||||
// Even if is_private_or_local_host doesn't flag these, they
|
||||
// fail the allowlist because they're treated as hostnames.
|
||||
let tool = test_tool(vec!["example.com"]);
|
||||
for notation in [
|
||||
"http://0177.0.0.1",
|
||||
"http://0x7f000001",
|
||||
"http://2130706433",
|
||||
"http://127.000.000.001",
|
||||
] {
|
||||
let err = tool.validate_url(notation).unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("allowed_domains"),
|
||||
"Expected allowlist rejection for {notation}, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod schema;
|
||||
pub mod screenshot;
|
||||
pub mod shell;
|
||||
pub mod traits;
|
||||
pub mod transcribe;
|
||||
|
||||
pub use browser::{BrowserTool, ComputerUseConfig};
|
||||
pub use browser_open::BrowserOpenTool;
|
||||
@@ -53,6 +54,7 @@ pub use schema::{CleaningStrategy, SchemaCleanr};
|
||||
pub use screenshot::ScreenshotTool;
|
||||
pub use shell::ShellTool;
|
||||
pub use traits::Tool;
|
||||
pub use transcribe::TranscribeTool;
|
||||
#[allow(unused_imports)]
|
||||
pub use traits::{ToolResult, ToolSpec};
|
||||
|
||||
@@ -191,6 +193,8 @@ pub fn all_tools_with_runtime(
|
||||
tools.push(Box::new(ScreenshotTool::new(security.clone())));
|
||||
tools.push(Box::new(ImageInfoTool::new(security.clone())));
|
||||
|
||||
tools.push(Box::new(TranscribeTool::new(security.clone(), None, None)));
|
||||
|
||||
if let Some(key) = composio_key {
|
||||
if !key.is_empty() {
|
||||
tools.push(Box::new(ComposioTool::new(key, composio_entity_id)));
|
||||
|
||||
283
src/tools/transcribe.rs
Normal file
283
src/tools/transcribe.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::process::Command;
|
||||
|
||||
const MAX_AUDIO_BYTES: u64 = 104_857_600;
|
||||
const SUPPORTED_FORMATS: &[&str] = &["mp3", "wav", "m4a", "flac", "ogg", "webm", "mp4", "mpeg", "mpga"];
|
||||
|
||||
pub struct TranscribeTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
model: String,
|
||||
device: String,
|
||||
}
|
||||
|
||||
impl TranscribeTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>, model: Option<String>, device: Option<String>) -> Self {
|
||||
Self {
|
||||
security,
|
||||
model: model.unwrap_or_else(|| "base".to_string()),
|
||||
device: device.unwrap_or_else(|| "cpu".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_supported_format(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| SUPPORTED_FORMATS.contains(&ext.to_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn transcription_script() -> &'static str {
|
||||
r#"
|
||||
import sys
|
||||
import json
|
||||
|
||||
def transcribe(audio_path, model_size, device):
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
model = WhisperModel(model_size, device=device, compute_type="int8")
|
||||
segments, info = model.transcribe(audio_path, beam_size=5)
|
||||
|
||||
transcription = []
|
||||
for segment in segments:
|
||||
transcription.append({
|
||||
"start": round(segment.start, 2),
|
||||
"end": round(segment.end, 2),
|
||||
"text": segment.text.strip()
|
||||
})
|
||||
|
||||
result = {
|
||||
"language": info.language,
|
||||
"language_probability": round(info.language_probability, 2),
|
||||
"duration": round(info.duration, 2),
|
||||
"segments": transcription,
|
||||
"text": " ".join(s["text"] for s in transcription)
|
||||
}
|
||||
|
||||
print(json.dumps(result))
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 4:
|
||||
print(json.dumps({"error": "Usage: script.py <audio_path> <model> <device>"}))
|
||||
sys.exit(1)
|
||||
|
||||
transcribe(sys.argv[1], sys.argv[2], sys.argv[3])
|
||||
"#
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TranscribeTool {
|
||||
fn name(&self) -> &str {
|
||||
"transcribe"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Transcribe audio files to text using faster-whisper. \
|
||||
Supports mp3, wav, m4a, flac, ogg, webm, and other common audio formats. \
|
||||
Returns the transcription with timestamps and detected language."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the audio file to transcribe"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"enum": ["tiny", "base", "small", "medium", "large-v2", "large-v3"],
|
||||
"description": "Whisper model size (default: base). Larger models are more accurate but slower."
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Hint for the spoken language (e.g., 'en', 'es', 'zh'). Optional."
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let path_str = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?;
|
||||
|
||||
if self.security.is_rate_limited() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Rate limit exceeded: too many actions in the last hour".into()),
|
||||
});
|
||||
}
|
||||
|
||||
if !self.security.is_path_allowed(path_str) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Path not allowed by security policy: {}", path_str)),
|
||||
});
|
||||
}
|
||||
|
||||
if !self.security.record_action() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Rate limit exceeded: action budget exhausted".into()),
|
||||
});
|
||||
}
|
||||
|
||||
let path = Path::new(path_str);
|
||||
let full_path = self.security.workspace_dir.join(path);
|
||||
|
||||
if !full_path.exists() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("File not found: {}", path_str)),
|
||||
});
|
||||
}
|
||||
|
||||
let metadata = match std::fs::metadata(&full_path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Cannot read file metadata: {}", e)),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if metadata.len() > MAX_AUDIO_BYTES {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"File too large: {} bytes (max: {} bytes)",
|
||||
metadata.len(),
|
||||
MAX_AUDIO_BYTES
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
if !Self::is_supported_format(&full_path) {
|
||||
let ext = full_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("unknown");
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Unsupported audio format: {}. Supported: {}",
|
||||
ext,
|
||||
SUPPORTED_FORMATS.join(", ")
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
let model = args
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&self.model);
|
||||
|
||||
let script = Self::transcription_script();
|
||||
let output = Command::new("python3")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.arg(&full_path)
|
||||
.arg(model)
|
||||
.arg(&self.device)
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match output {
|
||||
Ok(result) => {
|
||||
if result.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||
match serde_json::from_str::<serde_json::Value>(&stdout) {
|
||||
Ok(json) => {
|
||||
let text = json
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or(&stdout);
|
||||
let language = json
|
||||
.get("language")
|
||||
.and_then(|l| l.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let duration = json
|
||||
.get("duration")
|
||||
.and_then(|d| d.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let duration_f = duration;
|
||||
let mut out = format!(
|
||||
"**Transcription** ({:.1}, language: {})\n\n{}\n",
|
||||
duration_f, language, text
|
||||
);
|
||||
|
||||
if let Some(segments) = json.get("segments").and_then(|s| s.as_array())
|
||||
{
|
||||
if segments.len() > 1 {
|
||||
out.push_str("\n**Segments:**\n");
|
||||
for seg in segments.iter().take(20) {
|
||||
if let (Some(start), Some(end), Some(seg_text)) = (
|
||||
seg.get("start").and_then(|v| v.as_f64()),
|
||||
seg.get("end").and_then(|v| v.as_f64()),
|
||||
seg.get("text").and_then(|v| v.as_str()),
|
||||
) {
|
||||
let start_f = start;
|
||||
let end_f = end;
|
||||
out.push_str(&format!(
|
||||
"[{:05.1} - {:05.1}] {}\n",
|
||||
start_f, end_f, seg_text
|
||||
));
|
||||
}
|
||||
}
|
||||
if segments.len() > 20 {
|
||||
out.push_str(&format!(
|
||||
"... and {} more segments\n",
|
||||
segments.len() - 20
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: out,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(_) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: stdout.to_string(),
|
||||
error: None,
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Transcription failed: {}", stderr.trim())),
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Failed to run transcription: {}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user