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`):
|
- Risk label (`risk: low|medium|high`):
|
||||||
- Size label (`size: XS|S|M|L|XL`, auto-managed/read-only):
|
- 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):
|
- 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`):
|
- 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):
|
- 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:
|
- 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
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Apply contributor tier label for issue author
|
- name: Apply contributor tier label for issue author
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
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);
|
return pattern.test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatModuleLabel(prefix, segment) {
|
||||||
|
return `${prefix}: ${segment}`;
|
||||||
|
}
|
||||||
|
|
||||||
function parseModuleLabel(label) {
|
function parseModuleLabel(label) {
|
||||||
const separatorIndex = label.indexOf(":");
|
if (typeof label !== "string") return null;
|
||||||
if (separatorIndex <= 0 || separatorIndex >= label.length - 1) return null;
|
const match = label.match(/^([^:]+):\s*(.+)$/);
|
||||||
return {
|
if (!match) return null;
|
||||||
prefix: label.slice(0, separatorIndex),
|
const prefix = match[1].trim().toLowerCase();
|
||||||
segment: label.slice(separatorIndex + 1),
|
const segment = (match[2] || "").trim().toLowerCase();
|
||||||
};
|
if (!prefix || !segment) return null;
|
||||||
|
return { prefix, segment };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortByPriority(labels, priorityIndex) {
|
function sortByPriority(labels, priorityIndex) {
|
||||||
@@ -389,7 +394,7 @@ jobs:
|
|||||||
for (const [prefix, segments] of segmentsByPrefix) {
|
for (const [prefix, segments] of segmentsByPrefix) {
|
||||||
const hasSpecificSegment = [...segments].some((segment) => segment !== "core");
|
const hasSpecificSegment = [...segments].some((segment) => segment !== "core");
|
||||||
if (hasSpecificSegment) {
|
if (hasSpecificSegment) {
|
||||||
refined.delete(`${prefix}:core`);
|
refined.delete(formatModuleLabel(prefix, "core"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +423,7 @@ jobs:
|
|||||||
if (uniqueSegments.length === 0) continue;
|
if (uniqueSegments.length === 0) continue;
|
||||||
|
|
||||||
if (uniqueSegments.length === 1) {
|
if (uniqueSegments.length === 1) {
|
||||||
compactedModuleLabels.add(`${prefix}:${uniqueSegments[0]}`);
|
compactedModuleLabels.add(formatModuleLabel(prefix, uniqueSegments[0]));
|
||||||
} else {
|
} else {
|
||||||
forcePathPrefixes.add(prefix);
|
forcePathPrefixes.add(prefix);
|
||||||
}
|
}
|
||||||
@@ -609,7 +614,7 @@ jobs:
|
|||||||
segment = normalizeLabelSegment(segment);
|
segment = normalizeLabelSegment(segment);
|
||||||
if (!segment) continue;
|
if (!segment) continue;
|
||||||
|
|
||||||
detectedModuleLabels.add(`${rule.prefix}:${segment}`);
|
detectedModuleLabels.add(formatModuleLabel(rule.prefix, segment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +640,7 @@ jobs:
|
|||||||
|
|
||||||
for (const keyword of providerKeywordHints) {
|
for (const keyword of providerKeywordHints) {
|
||||||
if (containsKeyword(searchableText, keyword)) {
|
if (containsKeyword(searchableText, keyword)) {
|
||||||
detectedModuleLabels.add(`provider:${keyword}`);
|
detectedModuleLabels.add(formatModuleLabel("provider", keyword));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -661,7 +666,7 @@ jobs:
|
|||||||
|
|
||||||
for (const keyword of channelKeywordHints) {
|
for (const keyword of channelKeywordHints) {
|
||||||
if (containsKeyword(searchableText, keyword)) {
|
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)
|
# Environment files (may contain secrets)
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Python virtual environments
|
||||||
|
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# ESP32 build cache (esp-idf-sys managed)
|
||||||
|
|
||||||
|
.embuild/
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4927,6 +4927,7 @@ dependencies = [
|
|||||||
"prometheus",
|
"prometheus",
|
||||||
"prost",
|
"prost",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rppal",
|
"rppal",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["."]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "zeroclaw"
|
name = "zeroclaw"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -86,6 +90,7 @@ glob = "0.3"
|
|||||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
regex = "1.10"
|
||||||
hostname = "0.4.2"
|
hostname = "0.4.2"
|
||||||
lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
|
lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
|
||||||
mail-parser = "0.11.2"
|
mail-parser = "0.11.2"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
Fast, small, and fully autonomous AI assistant infrastructure — deploy anywhere, swap anything.
|
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
|
### ✨ Features
|
||||||
@@ -191,7 +191,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze
|
|||||||
|
|
||||||
| Subsystem | Trait | Ships with | Extend |
|
| 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 |
|
| **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 |
|
| **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 |
|
| **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
|
### Optional Repository Automation
|
||||||
|
|
||||||
- `.github/workflows/labeler.yml` (`PR Labeler`)
|
- `.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: 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: 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`)
|
- 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.
|
- Path labels identify subsystem ownership quickly.
|
||||||
- Size labels drive batching strategy.
|
- Size labels drive batching strategy.
|
||||||
- Risk labels drive review depth (`risk: low/medium/high`).
|
- 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.
|
- `risk: manual` allows maintainers to preserve a human risk judgment when automation lacks context.
|
||||||
- `no-stale` is reserved for accepted-but-blocked work.
|
- `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:
|
For every new PR, do a fast intake pass:
|
||||||
|
|
||||||
1. Confirm template completeness (`summary`, `validation`, `security`, `rollback`).
|
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`).
|
3. Confirm CI signal status (`CI Required Gate`).
|
||||||
4. Confirm scope is one concern (reject mixed mega-PRs unless justified).
|
4. Confirm scope is one concern (reject mixed mega-PRs unless justified).
|
||||||
5. Confirm privacy/data-hygiene and neutral test wording requirements are satisfied.
|
5. Confirm privacy/data-hygiene and neutral test wording requirements are satisfied.
|
||||||
|
|||||||
@@ -2,4 +2,10 @@
|
|||||||
target = "riscv32imc-esp-espidf"
|
target = "riscv32imc-esp-espidf"
|
||||||
|
|
||||||
[target.riscv32imc-esp-espidf]
|
[target.riscv32imc-esp-espidf]
|
||||||
|
linker = "ldproxy"
|
||||||
runner = "espflash flash --monitor"
|
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]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.63.0"
|
version = "0.71.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885"
|
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 2.11.0",
|
||||||
"cexpr",
|
"cexpr",
|
||||||
"clang-sys",
|
"clang-sys",
|
||||||
"lazy_static",
|
"itertools",
|
||||||
"lazycell",
|
|
||||||
"log",
|
"log",
|
||||||
"peeking_take_while",
|
"prettyplease",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"shlex",
|
"shlex",
|
||||||
"syn 1.0.109",
|
"syn 2.0.116",
|
||||||
"which",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -374,14 +372,15 @@ checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embassy-sync"
|
name = "embassy-sync"
|
||||||
version = "0.5.0"
|
version = "0.7.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd938f25c0798db4280fcd8026bf4c2f48789aebf8f77b6e5cf8a7693ba114ec"
|
checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"critical-section",
|
"critical-section",
|
||||||
"embedded-io-async",
|
"embedded-io-async",
|
||||||
"futures-util",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
"heapless",
|
"heapless",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -446,16 +445,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embedded-svc"
|
name = "embedded-svc"
|
||||||
version = "0.27.1"
|
version = "0.28.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac6f87e7654f28018340aa55f933803017aefabaa5417820a3b2f808033c7bbc"
|
checksum = "a7770e30ab55cfbf954c00019522490d6ce26a3334bede05a732ba61010e98e0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defmt 0.3.100",
|
"defmt 0.3.100",
|
||||||
"embedded-io",
|
"embedded-io",
|
||||||
"embedded-io-async",
|
"embedded-io-async",
|
||||||
"enumset",
|
"enumset",
|
||||||
"heapless",
|
"heapless",
|
||||||
"no-std-net",
|
|
||||||
"num_enum",
|
"num_enum",
|
||||||
"serde",
|
"serde",
|
||||||
"strum 0.25.0",
|
"strum 0.25.0",
|
||||||
@@ -463,9 +461,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embuild"
|
name = "embuild"
|
||||||
version = "0.31.4"
|
version = "0.33.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4caa4f198bb9152a55c0103efb83fa4edfcbb8625f4c9e94ae8ec8e23827c563"
|
checksum = "e188ad2bbe82afa841ea4a29880651e53ab86815db036b2cb9f8de3ac32dad75"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -475,6 +473,7 @@ dependencies = [
|
|||||||
"globwalk",
|
"globwalk",
|
||||||
"home",
|
"home",
|
||||||
"log",
|
"log",
|
||||||
|
"regex",
|
||||||
"remove_dir_all",
|
"remove_dir_all",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -533,9 +532,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "esp-idf-hal"
|
name = "esp-idf-hal"
|
||||||
version = "0.43.1"
|
version = "0.45.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/esp-rs/esp-idf-hal#bc48639bd626c72afc1e25e5d497b5c639161d30"
|
||||||
checksum = "f7adf3fb19a9ca016cbea1ab8a7b852ac69df8fcde4923c23d3b155efbc42a74"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"embassy-sync",
|
"embassy-sync",
|
||||||
@@ -552,14 +550,12 @@ dependencies = [
|
|||||||
"heapless",
|
"heapless",
|
||||||
"log",
|
"log",
|
||||||
"nb 1.1.0",
|
"nb 1.1.0",
|
||||||
"num_enum",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "esp-idf-svc"
|
name = "esp-idf-svc"
|
||||||
version = "0.48.1"
|
version = "0.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/esp-rs/esp-idf-svc#dee202f146c7681e54eabbf118a216fc0195d203"
|
||||||
checksum = "2180642ca122a7fec1ec417a9b1a77aa66aaa067fdf1daae683dd8caba84f26b"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"embassy-futures",
|
"embassy-futures",
|
||||||
"embedded-hal-async",
|
"embedded-hal-async",
|
||||||
@@ -567,6 +563,7 @@ dependencies = [
|
|||||||
"embuild",
|
"embuild",
|
||||||
"enumset",
|
"enumset",
|
||||||
"esp-idf-hal",
|
"esp-idf-hal",
|
||||||
|
"futures-io",
|
||||||
"heapless",
|
"heapless",
|
||||||
"log",
|
"log",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
@@ -575,14 +572,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "esp-idf-sys"
|
name = "esp-idf-sys"
|
||||||
version = "0.34.1"
|
version = "0.36.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/esp-rs/esp-idf-sys#64667a38fb8004e1fc3b032488af6857ca3cd849"
|
||||||
checksum = "2e148f97c04ed3e9181a08bcdc9560a515aad939b0ba7f50a0022e294665e0af"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bindgen",
|
|
||||||
"build-time",
|
"build-time",
|
||||||
"cargo_metadata",
|
"cargo_metadata",
|
||||||
|
"cmake",
|
||||||
"const_format",
|
"const_format",
|
||||||
"embuild",
|
"embuild",
|
||||||
"envy",
|
"envy",
|
||||||
@@ -649,21 +645,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-io"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"futures-task",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
@@ -827,6 +818,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -843,18 +843,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "leb128fmt"
|
name = "leb128fmt"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -945,12 +933,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "no-std-net"
|
|
||||||
version = "0.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1bcece43b12349917e096cddfa66107277f123e6c96a5aea78711dc601a47152"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
@@ -1007,18 +989,6 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
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]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.37"
|
version = "0.2.37"
|
||||||
@@ -1138,9 +1108,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "2.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
|
|||||||
@@ -14,15 +14,21 @@ edition = "2021"
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "ZeroClaw ESP32 peripheral firmware — GPIO over JSON serial"
|
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]
|
[dependencies]
|
||||||
esp-idf-svc = "0.48"
|
esp-idf-svc = { git = "https://github.com/esp-rs/esp-idf-svc" }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
embuild = "0.31"
|
embuild = { version = "0.33", features = ["espidf"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "s"
|
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.
|
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
|
## Protocol
|
||||||
|
|
||||||
|
|
||||||
- **Request** (host → ESP32): `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}\n`
|
- **Request** (host → ESP32): `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}\n`
|
||||||
- **Response** (ESP32 → host): `{"id":"1","ok":true,"result":"done"}\n`
|
- **Response** (ESP32 → host): `{"id":"1","ok":true,"result":"done"}\n`
|
||||||
|
|
||||||
@@ -11,19 +14,44 @@ Commands: `gpio_read`, `gpio_write`.
|
|||||||
|
|
||||||
## Prerequisites
|
## 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
|
```sh
|
||||||
cargo install espup espflash
|
cargo install espup espflash
|
||||||
espup install
|
espup install
|
||||||
source ~/export-esp.sh # or ~/export-esp.fish for Fish
|
source ~/export-esp.sh
|
||||||
```
|
```
|
||||||
|
Then edit `.cargo/config.toml` to change the target (e.g. `xtensa-esp32-espidf`).
|
||||||
2. **Target**: ESP32-C3 (RISC-V) by default. Edit `.cargo/config.toml` for other targets (e.g. `xtensa-esp32-espidf` for original ESP32).
|
|
||||||
|
|
||||||
## Build & Flash
|
## Build & Flash
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware/zeroclaw-esp32
|
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
|
cargo build --release
|
||||||
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
|
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
|
//! Protocol: same as STM32 — see docs/hardware-peripherals-design.md
|
||||||
|
|
||||||
use esp_idf_svc::hal::gpio::PinDriver;
|
use esp_idf_svc::hal::gpio::PinDriver;
|
||||||
use esp_idf_svc::hal::prelude::*;
|
use esp_idf_svc::hal::peripherals::Peripherals;
|
||||||
use esp_idf_svc::hal::uart::*;
|
use esp_idf_svc::hal::uart::{UartConfig, UartDriver};
|
||||||
|
use esp_idf_svc::hal::units::Hertz;
|
||||||
use log::info;
|
use log::info;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -36,9 +37,13 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let peripherals = Peripherals::take()?;
|
let peripherals = Peripherals::take()?;
|
||||||
let pins = peripherals.pins;
|
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
|
// 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 config = UartConfig::new().baudrate(Hertz(115_200));
|
||||||
let mut uart = UartDriver::new(
|
let uart = UartDriver::new(
|
||||||
peripherals.uart0,
|
peripherals.uart0,
|
||||||
pins.gpio21,
|
pins.gpio21,
|
||||||
pins.gpio20,
|
pins.gpio20,
|
||||||
@@ -60,7 +65,8 @@ fn main() -> anyhow::Result<()> {
|
|||||||
if b == b'\n' {
|
if b == b'\n' {
|
||||||
if !line.is_empty() {
|
if !line.is_empty() {
|
||||||
if let Ok(line_str) = std::str::from_utf8(&line) {
|
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 out = serde_json::to_string(&resp).unwrap_or_default();
|
||||||
let _ = uart.write(format!("{}\n", out).as_bytes());
|
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,
|
line: &str,
|
||||||
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
|
gpio2: &mut PinDriver<'_, G2>,
|
||||||
) -> anyhow::Result<Response> {
|
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 req: Request = serde_json::from_str(line.trim())?;
|
||||||
let id = req.id.clone();
|
let id = req.id.clone();
|
||||||
|
|
||||||
@@ -98,13 +109,13 @@ fn handle_request(
|
|||||||
}
|
}
|
||||||
"gpio_read" => {
|
"gpio_read" => {
|
||||||
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
|
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())
|
Ok(value.to_string())
|
||||||
}
|
}
|
||||||
"gpio_write" => {
|
"gpio_write" => {
|
||||||
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
|
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);
|
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())
|
Ok("done".into())
|
||||||
}
|
}
|
||||||
_ => Err(anyhow::anyhow!("Unknown command: {}", req.cmd)),
|
_ => 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
|
// TODO: implement input pin read — requires storing InputPin drivers per pin
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gpio_write(
|
fn gpio_write<G2, G13>(
|
||||||
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
|
gpio2: &mut PinDriver<'_, G2>,
|
||||||
|
gpio13: &mut PinDriver<'_, G13>,
|
||||||
pin: i32,
|
pin: i32,
|
||||||
value: u64,
|
value: u64,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()>
|
||||||
let pins = peripherals.pins;
|
where
|
||||||
let level = value != 0;
|
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 {
|
match pin {
|
||||||
2 => {
|
2 => gpio2.set_level(level)?,
|
||||||
let mut out = PinDriver::output(pins.gpio2)?;
|
13 => gpio13.set_level(level)?,
|
||||||
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))?;
|
|
||||||
}
|
|
||||||
_ => anyhow::bail!("Pin {} not configured (add to gpio_write)", pin),
|
_ => anyhow::bail!("Pin {} not configured (add to gpio_write)", pin),
|
||||||
}
|
}
|
||||||
Ok(())
|
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::tools::{self, Tool};
|
||||||
use crate::util::truncate_with_ellipsis;
|
use crate::util::truncate_with_ellipsis;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use regex::{Regex, RegexSet};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, LazyLock};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Maximum agentic tool-use iterations per user message to prevent runaway loops.
|
/// Maximum agentic tool-use iterations per user message to prevent runaway loops.
|
||||||
const MAX_TOOL_ITERATIONS: usize = 10;
|
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.
|
/// Trigger auto-compaction when non-system message count exceeds this threshold.
|
||||||
const MAX_HISTORY_MESSAGES: usize = 50;
|
const MAX_HISTORY_MESSAGES: usize = 50;
|
||||||
|
|
||||||
@@ -608,7 +664,7 @@ pub(crate) async fn run_tool_call_loop(
|
|||||||
success: r.success,
|
success: r.success,
|
||||||
});
|
});
|
||||||
if r.success {
|
if r.success {
|
||||||
r.output
|
scrub_credentials(&r.output)
|
||||||
} else {
|
} else {
|
||||||
format!("Error: {}", r.error.unwrap_or_else(|| r.output))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 crate::memory::{Memory, MemoryCategory, SqliteMemory};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ impl Channel for CliChannel {
|
|||||||
let msg = ChannelMessage {
|
let msg = ChannelMessage {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
sender: "user".to_string(),
|
sender: "user".to_string(),
|
||||||
reply_to: "user".to_string(),
|
reply_target: "user".to_string(),
|
||||||
content: line,
|
content: line,
|
||||||
channel: "cli".to_string(),
|
channel: "cli".to_string(),
|
||||||
timestamp: std::time::SystemTime::now()
|
timestamp: std::time::SystemTime::now()
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ impl Channel for DingTalkChannel {
|
|||||||
let channel_msg = ChannelMessage {
|
let channel_msg = ChannelMessage {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
sender: sender_id.to_string(),
|
sender: sender_id.to_string(),
|
||||||
reply_to: chat_id,
|
reply_target: chat_id,
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
channel: "dingtalk".to_string(),
|
channel: "dingtalk".to_string(),
|
||||||
timestamp: std::time::SystemTime::now()
|
timestamp: std::time::SystemTime::now()
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ impl LarkChannel {
|
|||||||
let channel_msg = ChannelMessage {
|
let channel_msg = ChannelMessage {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
sender: lark_msg.chat_id.clone(),
|
sender: lark_msg.chat_id.clone(),
|
||||||
reply_to: lark_msg.chat_id.clone(),
|
reply_target: lark_msg.chat_id.clone(),
|
||||||
content: text,
|
content: text,
|
||||||
channel: "lark".to_string(),
|
channel: "lark".to_string(),
|
||||||
timestamp: std::time::SystemTime::now()
|
timestamp: std::time::SystemTime::now()
|
||||||
|
|||||||
@@ -633,6 +633,7 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
|
|||||||
Arc::new(TelegramChannel::new(
|
Arc::new(TelegramChannel::new(
|
||||||
tg.bot_token.clone(),
|
tg.bot_token.clone(),
|
||||||
tg.allowed_users.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(
|
channels.push(Arc::new(TelegramChannel::new(
|
||||||
tg.bot_token.clone(),
|
tg.bot_token.clone(),
|
||||||
tg.allowed_users.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 reqwest::multipart::{Form, Part};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
/// Telegram's maximum message length for text messages
|
/// Telegram's maximum message length for text messages
|
||||||
const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
|
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
|
/// Telegram channel — long-polls the Bot API for updates
|
||||||
pub struct TelegramChannel {
|
pub struct TelegramChannel {
|
||||||
|
workspace_dir: std::path::PathBuf,
|
||||||
bot_token: String,
|
bot_token: String,
|
||||||
allowed_users: Vec<String>,
|
allowed_users: Vec<String>,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TelegramChannel {
|
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 {
|
Self {
|
||||||
bot_token,
|
bot_token,
|
||||||
allowed_users,
|
allowed_users,
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
|
workspace_dir: workspace_dir.unwrap_or_else(|| std::path::PathBuf::from("/tmp")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_url(&self, method: &str) -> String {
|
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 {
|
fn is_user_allowed(&self, username: &str) -> bool {
|
||||||
@@ -209,6 +223,131 @@ impl TelegramChannel {
|
|||||||
identities.into_iter().any(|id| self.is_user_allowed(id))
|
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> {
|
fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
|
||||||
let message = update.get("message")?;
|
let message = update.get("message")?;
|
||||||
|
|
||||||
@@ -240,8 +379,9 @@ impl TelegramChannel {
|
|||||||
|
|
||||||
if !self.is_any_user_allowed(identities.iter().copied()) {
|
if !self.is_any_user_allowed(identities.iter().copied()) {
|
||||||
tracing::warn!(
|
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`.",
|
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
|
||||||
|
username,
|
||||||
user_id.as_deref().unwrap_or("unknown")
|
user_id.as_deref().unwrap_or("unknown")
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
@@ -259,7 +399,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Some(ChannelMessage {
|
Some(ChannelMessage {
|
||||||
id: format!("telegram_{chat_id}_{message_id}"),
|
id: format!("telegram_{}_{}", chat_id, message_id),
|
||||||
sender: sender_identity,
|
sender: sender_identity,
|
||||||
reply_target: chat_id,
|
reply_target: chat_id,
|
||||||
content: text.to_string(),
|
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<()> {
|
async fn send_text_chunks(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
|
||||||
let chunks = split_message_for_telegram(message);
|
let chunks = split_message_for_telegram(message);
|
||||||
|
|
||||||
for (index, chunk) in chunks.iter().enumerate() {
|
for (index, chunk) in chunks.iter().enumerate() {
|
||||||
let text = if chunks.len() > 1 {
|
let text = if chunks.len() > 1 {
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
format!("{chunk}\n\n(continues...)")
|
format!("{}\n\n(continues...)", chunk)
|
||||||
} else if index == chunks.len() - 1 {
|
} else if index == chunks.len() - 1 {
|
||||||
format!("(continued)\n\n{chunk}")
|
format!("(continued)\n\n{}", chunk)
|
||||||
} else {
|
} else {
|
||||||
format!("(continued)\n\n{chunk}\n\n(continues...)")
|
format!("(continued)\n\n{}\n\n(continues...)", chunk)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
chunk.to_string()
|
chunk.to_string()
|
||||||
@@ -371,10 +574,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +610,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
let path = Path::new(target);
|
let path = Path::new(target);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
anyhow::bail!("Telegram attachment path not found: {target}");
|
anyhow::bail!("Telegram attachment path not found: {}", target);
|
||||||
}
|
}
|
||||||
|
|
||||||
match attachment.kind {
|
match attachment.kind {
|
||||||
@@ -451,10 +654,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,10 +688,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,10 +727,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,10 +761,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,10 +800,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,10 +839,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,10 +878,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,10 +910,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,10 +942,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch
|
|||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let err = resp.text().await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,7 +1029,7 @@ impl Channel for TelegramChannel {
|
|||||||
let resp = match self.client.post(&url).json(&body).send().await {
|
let resp = match self.client.post(&url).json(&body).send().await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Telegram poll error: {e}");
|
tracing::warn!("Telegram poll error: {}", e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -835,7 +1038,7 @@ impl Channel for TelegramChannel {
|
|||||||
let data: serde_json::Value = match resp.json().await {
|
let data: serde_json::Value = match resp.json().await {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("Telegram parse error: {e}");
|
tracing::warn!("Telegram parse error: {}", e);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -848,24 +1051,81 @@ impl Channel for TelegramChannel {
|
|||||||
offset = uid + 1;
|
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;
|
continue;
|
||||||
};
|
}
|
||||||
|
|
||||||
// Send "typing" indicator immediately when we receive a message
|
// Then, try to parse as voice/audio message
|
||||||
let typing_body = serde_json::json!({
|
if let Some((file_id, sender, chat_id, message_id)) =
|
||||||
"chat_id": &msg.reply_target,
|
self.parse_voice_message(update)
|
||||||
"action": "typing"
|
{
|
||||||
});
|
// Send "typing" indicator
|
||||||
let _ = self
|
let typing_body = serde_json::json!({
|
||||||
.client
|
"chat_id": &chat_id,
|
||||||
.post(self.api_url("sendChatAction"))
|
"action": "typing"
|
||||||
.json(&typing_body)
|
});
|
||||||
.send()
|
let _ = self
|
||||||
.await; // Ignore errors for typing indicator
|
.client
|
||||||
|
.post(self.api_url("sendChatAction"))
|
||||||
|
.json(&typing_body)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
if tx.send(msg).await.is_err() {
|
// Download and transcribe the voice file
|
||||||
return Ok(());
|
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(Ok(resp)) => resp.status().is_success(),
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
tracing::debug!("Telegram health check failed: {e}");
|
tracing::debug!("Telegram health check failed: {}", e);
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -900,41 +1160,50 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_channel_name() {
|
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");
|
assert_eq!(ch.name(), "telegram");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url() {
|
fn telegram_api_url() {
|
||||||
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
let ch = TelegramChannel::new("123:ABC".into(), vec![], None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
ch.api_url("getMe"),
|
ch.api_url("getMe"),
|
||||||
"https://api.telegram.org/bot123:ABC/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]
|
#[test]
|
||||||
fn telegram_user_allowed_wildcard() {
|
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"));
|
assert!(ch.is_user_allowed("anyone"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_allowed_specific() {
|
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("alice"));
|
||||||
assert!(!ch.is_user_allowed("eve"));
|
assert!(!ch.is_user_allowed("eve"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_denied_empty() {
|
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"));
|
assert!(!ch.is_user_allowed("anyone"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_exact_match_not_substring() {
|
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("alice_bot"));
|
||||||
assert!(!ch.is_user_allowed("alic"));
|
assert!(!ch.is_user_allowed("alic"));
|
||||||
assert!(!ch.is_user_allowed("malice"));
|
assert!(!ch.is_user_allowed("malice"));
|
||||||
@@ -942,13 +1211,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_empty_string_denied() {
|
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(""));
|
assert!(!ch.is_user_allowed(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_case_sensitive() {
|
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"));
|
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]
|
#[test]
|
||||||
fn telegram_wildcard_with_specific_users() {
|
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("alice"));
|
||||||
assert!(ch.is_user_allowed("bob"));
|
assert!(ch.is_user_allowed("bob"));
|
||||||
assert!(ch.is_user_allowed("anyone"));
|
assert!(ch.is_user_allowed("anyone"));
|
||||||
@@ -964,13 +1233,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_allowed_by_numeric_id_identity() {
|
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"]));
|
assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_user_denied_when_none_of_identities_match() {
|
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"]));
|
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,7 +1293,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_update_message_uses_chat_id_as_reply_target() {
|
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!({
|
let update = serde_json::json!({
|
||||||
"update_id": 1,
|
"update_id": 1,
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1052,7 +1321,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_update_message_allows_numeric_id_without_username() {
|
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!({
|
let update = serde_json::json!({
|
||||||
"update_id": 2,
|
"update_id": 2,
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1075,11 +1344,63 @@ mod tests {
|
|||||||
assert_eq!(msg.reply_target, "12345");
|
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 ──────────────────────────────────
|
// ── File sending API URL tests ──────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_document() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendDocument"),
|
ch.api_url("sendDocument"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendDocument"
|
"https://api.telegram.org/bot123:ABC/sendDocument"
|
||||||
@@ -1088,7 +1409,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_photo() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendPhoto"),
|
ch.api_url("sendPhoto"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendPhoto"
|
"https://api.telegram.org/bot123:ABC/sendPhoto"
|
||||||
@@ -1097,7 +1418,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_video() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendVideo"),
|
ch.api_url("sendVideo"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendVideo"
|
"https://api.telegram.org/bot123:ABC/sendVideo"
|
||||||
@@ -1106,7 +1427,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_audio() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendAudio"),
|
ch.api_url("sendAudio"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendAudio"
|
"https://api.telegram.org/bot123:ABC/sendAudio"
|
||||||
@@ -1115,7 +1436,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn telegram_api_url_send_voice() {
|
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!(
|
assert_eq!(
|
||||||
ch.api_url("sendVoice"),
|
ch.api_url("sendVoice"),
|
||||||
"https://api.telegram.org/bot123:ABC/sendVoice"
|
"https://api.telegram.org/bot123:ABC/sendVoice"
|
||||||
@@ -1127,7 +1448,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_builds_correct_form() {
|
async fn telegram_send_document_bytes_builds_correct_form() {
|
||||||
// This test verifies the method doesn't panic and handles bytes correctly
|
// 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();
|
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
|
// 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
|
// Error should be network-related, not a code bug
|
||||||
assert!(
|
assert!(
|
||||||
err.contains("error") || err.contains("failed") || err.contains("connect"),
|
err.contains("error") || err.contains("failed") || err.contains("connect"),
|
||||||
"Expected network error, got: {err}"
|
"Expected network error, got: {}",
|
||||||
|
err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_photo_bytes_builds_correct_form() {
|
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
|
// Minimal valid PNG header bytes
|
||||||
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
|
||||||
@@ -1161,7 +1483,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_by_url_builds_correct_json() {
|
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
|
let result = ch
|
||||||
.send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc"))
|
.send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc"))
|
||||||
@@ -1172,7 +1494,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_photo_by_url_builds_correct_json() {
|
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
|
let result = ch
|
||||||
.send_photo_by_url("123456", "https://example.com/image.jpg", None)
|
.send_photo_by_url("123456", "https://example.com/image.jpg", None)
|
||||||
@@ -1185,7 +1507,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/file.txt");
|
||||||
|
|
||||||
let result = ch.send_document("123456", path, None).await;
|
let result = ch.send_document("123456", path, None).await;
|
||||||
@@ -1195,13 +1517,14 @@ mod tests {
|
|||||||
// Should fail with file not found error
|
// Should fail with file not found error
|
||||||
assert!(
|
assert!(
|
||||||
err.contains("No such file") || err.contains("not found") || err.contains("os error"),
|
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]
|
#[tokio::test]
|
||||||
async fn telegram_send_photo_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/photo.jpg");
|
||||||
|
|
||||||
let result = ch.send_photo("123456", path, None).await;
|
let result = ch.send_photo("123456", path, None).await;
|
||||||
@@ -1211,7 +1534,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_video_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/video.mp4");
|
||||||
|
|
||||||
let result = ch.send_video("123456", path, None).await;
|
let result = ch.send_video("123456", path, None).await;
|
||||||
@@ -1221,7 +1544,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_audio_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/audio.mp3");
|
||||||
|
|
||||||
let result = ch.send_audio("123456", path, None).await;
|
let result = ch.send_audio("123456", path, None).await;
|
||||||
@@ -1231,7 +1554,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_voice_nonexistent_file() {
|
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 path = Path::new("/nonexistent/path/to/voice.ogg");
|
||||||
|
|
||||||
let result = ch.send_voice("123456", path, None).await;
|
let result = ch.send_voice("123456", path, None).await;
|
||||||
@@ -1319,7 +1642,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_with_caption() {
|
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();
|
let file_bytes = b"test content".to_vec();
|
||||||
|
|
||||||
// With caption
|
// With caption
|
||||||
@@ -1337,7 +1660,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_photo_bytes_with_caption() {
|
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];
|
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
|
||||||
|
|
||||||
// With caption
|
// With caption
|
||||||
@@ -1362,7 +1685,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_empty_file() {
|
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 file_bytes: Vec<u8> = vec![];
|
||||||
|
|
||||||
let result = ch
|
let result = ch
|
||||||
@@ -1375,7 +1698,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_empty_filename() {
|
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 file_bytes = b"content".to_vec();
|
||||||
|
|
||||||
let result = ch.send_document_bytes("123456", file_bytes, "", None).await;
|
let result = ch.send_document_bytes("123456", file_bytes, "", None).await;
|
||||||
@@ -1386,7 +1709,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn telegram_send_document_bytes_empty_chat_id() {
|
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 file_bytes = b"content".to_vec();
|
||||||
|
|
||||||
let result = ch
|
let result = ch
|
||||||
@@ -1404,7 +1727,7 @@ mod tests {
|
|||||||
// Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
|
// Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let message_id = 789;
|
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");
|
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)
|
// Same chat_id + same message_id = same ID (prevents duplicates after restart)
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let message_id = 789;
|
let message_id = 789;
|
||||||
let id1 = format!("telegram_{chat_id}_{message_id}");
|
let id1 = format!("telegram_{}_{}", chat_id, message_id);
|
||||||
let id2 = format!("telegram_{chat_id}_{message_id}");
|
let id2 = format!("telegram_{}_{}", chat_id, message_id);
|
||||||
assert_eq!(id1, id2);
|
assert_eq!(id1, id2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1422,8 +1745,8 @@ mod tests {
|
|||||||
fn telegram_message_id_different_message_different_id() {
|
fn telegram_message_id_different_message_different_id() {
|
||||||
// Different message IDs produce different IDs
|
// Different message IDs produce different IDs
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let id1 = format!("telegram_{chat_id}_789");
|
let id1 = format!("telegram_{}_789", chat_id);
|
||||||
let id2 = format!("telegram_{chat_id}_790");
|
let id2 = format!("telegram_{}_790", chat_id);
|
||||||
assert_ne!(id1, id2);
|
assert_ne!(id1, id2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1431,8 +1754,8 @@ mod tests {
|
|||||||
fn telegram_message_id_different_chat_different_id() {
|
fn telegram_message_id_different_chat_different_id() {
|
||||||
// Different chats produce different IDs even with same message_id
|
// Different chats produce different IDs even with same message_id
|
||||||
let message_id = 789;
|
let message_id = 789;
|
||||||
let id1 = format!("telegram_123456_{message_id}");
|
let id1 = format!("telegram_123456_{}", message_id);
|
||||||
let id2 = format!("telegram_789012_{message_id}");
|
let id2 = format!("telegram_789012_{}", message_id);
|
||||||
assert_ne!(id1, id2);
|
assert_ne!(id1, id2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1441,7 +1764,7 @@ mod tests {
|
|||||||
// Verify format doesn't contain random UUID components
|
// Verify format doesn't contain random UUID components
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let message_id = 789;
|
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.contains('-')); // No UUID dashes
|
||||||
assert!(id.starts_with("telegram_"));
|
assert!(id.starts_with("telegram_"));
|
||||||
}
|
}
|
||||||
@@ -1451,7 +1774,7 @@ mod tests {
|
|||||||
// Edge case: message_id can be 0 (fallback/missing case)
|
// Edge case: message_id can be 0 (fallback/missing case)
|
||||||
let chat_id = "123456";
|
let chat_id = "123456";
|
||||||
let message_id = 0;
|
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");
|
assert_eq!(id, "telegram_123456_0");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ pub struct Config {
|
|||||||
/// Hardware configuration (wizard-driven physical world setup).
|
/// Hardware configuration (wizard-driven physical world setup).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hardware: HardwareConfig,
|
pub hardware: HardwareConfig,
|
||||||
|
|
||||||
|
/// SkillForge auto-discovery configuration.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skillforge: crate::skillforge::SkillForgeConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delegate Agents ──────────────────────────────────────────────
|
// ── Delegate Agents ──────────────────────────────────────────────
|
||||||
@@ -1645,6 +1649,7 @@ impl Default for Config {
|
|||||||
peripherals: PeripheralsConfig::default(),
|
peripherals: PeripheralsConfig::default(),
|
||||||
agents: HashMap::new(),
|
agents: HashMap::new(),
|
||||||
hardware: HardwareConfig::default(),
|
hardware: HardwareConfig::default(),
|
||||||
|
skillforge: crate::skillforge::SkillForgeConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) ->
|
|||||||
.telegram
|
.telegram
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("telegram channel not configured"))?;
|
.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?;
|
channel.send(output, target).await?;
|
||||||
}
|
}
|
||||||
"discord" => {
|
"discord" => {
|
||||||
|
|||||||
@@ -709,7 +709,7 @@ async fn handle_whatsapp_message(
|
|||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
// Send reply via WhatsApp
|
// 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}");
|
tracing::error!("Failed to send WhatsApp reply: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -718,7 +718,7 @@ async fn handle_whatsapp_message(
|
|||||||
let _ = wa
|
let _ = wa
|
||||||
.send(
|
.send(
|
||||||
"Sorry, I couldn't process your message right now.",
|
"Sorry, I couldn't process your message right now.",
|
||||||
&msg.reply_to,
|
&msg.reply_target,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub mod rag;
|
|||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
pub mod skillforge;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
|
|||||||
10
src/main.rs
10
src/main.rs
@@ -209,6 +209,12 @@ enum Commands {
|
|||||||
skill_command: SkillCommands,
|
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 data from other agent runtimes
|
||||||
Migrate {
|
Migrate {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -563,6 +569,10 @@ async fn main() -> Result<()> {
|
|||||||
skills::handle_command(skill_command, &config.workspace_dir)
|
skills::handle_command(skill_command, &config.workspace_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Commands::Skillforge { skillforge_command } => {
|
||||||
|
skillforge::handle_command(skillforge_command, &config).await
|
||||||
|
}
|
||||||
|
|
||||||
Commands::Migrate { migrate_command } => {
|
Commands::Migrate { migrate_command } => {
|
||||||
migration::handle_command(migrate_command, &config).await
|
migration::handle_command(migrate_command, &config).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ pub fn run_wizard() -> Result<Config> {
|
|||||||
peripherals: crate::config::PeripheralsConfig::default(),
|
peripherals: crate::config::PeripheralsConfig::default(),
|
||||||
agents: std::collections::HashMap::new(),
|
agents: std::collections::HashMap::new(),
|
||||||
hardware: hardware_config,
|
hardware: hardware_config,
|
||||||
|
skillforge: crate::skillforge::SkillForgeConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
@@ -346,6 +347,7 @@ pub fn run_quick_setup(
|
|||||||
peripherals: crate::config::PeripheralsConfig::default(),
|
peripherals: crate::config::PeripheralsConfig::default(),
|
||||||
agents: std::collections::HashMap::new(),
|
agents: std::collections::HashMap::new(),
|
||||||
hardware: crate::config::HardwareConfig::default(),
|
hardware: crate::config::HardwareConfig::default(),
|
||||||
|
skillforge: crate::skillforge::SkillForgeConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.save()?;
|
config.save()?;
|
||||||
|
|||||||
@@ -894,6 +894,7 @@ mod tests {
|
|||||||
make_provider("Groq", "https://api.groq.com/openai", None),
|
make_provider("Groq", "https://api.groq.com/openai", None),
|
||||||
make_provider("Mistral", "https://api.mistral.ai", None),
|
make_provider("Mistral", "https://api.mistral.ai", None),
|
||||||
make_provider("xAI", "https://api.x.ai", None),
|
make_provider("xAI", "https://api.x.ai", None),
|
||||||
|
make_provider("Astrai", "https://as-trai.com/v1", None),
|
||||||
];
|
];
|
||||||
|
|
||||||
for p in providers {
|
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"],
|
"zai" | "z.ai" => vec!["ZAI_API_KEY"],
|
||||||
"nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
|
"nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
|
||||||
"synthetic" => vec!["SYNTHETIC_API_KEY"],
|
"synthetic" => vec!["SYNTHETIC_API_KEY"],
|
||||||
|
"nanogpt" | "nano-gpt" => vec!["NANO_GPT_API_KEY"],
|
||||||
"opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
|
"opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
|
||||||
"vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
|
"vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
|
||||||
"cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
|
"cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
|
||||||
|
"astrai" => vec!["ASTRAI_API_KEY"],
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,6 +247,12 @@ pub fn create_provider_with_url(
|
|||||||
key,
|
key,
|
||||||
AuthStyle::Bearer,
|
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(
|
"bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||||
"Amazon Bedrock",
|
"Amazon Bedrock",
|
||||||
"https://bedrock-runtime.us-east-1.amazonaws.com",
|
"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) ───────────
|
// ── Bring Your Own Provider (custom URL) ───────────
|
||||||
// Format: "custom:https://your-api.com" or "custom:http://localhost:1234"
|
// Format: "custom:https://your-api.com" or "custom:http://localhost:1234"
|
||||||
name if name.starts_with("custom:") => {
|
name if name.starts_with("custom:") => {
|
||||||
@@ -651,6 +664,13 @@ mod tests {
|
|||||||
assert!(create_provider("build.nvidia.com", Some("nvapi-test")).is_ok());
|
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 ─────────────────────────────
|
// ── Custom / BYOP provider ─────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ impl SecurityPolicy {
|
|||||||
/// validates each sub-command against the allowlist
|
/// validates each sub-command against the allowlist
|
||||||
/// - Blocks single `&` background chaining (`&&` remains supported)
|
/// - Blocks single `&` background chaining (`&&` remains supported)
|
||||||
/// - Blocks output redirections (`>`, `>>`) that could write outside workspace
|
/// - 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 {
|
pub fn is_command_allowed(&self, command: &str) -> bool {
|
||||||
if self.autonomy == AutonomyLevel::ReadOnly {
|
if self.autonomy == AutonomyLevel::ReadOnly {
|
||||||
return false;
|
return false;
|
||||||
@@ -398,13 +399,9 @@ impl SecurityPolicy {
|
|||||||
// Strip leading env var assignments (e.g. FOO=bar cmd)
|
// Strip leading env var assignments (e.g. FOO=bar cmd)
|
||||||
let cmd_part = skip_env_assignments(segment);
|
let cmd_part = skip_env_assignments(segment);
|
||||||
|
|
||||||
let base_cmd = cmd_part
|
let mut words = cmd_part.split_whitespace();
|
||||||
.split_whitespace()
|
let base_raw = words.next().unwrap_or("");
|
||||||
.next()
|
let base_cmd = base_raw.rsplit('/').next().unwrap_or("");
|
||||||
.unwrap_or("")
|
|
||||||
.rsplit('/')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if base_cmd.is_empty() {
|
if base_cmd.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -417,6 +414,12 @@ impl SecurityPolicy {
|
|||||||
{
|
{
|
||||||
return false;
|
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
|
// At least one command must be present
|
||||||
@@ -428,6 +431,29 @@ impl SecurityPolicy {
|
|||||||
has_cmd
|
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)
|
/// Check if a file path is allowed (no path traversal, within workspace)
|
||||||
pub fn is_path_allowed(&self, path: &str) -> bool {
|
pub fn is_path_allowed(&self, path: &str) -> bool {
|
||||||
// Block null bytes (can truncate paths in C-backed syscalls)
|
// 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"));
|
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]
|
#[test]
|
||||||
fn command_injection_dollar_brace_blocked() {
|
fn command_injection_dollar_brace_blocked() {
|
||||||
let p = default_policy();
|
let p = default_policy();
|
||||||
|
|||||||
@@ -16,6 +16,136 @@ use self::evaluate::{EvalResult, Evaluator, Recommendation};
|
|||||||
use self::integrate::Integrator;
|
use self::integrate::Integrator;
|
||||||
use self::scout::{GitHubScout, Scout, ScoutResult, ScoutSource};
|
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
|
// 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.
|
/// Parse the GitHub search/repositories JSON response.
|
||||||
fn parse_items(body: &serde_json::Value) -> Vec<ScoutResult> {
|
fn parse_items(body: &serde_json::Value) -> Vec<ScoutResult> {
|
||||||
let items = match body.get("items").and_then(|v| v.as_array()) {
|
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);
|
let _ = HttpRequestTool::redact_headers_for_display(&headers);
|
||||||
assert_eq!(headers[0].1, "Bearer real-token");
|
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 screenshot;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
pub mod transcribe;
|
||||||
|
|
||||||
pub use browser::{BrowserTool, ComputerUseConfig};
|
pub use browser::{BrowserTool, ComputerUseConfig};
|
||||||
pub use browser_open::BrowserOpenTool;
|
pub use browser_open::BrowserOpenTool;
|
||||||
@@ -53,6 +54,7 @@ pub use schema::{CleaningStrategy, SchemaCleanr};
|
|||||||
pub use screenshot::ScreenshotTool;
|
pub use screenshot::ScreenshotTool;
|
||||||
pub use shell::ShellTool;
|
pub use shell::ShellTool;
|
||||||
pub use traits::Tool;
|
pub use traits::Tool;
|
||||||
|
pub use transcribe::TranscribeTool;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use traits::{ToolResult, ToolSpec};
|
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(ScreenshotTool::new(security.clone())));
|
||||||
tools.push(Box::new(ImageInfoTool::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 let Some(key) = composio_key {
|
||||||
if !key.is_empty() {
|
if !key.is_empty() {
|
||||||
tools.push(Box::new(ComposioTool::new(key, composio_entity_id)));
|
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