Merge remote-tracking branch 'origin/main' into fix/gateway-timeout-layer
This commit is contained in:
commit
0247ac13e8
11 changed files with 1257 additions and 128 deletions
67
.github/workflows/ci.yml
vendored
67
.github/workflows/ci.yml
vendored
|
|
@ -13,27 +13,18 @@ jobs:
|
||||||
test:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true # Don't block PRs
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
|
||||||
components: rustfmt, clippy
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: cargo fmt -- --check
|
|
||||||
|
|
||||||
- name: Run clippy
|
|
||||||
run: cargo clippy -- -D warnings
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --verbose
|
run: cargo test --verbose
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
continue-on-error: true # Don't block PRs
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
|
|
@ -45,58 +36,10 @@ jobs:
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Build
|
||||||
- name: Build release
|
run: cargo build --release --verbose
|
||||||
run: cargo build --release --target ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: zeroclaw-${{ matrix.target }}
|
|
||||||
path: target/${{ matrix.target }}/release/zeroclaw*
|
|
||||||
|
|
||||||
docker:
|
|
||||||
name: Docker Security
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: docker build -t zeroclaw:test .
|
|
||||||
|
|
||||||
- name: Verify non-root user (UID != 0)
|
|
||||||
run: |
|
|
||||||
USER_ID=$(docker inspect --format='{{.Config.User}}' zeroclaw:test)
|
|
||||||
echo "Container user: $USER_ID"
|
|
||||||
if [ "$USER_ID" = "0" ] || [ "$USER_ID" = "root" ] || [ -z "$USER_ID" ]; then
|
|
||||||
echo "❌ FAIL: Container runs as root (UID 0)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ PASS: Container runs as non-root user ($USER_ID)"
|
|
||||||
|
|
||||||
- name: Verify distroless nonroot base image
|
|
||||||
run: |
|
|
||||||
BASE_IMAGE=$(grep -E '^FROM.*runtime|^FROM gcr.io/distroless' Dockerfile | tail -1)
|
|
||||||
echo "Base image line: $BASE_IMAGE"
|
|
||||||
if ! echo "$BASE_IMAGE" | grep -q ':nonroot'; then
|
|
||||||
echo "❌ FAIL: Runtime stage does not use :nonroot variant"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ PASS: Using distroless :nonroot variant"
|
|
||||||
|
|
||||||
- name: Verify USER directive exists
|
|
||||||
run: |
|
|
||||||
if ! grep -qE '^USER\s+[0-9]+' Dockerfile; then
|
|
||||||
echo "❌ FAIL: No explicit USER directive with numeric UID"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ PASS: Explicit USER directive found"
|
|
||||||
|
|
|
||||||
65
.github/workflows/docker.yml
vendored
Normal file
65
.github/workflows/docker.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
name: Docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
- name: Verify image (PR only)
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
docker build -t zeroclaw-test .
|
||||||
|
docker run --rm zeroclaw-test --version
|
||||||
57
Cargo.lock
generated
57
Cargo.lock
generated
|
|
@ -528,6 +528,17 @@ version = "0.3.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
|
|
@ -548,6 +559,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -1008,6 +1020,16 @@ version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -1309,6 +1331,7 @@ dependencies = [
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
|
|
@ -1320,12 +1343,14 @@ dependencies = [
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots 1.0.6",
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
@ -1783,6 +1808,19 @@ dependencies = [
|
||||||
"webpki-roots 0.26.11",
|
"webpki-roots 0.26.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
|
|
@ -1934,6 +1972,12 @@ version = "1.19.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|
@ -2098,6 +2142,19 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ clap = { version = "4.5", features = ["derive"] }
|
||||||
tokio = { version = "1.42", default-features = false, features = ["rt-multi-thread", "macros", "time", "net", "io-util", "sync", "process", "io-std", "fs", "signal"] }
|
tokio = { version = "1.42", default-features = false, features = ["rt-multi-thread", "macros", "time", "net", "io-util", "sync", "process", "io-std", "fs", "signal"] }
|
||||||
|
|
||||||
# HTTP client - minimal features
|
# HTTP client - minimal features
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking", "multipart", "stream"] }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1.0", default-features = false, features = ["derive"] }
|
serde = { version = "1.0", default-features = false, features = ["derive"] }
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
zeroclaw:
|
zeroclaw:
|
||||||
image: zeroclaw/zeroclaw:latest
|
image: ghcr.io/theonlyhennygod/zeroclaw:latest
|
||||||
# Or build locally:
|
# Or build locally:
|
||||||
# build: .
|
# build: .
|
||||||
container_name: zeroclaw
|
container_name: zeroclaw
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
use crate::channels::traits::{Channel, ChannelMessage};
|
use crate::channels::traits::{Channel, ChannelMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use directories::UserDirs;
|
use directories::UserDirs;
|
||||||
|
use rusqlite::{Connection, OpenFlags};
|
||||||
|
use std::path::Path;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// iMessage channel using macOS `AppleScript` bridge.
|
/// iMessage channel using macOS `AppleScript` bridge.
|
||||||
|
|
@ -199,60 +201,58 @@ end tell"#
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current max ROWID from the messages table
|
/// Get the current max ROWID from the messages table.
|
||||||
async fn get_max_rowid(db_path: &std::path::Path) -> anyhow::Result<i64> {
|
/// Uses rusqlite with parameterized queries for security (CWE-89 prevention).
|
||||||
let output = tokio::process::Command::new("sqlite3")
|
async fn get_max_rowid(db_path: &Path) -> anyhow::Result<i64> {
|
||||||
.arg(db_path)
|
let path = db_path.to_path_buf();
|
||||||
.arg("SELECT MAX(ROWID) FROM message WHERE is_from_me = 0;")
|
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<i64> {
|
||||||
.output()
|
let conn = Connection::open_with_flags(
|
||||||
.await?;
|
&path,
|
||||||
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
)?;
|
||||||
let rowid = stdout.trim().parse::<i64>().unwrap_or(0);
|
let mut stmt = conn.prepare(
|
||||||
Ok(rowid)
|
"SELECT MAX(ROWID) FROM message WHERE is_from_me = 0"
|
||||||
|
)?;
|
||||||
|
let rowid: Option<i64> = stmt.query_row([], |row| row.get(0))?;
|
||||||
|
Ok(rowid.unwrap_or(0))
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch messages newer than `since_rowid`
|
/// Fetch messages newer than `since_rowid`.
|
||||||
|
/// Uses rusqlite with parameterized queries for security (CWE-89 prevention).
|
||||||
|
/// The `since_rowid` parameter is bound safely, preventing SQL injection.
|
||||||
async fn fetch_new_messages(
|
async fn fetch_new_messages(
|
||||||
db_path: &std::path::Path,
|
db_path: &Path,
|
||||||
since_rowid: i64,
|
since_rowid: i64,
|
||||||
) -> anyhow::Result<Vec<(i64, String, String)>> {
|
) -> anyhow::Result<Vec<(i64, String, String)>> {
|
||||||
let query = format!(
|
let path = db_path.to_path_buf();
|
||||||
"SELECT m.ROWID, h.id, m.text \
|
let results = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<(i64, String, String)>> {
|
||||||
FROM message m \
|
let conn = Connection::open_with_flags(
|
||||||
JOIN handle h ON m.handle_id = h.ROWID \
|
&path,
|
||||||
WHERE m.ROWID > {since_rowid} \
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
AND m.is_from_me = 0 \
|
)?;
|
||||||
AND m.text IS NOT NULL \
|
let mut stmt = conn.prepare(
|
||||||
ORDER BY m.ROWID ASC \
|
"SELECT m.ROWID, h.id, m.text \
|
||||||
LIMIT 20;"
|
FROM message m \
|
||||||
);
|
JOIN handle h ON m.handle_id = h.ROWID \
|
||||||
|
WHERE m.ROWID > ?1 \
|
||||||
let output = tokio::process::Command::new("sqlite3")
|
AND m.is_from_me = 0 \
|
||||||
.arg("-separator")
|
AND m.text IS NOT NULL \
|
||||||
.arg("|")
|
ORDER BY m.ROWID ASC \
|
||||||
.arg(db_path)
|
LIMIT 20"
|
||||||
.arg(&query)
|
)?;
|
||||||
.output()
|
let rows = stmt.query_map([since_rowid], |row| {
|
||||||
.await?;
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
if !output.status.success() {
|
row.get::<_, String>(1)?,
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
row.get::<_, String>(2)?,
|
||||||
anyhow::bail!("sqlite3 query failed: {stderr}");
|
))
|
||||||
}
|
})?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
})
|
||||||
let mut results = Vec::new();
|
.await??;
|
||||||
|
|
||||||
for line in stdout.lines() {
|
|
||||||
let parts: Vec<&str> = line.splitn(3, '|').collect();
|
|
||||||
if parts.len() == 3 {
|
|
||||||
if let Ok(rowid) = parts[0].parse::<i64>() {
|
|
||||||
results.push((rowid, parts[1].to_string(), parts[2].to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,4 +527,332 @@ mod tests {
|
||||||
assert!(is_valid_imessage_target(" +1234567890 "));
|
assert!(is_valid_imessage_target(" +1234567890 "));
|
||||||
assert!(is_valid_imessage_target(" user@example.com "));
|
assert!(is_valid_imessage_target(" user@example.com "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// SQLite/rusqlite Database Tests (CWE-89 Prevention)
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Helper to create a temporary test database with Messages schema
|
||||||
|
fn create_test_db() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("chat.db");
|
||||||
|
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
|
||||||
|
// Create minimal schema matching macOS Messages.app
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE handle (
|
||||||
|
ROWID INTEGER PRIMARY KEY,
|
||||||
|
id TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE message (
|
||||||
|
ROWID INTEGER PRIMARY KEY,
|
||||||
|
handle_id INTEGER,
|
||||||
|
text TEXT,
|
||||||
|
is_from_me INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY (handle_id) REFERENCES handle(ROWID)
|
||||||
|
);"
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
(dir, db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_max_rowid_empty_database() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
let result = get_max_rowid(&db_path).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
// Empty table returns 0 (NULL coalesced)
|
||||||
|
assert_eq!(result.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_max_rowid_with_messages() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (100, 1, 'Hello', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (200, 1, 'World', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
// This one is from_me=1, should be ignored
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (300, 1, 'Sent', 1)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = get_max_rowid(&db_path).await.unwrap();
|
||||||
|
// Should return 200, not 300 (ignores is_from_me=1)
|
||||||
|
assert_eq!(result, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_max_rowid_nonexistent_database() {
|
||||||
|
let path = std::path::Path::new("/nonexistent/path/chat.db");
|
||||||
|
let result = get_max_rowid(path).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_empty_database() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(result.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_returns_correct_data() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (2, 'user@example.com')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First message', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 2, 'Second message', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(result[0], (10, "+1234567890".to_string(), "First message".to_string()));
|
||||||
|
assert_eq!(result[1], (20, "user@example.com".to_string(), "Second message".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_filters_by_rowid() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Old message', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'New message', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch only messages after ROWID 15
|
||||||
|
let result = fetch_new_messages(&db_path, 15).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].0, 20);
|
||||||
|
assert_eq!(result[0].2, "New message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_excludes_sent_messages() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Received', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'Sent by me', 1)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].2, "Received");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_excludes_null_text() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert test data
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Has text', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, NULL, 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].2, "Has text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_respects_limit() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert 25 messages (limit is 20)
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
for i in 1..=25 {
|
||||||
|
conn.execute(
|
||||||
|
&format!("INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES ({i}, 1, 'Message {i}', 0)"),
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 20); // Limited to 20
|
||||||
|
assert_eq!(result[0].0, 1); // First message
|
||||||
|
assert_eq!(result[19].0, 20); // 20th message
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_ordered_by_rowid_asc() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert messages out of order
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (30, 1, 'Third', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'Second', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 3);
|
||||||
|
assert_eq!(result[0].0, 10);
|
||||||
|
assert_eq!(result[1].0, 20);
|
||||||
|
assert_eq!(result[2].0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_nonexistent_database() {
|
||||||
|
let path = std::path::Path::new("/nonexistent/path/chat.db");
|
||||||
|
let result = fetch_new_messages(path, 0).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_handles_special_characters() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
// Insert message with special characters (potential SQL injection patterns)
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello \"world'' OR 1=1; DROP TABLE message;--', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
// The special characters should be preserved, not interpreted as SQL
|
||||||
|
assert!(result[0].2.contains("DROP TABLE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_handles_unicode() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello 🦀 世界 مرحبا', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].2, "Hello 🦀 世界 مرحبا");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_handles_empty_text() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, '', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = fetch_new_messages(&db_path, 0).await.unwrap();
|
||||||
|
// Empty string is NOT NULL, so it's included
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert_eq!(result[0].2, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_negative_rowid_edge_case() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative rowid should still work (fetch all messages with ROWID > -1)
|
||||||
|
let result = fetch_new_messages(&db_path, -1).await.unwrap();
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fetch_new_messages_large_rowid_edge_case() {
|
||||||
|
let (_dir, db_path) = create_test_db();
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(&db_path).unwrap();
|
||||||
|
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)",
|
||||||
|
[]
|
||||||
|
).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Very large rowid should return empty (no messages after this)
|
||||||
|
let result = fetch_new_messages(&db_path, i64::MAX - 1).await.unwrap();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
use super::traits::{Channel, ChannelMessage};
|
use super::traits::{Channel, ChannelMessage};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use reqwest::multipart::{Form, Part};
|
||||||
|
use std::path::Path;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Telegram channel — long-polls the Bot API for updates
|
/// Telegram channel — long-polls the Bot API for updates
|
||||||
|
|
@ -32,6 +34,333 @@ impl TelegramChannel {
|
||||||
{
|
{
|
||||||
identities.into_iter().any(|id| self.is_user_allowed(id))
|
identities.into_iter().any(|id| self.is_user_allowed(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a document/file to a Telegram chat
|
||||||
|
pub async fn send_document(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
file_path: &Path,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let file_name = file_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("file");
|
||||||
|
|
||||||
|
let file_bytes = tokio::fs::read(file_path).await?;
|
||||||
|
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
|
||||||
|
|
||||||
|
let mut form = Form::new()
|
||||||
|
.text("chat_id", chat_id.to_string())
|
||||||
|
.part("document", part);
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
form = form.text("caption", cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendDocument"))
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram sendDocument failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a document from bytes (in-memory) to a Telegram chat
|
||||||
|
pub async fn send_document_bytes(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
file_bytes: Vec<u8>,
|
||||||
|
file_name: &str,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
|
||||||
|
|
||||||
|
let mut form = Form::new()
|
||||||
|
.text("chat_id", chat_id.to_string())
|
||||||
|
.part("document", part);
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
form = form.text("caption", cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendDocument"))
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram sendDocument failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram document sent to {chat_id}: {file_name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a photo to a Telegram chat
|
||||||
|
pub async fn send_photo(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
file_path: &Path,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let file_name = file_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("photo.jpg");
|
||||||
|
|
||||||
|
let file_bytes = tokio::fs::read(file_path).await?;
|
||||||
|
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
|
||||||
|
|
||||||
|
let mut form = Form::new()
|
||||||
|
.text("chat_id", chat_id.to_string())
|
||||||
|
.part("photo", part);
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
form = form.text("caption", cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendPhoto"))
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram sendPhoto failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a photo from bytes (in-memory) to a Telegram chat
|
||||||
|
pub async fn send_photo_bytes(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
file_bytes: Vec<u8>,
|
||||||
|
file_name: &str,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
|
||||||
|
|
||||||
|
let mut form = Form::new()
|
||||||
|
.text("chat_id", chat_id.to_string())
|
||||||
|
.part("photo", part);
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
form = form.text("caption", cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendPhoto"))
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram sendPhoto failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a video to a Telegram chat
|
||||||
|
pub async fn send_video(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
file_path: &Path,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let file_name = file_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("video.mp4");
|
||||||
|
|
||||||
|
let file_bytes = tokio::fs::read(file_path).await?;
|
||||||
|
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
|
||||||
|
|
||||||
|
let mut form = Form::new()
|
||||||
|
.text("chat_id", chat_id.to_string())
|
||||||
|
.part("video", part);
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
form = form.text("caption", cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendVideo"))
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram sendVideo failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram video sent to {chat_id}: {file_name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an audio file to a Telegram chat
|
||||||
|
pub async fn send_audio(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
file_path: &Path,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let file_name = file_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("audio.mp3");
|
||||||
|
|
||||||
|
let file_bytes = tokio::fs::read(file_path).await?;
|
||||||
|
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
|
||||||
|
|
||||||
|
let mut form = Form::new()
|
||||||
|
.text("chat_id", chat_id.to_string())
|
||||||
|
.part("audio", part);
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
form = form.text("caption", cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendAudio"))
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram sendAudio failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram audio sent to {chat_id}: {file_name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a voice message to a Telegram chat
|
||||||
|
pub async fn send_voice(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
file_path: &Path,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let file_name = file_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("voice.ogg");
|
||||||
|
|
||||||
|
let file_bytes = tokio::fs::read(file_path).await?;
|
||||||
|
let part = Part::bytes(file_bytes).file_name(file_name.to_string());
|
||||||
|
|
||||||
|
let mut form = Form::new()
|
||||||
|
.text("chat_id", chat_id.to_string())
|
||||||
|
.part("voice", part);
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
form = form.text("caption", cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendVoice"))
|
||||||
|
.multipart(form)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram sendVoice failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram voice sent to {chat_id}: {file_name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a file by URL (Telegram will download it)
|
||||||
|
pub async fn send_document_by_url(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
url: &str,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"document": url
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
body["caption"] = serde_json::Value::String(cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendDocument"))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram sendDocument by URL failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram document (URL) sent to {chat_id}: {url}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a photo by URL (Telegram will download it)
|
||||||
|
pub async fn send_photo_by_url(
|
||||||
|
&self,
|
||||||
|
chat_id: &str,
|
||||||
|
url: &str,
|
||||||
|
caption: Option<&str>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"photo": url
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(cap) = caption {
|
||||||
|
body["caption"] = serde_json::Value::String(cap.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.api_url("sendPhoto"))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let err = resp.text().await?;
|
||||||
|
anyhow::bail!("Telegram sendPhoto by URL failed: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -243,4 +572,250 @@ mod tests {
|
||||||
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]);
|
let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]);
|
||||||
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
|
assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── File sending API URL tests ──────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telegram_api_url_send_document() {
|
||||||
|
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||||
|
assert_eq!(
|
||||||
|
ch.api_url("sendDocument"),
|
||||||
|
"https://api.telegram.org/bot123:ABC/sendDocument"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telegram_api_url_send_photo() {
|
||||||
|
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||||
|
assert_eq!(
|
||||||
|
ch.api_url("sendPhoto"),
|
||||||
|
"https://api.telegram.org/bot123:ABC/sendPhoto"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telegram_api_url_send_video() {
|
||||||
|
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||||
|
assert_eq!(
|
||||||
|
ch.api_url("sendVideo"),
|
||||||
|
"https://api.telegram.org/bot123:ABC/sendVideo"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telegram_api_url_send_audio() {
|
||||||
|
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||||
|
assert_eq!(
|
||||||
|
ch.api_url("sendAudio"),
|
||||||
|
"https://api.telegram.org/bot123:ABC/sendAudio"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn telegram_api_url_send_voice() {
|
||||||
|
let ch = TelegramChannel::new("123:ABC".into(), vec![]);
|
||||||
|
assert_eq!(
|
||||||
|
ch.api_url("sendVoice"),
|
||||||
|
"https://api.telegram.org/bot123:ABC/sendVoice"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File sending integration tests (with mock server) ──────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_document_bytes_builds_correct_form() {
|
||||||
|
// This test verifies the method doesn't panic and handles bytes correctly
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let 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
|
||||||
|
// and handles the input correctly up to the network call
|
||||||
|
let result = ch
|
||||||
|
.send_document_bytes("123456", file_bytes, "test.txt", Some("Test caption"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Should fail with network error, not a panic or type error
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err().to_string();
|
||||||
|
// Error should be network-related, not a code bug
|
||||||
|
assert!(
|
||||||
|
err.contains("error") || err.contains("failed") || err.contains("connect"),
|
||||||
|
"Expected network error, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_photo_bytes_builds_correct_form() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
// Minimal valid PNG header bytes
|
||||||
|
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
|
||||||
|
let result = ch
|
||||||
|
.send_photo_bytes("123456", file_bytes, "test.png", None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_document_by_url_builds_correct_json() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
|
||||||
|
let result = ch
|
||||||
|
.send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_photo_by_url_builds_correct_json() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
|
||||||
|
let result = ch
|
||||||
|
.send_photo_by_url("123456", "https://example.com/image.jpg", None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File path handling tests ────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_document_nonexistent_file() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let path = Path::new("/nonexistent/path/to/file.txt");
|
||||||
|
|
||||||
|
let result = ch.send_document("123456", path, None).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err().to_string();
|
||||||
|
// Should fail with file not found error
|
||||||
|
assert!(
|
||||||
|
err.contains("No such file") || err.contains("not found") || err.contains("os error"),
|
||||||
|
"Expected file not found error, got: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_photo_nonexistent_file() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let path = Path::new("/nonexistent/path/to/photo.jpg");
|
||||||
|
|
||||||
|
let result = ch.send_photo("123456", path, None).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_video_nonexistent_file() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let path = Path::new("/nonexistent/path/to/video.mp4");
|
||||||
|
|
||||||
|
let result = ch.send_video("123456", path, None).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_audio_nonexistent_file() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let path = Path::new("/nonexistent/path/to/audio.mp3");
|
||||||
|
|
||||||
|
let result = ch.send_audio("123456", path, None).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_voice_nonexistent_file() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let path = Path::new("/nonexistent/path/to/voice.ogg");
|
||||||
|
|
||||||
|
let result = ch.send_voice("123456", path, None).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Caption handling tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_document_bytes_with_caption() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let file_bytes = b"test content".to_vec();
|
||||||
|
|
||||||
|
// With caption
|
||||||
|
let result = ch
|
||||||
|
.send_document_bytes("123456", file_bytes.clone(), "test.txt", Some("My caption"))
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err()); // Network error expected
|
||||||
|
|
||||||
|
// Without caption
|
||||||
|
let result = ch
|
||||||
|
.send_document_bytes("123456", file_bytes, "test.txt", None)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err()); // Network error expected
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_photo_bytes_with_caption() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
|
||||||
|
|
||||||
|
// With caption
|
||||||
|
let result = ch
|
||||||
|
.send_photo_bytes(
|
||||||
|
"123456",
|
||||||
|
file_bytes.clone(),
|
||||||
|
"test.png",
|
||||||
|
Some("Photo caption"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
// Without caption
|
||||||
|
let result = ch
|
||||||
|
.send_photo_bytes("123456", file_bytes, "test.png", None)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty/edge case tests ───────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_document_bytes_empty_file() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let file_bytes: Vec<u8> = vec![];
|
||||||
|
|
||||||
|
let result = ch
|
||||||
|
.send_document_bytes("123456", file_bytes, "empty.txt", None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Should not panic, will fail at API level
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_document_bytes_empty_filename() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let file_bytes = b"content".to_vec();
|
||||||
|
|
||||||
|
let result = ch.send_document_bytes("123456", file_bytes, "", None).await;
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn telegram_send_document_bytes_empty_chat_id() {
|
||||||
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]);
|
||||||
|
let file_bytes = b"content".to_vec();
|
||||||
|
|
||||||
|
let result = ch
|
||||||
|
.send_document_bytes("", file_bytes, "test.txt", None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,12 @@ impl Default for IdentityConfig {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct GatewayConfig {
|
pub struct GatewayConfig {
|
||||||
|
/// Gateway port (default: 3000)
|
||||||
|
#[serde(default = "default_gateway_port")]
|
||||||
|
pub port: u16,
|
||||||
|
/// Gateway host/bind address (default: 127.0.0.1)
|
||||||
|
#[serde(default = "default_gateway_host")]
|
||||||
|
pub host: String,
|
||||||
/// Require pairing before accepting requests (default: true)
|
/// Require pairing before accepting requests (default: true)
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub require_pairing: bool,
|
pub require_pairing: bool,
|
||||||
|
|
@ -100,6 +106,14 @@ pub struct GatewayConfig {
|
||||||
pub paired_tokens: Vec<String>,
|
pub paired_tokens: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_gateway_port() -> u16 {
|
||||||
|
3000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_gateway_host() -> String {
|
||||||
|
"127.0.0.1".into()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
@ -107,6 +121,8 @@ fn default_true() -> bool {
|
||||||
impl Default for GatewayConfig {
|
impl Default for GatewayConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
port: default_gateway_port(),
|
||||||
|
host: default_gateway_host(),
|
||||||
require_pairing: true,
|
require_pairing: true,
|
||||||
allow_public_bind: false,
|
allow_public_bind: false,
|
||||||
paired_tokens: Vec::new(),
|
paired_tokens: Vec::new(),
|
||||||
|
|
@ -669,8 +685,14 @@ impl Config {
|
||||||
|
|
||||||
/// Apply environment variable overrides to config.
|
/// Apply environment variable overrides to config.
|
||||||
///
|
///
|
||||||
/// Supports: `ZEROCLAW_API_KEY`, `API_KEY`, `ZEROCLAW_PROVIDER`, `PROVIDER`,
|
/// Supports:
|
||||||
/// `ZEROCLAW_MODEL`, `ZEROCLAW_WORKSPACE`, `ZEROCLAW_GATEWAY_PORT`
|
/// - `ZEROCLAW_API_KEY` or `API_KEY` - LLM provider API key
|
||||||
|
/// - `ZEROCLAW_PROVIDER` or `PROVIDER` - Provider name (openrouter, openai, anthropic, ollama)
|
||||||
|
/// - `ZEROCLAW_MODEL` - Model name/ID
|
||||||
|
/// - `ZEROCLAW_WORKSPACE` - Workspace directory path
|
||||||
|
/// - `ZEROCLAW_GATEWAY_PORT` or `PORT` - Gateway server port
|
||||||
|
/// - `ZEROCLAW_GATEWAY_HOST` or `HOST` - Gateway bind address
|
||||||
|
/// - `ZEROCLAW_TEMPERATURE` - Default temperature (0.0-2.0)
|
||||||
pub fn apply_env_overrides(&mut self) {
|
pub fn apply_env_overrides(&mut self) {
|
||||||
// API Key: ZEROCLAW_API_KEY or API_KEY
|
// API Key: ZEROCLAW_API_KEY or API_KEY
|
||||||
if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) {
|
if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) {
|
||||||
|
|
@ -695,6 +717,15 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Temperature: ZEROCLAW_TEMPERATURE
|
||||||
|
if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") {
|
||||||
|
if let Ok(temp) = temp_str.parse::<f64>() {
|
||||||
|
if (0.0..=2.0).contains(&temp) {
|
||||||
|
self.default_temperature = temp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Workspace directory: ZEROCLAW_WORKSPACE
|
// Workspace directory: ZEROCLAW_WORKSPACE
|
||||||
if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
||||||
if !workspace.is_empty() {
|
if !workspace.is_empty() {
|
||||||
|
|
@ -707,9 +738,15 @@ impl Config {
|
||||||
std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
|
std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
|
||||||
{
|
{
|
||||||
if let Ok(port) = port_str.parse::<u16>() {
|
if let Ok(port) = port_str.parse::<u16>() {
|
||||||
// Gateway config doesn't have port yet, but we can add it
|
self.gateway.port = port;
|
||||||
// For now, this is a placeholder for future gateway port config
|
}
|
||||||
let _ = port; // Suppress unused warning
|
}
|
||||||
|
|
||||||
|
// Gateway host: ZEROCLAW_GATEWAY_HOST or HOST
|
||||||
|
if let Ok(host) = std::env::var("ZEROCLAW_GATEWAY_HOST").or_else(|_| std::env::var("HOST"))
|
||||||
|
{
|
||||||
|
if !host.is_empty() {
|
||||||
|
self.gateway.host = host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1256,6 +1293,8 @@ channel_id = "C123"
|
||||||
#[test]
|
#[test]
|
||||||
fn checklist_gateway_serde_roundtrip() {
|
fn checklist_gateway_serde_roundtrip() {
|
||||||
let g = GatewayConfig {
|
let g = GatewayConfig {
|
||||||
|
port: 3000,
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
require_pairing: true,
|
require_pairing: true,
|
||||||
allow_public_bind: false,
|
allow_public_bind: false,
|
||||||
paired_tokens: vec!["zc_test_token".into()],
|
paired_tokens: vec!["zc_test_token".into()],
|
||||||
|
|
@ -1523,4 +1562,102 @@ default_temperature = 0.7
|
||||||
// Clean up
|
// Clean up
|
||||||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_gateway_port() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
assert_eq!(config.gateway.port, 3000);
|
||||||
|
|
||||||
|
std::env::set_var("ZEROCLAW_GATEWAY_PORT", "8080");
|
||||||
|
config.apply_env_overrides();
|
||||||
|
assert_eq!(config.gateway.port, 8080);
|
||||||
|
|
||||||
|
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_port_fallback() {
|
||||||
|
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||||||
|
let mut config = Config::default();
|
||||||
|
|
||||||
|
std::env::set_var("PORT", "9000");
|
||||||
|
config.apply_env_overrides();
|
||||||
|
assert_eq!(config.gateway.port, 9000);
|
||||||
|
|
||||||
|
std::env::remove_var("PORT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_gateway_host() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
assert_eq!(config.gateway.host, "127.0.0.1");
|
||||||
|
|
||||||
|
std::env::set_var("ZEROCLAW_GATEWAY_HOST", "0.0.0.0");
|
||||||
|
config.apply_env_overrides();
|
||||||
|
assert_eq!(config.gateway.host, "0.0.0.0");
|
||||||
|
|
||||||
|
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_host_fallback() {
|
||||||
|
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||||||
|
let mut config = Config::default();
|
||||||
|
|
||||||
|
std::env::set_var("HOST", "0.0.0.0");
|
||||||
|
config.apply_env_overrides();
|
||||||
|
assert_eq!(config.gateway.host, "0.0.0.0");
|
||||||
|
|
||||||
|
std::env::remove_var("HOST");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_temperature() {
|
||||||
|
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||||
|
let mut config = Config::default();
|
||||||
|
|
||||||
|
std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5");
|
||||||
|
config.apply_env_overrides();
|
||||||
|
assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
|
||||||
|
|
||||||
|
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_temperature_out_of_range_ignored() {
|
||||||
|
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||||
|
let mut config = Config::default();
|
||||||
|
let original_temp = config.default_temperature;
|
||||||
|
|
||||||
|
std::env::set_var("ZEROCLAW_TEMPERATURE", "3.0");
|
||||||
|
config.apply_env_overrides();
|
||||||
|
assert!(
|
||||||
|
(config.default_temperature - original_temp).abs() < f64::EPSILON,
|
||||||
|
"Temperature 3.0 should be ignored (out of range)"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_invalid_port_ignored() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
let original_port = config.gateway.port;
|
||||||
|
|
||||||
|
std::env::set_var("PORT", "not_a_number");
|
||||||
|
config.apply_env_overrides();
|
||||||
|
assert_eq!(config.gateway.port, original_port);
|
||||||
|
|
||||||
|
std::env::remove_var("PORT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gateway_config_default_values() {
|
||||||
|
let g = GatewayConfig::default();
|
||||||
|
assert_eq!(g.port, 3000);
|
||||||
|
assert_eq!(g.host, "127.0.0.1");
|
||||||
|
assert!(g.require_pairing);
|
||||||
|
assert!(!g.allow_public_bind);
|
||||||
|
assert!(g.paired_tokens.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -306,7 +306,8 @@ async fn handle_webhook(
|
||||||
(StatusCode::OK, Json(body))
|
(StatusCode::OK, Json(body))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err = serde_json::json!({"error": format!("LLM error: {e}")});
|
tracing::error!("LLM error: {e:#}");
|
||||||
|
let err = serde_json::json!({"error": "Internal error processing your request"});
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(err))
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -409,8 +410,10 @@ async fn handle_whatsapp_message(State(state): State<AppState>, body: Bytes) ->
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("LLM error for WhatsApp message: {e}");
|
tracing::error!("LLM error for WhatsApp message: {e:#}");
|
||||||
let _ = wa.send(&format!("⚠️ Error: {e}"), &msg.sender).await;
|
let _ = wa
|
||||||
|
.send("Sorry, I couldn't process your message right now.", &msg.sender)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,11 @@ pub fn run_channels_repair_wizard() -> Result<Config> {
|
||||||
/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite`.
|
/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite`.
|
||||||
/// Use `zeroclaw onboard --interactive` for the full wizard.
|
/// Use `zeroclaw onboard --interactive` for the full wizard.
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn run_quick_setup(api_key: Option<&str>, provider: Option<&str>, memory_backend: Option<&str>) -> Result<Config> {
|
pub fn run_quick_setup(
|
||||||
|
api_key: Option<&str>,
|
||||||
|
provider: Option<&str>,
|
||||||
|
memory_backend: Option<&str>,
|
||||||
|
) -> Result<Config> {
|
||||||
println!("{}", style(BANNER).cyan().bold());
|
println!("{}", style(BANNER).cyan().bold());
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
|
|
@ -245,15 +249,27 @@ pub fn run_quick_setup(api_key: Option<&str>, provider: Option<&str>, memory_bac
|
||||||
backend: memory_backend_name.clone(),
|
backend: memory_backend_name.clone(),
|
||||||
auto_save: memory_backend_name != "none",
|
auto_save: memory_backend_name != "none",
|
||||||
hygiene_enabled: memory_backend_name == "sqlite",
|
hygiene_enabled: memory_backend_name == "sqlite",
|
||||||
archive_after_days: if memory_backend_name == "sqlite" { 7 } else { 0 },
|
archive_after_days: if memory_backend_name == "sqlite" {
|
||||||
purge_after_days: if memory_backend_name == "sqlite" { 30 } else { 0 },
|
7
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
purge_after_days: if memory_backend_name == "sqlite" {
|
||||||
|
30
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
conversation_retention_days: 30,
|
conversation_retention_days: 30,
|
||||||
embedding_provider: "none".to_string(),
|
embedding_provider: "none".to_string(),
|
||||||
embedding_model: "text-embedding-3-small".to_string(),
|
embedding_model: "text-embedding-3-small".to_string(),
|
||||||
embedding_dimensions: 1536,
|
embedding_dimensions: 1536,
|
||||||
vector_weight: 0.7,
|
vector_weight: 0.7,
|
||||||
keyword_weight: 0.3,
|
keyword_weight: 0.3,
|
||||||
embedding_cache_size: if memory_backend_name == "sqlite" { 10000 } else { 0 },
|
embedding_cache_size: if memory_backend_name == "sqlite" {
|
||||||
|
10000
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
chunk_max_tokens: 512,
|
chunk_max_tokens: 512,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -325,7 +341,11 @@ pub fn run_quick_setup(api_key: Option<&str>, provider: Option<&str>, memory_bac
|
||||||
" {} Memory: {} (auto-save: {})",
|
" {} Memory: {} (auto-save: {})",
|
||||||
style("✓").green().bold(),
|
style("✓").green().bold(),
|
||||||
style(&memory_backend_name).green(),
|
style(&memory_backend_name).green(),
|
||||||
if memory_backend_name == "none" { "off" } else { "on" }
|
if memory_backend_name == "none" {
|
||||||
|
"off"
|
||||||
|
} else {
|
||||||
|
"on"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
" {} Secrets: {}",
|
" {} Secrets: {}",
|
||||||
|
|
@ -975,7 +995,7 @@ fn setup_memory() -> Result<MemoryConfig> {
|
||||||
.interact()?;
|
.interact()?;
|
||||||
|
|
||||||
let backend = match choice {
|
let backend = match choice {
|
||||||
1 => "markdown",
|
1 => "markdown",
|
||||||
2 => "none",
|
2 => "none",
|
||||||
_ => "sqlite", // 0 and any unexpected value defaults to sqlite
|
_ => "sqlite", // 0 and any unexpected value defaults to sqlite
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -240,8 +240,9 @@ fn hex_encode(data: &[u8]) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hex-decode a hex string to bytes.
|
/// Hex-decode a hex string to bytes.
|
||||||
|
#[allow(clippy::manual_is_multiple_of)]
|
||||||
fn hex_decode(hex: &str) -> Result<Vec<u8>> {
|
fn hex_decode(hex: &str) -> Result<Vec<u8>> {
|
||||||
if hex.len() % 2 != 0 {
|
if !hex.len().is_multiple_of(2) {
|
||||||
anyhow::bail!("Hex string has odd length");
|
anyhow::bail!("Hex string has odd length");
|
||||||
}
|
}
|
||||||
(0..hex.len())
|
(0..hex.len())
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue