Compare commits

...
53 Commits
Author SHA1 Message Date
jaberjaber23 ea287093c4 bug fixes 2026-03-18 05:58:22 +03:00
jaberjaber23 3688d86ef8 bug fixes 2026-03-18 05:49:42 +03:00
jaberjaber23 9f9903797e bump v0.4.5 2026-03-18 05:42:11 +03:00
jaberjaber23 3cd8847a95 bug fixes 2026-03-18 05:37:32 +03:00
jaberjaber23 88bb55c8f2 bug fixes 2026-03-18 05:08:12 +03:00
jaberjaber23 f1ca52714d feature batch 2026-03-15 20:23:30 +03:00
Evan HuandClaude Opus 4.6 77ed954d18 wecom channel adapter
* feat: Add WeCom (WeChat Work) channel adapter

- Add wecom.rs channel adapter implementation
- Add WeComConfig in config.rs
- Register WeCom adapter in channel_bridge.rs

WeCom channel supports:
- Inbound messages via callback webhook
- Outbound messages via WeCom API
- Access token caching and auto-refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle WeCom callbacks and preserve hand extension tools

* fix: render WeCom replies as plain text

* fix: resolve clippy warnings in wecom adapter

- Remove unused WECOM_API_HOST constant
- Fix needless borrow in send_text call
- Replace assert_eq!(bool, true) with assert!()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt for wecom-related files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt --all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: upgrade quinn-proto and add cargo audit ignore list

- Upgrade quinn-proto 0.11.13 → 0.11.14 (RUSTSEC-2026-0037 DoS fix)
- Add .cargo/audit.toml to ignore unmaintainable transitive deps
  (tauri GTK3 bindings, time pinned by mac-notification-sys, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:43 +03:00
jaberjaber23 4fa2f9474b bug fixes 2026-03-15 19:39:39 +03:00
jaberjaber23 75c9c80679 bug fixes 2026-03-15 18:26:53 +03:00
jaberjaber23 3eaa9e02c9 community fixes 2026-03-15 17:48:09 +03:00
Tilman Baumann f165263296 test merge
* Feat: Add Nix support

Adding Nix support. Nixos modules may follow...

Run directly with `nix run github:RightNow-AI/openfang`

There are a bunch of flake outputs (based on cargo workspace)
Focus on these:
* openfang-cli (default)
* openfang-desktop

* nix: cmake depdencency was introduced via llama

* Follow upstream style
2026-03-15 17:40:22 +03:00
NextDoorLaoHuang-HFandroot c122e1ddd7 test merge
* Improve OpenClaw provider alias migration compatibility

* Fix local provider env mapping regression in migration

* test(migrate): cover json5 default_model provider/env mapping

* test(migrate): add JSON5 agent provider mapping integration tests

* test(migrate): add legacy YAML provider alias integration coverage

* fix(migrate): harden JSON5 provider catalog resolution

* chore(migrate): scope split_model_ref helper to tests

---------

Co-authored-by: root <root@LAPTOP-NGAQG9OH.localdomain>
2026-03-15 17:40:09 +03:00
pluginmdandClaude Opus 4.6 d2ea030f03 test merge
Merge lark.rs features (dedup, encryption, group filtering, rich text parsing)
into feishu.rs with FeishuRegion toggle (cn/intl). Single [channels.feishu]
config handles both domestic Feishu and international Lark via region field.

- Expand FeishuConfig: region, webhook_path, verification_token, encrypt_key_env, bot_names
- Add FeishuRegion enum with domain switching (open.feishu.cn / open.larksuite.com)
- Add AES-256-CBC event decryption, message/event dedup, group chat filtering
- Update channel_bridge.rs wiring for full config
- Update routes.rs ChannelMeta with new UI fields (region basic, rest advanced)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 17:39:56 +03:00
tuzkierandWang Hanbin eb87e3fd42 test merge
Adds a WebSocket-based DingTalk Stream channel adapter as an alternative
to the existing webhook-based DingTalk adapter.

DingTalk Stream Mode uses a long-lived WebSocket connection to the
DingTalk Gateway, eliminating the need for a public webhook endpoint.

Changes:
- `openfang-types`: add `DingTalkStreamConfig` struct and wire into
  `ChannelsConfig` alongside the existing `DingTalkConfig`
- `openfang-channels`: implement `DingTalkStreamAdapter` (WebSocket
  connection management, ping/pong, token refresh, send via batchSend API)
- `openfang-api`: register `dingtalk_stream` in the channel registry,
  `is_channel_configured`, and `channel_config_values`
- `openfang-api`: wire adapter startup in `channel_bridge.rs`
- `openfang-cli`: add `dingtalk_stream` entry to the TUI channels list

Configuration:
```toml
[channels.dingtalk_stream]
app_key_env = "DINGTALK_APP_KEY"      # Enterprise Internal App Key
app_secret_env = "DINGTALK_APP_SECRET" # Enterprise Internal App Secret
robot_code_env = "DINGTALK_ROBOT_CODE" # optional, defaults to app_key
```

Requires an Enterprise Internal App in the DingTalk Open Platform with
Stream Mode enabled. No public endpoint needed.

Made-with: Cursor

Co-authored-by: Wang Hanbin <wanghb@best-inc.com>
2026-03-15 17:39:52 +03:00
6d742e9081 test merge
* feat: heartbeat auto-recovery for crashed agents

Extend the heartbeat monitor to detect and automatically recover crashed
agents, reducing operator intervention for 24/7 autonomous deployments:

- Add RecoveryTracker: per-agent failure count with configurable cooldown
- Heartbeat now monitors both Running and Crashed agents
- Crashed agents auto-recover up to max_recovery_attempts (default 3)
- After exhausting attempts, agents are marked Terminated
- Unresponsive Running agents marked Crashed for next-cycle recovery
- Increase default timeout from 60s to 180s (browser/LLM tasks need time)
- Add HeartbeatStatus.state field for downstream consumers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: claude code driver — PID tracking and message timeout

Add subprocess lifecycle management to prevent hung CLI processes from
blocking agents indefinitely:

- Track active subprocess PIDs in a concurrent DashMap for external monitoring
- Enforce configurable message timeout (default 300s) with automatic process kill
- Return proper LlmError::Api on non-zero exit in streaming mode (was silently ignored)
- Add with_timeout(), active_pids(), pid_map() public methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: /restart endpoint — manual per-agent recovery without daemon bounce

POST /api/agents/{id}/restart and /api/agents/{id}/start both:
- Cancel any active task via stop_agent_run()
- Reset agent state to Running (updates last_active)
- Return JSON with previous state and whether a task was cancelled

Enables operators to recover individual crashed/stuck agents through the
API without restarting the entire daemon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ZiLLA Dev <dev@zilla.wtf>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 17:39:26 +03:00
Victor Duarte 317b947608 docker runtimes 2026-03-15 17:27:13 +03:00
jaberjaber23 1cb8b989d1 community fixes 2026-03-15 16:59:21 +03:00
psumotek bde1f5414c channel agent reresolution
When an agent is restarted, its UUID changes but the channel bridge still
holds the old UUID from startup. This causes "Agent not found" errors.

This fix stores the agent *name* alongside the cached UUID at bridge
startup and, on "Agent not found" errors, re-resolves the name to a
fresh UUID via find_agent_by_name(), updates the cache, and retries the
message — all transparently to the end user.

Changes:
- router.rs: add channel_default_names DashMap, set_channel_default_with_name(),
  channel_default_name(), update_channel_default()
- channel_bridge.rs: use set_channel_default_with_name() at startup
- bridge.rs: add try_reresolution() helper, integrate retry logic into
  dispatch_message() and dispatch_with_blocks() error paths with proper
  lifecycle_reactions guards and sanitize_agent_error() usage
2026-03-15 16:55:08 +03:00
TJUEZandTJUEZ d15207fa51 shell skill runtime
Add Shell runtime type to SkillRuntime enum and implement
execute_shell function for running Bash scripts as skills.

This allows skills to use Bash script files, which many
existing skills rely on.

Ref: RightNow-AI/openfang/issues/620

Co-authored-by: TJUEZ <tjuez@email.com>
2026-03-15 16:54:57 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e808ca0d08 bump mailparse
Bumps [mailparse](https://github.com/staktrace/mailparse) from 0.15.0 to 0.16.1.
- [Commits](https://github.com/staktrace/mailparse/compare/v0.15.0...v0.16.1)

---
updated-dependencies:
- dependency-name: mailparse
  dependency-version: 0.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:54:06 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c554adae0d bump ci action
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:33 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 590121d5f3 bump ci action
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:31 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 58a7a07942 bump ci action
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:28 +03:00
Nahuel Gonzalez 72b87dfaa9 chromium no-sandbox root
Chromium refuses to launch without --no-sandbox when the process is
running as UID 0. This causes the browser hand to fail immediately with
'Chromium exited before printing DevTools URL' on any server-based
OpenFang installation that runs as root (the default install).

Added is_running_as_root() which reads /proc/self/status on Linux to
detect UID 0 without requiring a libc dependency, with a fallback to
the HOME env var for other Unix systems. When root is detected,
--no-sandbox is appended to the Chromium launch args automatically.
2026-03-15 15:30:29 +03:00
Frankandtsubasakong b8fb6987e0 tool error guidance
Co-authored-by: tsubasakong <185121705+tsubasakong@users.noreply.github.com>
2026-03-15 15:08:19 +03:00
Vincent LeraitreandClaude Opus 4.6 dec081a326 slack unfurl links
* Add unfurl_links config for Slack channel

Add unfurl_links: bool (default true) to SlackConfig to control
Slack's automatic URL preview expansion. When set to false, links
in agent messages are not unfurled, keeping output compact.

Applied to SlackAdapter's chat.postMessage payload via unfurl_links
and unfurl_media parameters, affecting both real-time and cron
delivery paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Rename test per review: clarify it tests explicit true, not default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:44:53 +03:00
Mark BandClaude Sonnet 4.6 52647b2996 agent rename fix
* feat: add release-fast Cargo profile for faster dev builds

Introduces a `release-fast` profile that inherits from `release` but
uses thin LTO and 8 codegen units instead of full LTO + 1, cutting
link time significantly while remaining fast enough for integration
testing. Documents usage in CONTRIBUTING.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: allow renaming an agent to its current name

AgentRegistry::update_name was calling name_index.contains_key()
without excluding the agent being renamed. Renaming to the same name
always returned AgentAlreadyExists instead of succeeding silently.

Fix: only error when a *different* agent owns the target name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:44:22 +03:00
Frank 905cfd2c21 docs link fix 2026-03-15 14:43:55 +03:00
jaberjaber23 7ad7489860 community fixes 2026-03-15 14:43:10 +03:00
Jones Fernandes 77c4c9add8 whatsapp setup docs 2026-03-15 14:34:31 +03:00
jaberjaber23 07019f764e community fixes 2026-03-15 06:28:23 +03:00
jaberjaber23 80eed53305 community fixes 2026-03-15 06:23:04 +03:00
Evan Hu 14c4c1d1f5 stable hand agent IDs
* fix: use fixed agent ID for hand agents based on hand_id

This ensures triggers and cron jobs continue to work after daemon restart,
as hand agents now have stable IDs instead of generating a new UUID each time.

Changes:
- Add AgentId::from_string() method for deterministic ID generation
- Modify spawn_agent_with_parent() to accept optional fixed_id
- Use hand_id-based fixed ID in activate_hand()

See: #519

* remove: remove serena local config from commit

* chore: ignore .serena directory
2026-03-15 06:18:48 +03:00
jaberjaber23 fa7dd277e6 community fixes 2026-03-15 06:16:06 +03:00
jaberjaber23 0e589e3f9a community fixes 2026-03-15 06:12:33 +03:00
Daniel.Chung 59703d50d6 codex id_token
Signed-off-by: zhong <zdianjiang@gmail.com>
2026-03-15 06:05:52 +03:00
Sky Moore 5413269943 async session save
* fix: use async save_session to avoid blocking tokio runtime

save_session() was synchronous, holding a Mutex<Connection> on the
tokio worker thread during SQLite writes. On pods with 1 CPU core
(1 tokio worker thread), this starved the entire runtime — including
health check endpoints — causing K8s to mark the pod not-ready and
return 504 on all subsequent requests.

Add save_session_async() that wraps the SQLite write in
spawn_blocking, matching the pattern already used by other memory
operations (recall, remember, etc.). Update all 12 call sites in
the agent loop.

* fix: move health check DB query to spawn_blocking and add SSE keep_alive

The health endpoint called structured_get() synchronously on the tokio
async runtime, acquiring the shared std::sync::Mutex<Connection> on a
worker thread. When the agent loop held this mutex during session saves,
the health check blocked the tokio thread, starving the SSE stream and
causing Kubernetes probe timeouts.

- Health and health_detail now run the DB check via spawn_blocking
- SSE message/stream endpoint now includes keep_alive to flush periodic
  heartbeats even during contention

* feat: add hands upsert API for idempotent hand definition updates

Add upsert_from_content() to HandRegistry that overwrites existing
definitions instead of rejecting duplicates. Exposed as POST
/api/hands/upsert for use by the shard manager to keep hand definitions
up to date across pod restarts.

* fix: websocket streaming delays

* fix: get response immediately
2026-03-15 06:05:19 +03:00
mdrissel c5582ceb1e docker build args
Adds LTO and CODEGEN_UNITS arguments that default to optimized prod settings but can be overridden (e.g., LTO=false, CODEGEN_UNITS=16) by developers for faster iteration.
2026-03-15 06:04:37 +03:00
Mark BandClaude Sonnet 4.6 36dc62745c mastodon polling fix
The polling loop was updating last_notification_id on every iteration,
leaving it set to the oldest (smallest) ID in the batch after the loop
completed. On the next poll, since_id was set to that oldest ID, causing
Mastodon to return all previously seen notifications again.

Re-delivered notifications caused the bot to respond to the same user
mention repeatedly. Combined with api_post_status chaining each response
chunk as a reply to the previous chunk, this produced long self-reply
threads that appeared to be the bot conversing with itself.

Fix: capture the first (newest) notification ID before processing the
batch, so since_id always advances correctly on each poll cycle.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 06:04:16 +03:00
JingyiQiu cb6e6909d4 telegram formatting 2026-03-15 06:01:45 +03:00
jaberjaber23 b3be3f4940 community fixes 2026-03-15 05:58:00 +03:00
Mark BandClaude Sonnet 4.6 7505007d8c release-fast profile
Introduces a `release-fast` profile that inherits from `release` but
uses thin LTO and 8 codegen units instead of full LTO + 1, cutting
link time significantly while remaining fast enough for integration
testing. Documents usage in CONTRIBUTING.md.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 05:57:33 +03:00
jaberjaber23 fdd6c1a1f7 vault wiring 2026-03-15 05:48:09 +03:00
jaberjaber23 135c37fbf7 community fixes 2026-03-15 01:25:15 +03:00
jaberjaber23 a7a96a7b0f community fixes 2026-03-15 00:18:34 +03:00
jaberjaber23 52bacf0946 community fixes 2026-03-14 22:49:43 +03:00
jaberjaber23 d55e1b8545 community batch v0.4.0 2026-03-12 23:33:19 +03:00
jaberjaber23 0c059d1dc1 bump v0.3.49 2026-03-12 18:34:16 +03:00
jaberjaber23 b6b8b4ebe1 fix community issues 2026-03-12 16:42:39 +03:00
jaberjaber23 14f4845170 trader dashboard 2026-03-12 06:20:46 +03:00
jaberjaber23 be8a589986 bump v0.3.47 2026-03-12 01:23:35 +03:00
jaberjaber23 951e8d0feb fix 11 issues 2026-03-12 01:22:45 +03:00
jaberjaber23 98f8d1ca79 fix community PRs (inspired by #438 @pandego, #433 @ozekimasaki, #417 @f-liva, #392 @cryptonahue, #410 @hobostay, #413 @castorinop, #275 @woodcoal, #464 @citadelgrad, #419 @shipdocs, #480 @skeltavik, #439 @modship) 2026-03-11 03:25:16 +03:00
150 changed files with 14872 additions and 1929 deletions
+34
View File
@@ -0,0 +1,34 @@
# Ignored advisories — all are transitive dependencies we cannot upgrade directly.
#
# time 0.3.45: pinned by mac-notification-sys (tauri dependency), awaiting upstream fix
# GTK3/glib/pango/etc: tauri uses gtk-rs GTK3 bindings which are unmaintained
# paste, proc-macro-error, fxhash: unmaintained transitive deps
# lexical-core: unmaintained, pulled by tauri dep chain
# serde_cbor: unmaintained, pulled by tao (tauri)
# cocoa/cocoa-foundation: unmaintained, pulled by tauri/tao
[advisories]
ignore = [
"RUSTSEC-2026-0009", # time DoS — pinned by mac-notification-sys
"RUSTSEC-2024-0370", # proc-macro-error unmaintained
"RUSTSEC-2024-0411", # gtk-rs GTK3 unmaintained (gdk-pixbuf)
"RUSTSEC-2024-0412", # gtk-rs GTK3 unmaintained (gdk)
"RUSTSEC-2024-0413", # gtk-rs GTK3 unmaintained (atk)
"RUSTSEC-2024-0414", # gtk-rs GTK3 unmaintained (pango)
"RUSTSEC-2024-0415", # gtk-rs GTK3 unmaintained (gio)
"RUSTSEC-2024-0416", # gtk-rs GTK3 unmaintained (atk-sys)
"RUSTSEC-2024-0417", # gtk-rs GTK3 unmaintained (gdk-pixbuf-sys)
"RUSTSEC-2024-0418", # gtk-rs GTK3 unmaintained (gdk-sys)
"RUSTSEC-2024-0419", # gtk-rs GTK3 unmaintained (gtk3-macros)
"RUSTSEC-2024-0420", # gtk-rs GTK3 unmaintained (pango-sys)
"RUSTSEC-2024-0429", # gtk-rs GTK3 unmaintained (gtk-sys)
"RUSTSEC-2024-0436", # paste unmaintained
"RUSTSEC-2025-0057", # fxhash unmaintained
"RUSTSEC-2025-0075", # glib unmaintained
"RUSTSEC-2025-0080", # cocoa unmaintained
"RUSTSEC-2025-0081", # cocoa-foundation unmaintained
"RUSTSEC-2025-0098", # lexical-core unmaintained
"RUSTSEC-2025-0100", # gio-sys unmaintained
"RUSTSEC-2026-0002", # serde_cbor unmaintained
"RUSTSEC-2023-0086", # lexopt unmaintained (if present)
]
+6 -6
View File
@@ -20,7 +20,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
@@ -45,7 +45,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
@@ -67,7 +67,7 @@ jobs:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@@ -87,7 +87,7 @@ jobs:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
@@ -97,7 +97,7 @@ jobs:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-audit
@@ -109,7 +109,7 @@ jobs:
name: Secrets Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install trufflehog
+5 -5
View File
@@ -49,7 +49,7 @@ jobs:
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install system deps (Linux)
if: runner.os == 'Linux'
@@ -162,7 +162,7 @@ jobs:
archive: zip
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -212,15 +212,15 @@ jobs:
name: Docker Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU (for arm64 emulation)
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version
+2
View File
@@ -34,6 +34,7 @@ BUILD_LOG.md
# OS
.DS_Store
._*
Thumbs.db
# IDE & tools
@@ -43,3 +44,4 @@ Thumbs.db
*.swp
*.swo
*~
.serena/
+10
View File
@@ -56,6 +56,16 @@ Tests that require a real LLM key will skip gracefully if the env var is absent.
cargo build --workspace
```
### Fast Release Build (for development)
The default `--release` profile uses full LTO and single-codegen-unit, which produces the smallest/fastest binary but is slow to compile. For iterating locally, use the `release-fast` profile instead:
```bash
cargo build --profile release-fast -p openfang-cli
```
This cuts link time significantly (thin LTO, 8 codegen units, `opt-level=2`) while still producing a binary fast enough to run integration tests against. Use `--release` only for final binaries or CI.
### Run All Tests
```bash
Generated
+59 -18
View File
@@ -505,6 +505,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -672,6 +681,15 @@ dependencies = [
"rustversion",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.2.56"
@@ -2885,6 +2903,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
@@ -3307,9 +3326,9 @@ dependencies = [
[[package]]
name = "mailparse"
version = "0.15.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e"
checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f"
dependencies = [
"charset",
"data-encoding",
@@ -3792,7 +3811,7 @@ dependencies = [
[[package]]
name = "openfang-api"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"async-trait",
"axum",
@@ -3801,6 +3820,8 @@ dependencies = [
"dashmap",
"futures",
"governor",
"hex",
"hmac",
"openfang-channels",
"openfang-extensions",
"openfang-hands",
@@ -3814,6 +3835,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"socket2 0.5.10",
"subtle",
"tempfile",
@@ -3829,11 +3851,13 @@ dependencies = [
[[package]]
name = "openfang-channels"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"aes",
"async-trait",
"axum",
"base64 0.22.1",
"cbc",
"chrono",
"dashmap",
"futures",
@@ -3845,9 +3869,12 @@ dependencies = [
"mailparse",
"native-tls",
"openfang-types",
"regex-lite",
"reqwest 0.12.28",
"roxmltree",
"serde",
"serde_json",
"sha1",
"sha2",
"tokio",
"tokio-stream",
@@ -3861,7 +3888,7 @@ dependencies = [
[[package]]
name = "openfang-cli"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"clap",
"clap_complete",
@@ -3888,7 +3915,7 @@ dependencies = [
[[package]]
name = "openfang-desktop"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"axum",
"open",
@@ -3914,7 +3941,7 @@ dependencies = [
[[package]]
name = "openfang-extensions"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"aes-gcm",
"argon2",
@@ -3942,7 +3969,7 @@ dependencies = [
[[package]]
name = "openfang-hands"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"chrono",
"dashmap",
@@ -3959,7 +3986,7 @@ dependencies = [
[[package]]
name = "openfang-kernel"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"async-trait",
"chrono",
@@ -3992,11 +4019,12 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"zeroize",
]
[[package]]
name = "openfang-memory"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"async-trait",
"chrono",
@@ -4015,7 +4043,7 @@ dependencies = [
[[package]]
name = "openfang-migrate"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -4034,7 +4062,7 @@ dependencies = [
[[package]]
name = "openfang-runtime"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"anyhow",
"async-trait",
@@ -4068,7 +4096,7 @@ dependencies = [
[[package]]
name = "openfang-skills"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"chrono",
"hex",
@@ -4091,7 +4119,7 @@ dependencies = [
[[package]]
name = "openfang-types"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"async-trait",
"chrono",
@@ -4110,7 +4138,7 @@ dependencies = [
[[package]]
name = "openfang-wire"
version = "0.3.44"
version = "0.4.5"
dependencies = [
"async-trait",
"chrono",
@@ -4832,9 +4860,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
@@ -5315,6 +5343,12 @@ dependencies = [
"serde",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rusqlite"
version = "0.31.0"
@@ -5795,6 +5829,12 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -7369,6 +7409,7 @@ dependencies = [
"getrandom 0.4.2",
"js-sys",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
@@ -8773,7 +8814,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "xtask"
version = "0.3.44"
version = "0.4.5"
[[package]]
name = "yoke"
+16 -3
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.45"
version = "0.4.5"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -52,7 +52,7 @@ chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
# IDs
uuid = { version = "1", features = ["v4", "serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
# Database
rusqlite = { version = "0.31", features = ["bundled", "serde_json"] }
@@ -102,6 +102,9 @@ walkdir = "2"
# Security
sha2 = "0.10"
sha1 = "0.10"
aes = "0.8"
cbc = "0.1"
hmac = "0.12"
hex = "0.4"
subtle = "2"
@@ -136,7 +139,10 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
imap = "2"
native-tls = "0.2"
mailparse = "0.15"
mailparse = "0.16"
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
openssl = { version = "0.10", features = ["vendored"] }
# Testing
tokio-test = "0.4"
@@ -147,3 +153,10 @@ lto = true
codegen-units = 1
strip = true
opt-level = 3
[profile.release-fast]
inherits = "release"
lto = "thin"
codegen-units = 8
opt-level = 2
strip = false
+16 -2
View File
@@ -7,10 +7,24 @@ COPY crates ./crates
COPY xtask ./xtask
COPY agents ./agents
COPY packages ./packages
# Optional build args for dev environments to speed up compilation
# Example: docker build --build-arg LTO=false --build-arg CODEGEN_UNITS=16 .
ARG LTO=true
ARG CODEGEN_UNITS=1
ENV CARGO_PROFILE_RELEASE_LTO=${LTO} \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CODEGEN_UNITS}
RUN cargo build --release --bin openfang
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
FROM rust:1-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
python3-pip \
python3-venv \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/openfang /usr/local/bin/
COPY --from=builder /build/agents /opt/openfang/agents
EXPOSE 4200
+91
View File
@@ -264,6 +264,97 @@ Connect your agents to every platform your users are on.
Each adapter supports per-channel model overrides, DM/group policies, rate limiting, and output formatting.
---
## WhatsApp Web Gateway (QR Code)
Connect your personal WhatsApp account to OpenFang via QR code — just like WhatsApp Web. No Meta Business account required.
### Prerequisites
- **Node.js >= 18** installed ([download](https://nodejs.org/))
- OpenFang installed and initialized
### Setup
**1. Install the gateway dependencies:**
```bash
cd packages/whatsapp-gateway
npm install
```
**2. Configure `config.toml`:**
```toml
[channels.whatsapp]
mode = "web"
default_agent = "assistant"
```
**3. Set the gateway URL (choose one):**
Add to your shell profile for persistence:
```bash
# macOS / Linux
echo 'export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"' >> ~/.zshrc
source ~/.zshrc
```
Or set it inline when starting the gateway:
```bash
export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"
```
**4. Start the gateway:**
```bash
node packages/whatsapp-gateway/index.js
```
The gateway listens on port `3009` by default. Override with `WHATSAPP_GATEWAY_PORT`.
**5. Start OpenFang:**
```bash
openfang start
# Dashboard at http://localhost:4200
```
**6. Scan the QR code:**
Open the dashboard → **Channels****WhatsApp**. A QR code will appear. Scan it with your phone:
> **WhatsApp** → **Settings** → **Linked Devices** → **Link a Device**
Once scanned, the status changes to `connected` and incoming messages are routed to your configured agent.
### Gateway Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `WHATSAPP_WEB_GATEWAY_URL` | Gateway URL for OpenFang to connect to | _(empty = disabled)_ |
| `WHATSAPP_GATEWAY_PORT` | Port the gateway listens on | `3009` |
| `OPENFANG_URL` | OpenFang API URL the gateway reports to | `http://127.0.0.1:4200` |
| `OPENFANG_DEFAULT_AGENT` | Agent that handles incoming messages | `assistant` |
### Gateway API Endpoints
| Method | Route | Description |
|--------|-------|-------------|
| `POST` | `/login/start` | Generate QR code (returns base64 PNG) |
| `GET` | `/login/status` | Connection status (`disconnected`, `qr_ready`, `connected`) |
| `POST` | `/message/send` | Send a message (`{ "to": "5511999999999", "text": "Hello" }`) |
| `GET` | `/health` | Health check |
### Alternative: WhatsApp Cloud API
For production workloads, use the [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api) with a Meta Business account. See the [Cloud API configuration docs](https://openfang.sh/docs/channels/whatsapp).
---
## 27 LLM Providers — 123+ Models
+3
View File
@@ -33,6 +33,9 @@ governor = { workspace = true }
tokio-stream = { workspace = true }
subtle = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
hex = { workspace = true }
socket2 = { workspace = true }
reqwest = { workspace = true }
+129 -21
View File
@@ -43,6 +43,7 @@ use openfang_channels::webex::WebexAdapter;
// Wave 5
use async_trait::async_trait;
use openfang_channels::dingtalk::DingTalkAdapter;
use openfang_channels::dingtalk_stream::DingTalkStreamAdapter;
use openfang_channels::discourse::DiscourseAdapter;
use openfang_channels::gitter::GitterAdapter;
use openfang_channels::gotify::GotifyAdapter;
@@ -50,12 +51,15 @@ use openfang_channels::linkedin::LinkedInAdapter;
use openfang_channels::mumble::MumbleAdapter;
use openfang_channels::ntfy::NtfyAdapter;
use openfang_channels::webhook::WebhookAdapter;
use openfang_channels::wecom::WeComAdapter;
use openfang_kernel::OpenFangKernel;
use openfang_types::agent::AgentId;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
use openfang_runtime::str_utils::safe_truncate_str;
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
pub struct KernelBridgeAdapter {
kernel: Arc<OpenFangKernel>,
@@ -70,6 +74,10 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.send_message(agent_id, message)
.await
.map_err(|e| format!("{e}"))?;
// Silent/NO_REPLY responses should not be forwarded to channels
if result.silent {
return Ok(String::new());
}
Ok(result.response)
}
@@ -82,7 +90,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
let text: String = blocks
.iter()
.filter_map(|b| match b {
openfang_types::message::ContentBlock::Text { text } => Some(text.as_str()),
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
@@ -378,7 +386,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| t.agent_id.to_string());
let status = if t.enabled { "on" } else { "off" };
let id_short = &t.id.0.to_string()[..8];
let id_str = t.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
msg.push_str(&format!(
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
id_short,
@@ -417,7 +426,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.kernel
.triggers
.register(agent.id, pattern, prompt.to_string(), 0);
let id_short = &trigger_id.0.to_string()[..8];
let id_str = trigger_id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
}
@@ -432,7 +442,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
1 => {
let t = matched[0];
if self.kernel.triggers.remove(t.id) {
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
let id_str = t.id.0.to_string();
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
} else {
"Failed to remove trigger.".to_string()
}
@@ -455,7 +466,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| job.agent_id.to_string());
let status = if job.enabled { "on" } else { "off" };
let id_short = &job.id.0.to_string()[..8];
let id_str = job.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let sched = match &job.schedule {
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
@@ -477,6 +489,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg
}
#[allow(dead_code)]
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
match action {
"add" => {
@@ -515,7 +528,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
match self.kernel.cron_scheduler.add_job(job, false) {
Ok(id) => {
let id_short = &id.0.to_string()[..8];
let id_str = id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
}
Err(e) => format!("Failed to create job: {e}"),
@@ -537,7 +551,12 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
let j = matched[0];
match self.kernel.cron_scheduler.remove_job(j.id) {
Ok(_) => {
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
let id_str = j.id.0.to_string();
format!(
"Job [{}] '{}' removed.",
safe_truncate_str(&id_str, 8),
j.name
)
}
Err(e) => format!("Failed to remove job: {e}"),
}
@@ -566,10 +585,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
openfang_types::scheduler::CronAction::SystemEvent { text } => {
text.clone()
}
openfang_types::scheduler::CronAction::WorkflowRun {
workflow_id,
input,
..
} => {
format!(
"Run workflow {workflow_id}{}",
input
.as_deref()
.map(|i| format!(" with input: {i}"))
.unwrap_or_default()
)
}
};
match self.kernel.send_message(j.agent_id, &message).await {
Ok(result) => {
let id_short = &j.id.0.to_string()[..8];
let id_str = j.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] ran:\n{}", result.response)
}
Err(e) => format!("Failed to run job: {e}"),
@@ -589,7 +622,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
let mut msg = format!("Pending approvals ({}):\n", pending.len());
for req in &pending {
let id_short = &req.id.to_string()[..8];
let id_str = req.id.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
let age = if age_secs >= 60 {
format!("{}m", age_secs / 60)
@@ -630,10 +664,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
) {
Ok(_) => {
let verb = if approve { "Approved" } else { "Rejected" };
let id_str = req.id.to_string();
format!(
"{} [{}] {}{}",
verb,
&req.id.to_string()[..8],
safe_truncate_str(&id_str, 8),
req.tool_name,
req.agent_id
)
@@ -673,7 +708,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
));
}
self.kernel
.set_agent_model(agent_id, model)
.set_agent_model(agent_id, model, None)
.map_err(|e| format!("{e}"))?;
// Read back resolved model+provider from registry
let entry = self
@@ -763,12 +798,17 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
// Wave 5
"mumble" => channels.mumble.as_ref().map(|c| c.overrides.clone()),
"dingtalk" => channels.dingtalk.as_ref().map(|c| c.overrides.clone()),
"dingtalk_stream" => channels
.dingtalk_stream
.as_ref()
.map(|c| c.overrides.clone()),
"discourse" => channels.discourse.as_ref().map(|c| c.overrides.clone()),
"gitter" => channels.gitter.as_ref().map(|c| c.overrides.clone()),
"ntfy" => channels.ntfy.as_ref().map(|c| c.overrides.clone()),
"gotify" => channels.gotify.as_ref().map(|c| c.overrides.clone()),
"webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()),
"linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()),
"wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()),
_ => None,
}
}
@@ -900,7 +940,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
return "OFP peer network is disabled. Set network_enabled = true in config.toml."
.to_string();
}
match &self.kernel.peer_registry {
match self.kernel.peer_registry.get() {
Some(registry) => {
let peers = registry.all_peers();
if peers.is_empty() {
@@ -975,16 +1015,38 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
}
}
/// Read a token from an env var, returning None with a warning if missing/empty.
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
match std::env::var(env_var) {
/// Resolve a token: if the value looks like an actual secret (contains `:`,
/// starts with `xoxb-`, `xapp-`, `sk-`, etc.), use it directly.
/// Otherwise treat it as an env var name and look it up.
fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
// Heuristic: actual tokens contain `:` (Telegram, Discord) or start with
// known prefixes. Env var names are uppercase ASCII identifiers.
let looks_like_token = env_var_or_token.contains(':')
|| env_var_or_token.starts_with("xoxb-")
|| env_var_or_token.starts_with("xapp-")
|| env_var_or_token.starts_with("sk-")
|| env_var_or_token.starts_with("Bearer ");
if looks_like_token {
warn!(
"{adapter_name}: config field contains what looks like an actual token \
rather than an env var name — using it directly. \
Tip: store the token in an env var and use the var name instead for security."
);
return Some(env_var_or_token.to_string());
}
match std::env::var(env_var_or_token) {
Ok(t) if !t.is_empty() => Some(t),
Ok(_) => {
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping");
None
}
Err(_) => {
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
warn!(
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
Set it with: export {env_var_or_token}=<your-token>"
);
None
}
}
@@ -1044,6 +1106,7 @@ pub async fn start_channel_bridge_with_config(
// Wave 5
|| config.mumble.is_some()
|| config.dingtalk.is_some()
|| config.dingtalk_stream.is_some()
|| config.discourse.is_some()
|| config.gitter.is_some()
|| config.ntfy.is_some()
@@ -1099,6 +1162,9 @@ pub async fn start_channel_bridge_with_config(
app_token,
bot_token,
sl_config.allowed_channels.clone(),
sl_config.auto_thread_reply,
sl_config.thread_ttl_hours,
sl_config.unfurl_links,
));
adapters.push((adapter, sl_config.default_agent.clone()));
}
@@ -1108,7 +1174,9 @@ pub async fn start_channel_bridge_with_config(
// WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL)
if let Some(ref wa_config) = config.whatsapp {
let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp");
let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty());
let gateway_url = std::env::var(&wa_config.gateway_url_env)
.ok()
.filter(|u| !u.is_empty());
if cloud_token.is_some() || gateway_url.is_some() {
let token = cloud_token.unwrap_or_default();
@@ -1356,10 +1424,20 @@ pub async fn start_channel_bridge_with_config(
// Feishu/Lark
if let Some(ref fs_config) = config.feishu {
if let Some(secret) = read_token(&fs_config.app_secret_env, "Feishu") {
let adapter = Arc::new(FeishuAdapter::new(
let region = openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region);
let encrypt_key = fs_config
.encrypt_key_env
.as_ref()
.and_then(|env| read_token(env, "Feishu encrypt_key"));
let adapter = Arc::new(FeishuAdapter::with_config(
fs_config.app_id.clone(),
secret,
fs_config.webhook_port,
region,
Some(fs_config.webhook_path.clone()),
fs_config.verification_token.clone(),
encrypt_key,
fs_config.bot_names.clone(),
));
adapters.push((adapter, fs_config.default_agent.clone()));
}
@@ -1373,6 +1451,21 @@ pub async fn start_channel_bridge_with_config(
}
}
// WeCom/WeChat Work
if let Some(ref wc_config) = config.wecom {
if let Some(secret) = read_token(&wc_config.secret_env, "WeCom") {
let adapter = Arc::new(WeComAdapter::with_verification(
wc_config.corp_id.clone(),
wc_config.agent_id.clone(),
secret,
wc_config.webhook_port,
wc_config.encoding_aes_key.clone(),
wc_config.token.clone(),
));
adapters.push((adapter, wc_config.default_agent.clone()));
}
}
// ── Wave 4 ──────────────────────────────────────────────────
// Nextcloud Talk
@@ -1479,7 +1572,7 @@ pub async fn start_channel_bridge_with_config(
}
}
// DingTalk
// DingTalk (webhook mode)
if let Some(ref dt_config) = config.dingtalk {
if let Some(token) = read_token(&dt_config.access_token_env, "DingTalk") {
let secret = read_token(&dt_config.secret_env, "DingTalk (secret)").unwrap_or_default();
@@ -1488,6 +1581,21 @@ pub async fn start_channel_bridge_with_config(
}
}
// DingTalk (stream mode)
if let Some(ref ds_config) = config.dingtalk_stream {
if let Some(app_key) = read_token(&ds_config.app_key_env, "DingTalk Stream (app_key)") {
if let Some(app_secret) =
read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)")
{
let robot_code =
read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)")
.unwrap_or_else(|| app_key.clone());
let adapter = Arc::new(DingTalkStreamAdapter::new(app_key, app_secret, robot_code));
adapters.push((adapter, ds_config.default_agent.clone()));
}
}
}
// Discourse
if let Some(ref dc_config) = config.discourse {
if let Some(api_key) = read_token(&dc_config.api_key_env, "Discourse") {
@@ -1592,7 +1700,7 @@ pub async fn start_channel_bridge_with_config(
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
adapter.name()
);
router.set_channel_default(channel_key, agent_id);
router.set_channel_default_with_name(channel_key, agent_id, name.clone());
// First configured default also becomes system-wide fallback
if !system_default_set {
router.set_default(agent_id);
+1
View File
@@ -9,6 +9,7 @@ pub mod openai_compat;
pub mod rate_limiter;
pub mod routes;
pub mod server;
pub mod session_auth;
pub mod stream_chunker;
pub mod stream_dedup;
pub mod types;
+44 -4
View File
@@ -43,14 +43,24 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
response
}
/// Authentication state passed to the auth middleware.
#[derive(Clone)]
pub struct AuthState {
pub api_key: String,
pub auth_enabled: bool,
pub session_secret: String,
}
/// Bearer token authentication middleware.
///
/// When `api_key` is non-empty (after trimming), requests to non-public
/// endpoints must include `Authorization: Bearer <api_key>`.
/// If the key is empty or whitespace-only, auth is disabled entirely
/// (public/local development mode).
///
/// When dashboard auth is enabled, session cookies are also accepted.
pub async fn auth(
axum::extract::State(api_key): axum::extract::State<String>,
axum::extract::State(auth_state): axum::extract::State<AuthState>,
request: Request<Body>,
next: Next,
) -> Response<Body> {
@@ -114,7 +124,10 @@ pub async fn auth(
|| (path == "/api/workflows" && is_get)
|| path == "/api/logs/stream" // SSE stream, read-only
|| (path.starts_with("/api/cron/") && is_get)
|| path.starts_with("/api/providers/github-copilot/oauth/");
|| path.starts_with("/api/providers/github-copilot/oauth/")
|| path == "/api/auth/login"
|| path == "/api/auth/logout"
|| (path == "/api/auth/check" && is_get);
if is_public {
return next.run(request).await;
@@ -123,10 +136,11 @@ pub async fn auth(
// If no API key configured (empty, whitespace-only, or missing), skip auth
// entirely. Users who don't set api_key accept that all endpoints are open.
// To secure the dashboard, set a non-empty api_key in config.toml.
let api_key = api_key.trim();
if api_key.is_empty() {
let api_key_trimmed = auth_state.api_key.trim().to_string();
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
return next.run(request).await;
}
let api_key = api_key_trimmed.as_str();
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
let bearer_token = request
@@ -172,6 +186,17 @@ pub async fn auth(
return next.run(request).await;
}
// Check session cookie (dashboard login sessions)
if auth_state.auth_enabled {
if let Some(token) = extract_session_cookie(&request) {
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
.is_some()
{
return next.run(request).await;
}
}
}
// Determine error message: was a credential provided but wrong, or missing entirely?
let credential_provided = header_auth.is_some() || query_auth.is_some();
let error_msg = if credential_provided {
@@ -189,6 +214,21 @@ pub async fn auth(
.unwrap_or_default()
}
/// Extract the `openfang_session` cookie value from a request.
fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
c.trim()
.strip_prefix("openfang_session=")
.map(|v| v.to_string())
})
})
}
/// Security headers middleware — applied to ALL API responses.
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(request).await;
+6 -5
View File
@@ -202,9 +202,10 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
let blocks: Vec<ContentBlock> = parts
.iter()
.filter_map(|part| match part {
OaiContentPart::Text { text } => {
Some(ContentBlock::Text { text: text.clone() })
}
OaiContentPart::Text { text } => Some(ContentBlock::Text {
text: text.clone(),
provider_metadata: None,
}),
OaiContentPart::ImageUrl { image_url } => {
// Parse data URI: data:{media_type};base64,{data}
if let Some(rest) = image_url.url.strip_prefix("data:") {
@@ -322,7 +323,7 @@ pub async fn chat_completions(
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle))
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle), None, None)
.await
{
Ok(result) => {
@@ -378,7 +379,7 @@ async fn stream_response(
let (mut rx, _handle) = state
.kernel
.send_message_streaming(agent_id, message, Some(kernel_handle))
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None)
.map_err(|e| format!("Streaming setup failed: {e}"))?;
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
File diff suppressed because it is too large Load Diff
+50 -22
View File
@@ -45,7 +45,7 @@ pub async fn build_router(
let state = Arc::new(AppState {
kernel: kernel.clone(),
started_at: Instant::now(),
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
peer_registry: kernel.peer_registry.get().map(|r| Arc::new(r.clone())),
bridge_manager: tokio::sync::Mutex::new(bridge),
channels_config: tokio::sync::RwLock::new(channels_config),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
@@ -105,12 +105,25 @@ pub async fn build_router(
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = crate::middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let gcra_limiter = rate_limiter::create_rate_limiter();
let app = Router::new()
.route("/", axum::routing::get(webchat::webchat_page))
.route("/logo.png", axum::routing::get(webchat::logo_png))
.route("/favicon.ico", axum::routing::get(webchat::favicon_ico))
.route("/manifest.json", axum::routing::get(webchat::manifest_json))
.route("/sw.js", axum::routing::get(webchat::sw_js))
.route(
"/api/metrics",
axum::routing::get(routes::prometheus_metrics),
@@ -128,13 +141,23 @@ pub async fn build_router(
)
.route(
"/api/agents/{id}",
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
axum::routing::get(routes::get_agent)
.delete(routes::kill_agent)
.patch(routes::patch_agent),
)
.route(
"/api/agents/{id}/mode",
axum::routing::put(routes::set_agent_mode),
)
.route("/api/profiles", axum::routing::get(routes::list_profiles))
.route(
"/api/agents/{id}/restart",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/start",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/message",
axum::routing::post(routes::send_message),
@@ -288,6 +311,12 @@ pub async fn build_router(
"/api/workflows",
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
)
.route(
"/api/workflows/{id}",
axum::routing::get(routes::get_workflow)
.put(routes::update_workflow)
.delete(routes::delete_workflow),
)
.route(
"/api/workflows/{id}/run",
axum::routing::post(routes::run_workflow),
@@ -337,6 +366,10 @@ pub async fn build_router(
"/api/hands/install",
axum::routing::post(routes::install_hand),
)
.route(
"/api/hands/upsert",
axum::routing::post(routes::upsert_hand),
)
.route(
"/api/hands/active",
axum::routing::get(routes::list_active_hands),
@@ -356,8 +389,7 @@ pub async fn build_router(
)
.route(
"/api/hands/{hand_id}/settings",
axum::routing::get(routes::get_hand_settings)
.put(routes::update_hand_settings),
axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings),
)
.route(
"/api/hands/instances/{id}/pause",
@@ -414,14 +446,11 @@ pub async fn build_router(
"/api/comms/events/stream",
axum::routing::get(routes::comms_events_stream),
)
.route(
"/api/comms/send",
axum::routing::post(routes::comms_send),
)
.route(
"/api/comms/task",
axum::routing::post(routes::comms_task),
)
.route("/api/comms/send", axum::routing::post(routes::comms_send))
.route("/api/comms/task", axum::routing::post(routes::comms_task));
// Split into a second router chunk to stay within axum's type nesting limit.
let app = app
// Tools endpoint
.route("/api/tools", axum::routing::get(routes::list_tools))
// Config endpoints
@@ -466,8 +495,7 @@ pub async fn build_router(
)
.route(
"/api/budget/agents/{id}",
axum::routing::get(routes::agent_budget_status)
.put(routes::update_agent_budget),
axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget),
)
// Session endpoints
.route("/api/sessions", axum::routing::get(routes::list_sessions))
@@ -671,8 +699,12 @@ pub async fn build_router(
"/v1/models",
axum::routing::get(crate::openai_compat::list_models),
)
// Dashboard authentication endpoints
.route("/api/auth/login", axum::routing::post(routes::auth_login))
.route("/api/auth/logout", axum::routing::post(routes::auth_logout))
.route("/api/auth/check", axum::routing::get(routes::auth_check))
.layer(axum::middleware::from_fn_with_state(
api_key,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn_with_state(
@@ -789,8 +821,7 @@ pub async fn run_daemon(
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
let listener =
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
// Run server with graceful shutdown.
// SECURITY: `into_make_service_with_connect_info` injects the peer
@@ -921,11 +952,8 @@ fn is_daemon_responding(addr: &str) -> bool {
.or_else(|| addr.strip_prefix("https://"))
.unwrap_or(addr);
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
std::net::TcpStream::connect_timeout(
&sock_addr,
std::time::Duration::from_millis(500),
)
.is_ok()
std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500))
.is_ok()
} else {
// Fallback: try connecting to hostname
std::net::TcpStream::connect(addr_only)
+109
View File
@@ -0,0 +1,109 @@
//! Stateless session token authentication for the dashboard.
//! Tokens are HMAC-SHA256 signed and contain username + expiry.
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Create a session token: base64(username:expiry_unix:hmac_hex)
pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> String {
use base64::Engine;
let expiry = chrono::Utc::now().timestamp() + (ttl_hours as i64 * 3600);
let payload = format!("{username}:{expiry}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
mac.update(payload.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
}
/// Verify a session token. Returns the username if valid and not expired.
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(token)
.ok()?;
let decoded_str = String::from_utf8(decoded).ok()?;
let parts: Vec<&str> = decoded_str.splitn(3, ':').collect();
if parts.len() != 3 {
return None;
}
let (username, expiry_str, provided_sig) = (parts[0], parts[1], parts[2]);
let expiry: i64 = expiry_str.parse().ok()?;
if chrono::Utc::now().timestamp() > expiry {
return None;
}
let payload = format!("{username}:{expiry_str}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(payload.as_bytes());
let expected_sig = hex::encode(mac.finalize().into_bytes());
use subtle::ConstantTimeEq;
if provided_sig.len() != expected_sig.len() {
return None;
}
if provided_sig
.as_bytes()
.ct_eq(expected_sig.as_bytes())
.into()
{
Some(username.to_string())
} else {
None
}
}
/// Hash a password with SHA256 for config storage.
pub fn hash_password(password: &str) -> String {
use sha2::Digest;
hex::encode(Sha256::digest(password.as_bytes()))
}
/// Verify a password against a stored SHA256 hash (constant-time).
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
let computed = hash_password(password);
use subtle::ConstantTimeEq;
if computed.len() != stored_hash.len() {
return false;
}
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let hash = hash_password("secret123");
assert!(verify_password("secret123", &hash));
assert!(!verify_password("wrong", &hash));
}
#[test]
fn test_create_and_verify_token() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "my-secret");
assert_eq!(user, Some("admin".to_string()));
}
#[test]
fn test_token_wrong_secret() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "wrong-secret");
assert_eq!(user, None);
}
#[test]
fn test_token_invalid_base64() {
let user = verify_session_token("not-valid-base64!!!", "secret");
assert_eq!(user, None);
}
#[test]
fn test_password_hash_length_mismatch() {
assert!(!verify_password("x", "short"));
}
}
+6
View File
@@ -42,6 +42,12 @@ pub struct MessageRequest {
/// Optional file attachments (uploaded via /upload endpoint).
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
/// Sender identity (e.g. WhatsApp phone number, Telegram user ID).
#[serde(default)]
pub sender_id: Option<String>,
/// Sender display name.
#[serde(default)]
pub sender_name: Option<String>,
}
/// Response from sending a message.
+32 -1
View File
@@ -46,6 +46,34 @@ pub async fn favicon_ico() -> impl IntoResponse {
)
}
/// Embedded PWA manifest for installable web app support.
const MANIFEST_JSON: &str = include_str!("../static/manifest.json");
/// Embedded service worker for PWA support.
const SW_JS: &str = include_str!("../static/sw.js");
/// GET /manifest.json — Serve the PWA web app manifest.
pub async fn manifest_json() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/manifest+json"),
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
],
MANIFEST_JSON,
)
}
/// GET /sw.js — Serve the PWA service worker.
pub async fn sw_js() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
SW_JS,
)
}
/// GET / — Serve the OpenFang Dashboard single-page application.
///
/// Returns the full SPA with ETag header based on package version for caching.
@@ -81,13 +109,16 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/vendor/github-dark.min.css"),
"\n</style>\n",
include_str!("../static/index_body.html"),
// Vendor libs: marked + highlight first (used by app.js)
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
"<script>\n",
include_str!("../static/vendor/marked.min.js"),
"\n</script>\n",
"<script>\n",
include_str!("../static/vendor/highlight.min.js"),
"\n</script>\n",
"<script>\n",
include_str!("../static/vendor/chart.umd.min.js"),
"\n</script>\n",
// App code
"<script>\n",
include_str!("../static/js/api.js"),
+104 -60
View File
@@ -502,16 +502,29 @@ async fn handle_text_message(
// Send message to agent with streaming
let kernel_handle: Arc<dyn KernelHandle> =
state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_streaming(agent_id, &content, Some(kernel_handle))
{
match state.kernel.send_message_streaming(
agent_id,
&content,
Some(kernel_handle),
None,
None,
) {
Ok((mut rx, handle)) => {
// Forward stream events to WebSocket with debouncing
// Forward stream events to WebSocket with debouncing.
//
// The stream_task also accumulates the full response text and
// captures ContentComplete usage data. This lets us send the
// `response` event immediately when the stream channel closes
// (after `drop(phase_cb)` in the kernel), WITHOUT waiting for
// post-processing (canonical session writes, JSONL, compaction)
// that happens in the kernel task after the loop.
let sender_stream = Arc::clone(sender);
let verbose_clone = Arc::clone(verbose);
let stream_task = tokio::spawn(async move {
let mut text_buffer = String::new();
let mut accumulated_text = String::new();
let mut stream_usage: Option<openfang_types::message::TokenUsage> = None;
let mut is_silent = false;
let far_future = tokio::time::Instant::now() + Duration::from_secs(86400);
let mut flush_deadline = far_future;
@@ -535,7 +548,15 @@ async fn handle_text_message(
break;
}
Some(ev) => {
// Capture ContentComplete for immediate response
if let StreamEvent::ContentComplete { usage, .. } = &ev {
stream_usage = Some(*usage);
// Don't forward — handled below
continue;
}
if let StreamEvent::TextDelta { ref text } = ev {
accumulated_text.push_str(text);
text_buffer.push_str(text);
if text_buffer.len() >= DEBOUNCE_CHARS {
let _ = flush_text_buffer(
@@ -600,14 +621,62 @@ async fn handle_text_message(
}
}
}
// Check if the agent signalled NO_REPLY via the stream
// (PhaseChange with a "silent" marker — currently the
// kernel sets result.silent after the loop, so we detect
// it from empty accumulated text when ContentComplete
// had no text deltas at all).
if accumulated_text.is_empty() && stream_usage.is_some() {
is_silent = true;
}
(accumulated_text, stream_usage, is_silent)
});
// Wait for the agent loop to complete
match handle.await {
Ok(Ok(result)) => {
// Cancel the stream forwarder (should be done by now)
stream_task.abort();
// Wait for the stream to finish (fast — closes as soon as
// drop(phase_cb) runs after the agent loop). This does NOT
// wait for post-processing.
let stream_result = stream_task.await;
// Spawn the kernel task in the background for cleanup
// (canonical session writes, JSONL mirror, compaction).
// We don't need its result for the response event.
let sender_bg = Arc::clone(sender);
tokio::spawn(async move {
match handle.await {
Ok(Err(e)) => {
warn!("Agent post-processing failed: {e}");
let user_msg = classify_streaming_error(&e);
let _ = send_json(
&sender_bg,
&serde_json::json!({
"type": "error",
"content": user_msg,
}),
)
.await;
}
Err(e) => {
warn!("Agent task panicked: {e}");
let _ = send_json(
&sender_bg,
&serde_json::json!({
"type": "error",
"content": "Internal error occurred",
}),
)
.await;
}
Ok(Ok(_)) => {
// Post-processing completed successfully — nothing to send
}
}
});
// Send the response immediately from stream data
match stream_result {
Ok((accumulated_text, stream_usage, is_silent)) => {
// Send typing lifecycle: stop
let _ = send_json(
sender,
@@ -618,43 +687,36 @@ async fn handle_text_message(
)
.await;
// NO_REPLY: agent intentionally chose not to reply
if result.silent {
let usage = stream_usage.unwrap_or_default();
if is_silent {
let _ = send_json(
sender,
&serde_json::json!({
"type": "silent_complete",
"input_tokens": result.total_usage.input_tokens,
"output_tokens": result.total_usage.output_tokens,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}),
)
.await;
return;
}
// Strip <think>...</think> blocks from model output
// (e.g. MiniMax, DeepSeek reasoning tokens)
let cleaned_response = strip_think_tags(&result.response);
// Strip <think>...</think> blocks
let cleaned = strip_think_tags(&accumulated_text);
// Guard: ensure we never send an empty response
let content = if cleaned_response.trim().is_empty() {
let content = if cleaned.trim().is_empty() {
format!(
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
result.total_usage.input_tokens,
result.total_usage.output_tokens,
result.iterations,
"[The agent completed processing but returned no text response. ({} in / {} out)]",
usage.input_tokens, usage.output_tokens,
)
} else {
cleaned_response
cleaned
};
// Estimate context pressure from last call
let per_call = if result.iterations > 0 {
result.total_usage.input_tokens / result.iterations as u64
} else {
result.total_usage.input_tokens
};
let ctx_pct = (per_call as f64 / 200_000.0 * 100.0).min(100.0);
// Estimate context pressure
let ctx_pct =
(usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0);
let pressure = if ctx_pct > 85.0 {
"critical"
} else if ctx_pct > 70.0 {
@@ -670,38 +732,17 @@ async fn handle_text_message(
&serde_json::json!({
"type": "response",
"content": content,
"input_tokens": result.total_usage.input_tokens,
"output_tokens": result.total_usage.output_tokens,
"iterations": result.iterations,
"cost_usd": result.cost_usd,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"iterations": 0, // Not available from stream; handle updates later if needed
"cost_usd": null,
"context_pressure": pressure,
}),
)
.await;
}
Ok(Err(e)) => {
stream_task.abort();
warn!("Agent message failed: {e}");
let _ = send_json(
sender,
&serde_json::json!({
"type": "typing", "state": "stop",
}),
)
.await;
let user_msg = classify_streaming_error(&e);
let _ = send_json(
sender,
&serde_json::json!({
"type": "error",
"content": user_msg,
}),
)
.await;
}
Err(e) => {
stream_task.abort();
warn!("Agent task panicked: {e}");
warn!("Stream task panicked: {e}");
let _ = send_json(
sender,
&serde_json::json!({
@@ -809,7 +850,7 @@ async fn handle_command(
serde_json::json!({"type": "error", "content": "Agent not found"})
}
} else {
match state.kernel.set_agent_model(agent_id, args) {
match state.kernel.set_agent_model(agent_id, args, None) {
Ok(()) => {
if let Some(entry) = state.kernel.registry.get(agent_id) {
let model = &entry.manifest.model.model;
@@ -912,7 +953,7 @@ async fn handle_command(
let msg = if !state.kernel.config.network_enabled {
"OFP network disabled.".to_string()
} else {
match &state.kernel.peer_registry {
match state.kernel.peer_registry.get() {
Some(registry) => {
let peers = registry.all_peers();
if peers.is_empty() {
@@ -1146,7 +1187,10 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
if inner.contains("localhost:11434") || inner.contains("ollama") {
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
} else {
format!("{}. Use /model to see options.", classified.sanitized_message)
format!(
"{}. Use /model to see options.",
classified.sanitized_message
)
}
}
llm_errors::LlmErrorCategory::Format => {
@@ -3238,3 +3238,206 @@ mark.search-highlight {
.comms-event-row:hover { background: var(--bg-hover); }
.comms-event-time { min-width: 50px; text-align: right; }
.comms-event-detail { margin-left: auto; }
/* ═══════════════════════════════════════════════════════════════════════════
Trader Dashboard
═══════════════════════════════════════════════════════════════════════════ */
.trader-dashboard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
width: 96vw;
max-width: 1200px;
max-height: 92vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.trader-dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-card);
z-index: 10;
border-radius: 12px 12px 0 0;
}
.trader-dashboard-body {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* KPI Cards */
.trader-kpi-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
}
@media (max-width: 900px) {
.trader-kpi-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 540px) {
.trader-kpi-row { grid-template-columns: repeat(2, 1fr); }
}
.trader-kpi-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
text-align: center;
}
.trader-kpi-label {
font-size: 0.7rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.trader-kpi-value {
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
font-family: var(--font-mono);
}
.kpi-positive { color: var(--success) !important; }
.kpi-negative { color: var(--error) !important; }
/* Chart Rows */
.trader-chart-row {
display: flex;
gap: 12px;
}
@media (max-width: 768px) {
.trader-chart-row { flex-direction: column; }
}
.trader-chart-panel {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
min-width: 0;
}
.trader-chart-title {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
font-weight: 600;
}
.trader-chart-wrap {
position: relative;
width: 100%;
min-height: 180px;
}
.trader-chart-wrap canvas {
width: 100% !important;
height: 100% !important;
}
.trader-chart-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 0.85rem;
}
/* Heatmap Table */
.trader-heatmap-wrap {
overflow-x: auto;
}
.trader-heatmap-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-heatmap-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-heatmap-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
}
.heatmap-positive { color: var(--success); font-weight: 600; }
.heatmap-negative { color: var(--error); font-weight: 600; }
/* Signal Badges */
.signal-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.3px;
}
.signal-strong_buy, .signal-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.signal-sell, .signal-strong_sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.signal-hold { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
/* Confidence Bar */
.confidence-bar-wrap {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
}
.confidence-bar {
height: 6px;
border-radius: 3px;
transition: width 0.3s ease;
}
.conf-high { background: var(--success); }
.conf-mid { background: var(--warning); }
.conf-low { background: var(--error); }
.confidence-label {
font-size: 0.7rem;
color: var(--text-dim);
min-width: 32px;
font-family: var(--font-mono);
}
/* Trades Table */
.trader-trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-trades-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-trades-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.trade-side-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 0.68rem;
font-weight: 700;
}
.trade-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.trade-sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
+263 -34
View File
@@ -1,13 +1,28 @@
<body x-data="app" :data-theme="theme">
<!-- API Key Auth Prompt -->
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
<!-- Auth Prompt (API Key or Username/Password) -->
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '', loginUser: '', loginPass: '' }">
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
<!-- Session login mode -->
<template x-if="$store.app.authMode === 'session'">
<div>
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">Sign In</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">Enter your dashboard credentials.</p>
<input type="text" x-model="loginUser" placeholder="Username" autocomplete="username" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.5rem">
<input type="password" x-model="loginPass" placeholder="Password" autocomplete="current-password" @keydown.enter="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Sign In</button>
</div>
</template>
<!-- API key mode -->
<template x-if="$store.app.authMode === 'apikey'">
<div>
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
</div>
</template>
</div>
</div>
@@ -171,6 +186,10 @@
</div>
<div class="sidebar-footer">
<div x-show="$store.app.sessionUser" style="padding:4px 16px;display:flex;align-items:center;justify-content:space-between">
<span class="text-xs text-dim" x-text="$store.app.sessionUser" style="letter-spacing:0.5px"></span>
<button @click="$store.app.sessionLogout()" class="btn btn-ghost btn-sm" style="font-size:11px;padding:2px 8px;opacity:0.7" title="Sign out">Logout</button>
</div>
<div class="sidebar-label text-xs text-dim" style="padding:0 16px 4px;letter-spacing:0.5px">Ctrl+K agents | Ctrl+N new</div>
</div>
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
@@ -736,7 +755,7 @@
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
</div>
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
@keydown.enter.prevent="if(!$event.isComposing && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.enter.prevent="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.escape="showSlashMenu = false; showModelPicker = false"
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
@@ -769,7 +788,7 @@
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<div class="model-switcher-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="!$event.isComposing && $event.keyCode !== 229 && filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
<option value="">All</option>
<template x-for="pn in switcherProviders" :key="pn">
@@ -792,7 +811,7 @@
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px">
<span class="model-switcher-item-name" x-text="m.display_name || m.id"></span>
<span class="model-switcher-item-name" x-text="m.provider + ':' + (m.display_name || m.id)"></span>
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
@@ -903,7 +922,23 @@
</select>
</div>
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
<div class="detail-row"><span class="detail-label">Provider</span>
<template x-if="!editingProvider">
<span>
<span class="detail-value" x-text="detailAgent.model_provider"></span>
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingProvider = true; newProviderValue = detailAgent.model_provider">Change</button>
</span>
</template>
<template x-if="editingProvider">
<span class="flex gap-1" style="align-items:center">
<input class="form-input" style="width:160px;font-size:12px" x-model="newProviderValue" placeholder="provider" @keydown.enter="changeProvider()" @keydown.escape="editingProvider = false">
<button class="btn btn-primary btn-sm" @click="changeProvider()" :disabled="modelSaving" style="padding:2px 10px">
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
</button>
<button class="btn btn-ghost btn-sm" @click="editingProvider = false" style="padding:2px 8px">Cancel</button>
</span>
</template>
</div>
<div class="detail-row"><span class="detail-label">Model</span>
<template x-if="!editingModel">
<span>
@@ -1091,7 +1126,7 @@
<div x-show="spawnStep === 1">
<div class="form-group">
<label>Agent Name</label>
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="nextStep()">
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) nextStep()">
</div>
<div class="form-group">
<label>Emoji</label>
@@ -1320,7 +1355,9 @@
<td class="text-xs" x-text="new Date(wf.created_at).toLocaleDateString()"></td>
<td>
<button class="btn btn-primary btn-sm" @click="showRunModal(wf)">Run</button>
<button class="btn btn-ghost btn-sm" @click="showEditModal(wf)">Edit</button>
<button class="btn btn-ghost btn-sm" @click="viewRuns(wf)">History</button>
<button class="btn btn-danger btn-sm" @click="deleteWorkflow(wf)">Delete</button>
</td>
</tr>
</template>
@@ -1385,6 +1422,39 @@
</div>
</div>
</template>
<!-- Edit modal -->
<template x-if="editModal">
<div class="modal-overlay" @click.self="editModal = null" @keydown.escape.window="editModal = null">
<div class="modal">
<div class="modal-header"><h3 x-text="'Edit: ' + editModal.name"></h3><button class="modal-close" @click="editModal = null">&times;</button></div>
<div class="form-group"><label>Name</label><input class="form-input" x-model="editWf.name" placeholder="Workflow name"></div>
<div class="form-group"><label>Description</label><input class="form-input" x-model="editWf.description" placeholder="What does this workflow do?"></div>
<div class="mb-4">
<div class="form-group" style="margin:0"><label>Steps</label></div>
<div class="text-xs text-dim mb-2">Each step runs an agent. Use <code style="color:var(--accent)">{{input}}</code> in prompts to pass the previous step's output.</div>
<template x-for="(step, i) in editWf.steps" :key="i">
<div class="card mt-2" style="padding:10px">
<div class="flex gap-2 items-center">
<span class="text-xs text-dim font-bold" x-text="'#' + (i+1)" style="width:24px"></span>
<input class="form-input" style="flex:1" x-model="step.name" placeholder="Step name">
<input class="form-input" style="flex:1" x-model="step.agent_name" placeholder="Agent name">
<select class="form-select" style="width:120px" x-model="step.mode">
<option value="sequential">Sequential</option>
<option value="fan_out">Fan Out</option>
<option value="conditional">Conditional</option>
<option value="loop">Loop</option>
</select>
<button class="btn btn-danger btn-sm" @click="editWf.steps.splice(i,1)">&times;</button>
</div>
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
</div>
</template>
<button class="btn btn-ghost btn-sm mt-2" @click="editWf.steps.push({name:'',agent_name:'',mode:'sequential',prompt:'{{input}}'})">+ Add Step</button>
</div>
<button class="btn btn-primary btn-block" @click="saveWorkflow()">Save Changes</button>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -2172,7 +2242,7 @@
<!-- Search bar with live search and clear button -->
<div class="search-input mb-4" style="position:relative">
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">&times;</button>
</div>
@@ -2223,7 +2293,7 @@
<div class="flex gap-3 items-center">
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
</div>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
</div>
</div>
</template>
@@ -2246,7 +2316,7 @@
<span class="text-xs text-dim" x-show="skill.stars" x-text="skill.stars + ' stars'"></span>
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
</div>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
</div>
</div>
</template>
@@ -2530,6 +2600,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Actions -->
<div class="flex gap-2 mt-3">
<button class="btn btn-ghost btn-sm" @click="loadStats(inst)">Stats</button>
<template x-if="isTraderHand(inst)">
<button class="btn btn-primary btn-sm" @click="openDashboard(inst)">Dashboard</button>
</template>
<template x-if="isBrowserHand(inst)">
<button class="btn btn-ghost btn-sm" @click="openBrowserViewer(inst)">View Browser</button>
</template>
@@ -2642,9 +2715,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- ═══ Step 1: Dependencies ═══ -->
<div class="hand-wizard-body" x-show="setupStep === 1">
<template x-for="req in (setupWizard.requirements || [])" :key="req.key">
<div class="dep-card" :class="req.satisfied ? 'dep-met' : 'dep-missing'">
<div class="dep-card" :class="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'dep-met' : 'dep-missing'">
<div class="dep-card-header">
<div class="dep-status-icon" :class="[req.satisfied ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="req.satisfied ? '\u2713' : '\u2717'"></div>
<div class="dep-status-icon" :class="[(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? '\u2713' : '\u2717'"></div>
<span class="dep-card-title" x-text="req.label"></span>
<template x-if="req.install && req.install.estimated_time">
<span class="dep-time-badge" x-text="req.install.estimated_time"></span>
@@ -2687,24 +2760,32 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</ol>
</template>
<!-- API Key: numbered steps + signup link -->
<!-- API Key: input field + numbered steps + signup link -->
<template x-if="req.type === 'ApiKey' && req.install">
<div>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
<div style="margin-bottom:10px">
<label class="text-xs text-dim" style="display:block;margin-bottom:4px" x-text="'Paste your ' + req.label + ':'"></label>
<input type="password" class="form-input" x-model="apiKeyInputs[req.key]" :placeholder="req.label" style="width:100%;font-family:var(--font-mono);font-size:12px">
<div class="text-xs" style="margin-top:4px;color:var(--green)" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">&check; Token entered</div>
</div>
<details style="margin-bottom:8px">
<summary class="text-xs text-dim" style="cursor:pointer;user-select:none">Or set as environment variable</summary>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
</div>
</div>
</div>
</template>
</template>
</details>
<div class="flex gap-2 mt-2">
<template x-if="req.install.signup_url">
<a :href="req.install.signup_url" target="_blank" rel="noopener" class="btn btn-primary btn-sm">Get API Key &rarr;</a>
@@ -2937,6 +3018,152 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- Trader Dashboard Modal -->
<template x-if="dashboardOpen">
<div class="modal-overlay" @click.self="closeDashboard()" @keydown.escape.window="closeDashboard()">
<div class="trader-dashboard">
<!-- Header -->
<div class="trader-dashboard-header">
<div class="flex items-center gap-2">
<span style="font-size:1.4rem">&#x1F4C8;</span>
<div>
<div style="font-weight:600;font-size:1.1rem" x-text="dashboardData ? (dashboardData.agent_name || 'Trading Hand') : 'Trading Hand'"></div>
<div class="text-xs text-dim">Live Trading Dashboard</div>
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" @click="refreshDashboard()">Refresh</button>
<button class="modal-close" @click="closeDashboard()">&times;</button>
</div>
</div>
<!-- Loading -->
<div x-show="dashboardLoading" class="text-center" style="padding:60px 0">
<div class="spinner"></div>
<div class="text-dim mt-2">Loading dashboard data...</div>
</div>
<!-- Dashboard Content -->
<div class="trader-dashboard-body" x-show="!dashboardLoading && dashboardData">
<!-- KPI Row -->
<div class="trader-kpi-row">
<div class="trader-kpi-card">
<div class="trader-kpi-label">Portfolio Value</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.portfolio_value || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Total P&amp;L</div>
<div class="trader-kpi-value" :class="dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('+') ? 'kpi-positive' : (dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('-') ? 'kpi-negative' : '')" x-text="dashboardData ? (dashboardData.total_pnl || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Win Rate</div>
<div class="trader-kpi-value" x-text="dashboardData && dashboardData.win_rate ? (dashboardData.win_rate + '%') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Sharpe Ratio</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.sharpe_ratio || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Max Drawdown</div>
<div class="trader-kpi-value kpi-negative" x-text="dashboardData && dashboardData.max_drawdown ? (dashboardData.max_drawdown + '%') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Trades</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.trades_count || '0') : '0'"></div>
</div>
</div>
<!-- Charts Row 1: Equity Curve + Daily P&L -->
<div class="trader-chart-row">
<div class="trader-chart-panel" style="flex:2">
<div class="trader-chart-title">Equity Curve</div>
<div class="trader-chart-wrap">
<canvas id="traderEquityChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.equity_curve || !dashboardData.equity_curve.length">No equity data yet</div>
</div>
</div>
<div class="trader-chart-panel" style="flex:1">
<div class="trader-chart-title">Daily P&amp;L</div>
<div class="trader-chart-wrap">
<canvas id="traderPnlChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.daily_pnl || !dashboardData.daily_pnl.length">No P&amp;L data yet</div>
</div>
</div>
</div>
<!-- Charts Row 2: Signal Radar + Watchlist Heatmap -->
<div class="trader-chart-row">
<div class="trader-chart-panel" style="flex:1;max-width:320px">
<div class="trader-chart-title">Signal Radar</div>
<div class="trader-chart-wrap" style="max-height:280px">
<canvas id="traderRadarChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.signal_radar">No signal data yet</div>
</div>
</div>
<div class="trader-chart-panel" style="flex:2">
<div class="trader-chart-title">Watchlist Heatmap</div>
<div class="trader-heatmap-wrap" x-show="dashboardData && dashboardData.watchlist_heatmap && dashboardData.watchlist_heatmap.length">
<table class="trader-heatmap-table">
<thead>
<tr><th>Ticker</th><th>Change</th><th>Signal</th><th>Confidence</th></tr>
</thead>
<tbody>
<template x-for="item in (dashboardData ? dashboardData.watchlist_heatmap || [] : [])" :key="item.ticker">
<tr>
<td style="font-weight:600" x-text="item.ticker"></td>
<td :class="item.change_pct >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(item.change_pct >= 0 ? '+' : '') + item.change_pct + '%'"></td>
<td><span class="signal-badge" :class="'signal-' + (item.signal || 'hold').toLowerCase()" x-text="item.signal || 'HOLD'"></span></td>
<td>
<div class="confidence-bar-wrap">
<div class="confidence-bar" :style="'width:' + (item.confidence || 0) + '%'" :class="item.confidence >= 70 ? 'conf-high' : (item.confidence >= 40 ? 'conf-mid' : 'conf-low')"></div>
<span class="confidence-label" x-text="(item.confidence || 0) + '%'"></span>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.watchlist_heatmap || !dashboardData.watchlist_heatmap.length">No watchlist data yet</div>
</div>
</div>
<!-- Recent Trades Table -->
<div class="trader-chart-panel">
<div class="trader-chart-title">Recent Trades</div>
<div x-show="dashboardData && dashboardData.recent_trades && dashboardData.recent_trades.length">
<table class="trader-trades-table">
<thead>
<tr><th>Date</th><th>Ticker</th><th>Side</th><th>Price</th><th>Qty</th><th>P&amp;L</th></tr>
</thead>
<tbody>
<template x-for="trade in (dashboardData ? dashboardData.recent_trades || [] : [])" :key="trade.date + trade.ticker">
<tr>
<td class="text-dim" x-text="trade.date"></td>
<td style="font-weight:600" x-text="trade.ticker"></td>
<td><span class="trade-side-badge" :class="trade.side === 'BUY' ? 'trade-buy' : 'trade-sell'" x-text="trade.side"></span></td>
<td x-text="'$' + Number(trade.price || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
<td x-text="trade.qty"></td>
<td :class="(trade.pnl || 0) >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(trade.pnl >= 0 ? '+$' : '-$') + Math.abs(trade.pnl || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.recent_trades || !dashboardData.recent_trades.length">No trades yet</div>
</div>
</div>
<!-- No data state -->
<div x-show="!dashboardLoading && !dashboardData" class="text-center" style="padding:60px 0">
<div style="font-size:2rem;margin-bottom:8px">&#x1F4C8;</div>
<div class="text-dim">Could not load dashboard data.</div>
<button class="btn btn-ghost btn-sm mt-3" @click="refreshDashboard()">Retry</button>
</div>
</div>
</div>
</template>
<!-- Activation result toast -->
<div x-show="activateResult" x-transition class="info-card" style="position:fixed;bottom:24px;right:24px;z-index:200;max-width:360px" @click="activateResult = null">
<div class="flex items-center gap-2">
@@ -4576,7 +4803,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div class="card" style="border-left:3px solid var(--accent)">
<div class="form-group" style="margin-bottom:8px">
<label>Agent Name</label>
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="createAgent()">
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) createAgent()">
</div>
<div class="text-xs text-dim" x-text="'Will use ' + templates[selectedTemplate].provider + ' / ' + templates[selectedTemplate].model + ' with ' + profileInfo(templates[selectedTemplate].profile).label + ' profile'"></div>
<div class="mt-2">
@@ -4622,7 +4849,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Input -->
<div style="display:flex;gap:8px;margin-top:12px">
<input class="form-input" type="text" x-model="tryItInput" placeholder="Type a message..."
@keydown.enter="sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
<button class="btn btn-primary btn-sm" @click="sendTryItMessage(tryItInput)" :disabled="tryItSending || !tryItInput.trim()">Send</button>
</div>
</div>
@@ -4808,3 +5035,5 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Toast notification container -->
<div id="toast-container" class="toast-container" aria-live="polite"></div>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
@@ -6,7 +6,12 @@
<title>OpenFang Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#6366f1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js"></script>
</head>
+101 -5
View File
@@ -24,14 +24,68 @@ function escapeHtml(text) {
function renderMarkdown(text) {
if (!text) return '';
if (typeof marked !== 'undefined') {
var html = marked.parse(text);
// Protect LaTeX blocks from marked.js mangling (underscores, backslashes, etc.)
var latexBlocks = [];
var protected_ = text;
// Protect display math $$...$$ first (greedy across lines)
protected_ = protected_.replace(/\$\$([\s\S]+?)\$\$/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect inline math $...$ (single line, not empty, not starting/ending with space)
protected_ = protected_.replace(/\$([^\s$](?:[^$]*[^\s$])?)\$/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect \[...\] display math
protected_ = protected_.replace(/\\\[([\s\S]+?)\\\]/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect \(...\) inline math
protected_ = protected_.replace(/\\\(([\s\S]+?)\\\)/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
var html = marked.parse(protected_);
// Restore LaTeX blocks
for (var i = 0; i < latexBlocks.length; i++) {
html = html.replace('\x00LATEX' + i + '\x00', latexBlocks[i]);
}
// Add copy buttons to code blocks
html = html.replace(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
// Open external links in new tab
html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"(?![^>]*target=)([^>]*)>/gi, '<a href="$1" target="_blank" rel="noopener"$2>');
return html;
}
return escapeHtml(text);
}
// Render LaTeX math in the chat message container using KaTeX auto-render.
// Call this after new messages are inserted into the DOM.
function renderLatex(el) {
if (typeof renderMathInElement !== 'function') return;
var target = el || document.getElementById('messages');
if (!target) return;
try {
renderMathInElement(target, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false }
],
throwOnError: false,
trust: false
});
} catch(e) { /* KaTeX render error — ignore gracefully */ }
}
function copyCode(btn) {
var code = btn.nextElementSibling;
if (code) {
@@ -104,6 +158,8 @@ document.addEventListener('alpine:init', function() {
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
showAuthPrompt: false,
authMode: 'apikey',
sessionUser: null,
toggleFocusMode() {
this.focusMode = !this.focusMode;
@@ -155,16 +211,33 @@ document.addEventListener('alpine:init', function() {
async checkAuth() {
try {
// Use a protected endpoint (not in the public allowlist) to detect
// whether the server requires an API key.
// First check if session-based auth is configured
var authInfo = await OpenFangAPI.get('/api/auth/check');
if (authInfo.mode === 'none') {
// No session auth — fall back to API key detection
this.authMode = 'apikey';
this.sessionUser = null;
} else if (authInfo.mode === 'session') {
this.authMode = 'session';
if (authInfo.authenticated) {
this.sessionUser = authInfo.username;
this.showAuthPrompt = false;
return;
}
// Session auth enabled but not authenticated — show login prompt
this.showAuthPrompt = true;
return;
}
} catch(e) { /* ignore — fall through to API key check */ }
// API key mode detection
try {
await OpenFangAPI.get('/api/tools');
this.showAuthPrompt = false;
} catch(e) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
// Only show prompt if we don't already have a saved key
var saved = localStorage.getItem('openfang-api-key');
if (saved) {
// Saved key might be stale — clear it and show prompt
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
@@ -181,6 +254,29 @@ document.addEventListener('alpine:init', function() {
this.refreshAgents();
},
async sessionLogin(username, password) {
try {
var result = await OpenFangAPI.post('/api/auth/login', { username: username, password: password });
if (result.status === 'ok') {
this.sessionUser = result.username;
this.showAuthPrompt = false;
this.refreshAgents();
} else {
OpenFangToast.error(result.error || 'Login failed');
}
} catch(e) {
OpenFangToast.error(e.message || 'Login failed');
}
},
async sessionLogout() {
try {
await OpenFangAPI.post('/api/auth/logout');
} catch(e) { /* ignore */ }
this.sessionUser = null;
this.showAuthPrompt = true;
},
clearApiKey() {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
+53 -6
View File
@@ -1,6 +1,21 @@
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
* broken up so the TOML parser never sees an unintended closing delimiter.
*/
function tomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("...").
* Backslashes, double-quotes, and common control chars are escaped.
*/
function tomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function agentsPage() {
return {
tab: 'agents',
@@ -62,6 +77,8 @@ function agentsPage() {
// -- Model switch --
editingModel: false,
newModelValue: '',
editingProvider: false,
newProviderValue: '',
modelSaving: false,
// -- Fallback chain --
editingFallback: false,
@@ -378,7 +395,7 @@ function agentsPage() {
},
// ── Multi-step wizard navigation ──
openSpawnWizard() {
async openSpawnWizard() {
this.showSpawnModal = true;
this.spawnStep = 1;
this.spawnMode = 'wizard';
@@ -386,8 +403,18 @@ function agentsPage() {
this.selectedPreset = '';
this.soulContent = '';
this.spawnForm.name = '';
this.spawnForm.provider = 'groq';
this.spawnForm.model = 'llama-3.3-70b-versatile';
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
this.spawnForm.profile = 'full';
try {
var res = await fetch('/api/status');
if (res.ok) {
var status = await res.json();
if (status.default_provider) this.spawnForm.provider = status.default_provider;
if (status.default_model) this.spawnForm.model = status.default_model;
}
} catch(e) { /* keep hardcoded defaults */ }
},
nextStep() {
@@ -411,7 +438,7 @@ function agentsPage() {
var f = this.spawnForm;
var si = this.spawnIdentity;
var lines = [
'name = "' + f.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"',
'name = "' + tomlBasicEscape(f.name) + '"',
'module = "builtin:chat"'
];
if (f.profile && f.profile !== 'custom') {
@@ -420,7 +447,7 @@ function agentsPage() {
lines.push('', '[model]');
lines.push('provider = "' + f.provider + '"');
lines.push('model = "' + f.model + '"');
lines.push('system_prompt = """\n' + f.systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"') + '\n"""');
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
if (f.profile === 'custom') {
lines.push('', '[capabilities]');
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
@@ -613,6 +640,26 @@ function agentsPage() {
this.modelSaving = false;
},
// ── Provider switch ──
async changeProvider() {
if (!this.detailAgent || !this.newProviderValue.trim()) return;
this.modelSaving = true;
try {
var combined = this.newProviderValue.trim() + '/' + this.detailAgent.model_name;
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: combined });
OpenFangToast.success('Provider changed to ' + (resp && resp.provider ? resp.provider : this.newProviderValue.trim()));
this.editingProvider = false;
await Alpine.store('app').refreshAgents();
var agents = Alpine.store('app').agents;
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
}
} catch(e) {
OpenFangToast.error('Failed to change provider: ' + e.message);
}
this.modelSaving = false;
},
// ── Fallback model chain ──
async addFallback() {
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
@@ -698,12 +745,12 @@ function agentsPage() {
},
async spawnBuiltin(t) {
var toml = 'name = "' + t.name + '"\n';
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
toml += 'module = "builtin:chat"\n';
toml += 'profile = "' + t.profile + '"\n\n';
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
try {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
+31 -10
View File
@@ -530,7 +530,7 @@ function chatPage() {
id: (t.name || 'tool') + '-hist-' + idx,
name: t.name || 'unknown',
running: false,
expanded: false,
expanded: true,
input: t.input || '',
result: t.result || '',
is_error: !!t.is_error
@@ -650,17 +650,32 @@ function chatPage() {
// Show tool/phase progress so the user sees the agent is working
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
var detail = data.detail || data.phase || 'Working...';
// Context warning: show prominently
// Skip phases that have no user-meaningful display text — "streaming"
// and "done" are lifecycle signals, not status to show in the chat bubble.
if (data.phase === 'streaming' || data.phase === 'done') {
break;
}
// Context warning: show prominently as a separate system message
if (data.phase === 'context_warning') {
this.messages.push({ id: ++msgId, role: 'system', text: detail, meta: '', tools: [] });
var cwDetail = data.detail || 'Context limit reached.';
this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] });
} else if (data.phase === 'thinking' && this.thinkingMode === 'stream') {
// Stream reasoning tokens to a collapsible panel
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
phaseMsg._reasoning += (detail || '') + '\n';
phaseMsg._reasoning += (data.detail || '') + '\n';
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
} else {
phaseMsg.text = detail;
} else if (phaseMsg.thinking) {
// Only update text on messages still in thinking state (not yet
// receiving streamed content) to avoid overwriting accumulated text.
var phaseDetail;
if (data.phase === 'tool_use') {
phaseDetail = 'Using ' + (data.detail || 'tool') + '...';
} else if (data.phase === 'thinking') {
phaseDetail = 'Thinking...';
} else {
phaseDetail = data.detail || 'Working...';
}
phaseMsg.text = phaseDetail;
}
}
this.scrollToBottom();
@@ -688,7 +703,7 @@ function chatPage() {
id: toolMatch[1] + '-txt-' + Date.now(),
name: toolMatch[1],
running: true,
expanded: false,
expanded: true,
input: inputMatch ? inputMatch[1].replace(/<\/function>?\s*$/, '').trim() : '',
result: '',
is_error: false
@@ -706,7 +721,7 @@ function chatPage() {
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
if (lastMsg && lastMsg.streaming) {
if (!lastMsg.tools) lastMsg.tools = [];
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: false, input: '', result: '', is_error: false });
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false });
}
this.scrollToBottom();
break;
@@ -1031,10 +1046,16 @@ function chatPage() {
});
},
_latexTimer: null,
scrollToBottom() {
var self = this;
var el = document.getElementById('messages');
if (el) self.$nextTick(function() { el.scrollTop = el.scrollHeight; });
if (el) self.$nextTick(function() {
el.scrollTop = el.scrollHeight;
// Debounce LaTeX rendering to avoid running on every streaming token
if (self._latexTimer) clearTimeout(self._latexTimer);
self._latexTimer = setTimeout(function() { renderLatex(el); }, 150);
});
},
addFiles(files) {
+453 -3
View File
@@ -18,6 +18,15 @@ function handsPage() {
browserViewerOpen: false,
_browserPollTimer: null,
// ── Trader Dashboard State ────────────────────────────────────────────
dashboardOpen: false,
dashboardLoading: false,
dashboardData: null,
_dashboardInst: null,
_chartEquity: null,
_chartPnl: null,
_chartRadar: null,
// ── Setup Wizard State ──────────────────────────────────────────────
setupWizard: null,
setupStep: 1,
@@ -27,6 +36,7 @@ function handsPage() {
_clipboardTimer: null,
detectedPlatform: 'linux',
installPlatforms: {},
apiKeyInputs: {},
async loadData() {
this.loading = true;
@@ -101,11 +111,15 @@ function handsPage() {
} else {
this._detectClientPlatform();
}
// Initialize per-requirement platform selections
// Initialize per-requirement platform selections and API key inputs
this.installPlatforms = {};
this.apiKeyInputs = {};
if (data.requirements) {
for (var j = 0; j < data.requirements.length; j++) {
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
if (data.requirements[j].type === 'ApiKey') {
this.apiKeyInputs[data.requirements[j].key] = '';
}
}
}
this.setupWizard = data;
@@ -274,7 +288,10 @@ function handsPage() {
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
var count = 0;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
if (this.setupWizard.requirements[i].satisfied) count++;
var req = this.setupWizard.requirements[i];
if (req.satisfied) { count++; continue; }
// Count API key reqs as met if user entered a value
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
}
return count;
},
@@ -285,7 +302,34 @@ function handsPage() {
},
get setupAllReqsMet() {
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
if (!this.setupWizard || !this.setupWizard.requirements) return false;
if (this.setupReqsTotal === 0) return false;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.satisfied) continue;
// API key reqs are satisfied if the user entered a value in the input
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
return false;
}
return true;
},
getSettingKeyForReq(req) {
// Find the matching setting key for an API key requirement.
// Convention: setting key is the lowercase version of the requirement key.
if (!this.setupWizard || !this.setupWizard.settings) return null;
var lowerKey = req.key.toLowerCase();
for (var i = 0; i < this.setupWizard.settings.length; i++) {
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
}
// Fallback: try matching by check_value lowercased
if (req.check_value) {
var lowerCheck = req.check_value.toLowerCase();
for (var j = 0; j < this.setupWizard.settings.length; j++) {
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
}
}
return null;
},
get setupHasReqs() {
@@ -297,6 +341,10 @@ function handsPage() {
},
setupNextStep() {
// When leaving step 1, sync API key inputs into settings values
if (this.setupStep === 1) {
this._syncApiKeysToSettings();
}
if (this.setupStep === 1 && this.setupHasSettings) {
this.setupStep = 2;
} else if (this.setupStep === 1) {
@@ -306,6 +354,19 @@ function handsPage() {
}
},
_syncApiKeysToSettings() {
if (!this.setupWizard || !this.setupWizard.requirements) return;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
},
setupPrevStep() {
if (this.setupStep === 3 && this.setupHasSettings) {
this.setupStep = 2;
@@ -323,11 +384,24 @@ function handsPage() {
this.setupChecking = false;
this.clipboardMsg = null;
this.installPlatforms = {};
this.apiKeyInputs = {};
},
async launchHand() {
if (!this.setupWizard) return;
var handId = this.setupWizard.id;
// Sync API key inputs from step 1 into settings values
if (this.setupWizard.requirements) {
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
}
var config = {};
for (var key in this.settingsValues) {
config[key] = this.settingsValues[key];
@@ -499,6 +573,382 @@ function handsPage() {
this.stopBrowserPolling();
this.browserViewerOpen = false;
this.browserViewer = null;
},
// ── Trader Dashboard ──────────────────────────────────────────────────
isTraderHand(inst) {
return inst.hand_id === 'trader';
},
async openDashboard(inst) {
this._dashboardInst = inst;
this.dashboardOpen = true;
this.dashboardLoading = true;
this.dashboardData = null;
await this._fetchDashboardData(inst);
this.dashboardLoading = false;
// Render charts after DOM update
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
async refreshDashboard() {
if (!this._dashboardInst) return;
this.dashboardLoading = true;
await this._fetchDashboardData(this._dashboardInst);
this.dashboardLoading = false;
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
closeDashboard() {
this.dashboardOpen = false;
this._destroyCharts();
this.dashboardData = null;
this._dashboardInst = null;
},
async _fetchDashboardData(inst) {
var data = {
agent_name: inst.agent_name || inst.hand_id,
portfolio_value: null,
total_pnl: null,
win_rate: null,
sharpe_ratio: null,
max_drawdown: null,
trades_count: null,
equity_curve: [],
daily_pnl: [],
watchlist_heatmap: [],
signal_radar: null,
recent_trades: []
};
// Fetch basic stats from the hand stats endpoint
try {
var stats = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
var m = stats.metrics || {};
if (m['Portfolio Value']) data.portfolio_value = this._metricVal(m['Portfolio Value']);
if (m['Total P&L']) data.total_pnl = this._metricVal(m['Total P&L']);
if (m['Win Rate']) data.win_rate = this._metricVal(m['Win Rate']);
if (m['Sharpe Ratio']) data.sharpe_ratio = this._metricVal(m['Sharpe Ratio']);
if (m['Max Drawdown']) data.max_drawdown = this._metricVal(m['Max Drawdown']);
if (m['Trades Executed']) data.trades_count = this._metricVal(m['Trades Executed']);
} catch(e) {
// Stats endpoint might fail — continue with KV data
}
// Fetch rich chart data from agent memory KV
var agentId = inst.agent_id || 'shared';
var kvKeys = [
'trader_hand_equity_curve',
'trader_hand_daily_pnl',
'trader_hand_watchlist_heatmap',
'trader_hand_signal_radar',
'trader_hand_recent_trades',
'trader_hand_portfolio_value',
'trader_hand_total_pnl',
'trader_hand_win_rate',
'trader_hand_sharpe_ratio',
'trader_hand_max_drawdown',
'trader_hand_trades_count'
];
for (var i = 0; i < kvKeys.length; i++) {
try {
var resp = await OpenFangAPI.get('/api/memory/agents/' + agentId + '/kv/' + kvKeys[i]);
if (resp && resp.value !== null && resp.value !== undefined) {
var val = resp.value;
this._applyKvToData(data, kvKeys[i], val);
}
} catch(e) {
// Key might not exist yet — that's fine
}
}
this.dashboardData = data;
},
_metricVal(metric) {
if (!metric) return null;
var v = metric.value;
if (v === null || v === undefined) return null;
// Values come as JSON values — could be string, number, etc.
if (typeof v === 'string') return v;
return String(v);
},
_applyKvToData(data, key, val) {
// Values from KV can be strings (JSON-encoded) or already parsed
var parsed = val;
if (typeof val === 'string') {
try { parsed = JSON.parse(val); } catch(e) { parsed = val; }
}
switch(key) {
case 'trader_hand_portfolio_value':
if (!data.portfolio_value) data.portfolio_value = String(parsed);
break;
case 'trader_hand_total_pnl':
if (!data.total_pnl) data.total_pnl = String(parsed);
break;
case 'trader_hand_win_rate':
if (!data.win_rate) data.win_rate = String(parsed);
break;
case 'trader_hand_sharpe_ratio':
if (!data.sharpe_ratio) data.sharpe_ratio = String(parsed);
break;
case 'trader_hand_max_drawdown':
if (!data.max_drawdown) data.max_drawdown = String(parsed);
break;
case 'trader_hand_trades_count':
if (!data.trades_count) data.trades_count = String(parsed);
break;
case 'trader_hand_equity_curve':
if (Array.isArray(parsed)) data.equity_curve = parsed;
break;
case 'trader_hand_daily_pnl':
if (Array.isArray(parsed)) data.daily_pnl = parsed;
break;
case 'trader_hand_watchlist_heatmap':
if (Array.isArray(parsed)) data.watchlist_heatmap = parsed;
break;
case 'trader_hand_signal_radar':
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data.signal_radar = parsed;
break;
case 'trader_hand_recent_trades':
if (Array.isArray(parsed)) data.recent_trades = parsed;
break;
}
},
_destroyCharts() {
if (this._chartEquity) { this._chartEquity.destroy(); this._chartEquity = null; }
if (this._chartPnl) { this._chartPnl.destroy(); this._chartPnl = null; }
if (this._chartRadar) { this._chartRadar.destroy(); this._chartRadar = null; }
},
_renderCharts() {
if (typeof Chart === 'undefined') return;
this._destroyCharts();
if (!this.dashboardData) return;
var d = this.dashboardData;
// Detect theme
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
var gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
var textColor = isDark ? '#8A8380' : '#6B6560';
var accentColor = '#FF5C00';
var successColor = isDark ? '#4ADE80' : '#22C55E';
var errorColor = '#EF4444';
// ── Equity Curve ──
if (d.equity_curve && d.equity_curve.length > 0) {
var eqCanvas = document.getElementById('traderEquityChart');
if (eqCanvas) {
var labels = [];
var values = [];
for (var i = 0; i < d.equity_curve.length; i++) {
labels.push(d.equity_curve[i].date || '');
values.push(parseFloat(d.equity_curve[i].value) || 0);
}
// Determine gradient
var eqCtx = eqCanvas.getContext('2d');
var gradient = eqCtx.createLinearGradient(0, 0, 0, eqCanvas.parentElement.clientHeight || 180);
gradient.addColorStop(0, isDark ? 'rgba(255, 92, 0, 0.25)' : 'rgba(255, 92, 0, 0.15)');
gradient.addColorStop(1, 'rgba(255, 92, 0, 0)');
this._chartEquity = new Chart(eqCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
data: values,
borderColor: accentColor,
backgroundColor: gradient,
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: d.equity_curve.length > 20 ? 0 : 3,
pointHoverRadius: 5,
pointBackgroundColor: accentColor
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
return '$' + ctx.parsed.y.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { color: gridColor },
ticks: { color: textColor, maxTicksLimit: 8, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) { return '$' + v.toLocaleString(); }
}
}
}
}
});
}
}
// ── Daily P&L Bar Chart ──
if (d.daily_pnl && d.daily_pnl.length > 0) {
var pnlCanvas = document.getElementById('traderPnlChart');
if (pnlCanvas) {
var pnlLabels = [];
var pnlValues = [];
var pnlColors = [];
for (var j = 0; j < d.daily_pnl.length; j++) {
pnlLabels.push(d.daily_pnl[j].date || '');
var pnlVal = parseFloat(d.daily_pnl[j].pnl) || 0;
pnlValues.push(pnlVal);
pnlColors.push(pnlVal >= 0 ? successColor : errorColor);
}
this._chartPnl = new Chart(pnlCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: pnlLabels,
datasets: [{
data: pnlValues,
backgroundColor: pnlColors,
borderRadius: 3,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
var v = ctx.parsed.y;
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: textColor, maxTicksLimit: 7, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) {
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString();
}
}
}
}
}
});
}
}
// ── Signal Radar Chart ──
if (d.signal_radar) {
var radarCanvas = document.getElementById('traderRadarChart');
if (radarCanvas) {
var radarLabels = [];
var radarValues = [];
var keys = ['technical', 'fundamental', 'sentiment', 'macro'];
var displayLabels = ['Technical', 'Fundamental', 'Sentiment', 'Macro'];
for (var k = 0; k < keys.length; k++) {
radarLabels.push(displayLabels[k]);
radarValues.push(parseFloat(d.signal_radar[keys[k]]) || 0);
}
this._chartRadar = new Chart(radarCanvas.getContext('2d'), {
type: 'radar',
data: {
labels: radarLabels,
datasets: [{
data: radarValues,
borderColor: accentColor,
backgroundColor: isDark ? 'rgba(255, 92, 0, 0.2)' : 'rgba(255, 92, 0, 0.12)',
borderWidth: 2,
pointBackgroundColor: accentColor,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) { return ctx.parsed.r + '/100'; }
}
}
},
scales: {
r: {
min: 0,
max: 100,
beginAtZero: true,
grid: { color: gridColor },
angleLines: { color: gridColor },
pointLabels: {
color: textColor,
font: { size: 11, weight: '600' }
},
ticks: {
color: textColor,
backdropColor: 'transparent',
stepSize: 25,
font: { size: 9 }
}
}
}
}
});
}
}
}
};
}
@@ -401,8 +401,12 @@ function settingsPage() {
var key = this.providerKeyInputs[provider.id];
if (!key || !key.trim()) { OpenFangToast.error('Please enter an API key'); return; }
try {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
OpenFangToast.success('API key saved for ' + provider.display_name);
var resp = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
if (resp && resp.switched_default) {
OpenFangToast.warning(resp.message || 'Default provider was switched to ' + provider.display_name);
} else {
OpenFangToast.success('API key saved for ' + provider.display_name);
}
this.providerKeyInputs[provider.id] = '';
await this.loadProviders();
await this.loadModels();
+13 -3
View File
@@ -1,6 +1,16 @@
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
function wizardTomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
function wizardTomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function wizardPage() {
return {
step: 1,
@@ -462,12 +472,12 @@ function wizardPage() {
}
var toml = '[agent]\n';
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
this.creatingAgent = true;
try {
@@ -13,6 +13,8 @@ function workflowsPage() {
loading: true,
loadError: '',
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
editModal: null,
editWf: { name: '', description: '', steps: [] },
// -- Workflows methods --
async loadWorkflows() {
@@ -74,6 +76,57 @@ function workflowsPage() {
} catch(e) {
OpenFangToast.error('Failed to load run history: ' + e.message);
}
},
async deleteWorkflow(wf) {
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
try {
await OpenFangAPI.delete('/api/workflows/' + wf.id);
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to delete workflow: ' + e.message);
}
},
async showEditModal(wf) {
try {
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
this.editWf = {
name: full.name || '',
description: full.description || '',
steps: (full.steps || []).map(function(s) {
return {
name: s.name || '',
agent_name: (s.agent && s.agent.name) || '',
mode: s.mode || 'sequential',
prompt: s.prompt_template || '{{input}}'
};
})
};
if (this.editWf.steps.length === 0) {
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
}
this.editModal = wf;
} catch(e) {
OpenFangToast.error('Failed to load workflow: ' + e.message);
}
},
async saveWorkflow() {
if (!this.editModal) return;
var steps = this.editWf.steps.map(function(s) {
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
});
try {
var wfName = this.editWf.name;
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
this.editModal = null;
OpenFangToast.success('Workflow "' + wfName + '" updated');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to update workflow: ' + e.message);
}
}
};
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "OpenFang Agent OS",
"short_name": "OpenFang",
"description": "Open-source Agent Operating System",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#6366f1",
"icons": [
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
]
}
+3
View File
@@ -0,0 +1,3 @@
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
File diff suppressed because one or more lines are too long
@@ -709,7 +709,18 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let api_key_state = state.kernel.config.api_key.clone();
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let app = Router::new()
.route("/api/health", axum::routing::get(routes::health))
@@ -753,7 +764,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
)
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.layer(axum::middleware::from_fn_with_state(
api_key_state,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn(middleware::request_logging))
+5
View File
@@ -24,9 +24,14 @@ zeroize = { workspace = true }
axum = { workspace = true }
hmac = { workspace = true }
sha2 = { workspace = true }
sha1 = { workspace = true }
aes = "0.8"
cbc = "0.1"
base64 = { workspace = true }
hex = { workspace = true }
html-escape = { workspace = true }
regex-lite = "0.1"
roxmltree = "0.20"
lettre = { workspace = true }
imap = { workspace = true }
+577 -67
View File
@@ -11,12 +11,12 @@ use crate::types::{
};
use async_trait::async_trait;
use dashmap::DashMap;
use openfang_types::message::ContentBlock;
use futures::StreamExt;
use openfang_types::agent::AgentId;
use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat};
use openfang_types::message::ContentBlock;
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use tokio::sync::watch;
use tracing::{debug, error, info, warn};
@@ -41,7 +41,7 @@ pub trait ChannelBridgeHandle: Send + Sync {
let text: String = blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
@@ -291,6 +291,11 @@ impl BridgeManager {
}
}
/// Return a reference to the underlying agent router.
pub fn router(&self) -> &Arc<AgentRouter> {
&self.router
}
/// Start an adapter: subscribe to its message stream and spawn a dispatch task.
///
/// Each incoming message is dispatched as a concurrent task so that slow LLM
@@ -342,6 +347,7 @@ impl BridgeManager {
&handle,
&router,
adapter.as_ref(),
&adapter,
&rate_limiter,
).await;
});
@@ -401,7 +407,11 @@ async fn send_response(
thread_id: Option<&str>,
output_format: OutputFormat,
) {
let formatted = formatter::format_for_channel(&text, output_format);
let formatted = if adapter.name() == "wecom" {
formatter::format_for_wecom(&text, output_format)
} else {
formatter::format_for_channel(&text, output_format)
};
let content = ChannelContent::Text(formatted);
let result = if let Some(tid) = thread_id {
@@ -415,6 +425,15 @@ async fn send_response(
}
}
fn default_output_format_for_channel(channel_type: &str) -> OutputFormat {
match channel_type {
"telegram" => OutputFormat::TelegramHtml,
"slack" => OutputFormat::SlackMrkdwn,
"wecom" => OutputFormat::PlainText,
_ => OutputFormat::Markdown,
}
}
/// Send a lifecycle reaction (best-effort, non-blocking for supported adapters).
///
/// Silently ignores errors — reactions are non-critical UX polish.
@@ -434,6 +453,76 @@ async fn send_lifecycle_reaction(
let _ = adapter.send_reaction(user, message_id, &reaction).await;
}
/// Spawn a background task that refreshes the typing indicator every 4 seconds.
///
/// Returns a `JoinHandle` that should be aborted once the LLM call completes.
/// Telegram (and similar platforms) expire typing indicators after ~5 seconds,
/// so refreshing at 4-second intervals keeps the indicator alive for the entire
/// duration of long LLM calls.
fn spawn_typing_loop(
adapter: Arc<dyn ChannelAdapter>,
sender: ChannelUser,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(4)).await;
let _ = adapter.send_typing(&sender).await;
}
})
}
/// Extract the sender's user identity from a message.
///
/// Some adapters (e.g. Slack) set `platform_id` to the channel/conversation ID
/// (needed for the send path) and store the actual user ID in metadata.
/// This helper returns the user ID for RBAC and rate limiting.
fn sender_user_id(message: &ChannelMessage) -> &str {
message
.metadata
.get("sender_user_id")
.and_then(|v| v.as_str())
.unwrap_or(&message.sender.platform_id)
}
/// If an error contains "Agent not found", try to re-resolve the channel's default agent
/// by name (the name stored at bridge startup). Returns `Some(new_id)` on success.
async fn try_reresolution(
err: &str,
channel_key: &str,
handle: &Arc<dyn ChannelBridgeHandle>,
router: &Arc<AgentRouter>,
) -> Option<AgentId> {
if !err.to_lowercase().contains("agent not found") {
return None;
}
let name = router.channel_default_name(channel_key)?;
info!(
channel = channel_key,
agent_name = %name,
"Agent not found — attempting re-resolution by name"
);
match handle.find_agent_by_name(&name).await {
Ok(Some(new_id)) => {
router.update_channel_default(channel_key, new_id);
info!(
channel = channel_key,
agent_name = %name,
new_id = %new_id,
"Re-resolved agent successfully"
);
Some(new_id)
}
_ => {
warn!(
channel = channel_key,
agent_name = %name,
"Re-resolution failed — agent not found by name"
);
None
}
}
}
/// Dispatch a single incoming message — handles bot commands or routes to an agent.
///
/// Applies per-channel policies (DM/group filtering, rate limiting, formatting, threading).
@@ -442,22 +531,23 @@ async fn dispatch_message(
handle: &Arc<dyn ChannelBridgeHandle>,
router: &Arc<AgentRouter>,
adapter: &dyn ChannelAdapter,
adapter_arc: &Arc<dyn ChannelAdapter>,
rate_limiter: &ChannelRateLimiter,
) {
let ct_str = channel_type_str(&message.channel);
// Fetch per-channel overrides (if configured)
let overrides = handle.channel_overrides(ct_str).await;
let channel_default_format = match ct_str {
"telegram" => OutputFormat::TelegramHtml,
"slack" => OutputFormat::SlackMrkdwn,
_ => OutputFormat::Markdown,
};
let channel_default_format = default_output_format_for_channel(ct_str);
let output_format = overrides
.as_ref()
.and_then(|o| o.output_format)
.unwrap_or(channel_default_format);
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
let lifecycle_reactions = overrides
.as_ref()
.map(|o| o.lifecycle_reactions)
.unwrap_or(true);
let thread_id = if threading_enabled {
message.thread_id.as_deref()
} else {
@@ -483,7 +573,9 @@ async fn dispatch_message(
}
GroupPolicy::MentionOnly => {
// Only allow messages where the bot was @mentioned or commands.
let was_mentioned = message.metadata.get("was_mentioned")
let was_mentioned = message
.metadata
.get("was_mentioned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_command = matches!(&message.content, ChannelContent::Command { .. });
@@ -513,7 +605,7 @@ async fn dispatch_message(
if let Some(ref ov) = overrides {
if ov.rate_limit_per_user > 0 {
if let Err(msg) =
rate_limiter.check(ct_str, &message.sender.platform_id, ov.rate_limit_per_user)
rate_limiter.check(ct_str, sender_user_id(message), ov.rate_limit_per_user)
{
send_response(adapter, &message.sender, msg, thread_id, output_format).await;
return;
@@ -529,9 +621,16 @@ async fn dispatch_message(
}
// For images: download, base64 encode, and send as multimodal content blocks
if let ChannelContent::Image { ref url, ref caption } = message.content {
if let ChannelContent::Image {
ref url,
ref caption,
} = message.content
{
let blocks = download_image_to_blocks(url, caption.as_deref()).await;
if blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })) {
if blocks
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. }))
{
// We have actual image data — send as structured blocks for vision
dispatch_with_blocks(
blocks,
@@ -539,9 +638,11 @@ async fn dispatch_message(
handle,
router,
adapter,
adapter_arc,
ct_str,
thread_id,
output_format,
lifecycle_reactions,
)
.await;
return;
@@ -552,22 +653,34 @@ async fn dispatch_message(
let text = match &message.content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Command { .. } => unreachable!(), // handled above
ChannelContent::Image { ref url, ref caption } => {
ChannelContent::Image {
ref url,
ref caption,
} => {
// Fallback when image download failed
match caption {
Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"),
None => format!("[User sent a photo: {url}]"),
}
}
ChannelContent::File { ref url, ref filename } => {
ChannelContent::File {
ref url,
ref filename,
} => {
format!("[User sent a file ({filename}): {url}]")
}
ChannelContent::Voice { ref url, duration_seconds } => {
ChannelContent::Voice {
ref url,
duration_seconds,
} => {
format!("[User sent a voice message ({duration_seconds}s): {url}]")
}
ChannelContent::Location { lat, lon } => {
format!("[User shared location: {lat}, {lon}]")
}
ChannelContent::FileData { ref filename, .. } => {
format!("[User sent a local file: {filename}]")
}
};
// Check if it's a slash command embedded in text (e.g. "/agents")
@@ -623,7 +736,7 @@ async fn dispatch_message(
if !targets.is_empty() {
// RBAC check applies to broadcast too
if let Err(denied) = handle
.authorize_channel_user(ct_str, &message.sender.platform_id, "chat")
.authorize_channel_user(ct_str, sender_user_id(message), "chat")
.await
{
send_response(
@@ -638,6 +751,8 @@ async fn dispatch_message(
}
let _ = adapter.send_typing(&message.sender).await;
let typing_task = spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
let strategy = router.broadcast_strategy();
let mut responses = Vec::new();
@@ -677,6 +792,8 @@ async fn dispatch_message(
}
}
typing_task.abort();
let combined = responses.join("\n\n");
send_response(adapter, &message.sender, combined, thread_id, output_format).await;
return;
@@ -725,7 +842,7 @@ async fn dispatch_message(
// RBAC: authorize the user before forwarding to agent
if let Err(denied) = handle
.authorize_channel_user(ct_str, &message.sender.platform_id, "chat")
.authorize_channel_user(ct_str, sender_user_id(message), "chat")
.await
{
send_response(
@@ -739,12 +856,22 @@ async fn dispatch_message(
return;
}
// Build channel key for re-resolution lookups
let channel_key = format!("{:?}", message.channel);
// Auto-reply check — if enabled, the engine decides whether to process this message.
// If auto-reply is enabled but suppressed for this message, skip agent call entirely.
if let Some(reply) = handle.check_auto_reply(agent_id, &text).await {
send_response(adapter, &message.sender, reply, thread_id, output_format).await;
handle
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
.record_delivery(
agent_id,
ct_str,
&message.sender.platform_id,
true,
None,
thread_id,
)
.await;
return;
}
@@ -754,30 +881,120 @@ async fn dispatch_message(
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
let msg_id = &message.platform_message_id;
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
}
// Continuous typing indicator — refreshes every 4s so platforms like Telegram
// (which expire typing after ~5s) keep showing it during long LLM calls.
let typing_task = spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
// Send to agent and relay response
match handle.send_message(agent_id, &text).await {
let result = handle.send_message(agent_id, &text).await;
// Stop the typing refresh now that we have a response
typing_task.abort();
match result {
Ok(response) => {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
}
send_response(adapter, &message.sender, response, thread_id, output_format).await;
handle
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
.record_delivery(
agent_id,
ct_str,
&message.sender.platform_id,
true,
None,
thread_id,
)
.await;
}
Err(e) => {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
// Try re-resolution before reporting error
if let Some(new_id) = try_reresolution(&e, &channel_key, handle, router).await {
let typing_task2 = spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
let retry = handle.send_message(new_id, &text).await;
typing_task2.abort();
match retry {
Ok(response) => {
if lifecycle_reactions {
send_lifecycle_reaction(
adapter,
&message.sender,
msg_id,
AgentPhase::Done,
)
.await;
}
send_response(adapter, &message.sender, response, thread_id, output_format)
.await;
handle
.record_delivery(
new_id,
ct_str,
&message.sender.platform_id,
true,
None,
thread_id,
)
.await;
}
Err(e2) => {
if lifecycle_reactions {
send_lifecycle_reaction(
adapter,
&message.sender,
msg_id,
AgentPhase::Error,
)
.await;
}
warn!("Agent error after re-resolution for {new_id}: {e2}");
let err_msg = sanitize_agent_error(&e2.to_string());
if !adapter.suppress_error_responses() {
send_response(
adapter,
&message.sender,
err_msg.clone(),
thread_id,
output_format,
)
.await;
}
handle
.record_delivery(
new_id,
ct_str,
&message.sender.platform_id,
false,
Some(&err_msg),
thread_id,
)
.await;
}
}
return;
}
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
}
warn!("Agent error for {agent_id}: {e}");
let err_msg = format!("Agent error: {e}");
send_response(
adapter,
&message.sender,
err_msg.clone(),
thread_id,
output_format,
)
.await;
let err_msg = sanitize_agent_error(&e.to_string());
if !adapter.suppress_error_responses() {
send_response(
adapter,
&message.sender,
err_msg.clone(),
thread_id,
output_format,
)
.await;
}
handle
.record_delivery(
agent_id,
@@ -792,6 +1009,112 @@ async fn dispatch_message(
}
}
fn sanitize_agent_error(raw: &str) -> String {
let lower = raw.to_lowercase();
if lower.contains("rate limit")
|| lower.contains("rate_limit")
|| lower.contains("429")
|| lower.contains("too many requests")
|| lower.contains("resource_exhausted")
{
return "Rate limit reached, please try again later.".to_string();
}
if lower.contains("authentication")
|| lower.contains("unauthorized")
|| lower.contains("invalid api key")
|| lower.contains("invalid x-goog-api-key")
|| lower.contains("incorrect api key")
|| lower.contains("permission denied")
|| lower.contains("billing")
|| lower.contains("quota exceeded")
{
return "Service temporarily unavailable.".to_string();
}
if lower.contains("context length")
|| lower.contains("token limit")
|| lower.contains("too many tokens")
|| lower.contains("maximum context")
|| lower.contains("max_tokens")
|| lower.contains("context window")
{
return "Message too long, try a shorter request.".to_string();
}
if lower.contains("overloaded")
|| lower.contains("503")
|| lower.contains("502")
|| lower.contains("server error")
|| lower.contains("internal error")
{
return "The AI service is temporarily overloaded, please try again shortly.".to_string();
}
if lower.contains("timeout") || lower.contains("timed out") || lower.contains("deadline") {
return "Request timed out, please try again.".to_string();
}
if lower.contains("model not found") || lower.contains("model_not_found") {
return "The requested model is currently unavailable.".to_string();
}
let cleaned = raw
.strip_prefix("LLM driver error: ")
.or_else(|| raw.strip_prefix("Agent error: "))
.unwrap_or(raw);
if let Some(first_sentence_end) = cleaned.find(". ") {
let first = &cleaned[..=first_sentence_end];
if first.len() < cleaned.len() / 2 {
return format!("Agent error: {first}");
}
}
if cleaned.contains('{') || cleaned.len() > 200 {
return "Something went wrong processing your request. Please try again.".to_string();
}
format!("Agent error: {cleaned}")
}
/// Detect image format from the first few magic bytes.
///
/// Returns `Some("image/...")` for JPEG, PNG, GIF, and WebP.
fn detect_image_magic(bytes: &[u8]) -> Option<String> {
if bytes.len() >= 3 && bytes[..3] == [0xFF, 0xD8, 0xFF] {
return Some("image/jpeg".to_string());
}
if bytes.len() >= 4 && bytes[..4] == [0x89, 0x50, 0x4E, 0x47] {
return Some("image/png".to_string());
}
if bytes.len() >= 4 && bytes[..4] == [0x47, 0x49, 0x46, 0x38] {
return Some("image/gif".to_string());
}
if bytes.len() >= 12
&& bytes[..4] == [0x52, 0x49, 0x46, 0x46]
&& bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
{
return Some("image/webp".to_string());
}
None
}
/// Guess image media type from the URL file extension.
fn media_type_from_url(url: &str) -> String {
if url.contains(".png") {
"image/png".to_string()
} else if url.contains(".gif") {
"image/gif".to_string()
} else if url.contains(".webp") {
"image/webp".to_string()
} else {
// JPEG is the most common image format — safe default
"image/jpeg".to_string()
}
}
/// Download an image from a URL and build content blocks for multimodal LLM input.
///
/// Returns a `Vec<ContentBlock>` containing an image block (base64-encoded) and
@@ -810,28 +1133,20 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<Conte
warn!("Failed to download image from channel: {e}");
return vec![ContentBlock::Text {
text: format!("[Image download failed: {e}]"),
provider_metadata: None,
}];
}
};
// Detect media type from Content-Type header, fall back to URL extension
let content_type = resp
// Detect media type from Content-Type header — but only trust it if it's
// actually an image/* type. Many APIs (Telegram, S3 pre-signed URLs) return
// `application/octet-stream` for all files, which breaks vision.
let header_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string());
let media_type = content_type.unwrap_or_else(|| {
if url.contains(".png") {
"image/png".to_string()
} else if url.contains(".gif") {
"image/gif".to_string()
} else if url.contains(".webp") {
"image/webp".to_string()
} else {
"image/jpeg".to_string()
}
});
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string())
.filter(|ct| ct.starts_with("image/"));
let bytes = match resp.bytes().await {
Ok(b) => b,
@@ -839,20 +1154,34 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<Conte
warn!("Failed to read image bytes: {e}");
return vec![ContentBlock::Text {
text: format!("[Image read failed: {e}]"),
provider_metadata: None,
}];
}
};
// Three-tier media type detection:
// 1. Trusted Content-Type header (only if image/*)
// 2. Magic byte sniffing (most reliable for binary data)
// 3. URL extension fallback
let media_type = header_type
.unwrap_or_else(|| detect_image_magic(&bytes).unwrap_or_else(|| media_type_from_url(url)));
if bytes.len() > MAX_IMAGE_BYTES {
warn!(
"Image too large ({} bytes), skipping vision — sending as text",
bytes.len()
);
let desc = match caption {
Some(c) => format!("[Image too large for vision ({} KB)]\nCaption: {c}", bytes.len() / 1024),
Some(c) => format!(
"[Image too large for vision ({} KB)]\nCaption: {c}",
bytes.len() / 1024
),
None => format!("[Image too large for vision ({} KB)]", bytes.len() / 1024),
};
return vec![ContentBlock::Text { text: desc }];
return vec![ContentBlock::Text {
text: desc,
provider_metadata: None,
}];
}
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
@@ -864,6 +1193,7 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<Conte
if !cap.is_empty() {
blocks.push(ContentBlock::Text {
text: cap.to_string(),
provider_metadata: None,
});
}
}
@@ -882,9 +1212,11 @@ async fn dispatch_with_blocks(
handle: &Arc<dyn ChannelBridgeHandle>,
router: &Arc<AgentRouter>,
adapter: &dyn ChannelAdapter,
adapter_arc: &Arc<dyn ChannelAdapter>,
ct_str: &str,
thread_id: Option<&str>,
output_format: OutputFormat,
lifecycle_reactions: bool,
) {
// Route to agent (same logic as text path)
let agent_id = router.resolve(
@@ -924,9 +1256,12 @@ async fn dispatch_with_blocks(
}
};
// Build channel key for re-resolution lookups
let channel_key = format!("{:?}", message.channel);
// RBAC check
if let Err(denied) = handle
.authorize_channel_user(ct_str, &message.sender.platform_id, "chat")
.authorize_channel_user(ct_str, sender_user_id(message), "chat")
.await
{
send_response(
@@ -944,29 +1279,119 @@ async fn dispatch_with_blocks(
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
let msg_id = &message.platform_message_id;
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
}
match handle.send_message_with_blocks(agent_id, blocks).await {
// Continuous typing indicator (see spawn_typing_loop doc)
let typing_task = spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
let result = handle
.send_message_with_blocks(agent_id, blocks.clone())
.await;
typing_task.abort();
match result {
Ok(response) => {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
}
send_response(adapter, &message.sender, response, thread_id, output_format).await;
handle
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
.record_delivery(
agent_id,
ct_str,
&message.sender.platform_id,
true,
None,
thread_id,
)
.await;
}
Err(e) => {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
// Try re-resolution before reporting error
if let Some(new_id) = try_reresolution(&e, &channel_key, handle, router).await {
let typing_task2 = spawn_typing_loop(adapter_arc.clone(), message.sender.clone());
let retry = handle.send_message_with_blocks(new_id, blocks).await;
typing_task2.abort();
match retry {
Ok(response) => {
if lifecycle_reactions {
send_lifecycle_reaction(
adapter,
&message.sender,
msg_id,
AgentPhase::Done,
)
.await;
}
send_response(adapter, &message.sender, response, thread_id, output_format)
.await;
handle
.record_delivery(
new_id,
ct_str,
&message.sender.platform_id,
true,
None,
thread_id,
)
.await;
}
Err(e2) => {
if lifecycle_reactions {
send_lifecycle_reaction(
adapter,
&message.sender,
msg_id,
AgentPhase::Error,
)
.await;
}
warn!("Agent error after re-resolution for {new_id}: {e2}");
let err_msg = sanitize_agent_error(&e2.to_string());
if !adapter.suppress_error_responses() {
send_response(
adapter,
&message.sender,
err_msg.clone(),
thread_id,
output_format,
)
.await;
}
handle
.record_delivery(
new_id,
ct_str,
&message.sender.platform_id,
false,
Some(&err_msg),
thread_id,
)
.await;
}
}
return;
}
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
}
warn!("Agent error for {agent_id}: {e}");
let err_msg = format!("Agent error: {e}");
send_response(
adapter,
&message.sender,
err_msg.clone(),
thread_id,
output_format,
)
.await;
let err_msg = sanitize_agent_error(&e.to_string());
if !adapter.suppress_error_responses() {
send_response(
adapter,
&message.sender,
err_msg.clone(),
thread_id,
output_format,
)
.await;
}
handle
.record_delivery(
agent_id,
@@ -1421,6 +1846,26 @@ mod tests {
);
}
#[test]
fn test_default_output_format_for_channel() {
assert_eq!(
default_output_format_for_channel("telegram"),
OutputFormat::TelegramHtml
);
assert_eq!(
default_output_format_for_channel("slack"),
OutputFormat::SlackMrkdwn
);
assert_eq!(
default_output_format_for_channel("wecom"),
OutputFormat::PlainText
);
assert_eq!(
default_output_format_for_channel("discord"),
OutputFormat::Markdown
);
}
#[tokio::test]
async fn test_send_message_with_blocks_default_fallback() {
// The default implementation of send_message_with_blocks extracts text
@@ -1433,6 +1878,7 @@ mod tests {
let blocks = vec![
ContentBlock::Text {
text: "What is in this photo?".to_string(),
provider_metadata: None,
},
ContentBlock::Image {
media_type: "image/jpeg".to_string(),
@@ -1468,4 +1914,68 @@ mod tests {
.unwrap();
assert_eq!(result, "Echo: ");
}
#[test]
fn test_detect_image_magic_jpeg() {
let bytes = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
assert_eq!(detect_image_magic(&bytes), Some("image/jpeg".to_string()));
}
#[test]
fn test_detect_image_magic_png() {
let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert_eq!(detect_image_magic(&bytes), Some("image/png".to_string()));
}
#[test]
fn test_detect_image_magic_gif() {
let bytes = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
assert_eq!(detect_image_magic(&bytes), Some("image/gif".to_string()));
}
#[test]
fn test_detect_image_magic_webp() {
let bytes = [
0x52, 0x49, 0x46, 0x46, // RIFF
0x00, 0x00, 0x00, 0x00, // size (don't care)
0x57, 0x45, 0x42, 0x50, // WEBP
];
assert_eq!(detect_image_magic(&bytes), Some("image/webp".to_string()));
}
#[test]
fn test_detect_image_magic_unknown() {
let bytes = [0x00, 0x01, 0x02, 0x03];
assert_eq!(detect_image_magic(&bytes), None);
}
#[test]
fn test_detect_image_magic_empty() {
assert_eq!(detect_image_magic(&[]), None);
}
#[test]
fn test_media_type_from_url() {
assert_eq!(
media_type_from_url("https://example.com/photo.png"),
"image/png"
);
assert_eq!(
media_type_from_url("https://example.com/anim.gif"),
"image/gif"
);
assert_eq!(
media_type_from_url("https://example.com/img.webp"),
"image/webp"
);
assert_eq!(
media_type_from_url("https://example.com/photo.jpg"),
"image/jpeg"
);
// No extension — defaults to JPEG
assert_eq!(
media_type_from_url("https://api.telegram.org/file/bot123/photos/file_42"),
"image/jpeg"
);
}
}
@@ -0,0 +1,600 @@
//! DingTalk Stream channel adapter.
//!
//! Uses DingTalk Stream Mode (WebSocket long-connection) instead of the
//! legacy webhook approach. The webhook adapter in `dingtalk.rs` is preserved
//! for backwards compatibility.
//!
//! Protocol:
//! 1. POST /v1.0/oauth2/accessToken → get access token
//! 2. POST /v1.0/gateway/connections/open → get WebSocket URL
//! 3. Connect via WebSocket, handle ping/pong and EVENT messages
//! 4. Outbound: POST /v1.0/robot/oToMessages/batchSend
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use chrono::Utc;
use futures::{SinkExt, Stream, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{error, info, warn};
const API_BASE: &str = "https://api.dingtalk.com";
const MAX_MESSAGE_LEN: usize = 20000;
// ─── Adapter ─────────────────────────────────────────────────────────────────
pub struct DingTalkStreamAdapter {
app_key: String,
app_secret: String,
robot_code: String,
client: reqwest::Client,
token_cache: Arc<Mutex<TokenCache>>,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
}
impl DingTalkStreamAdapter {
pub fn new(app_key: String, app_secret: String, robot_code: String) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_key,
app_secret,
robot_code,
client: reqwest::Client::new(),
token_cache: Arc::new(Mutex::new(TokenCache::default())),
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
}
}
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
{
let c = self.token_cache.lock().unwrap();
if !c.token.is_empty() && c.expire_at > now + 300 {
return Ok(c.token.clone());
}
}
let resp: serde_json::Value = self
.client
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
.json(&serde_json::json!({
"appKey": self.app_key,
"appSecret": self.app_secret,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = resp["accessToken"]
.as_str()
.ok_or("missing accessToken")?
.to_string();
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
{
let mut c = self.token_cache.lock().unwrap();
c.token = token.clone();
c.expire_at = now + expire_in;
}
Ok(token)
}
async fn send_to_ids(
&self,
user_ids: &[&str],
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let token = self
.get_token()
.await
.map_err(|e| -> Box<dyn std::error::Error> { e })?;
let (msg_key, _msg_param) = match &content {
ChannelContent::Text(t) => (
"sampleText",
serde_json::json!({ "content": t }).to_string(),
),
_ => (
"sampleText",
serde_json::json!({ "content": "(unsupported content type)" }).to_string(),
),
};
let text = match &content {
ChannelContent::Text(t) => t.as_str(),
_ => "(unsupported)",
};
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in &chunks {
let param = serde_json::json!({ "content": chunk }).to_string();
let body = serde_json::json!({
"robotCode": self.robot_code,
"userIds": user_ids,
"msgKey": msg_key,
"msgParam": param,
});
let resp = self
.client
.post(format!("{API_BASE}/v1.0/robot/oToMessages/batchSend"))
.header("x-acs-dingtalk-access-token", &token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err_body = resp.text().await.unwrap_or_default();
return Err(format!("DingTalk batchSend error {status}: {err_body}").into());
}
if chunks.len() > 1 {
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
Ok(())
}
}
#[async_trait]
impl ChannelAdapter for DingTalkStreamAdapter {
fn name(&self) -> &str {
"dingtalk_stream"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Custom("dingtalk_stream".to_string())
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let app_key = self.app_key.clone();
let app_secret = self.app_secret.clone();
let client = self.client.clone();
let token_cache = Arc::clone(&self.token_cache);
let mut shutdown_rx = self.shutdown_rx.clone();
info!("DingTalk Stream adapter starting WebSocket connection");
tokio::spawn(async move {
let mut attempt: u32 = 0;
loop {
if *shutdown_rx.borrow() {
info!("DingTalk Stream: shutdown requested");
break;
}
// 1. Get access token
let token =
match get_access_token(&client, &app_key, &app_secret, &token_cache).await {
Ok(t) => t,
Err(e) => {
warn!("DingTalk Stream: token fetch failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
// 2. Get WebSocket endpoint
let ws_url = match get_ws_endpoint(&client, &app_key, &app_secret, &token).await {
Ok(u) => u,
Err(e) => {
warn!("DingTalk Stream: endpoint fetch failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
info!(
"DingTalk Stream: connecting to {}...",
&ws_url[..ws_url.len().min(60)]
);
// 3. Connect
let ws_stream = match connect_async(&ws_url).await {
Ok((ws, _)) => ws,
Err(e) => {
warn!("DingTalk Stream: WS connect failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
info!("DingTalk Stream: connected");
attempt = 0;
let (mut sink, mut source) = ws_stream.split();
// 4. Message loop
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("DingTalk Stream: graceful shutdown");
return;
}
}
msg = source.next() => {
match msg {
None => { warn!("DingTalk Stream: connection closed"); break; }
Some(Err(e)) => { warn!("DingTalk Stream: WS error: {e}"); break; }
Some(Ok(Message::Text(text))) => {
handle_frame(&text, &mut sink, &tx).await;
}
Some(Ok(Message::Ping(d))) => { let _ = sink.send(Message::Pong(d)).await; }
Some(Ok(Message::Close(_))) => { info!("DingTalk Stream: close frame"); break; }
_ => {}
}
}
}
}
// Reconnect
attempt += 1;
let delay = backoff(attempt);
info!("DingTalk Stream: reconnecting in {delay:?}");
tokio::time::sleep(delay).await;
}
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let uid = &user.platform_id;
if uid.is_empty() {
return Err("DingTalk Stream: no platform_id to reply to".into());
}
self.send_to_ids(&[uid.as_str()], content).await
}
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
}
}
// ─── Token helpers ───────────────────────────────────────────────────────────
#[derive(Default)]
struct TokenCache {
token: String,
expire_at: u64,
}
async fn get_access_token(
http: &reqwest::Client,
app_key: &str,
app_secret: &str,
cache: &Arc<Mutex<TokenCache>>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
{
let c = cache.lock().unwrap();
if !c.token.is_empty() && c.expire_at > now + 300 {
return Ok(c.token.clone());
}
}
let resp: serde_json::Value = http
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
.json(&serde_json::json!({ "appKey": app_key, "appSecret": app_secret }))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = resp["accessToken"]
.as_str()
.ok_or("missing accessToken")?
.to_string();
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
{
let mut c = cache.lock().unwrap();
c.token = token.clone();
c.expire_at = now + expire_in;
}
Ok(token)
}
// ─── Gateway / WebSocket helpers ─────────────────────────────────────────────
#[derive(Serialize)]
struct OpenConnectionRequest<'a> {
#[serde(rename = "clientId")]
client_id: &'a str,
#[serde(rename = "clientSecret")]
client_secret: &'a str,
subscriptions: Vec<SubItem>,
ua: &'a str,
#[serde(rename = "localIp")]
local_ip: &'a str,
}
#[derive(Serialize)]
struct SubItem {
#[serde(rename = "type")]
sub_type: String,
topic: String,
}
#[derive(Deserialize)]
struct OpenConnectionResponse {
endpoint: String,
ticket: String,
}
async fn get_ws_endpoint(
http: &reqwest::Client,
app_key: &str,
app_secret: &str,
token: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let body = OpenConnectionRequest {
client_id: app_key,
client_secret: app_secret,
subscriptions: vec![SubItem {
sub_type: "CALLBACK".to_string(),
topic: "/v1.0/im/bot/messages/get".to_string(),
}],
ua: "openfang/0.3",
local_ip: "",
};
let resp: OpenConnectionResponse = http
.post(format!("{API_BASE}/v1.0/gateway/connections/open"))
.header("x-acs-dingtalk-access-token", token)
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
let sep = if resp.endpoint.contains('?') {
"&"
} else {
"?"
};
Ok(format!("{}{}ticket={}", resp.endpoint, sep, resp.ticket))
}
// ─── Frame handling ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ProtoFrame {
#[serde(rename = "type")]
msg_type: String,
headers: ProtoHeaders,
#[serde(default)]
data: serde_json::Value,
}
#[derive(Deserialize)]
struct ProtoHeaders {
#[serde(rename = "messageId", default)]
message_id: String,
#[serde(default)]
topic: String,
}
#[derive(Serialize)]
struct AckReply {
code: u32,
headers: AckHeaders,
message: String,
data: String,
}
#[derive(Serialize)]
struct AckHeaders {
#[serde(rename = "contentType")]
content_type: String,
#[serde(rename = "messageId")]
message_id: String,
topic: String,
}
fn make_ack(message_id: &str, topic: &str) -> String {
serde_json::to_string(&AckReply {
code: 200,
headers: AckHeaders {
content_type: "application/json".to_string(),
message_id: message_id.to_string(),
topic: topic.to_string(),
},
message: "OK".to_string(),
data: String::new(),
})
.unwrap_or_default()
}
#[derive(Deserialize)]
struct CallbackPayload {
#[serde(rename = "msgtype", default)]
msg_type: String,
#[serde(default)]
text: Option<TextContent>,
#[serde(rename = "senderStaffId", default)]
sender_staff_id: String,
#[serde(rename = "senderId", default)]
sender_id: String,
#[serde(rename = "senderNick", default)]
sender_nick: String,
#[serde(rename = "conversationId", default)]
conversation_id: String,
#[serde(rename = "conversationType", default)]
conversation_type: String,
#[serde(rename = "messageId", default)]
message_id: String,
}
#[derive(Deserialize)]
struct TextContent {
content: String,
}
async fn handle_frame<S>(text: &str, sink: &mut S, tx: &mpsc::Sender<ChannelMessage>)
where
S: SinkExt<Message> + Unpin,
<S as futures::Sink<Message>>::Error: std::fmt::Display,
{
let frame: ProtoFrame = match serde_json::from_str(text) {
Ok(f) => f,
Err(e) => {
warn!("DingTalk Stream: bad frame: {e}");
return;
}
};
let mid = &frame.headers.message_id;
let topic = &frame.headers.topic;
match frame.msg_type.as_str() {
"SYSTEM" if topic == "ping" => {
let _ = sink.send(Message::Text(make_ack(mid, "pong"))).await;
}
"CALLBACK" | "EVENT" => {
let data_str = frame.data.to_string();
// Try direct parse, then try unwrapping double-encoded string
let cb: Option<CallbackPayload> = serde_json::from_str(&data_str).ok().or_else(|| {
serde_json::from_str::<String>(&data_str)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
});
if let Some(cb) = cb {
if cb.msg_type == "text" {
if let Some(ref tc) = cb.text {
let trimmed = tc.content.trim().to_string();
if !trimmed.is_empty() {
let content = if trimmed.starts_with('/') {
let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
let cmd = parts[0].trim_start_matches('/');
let args: Vec<String> = parts
.get(1)
.map(|a| a.split_whitespace().map(String::from).collect())
.unwrap_or_default();
ChannelContent::Command {
name: cmd.to_string(),
args,
}
} else {
ChannelContent::Text(trimmed)
};
let mut meta = HashMap::new();
meta.insert(
"conversation_id".to_string(),
serde_json::Value::String(cb.conversation_id),
);
let uid = if cb.sender_staff_id.is_empty() {
cb.sender_id
} else {
cb.sender_staff_id
};
let msg = ChannelMessage {
channel: ChannelType::Custom("dingtalk_stream".to_string()),
platform_message_id: cb.message_id,
sender: ChannelUser {
platform_id: uid,
display_name: cb.sender_nick,
openfang_user: None,
},
content,
target_agent: None,
timestamp: Utc::now(),
is_group: cb.conversation_type == "2",
thread_id: None,
metadata: meta,
};
if tx.send(msg).await.is_err() {
error!("DingTalk Stream: channel receiver dropped");
}
}
}
}
}
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
}
_ => {
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
}
}
}
fn backoff(attempt: u32) -> Duration {
let ms = (1000u64 * 2u64.saturating_pow(attempt.min(6))).min(60_000);
Duration::from_millis(ms)
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adapter_creation() {
let a = DingTalkStreamAdapter::new("k".into(), "s".into(), "r".into());
assert_eq!(a.name(), "dingtalk_stream");
assert_eq!(
a.channel_type(),
ChannelType::Custom("dingtalk_stream".to_string())
);
}
#[test]
fn backoff_doubles() {
assert_eq!(backoff(0), Duration::from_millis(1000));
assert_eq!(backoff(1), Duration::from_millis(2000));
assert_eq!(backoff(2), Duration::from_millis(4000));
}
#[test]
fn backoff_capped() {
assert_eq!(backoff(10), Duration::from_millis(60_000));
assert_eq!(backoff(20), Duration::from_millis(60_000));
}
#[test]
fn make_ack_valid_json() {
let ack = make_ack("msg1", "topic1");
let v: serde_json::Value = serde_json::from_str(&ack).unwrap();
assert_eq!(v["code"], 200);
assert_eq!(v["headers"]["messageId"], "msg1");
}
}
+52 -16
View File
@@ -318,9 +318,14 @@ impl ChannelAdapter for DiscordAdapter {
}
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
if let Some(msg) =
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
.await
if let Some(msg) = parse_discord_message(
d,
&bot_user_id,
&allowed_guilds,
&allowed_users,
ignore_bots,
)
.await
{
debug!(
"Discord {event_name} from {}: {:?}",
@@ -517,8 +522,8 @@ async fn parse_discord_message(
.map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str())))
.unwrap_or(false);
// Also check content for <@bot_id> or <@!bot_id> patterns
let mentioned_in_content =
content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>"));
let mentioned_in_content = content_text.contains(&format!("<@{bid}>"))
|| content_text.contains(&format!("<@!{bid}>"));
mentioned_in_array || mentioned_in_content
} else {
false
@@ -566,7 +571,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert_eq!(msg.sender.display_name, "alice");
assert_eq!(msg.sender.platform_id, "ch1");
@@ -674,7 +681,8 @@ mod tests {
});
// Not in allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
let msg =
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
assert!(msg.is_none());
// In allowed guilds
@@ -697,7 +705,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -741,7 +751,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
}
@@ -763,7 +775,9 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert!(
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
@@ -786,7 +800,14 @@ mod tests {
});
// Not in allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await;
let msg = parse_discord_message(
&d,
&bot_id,
&[],
&["user111".into(), "user222".into()],
true,
)
.await;
assert!(msg.is_none());
// In allowed users
@@ -817,9 +838,14 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(msg.is_group);
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
Some(true)
);
// Message without mention in group
let d2 = serde_json::json!({
@@ -835,7 +861,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(msg2.is_group);
assert!(!msg2.metadata.contains_key("was_mentioned"));
}
@@ -855,13 +883,21 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(!msg.is_group);
}
#[test]
fn test_discord_adapter_creation() {
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], true, 37376);
let adapter = DiscordAdapter::new(
"test-token".to_string(),
vec!["123".to_string(), "456".to_string()],
vec![],
true,
37376,
);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
}
+4 -6
View File
@@ -139,8 +139,7 @@ impl EmailAdapter {
async fn build_smtp_transport(
&self,
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
let creds =
Credentials::new(self.username.clone(), self.password.as_str().to_string());
let creds = Credentials::new(self.username.clone(), self.password.as_str().to_string());
let transport = if self.smtp_port == 465 {
// Implicit TLS (port 465)
@@ -215,8 +214,8 @@ fn fetch_unseen_emails(
.build()
.map_err(|e| format!("TLS connector error: {e}"))?;
let client = imap::connect((host, port), host, &tls)
.map_err(|e| format!("IMAP connect failed: {e}"))?;
let client =
imap::connect((host, port), host, &tls).map_err(|e| format!("IMAP connect failed: {e}"))?;
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
// that reject LOGIN and only support AUTH=PLAIN (SASL).
@@ -390,8 +389,7 @@ impl ChannelAdapter for EmailAdapter {
}
// Extract target agent from subject brackets (stored in metadata for router)
let _target_agent =
EmailAdapter::extract_agent_from_subject(&subject);
let _target_agent = EmailAdapter::extract_agent_from_subject(&subject);
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
// Build the message body: prepend subject context
File diff suppressed because it is too large Load Diff
+466 -48
View File
@@ -17,21 +17,177 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String {
}
}
/// Format a message for WeCom, using a stronger plain-text conversion to avoid
/// leaking Markdown syntax into enterprise chat replies.
pub fn format_for_wecom(text: &str, format: OutputFormat) -> String {
match format {
OutputFormat::PlainText => markdown_to_wecom_plain(text),
_ => format_for_channel(text, format),
}
}
/// Convert Markdown to Telegram HTML subset.
///
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`.
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`, `<blockquote>`.
fn markdown_to_telegram_html(text: &str) -> String {
// Escape HTML special characters first so agent names and other text
// don't get interpreted as HTML tags by Telegram's parser.
let mut result = text
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let mut blocks = Vec::new();
let lines: Vec<&str> = normalized.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if trimmed.is_empty() {
i += 1;
continue;
}
// Fenced code block
if let Some(fence) = fence_delimiter(trimmed) {
i += 1;
let mut code_lines = Vec::new();
while i < lines.len() {
let candidate = lines[i].trim();
if candidate.starts_with(fence) {
i += 1;
break;
}
code_lines.push(lines[i]);
i += 1;
}
let code = escape_html(&code_lines.join("\n"));
blocks.push(format!("<pre><code>{}</code></pre>", code));
continue;
}
// ATX heading (#, ##, ...)
if let Some(content) = heading_text(trimmed) {
blocks.push(format!("<b>{}</b>", render_inline_markdown(content.trim())));
i += 1;
continue;
}
// Blockquote
if trimmed.starts_with('>') {
let mut quote_lines = Vec::new();
while i < lines.len() {
let current = lines[i].trim();
if current.is_empty() || !current.starts_with('>') {
break;
}
let content = current.strip_prefix('>').unwrap_or(current).trim_start();
quote_lines.push(render_inline_markdown(content));
i += 1;
}
blocks.push(format!(
"<blockquote>{}</blockquote>",
quote_lines.join("\n")
));
continue;
}
// Unordered list
if let Some(item) = unordered_list_item(trimmed) {
let mut items = vec![format!("{}", render_inline_markdown(item.trim()))];
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if let Some(next_item) = unordered_list_item(current) {
items.push(format!("{}", render_inline_markdown(next_item.trim())));
i += 1;
} else if current.is_empty() {
i += 1;
break;
} else {
break;
}
}
blocks.push(items.join("\n"));
continue;
}
// Ordered list
if let Some(item) = ordered_list_item(trimmed) {
let mut items = vec![format!("1. {}", render_inline_markdown(item.trim()))];
let mut counter = 2;
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if let Some(next_item) = ordered_list_item(current) {
items.push(format!(
"{}. {}",
counter,
render_inline_markdown(next_item.trim())
));
counter += 1;
i += 1;
} else if current.is_empty() {
i += 1;
break;
} else {
break;
}
}
blocks.push(items.join("\n"));
continue;
}
// Paragraph
let mut paragraph_lines = vec![trimmed];
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if current.is_empty()
|| fence_delimiter(current).is_some()
|| heading_text(current).is_some()
|| current.starts_with('>')
|| unordered_list_item(current).is_some()
|| ordered_list_item(current).is_some()
{
break;
}
paragraph_lines.push(current);
i += 1;
}
let joined = paragraph_lines.join("\n");
blocks.push(render_inline_markdown(&joined));
}
blocks.join("\n\n")
}
fn render_inline_markdown(text: &str) -> String {
let mut result = escape_html(text);
// Links: [text](url) → <a href="url">text</a>
while let Some(bracket_start) = result.find('[') {
if let Some(bracket_end_rel) = result[bracket_start..].find("](") {
let bracket_end = bracket_start + bracket_end_rel;
if let Some(paren_end_rel) = result[bracket_end + 2..].find(')') {
let paren_end = bracket_end + 2 + paren_end_rel;
let link_text = result[bracket_start + 1..bracket_end].to_string();
let url = result[bracket_end + 2..paren_end].to_string();
result = format!(
"{}<a href=\"{}\">{}</a>{}",
&result[..bracket_start],
url,
link_text,
&result[paren_end + 1..]
);
} else {
break;
}
} else {
break;
}
}
// Bold: **text** → <b>text</b>
while let Some(start) = result.find("**") {
if let Some(end) = result[start + 2..].find("**") {
let end = start + 2 + end;
if let Some(end_rel) = result[start + 2..].find("**") {
let end = start + 2 + end_rel;
let inner = result[start + 2..end].to_string();
result = format!("{}<b>{}</b>{}", &result[..start], inner, &result[end + 2..]);
} else {
@@ -39,8 +195,23 @@ fn markdown_to_telegram_html(text: &str) -> String {
}
}
// Italic: *text* → <i>text</i> (but not inside bold tags)
// Simple heuristic: match single * not preceded/followed by *
// Inline code: `text` → <code>text</code>
while let Some(start) = result.find('`') {
if let Some(end_rel) = result[start + 1..].find('`') {
let end = start + 1 + end_rel;
let inner = result[start + 1..end].to_string();
result = format!(
"{}<code>{}</code>{}",
&result[..start],
inner,
&result[end + 1..]
);
} else {
break;
}
}
// Italic: *text* → <i>text</i> (single star only)
let mut out = String::with_capacity(result.len());
let chars: Vec<char> = result.chars().collect();
let mut i = 0;
@@ -61,48 +232,57 @@ fn markdown_to_telegram_html(text: &str) -> String {
}
i += 1;
}
result = out;
// Inline code: `text` → <code>text</code>
while let Some(start) = result.find('`') {
if let Some(end) = result[start + 1..].find('`') {
let end = start + 1 + end;
let inner = result[start + 1..end].to_string();
result = format!(
"{}<code>{}</code>{}",
&result[..start],
inner,
&result[end + 1..]
);
} else {
break;
out
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn fence_delimiter(line: &str) -> Option<&'static str> {
if line.starts_with("```") {
Some("```")
} else if line.starts_with("~~~") {
Some("~~~")
} else {
None
}
}
fn heading_text(line: &str) -> Option<&str> {
let hashes = line.chars().take_while(|c| *c == '#').count();
if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
Some(&line[hashes + 1..])
} else {
None
}
}
fn unordered_list_item(line: &str) -> Option<&str> {
for prefix in ["- ", "* ", "+ "] {
if let Some(rest) = line.strip_prefix(prefix) {
return Some(rest);
}
}
None
}
// Links: [text](url) → <a href="url">text</a>
while let Some(bracket_start) = result.find('[') {
if let Some(bracket_end) = result[bracket_start..].find("](") {
let bracket_end = bracket_start + bracket_end;
if let Some(paren_end) = result[bracket_end + 2..].find(')') {
let paren_end = bracket_end + 2 + paren_end;
let link_text = &result[bracket_start + 1..bracket_end];
let url = &result[bracket_end + 2..paren_end];
result = format!(
"{}<a href=\"{}\">{}</a>{}",
&result[..bracket_start],
url,
link_text,
&result[paren_end + 1..]
);
} else {
break;
}
} else {
break;
}
fn ordered_list_item(line: &str) -> Option<&str> {
let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count();
if digit_count == 0 {
return None;
}
let rest = &line[digit_count..];
if let Some(item) = rest.strip_prefix(". ") {
Some(item)
} else if let Some(item) = rest.strip_prefix(") ") {
Some(item)
} else {
None
}
result
}
/// Convert Markdown to Slack mrkdwn format.
@@ -146,6 +326,192 @@ fn markdown_to_slack_mrkdwn(text: &str) -> String {
result
}
fn strip_atx_heading(line: &str) -> String {
let trimmed = line.trim_start();
let heading_level = trimmed.chars().take_while(|c| *c == '#').count();
if !(1..=6).contains(&heading_level) {
return line.to_string();
}
if trimmed.chars().nth(heading_level) != Some(' ') {
return line.to_string();
}
trimmed[heading_level..]
.trim()
.trim_end_matches('#')
.trim_end()
.to_string()
}
fn strip_blockquote_prefix(line: &str) -> String {
let mut trimmed = line.trim_start();
while let Some(rest) = trimmed.strip_prefix('>') {
trimmed = rest.trim_start();
}
trimmed.to_string()
}
fn strip_task_list_prefix(line: &str) -> String {
let trimmed = line.trim_start();
for prefix in [
"- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] ",
] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
return rest.to_string();
}
}
line.to_string()
}
fn is_fenced_code_marker(line: &str) -> bool {
let trimmed = line.trim();
let mut chars = trimmed.chars();
let Some(marker) = chars.next() else {
return false;
};
if marker != '`' && marker != '~' {
return false;
}
chars.all(|c| c == marker || c.is_ascii_alphanumeric())
}
fn is_setext_heading_underline(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.len() < 3 {
return false;
}
trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.contains(['=', '-'])
}
fn is_table_divider(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '|' | ':' | '-' | ' '))
}
fn strip_inline_markdown(mut text: String) -> String {
while let Some(start) = text.find("![") {
if let Some(mid) = text[start..].find("](") {
let mid = start + mid;
if let Some(end) = text[mid + 2..].find(')') {
let end = mid + 2 + end;
let alt = &text[start + 2..mid];
let url = &text[mid + 2..end];
let replacement = if alt.is_empty() {
url.to_string()
} else {
format!("{alt} ({url})")
};
text = format!("{}{}{}", &text[..start], replacement, &text[end + 1..]);
continue;
}
}
break;
}
while let Some(start) = text.find('[') {
if let Some(mid) = text[start..].find("](") {
let mid = start + mid;
if let Some(end) = text[mid + 2..].find(')') {
let end = mid + 2 + end;
let label = &text[start + 1..mid];
let url = &text[mid + 2..end];
text = format!("{}{} ({}){}", &text[..start], label, url, &text[end + 1..]);
continue;
}
}
break;
}
while let Some(start) = text.find('<') {
if let Some(end) = text[start + 1..].find('>') {
let end = start + 1 + end;
let inner = &text[start + 1..end];
if inner.starts_with("http://")
|| inner.starts_with("https://")
|| inner.starts_with("mailto:")
{
text = format!("{}{}{}", &text[..start], inner, &text[end + 1..]);
continue;
}
}
break;
}
text = text.replace("**", "");
text = text.replace("__", "");
text = text.replace("~~", "");
text = text.replace('`', "");
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if ch == '*'
&& (i == 0 || chars[i - 1] != '*')
&& (i + 1 >= chars.len() || chars[i + 1] != '*')
{
continue;
}
out.push(ch);
}
out
}
/// Strip common Markdown blocks for WeCom plain-text replies.
fn markdown_to_wecom_plain(text: &str) -> String {
let mut result_lines = Vec::new();
let mut in_fenced_code = false;
for raw_line in text.replace("\r\n", "\n").lines() {
let trimmed = raw_line.trim();
if is_fenced_code_marker(trimmed) {
in_fenced_code = !in_fenced_code;
continue;
}
if in_fenced_code {
result_lines.push(raw_line.trim_end().to_string());
continue;
}
if is_setext_heading_underline(trimmed) || is_table_divider(trimmed) {
continue;
}
let mut line = strip_atx_heading(raw_line);
line = strip_blockquote_prefix(&line);
line = strip_task_list_prefix(&line);
let trimmed_line = line.trim();
if trimmed_line.starts_with('|') && trimmed_line.ends_with('|') && trimmed_line.len() > 2 {
line = trimmed_line
.trim_matches('|')
.split('|')
.map(|cell| cell.trim())
.collect::<Vec<_>>()
.join(" ");
}
line = strip_inline_markdown(line);
result_lines.push(line.trim().to_string());
}
let mut collapsed = Vec::new();
for line in result_lines {
if line.is_empty()
&& collapsed
.last()
.is_some_and(|prev: &String| prev.is_empty())
{
continue;
}
collapsed.push(line);
}
collapsed.join("\n").trim().to_string()
}
/// Strip all Markdown formatting, producing plain text.
fn markdown_to_plain(text: &str) -> String {
let mut result = text.to_string();
@@ -231,6 +597,36 @@ mod tests {
assert_eq!(result, "<a href=\"https://example.com\">click here</a>");
}
#[test]
fn test_telegram_html_heading() {
let result = markdown_to_telegram_html("## Result");
assert_eq!(result, "<b>Result</b>");
}
#[test]
fn test_telegram_html_unordered_list() {
let result = markdown_to_telegram_html("- alpha\n- beta");
assert_eq!(result, "• alpha\n• beta");
}
#[test]
fn test_telegram_html_ordered_list() {
let result = markdown_to_telegram_html("1. alpha\n2. beta");
assert_eq!(result, "1. alpha\n2. beta");
}
#[test]
fn test_telegram_html_fenced_code_block() {
let result = markdown_to_telegram_html("```rust\nfn main() {}\n```");
assert_eq!(result, "<pre><code>fn main() {}</code></pre>");
}
#[test]
fn test_telegram_html_blockquote() {
let result = markdown_to_telegram_html("> note\n> second line");
assert_eq!(result, "<blockquote>note\nsecond line</blockquote>");
}
#[test]
fn test_slack_mrkdwn_bold() {
let result = markdown_to_slack_mrkdwn("Hello **world**!");
@@ -254,4 +650,26 @@ mod tests {
let result = markdown_to_plain("[click](https://example.com)");
assert_eq!(result, "click (https://example.com)");
}
#[test]
fn test_wecom_plain_text_strips_common_markdown_blocks() {
let result = markdown_to_wecom_plain(
"# Title\n\
\n\
> quoted text\n\
\n\
- [x] done item\n\
- [ ] todo item\n\
\n\
```rust\n\
let value = 1;\n\
```\n\
\n\
[docs](https://example.com)\n",
);
assert_eq!(
result,
"Title\n\nquoted text\n\ndone item\ntodo item\n\nlet value = 1;\n\ndocs (https://example.com)"
);
}
}
+2
View File
@@ -43,6 +43,7 @@ pub mod twist;
pub mod webex;
// Wave 5 — Niche & differentiating channels
pub mod dingtalk;
pub mod dingtalk_stream;
pub mod discourse;
pub mod gitter;
pub mod gotify;
@@ -50,3 +51,4 @@ pub mod linkedin;
pub mod mumble;
pub mod ntfy;
pub mod webhook;
pub mod wecom;
+14 -2
View File
@@ -456,10 +456,18 @@ impl ChannelAdapter for MastodonAdapter {
let notifications: Vec<serde_json::Value> =
poll_resp.json().await.unwrap_or_default();
for notif in &notifications {
if let Some(nid) = notif["id"].as_str() {
// Mastodon returns notifications newest-first. Record the first
// (highest) ID before processing so we never re-fetch these on
// the next poll. Updating inside the loop would leave us with
// the oldest ID, causing every previously seen notification to
// be re-delivered and re-processed.
if let Some(newest) = notifications.first() {
if let Some(nid) = newest["id"].as_str() {
last_notification_id = Some(nid.to_string());
}
}
for notif in &notifications {
if let Some(msg) = parse_mastodon_notification(notif, &own_account_id) {
if tx.send(msg).await.is_err() {
return;
@@ -518,6 +526,10 @@ impl ChannelAdapter for MastodonAdapter {
Ok(())
}
fn suppress_error_responses(&self) -> bool {
true
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
+136 -5
View File
@@ -12,7 +12,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
const SYNC_TIMEOUT_MS: u64 = 30000;
@@ -35,6 +35,8 @@ pub struct MatrixAdapter {
shutdown_rx: watch::Receiver<bool>,
/// Sync token for resuming /sync.
since_token: Arc<RwLock<Option<String>>>,
/// Whether to auto-accept room invites.
auto_accept_invites: bool,
}
impl MatrixAdapter {
@@ -55,6 +57,7 @@ impl MatrixAdapter {
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
since_token: Arc::new(RwLock::new(None)),
auto_accept_invites: true,
}
}
@@ -116,12 +119,84 @@ impl MatrixAdapter {
Ok(user_id)
}
#[allow(dead_code)]
#[cfg(test)]
fn is_allowed_room(&self, room_id: &str) -> bool {
self.allowed_rooms.is_empty() || self.allowed_rooms.iter().any(|r| r == room_id)
}
}
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
async fn accept_invite(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
room_id: &str,
) {
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/join");
match client
.post(&url)
.bearer_auth(access_token)
.json(&serde_json::json!({}))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
info!("Matrix: auto-accepted invite to {room_id}");
}
Ok(resp) => {
let status = resp.status();
warn!("Matrix: failed to accept invite to {room_id}: {status}");
}
Err(e) => {
warn!("Matrix: error accepting invite to {room_id}: {e}");
}
}
}
/// Get the number of joined members in a room.
async fn get_room_member_count(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
room_id: &str,
) -> Option<usize> {
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members");
let resp = client
.get(&url)
.bearer_auth(access_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body["joined"].as_object().map(|m| m.len())
}
/// Do an initial /sync with timeout=0 to get the since token without processing events.
/// This prevents replaying old messages when the adapter first connects.
async fn initial_sync(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
) -> Option<String> {
let url = format!(
"{homeserver}/_matrix/client/v3/sync?timeout=0&filter={{\"room\":{{\"timeline\":{{\"limit\":0}}}}}}"
);
let resp = client
.get(&url)
.bearer_auth(access_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body["next_batch"].as_str().map(String::from)
}
#[async_trait]
impl ChannelAdapter for MatrixAdapter {
fn name(&self) -> &str {
@@ -148,6 +223,15 @@ impl ChannelAdapter for MatrixAdapter {
let client = self.client.clone();
let since_token = Arc::clone(&self.since_token);
let mut shutdown_rx = self.shutdown_rx.clone();
let auto_accept = self.auto_accept_invites;
// FIX #4: Do an initial sync to get the since token, skipping old messages.
if since_token.read().await.is_none() {
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
info!("Matrix: initial sync complete, skipping old messages");
*since_token.write().await = Some(token);
}
}
tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
@@ -168,7 +252,7 @@ impl ChannelAdapter for MatrixAdapter {
info!("Matrix adapter shutting down");
break;
}
result = client.get(&url).bearer_auth(&*access_token).send() => {
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
match result {
Ok(r) => r,
Err(e) => {
@@ -203,6 +287,24 @@ impl ChannelAdapter for MatrixAdapter {
*since_token.write().await = Some(next.to_string());
}
// FIX #1: Auto-accept room invites.
if auto_accept {
if let Some(invites) = body["rooms"]["invite"].as_object() {
for (room_id, _invite_data) in invites {
if !allowed_rooms.is_empty()
&& !allowed_rooms.iter().any(|r| r == room_id)
{
debug!(
"Matrix: ignoring invite to {room_id} (not in allowed_rooms)"
);
continue;
}
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
.await;
}
}
}
// Process room events
if let Some(rooms) = body["rooms"]["join"].as_object() {
for (room_id, room_data) in rooms {
@@ -245,6 +347,35 @@ impl ChannelAdapter for MatrixAdapter {
let event_id = event["event_id"].as_str().unwrap_or("").to_string();
// FIX #2: Detect @mentions in message text.
let mut metadata = HashMap::new();
if content.contains(&user_id) {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
}
// FIX #3: Determine if room is a DM (2 members) or group.
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
room_id,
)
.await
.map(|count| count > 2)
.unwrap_or(true);
// For DMs, auto-set was_mentioned so dm_policy works.
if !is_group {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
metadata.insert("is_dm".to_string(), serde_json::json!(true));
}
let channel_msg = ChannelMessage {
channel: ChannelType::Matrix,
platform_message_id: event_id,
@@ -256,9 +387,9 @@ impl ChannelAdapter for MatrixAdapter {
content: msg_content,
target_agent: None,
timestamp: Utc::now(),
is_group: true,
is_group,
thread_id: None,
metadata: HashMap::new(),
metadata,
};
if tx.send(channel_msg).await.is_err() {
+4 -1
View File
@@ -165,7 +165,10 @@ impl ChannelAdapter for NostrAdapter {
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let pubkey = self.derive_pubkey();
info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16));
info!(
"Nostr adapter starting (pubkey: {}...)",
openfang_types::truncate_str(&pubkey, 16)
);
if self.relays.is_empty() {
return Err("Nostr: no relay URLs configured".into());
+28
View File
@@ -34,6 +34,8 @@ pub struct AgentRouter {
default_agent: Option<AgentId>,
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
channel_defaults: DashMap<String, AgentId>,
/// Per-channel-type default agent *name* (for re-resolution when UUID becomes stale).
channel_default_names: DashMap<String, String>,
/// Sorted bindings (most specific first). Uses Mutex for runtime updates via Arc.
bindings: Mutex<Vec<(AgentBinding, String)>>,
/// Broadcast configuration. Uses Mutex for runtime updates via Arc.
@@ -50,6 +52,7 @@ impl AgentRouter {
direct_routes: DashMap::new(),
default_agent: None,
channel_defaults: DashMap::new(),
channel_default_names: DashMap::new(),
bindings: Mutex::new(Vec::new()),
broadcast: Mutex::new(BroadcastConfig::default()),
agent_name_cache: DashMap::new(),
@@ -66,6 +69,31 @@ impl AgentRouter {
self.channel_defaults.insert(channel_key, agent_id);
}
/// Set a per-channel-type default agent AND remember the agent name for
/// re-resolution when the cached UUID becomes stale (e.g. after agent restart).
pub fn set_channel_default_with_name(
&self,
channel_key: String,
agent_id: AgentId,
agent_name: String,
) {
self.channel_defaults.insert(channel_key.clone(), agent_id);
self.channel_default_names.insert(channel_key, agent_name);
}
/// Retrieve the stored agent name for a channel default (if any).
pub fn channel_default_name(&self, channel_key: &str) -> Option<String> {
self.channel_default_names
.get(channel_key)
.map(|r| r.clone())
}
/// Update the cached agent ID for a channel default (after re-resolution).
pub fn update_channel_default(&self, channel_key: &str, new_agent_id: AgentId) {
self.channel_defaults
.insert(channel_key.to_string(), new_agent_id);
}
/// Set a user's default agent.
pub fn set_user_default(&self, user_key: String, agent_id: AgentId) {
self.user_defaults.insert(user_key, agent_id);
+189 -19
View File
@@ -7,11 +7,12 @@ use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use dashmap::DashMap;
use futures::{SinkExt, Stream, StreamExt};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
@@ -32,10 +33,25 @@ pub struct SlackAdapter {
shutdown_rx: watch::Receiver<bool>,
/// Bot's own user ID (populated after auth.test).
bot_user_id: Arc<RwLock<Option<String>>>,
/// Threads where the bot was @-mentioned. Maps thread_ts -> last interaction time.
active_threads: Arc<DashMap<String, Instant>>,
/// How long to track a thread after last interaction.
thread_ttl: Duration,
/// Whether auto-thread-reply is enabled.
auto_thread_reply: bool,
/// Whether to unfurl (expand previews for) links in posted messages.
unfurl_links: bool,
}
impl SlackAdapter {
pub fn new(app_token: String, bot_token: String, allowed_channels: Vec<String>) -> Self {
pub fn new(
app_token: String,
bot_token: String,
allowed_channels: Vec<String>,
auto_thread_reply: bool,
thread_ttl_hours: u64,
unfurl_links: bool,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_token: Zeroizing::new(app_token),
@@ -45,6 +61,10 @@ impl SlackAdapter {
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
bot_user_id: Arc::new(RwLock::new(None)),
active_threads: Arc::new(DashMap::new()),
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
auto_thread_reply,
unfurl_links,
}
}
@@ -76,14 +96,20 @@ impl SlackAdapter {
&self,
channel_id: &str,
text: &str,
thread_ts: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let chunks = split_message(text, SLACK_MSG_LIMIT);
for chunk in chunks {
let body = serde_json::json!({
let mut body = serde_json::json!({
"channel": channel_id,
"text": chunk,
"unfurl_links": self.unfurl_links,
"unfurl_media": self.unfurl_links,
});
if let Some(ts) = thread_ts {
body["thread_ts"] = serde_json::json!(ts);
}
let resp: serde_json::Value = self
.client
@@ -133,6 +159,30 @@ impl ChannelAdapter for SlackAdapter {
let allowed_channels = self.allowed_channels.clone();
let client = self.client.clone();
let mut shutdown = self.shutdown_rx.clone();
let active_threads = self.active_threads.clone();
let auto_thread_reply = self.auto_thread_reply;
// Spawn periodic cleanup of expired thread entries.
{
let active_threads = self.active_threads.clone();
let thread_ttl = self.thread_ttl;
let mut cleanup_shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
tokio::select! {
_ = interval.tick() => {
active_threads.retain(|_, last| last.elapsed() < thread_ttl);
}
_ = cleanup_shutdown.changed() => {
if *cleanup_shutdown.borrow() {
return;
}
}
}
}
});
}
tokio::spawn(async move {
let mut backoff = INITIAL_BACKOFF;
@@ -240,8 +290,14 @@ impl ChannelAdapter for SlackAdapter {
// Extract the event
let event = &payload["payload"]["event"];
if let Some(msg) =
parse_slack_event(event, &bot_user_id, &allowed_channels).await
if let Some(msg) = parse_slack_event(
event,
&bot_user_id,
&allowed_channels,
&active_threads,
auto_thread_reply,
)
.await
{
debug!(
"Slack message from {}: {:?}",
@@ -289,10 +345,30 @@ impl ChannelAdapter for SlackAdapter {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text).await?;
self.api_send_message(channel_id, &text, None).await?;
}
_ => {
self.api_send_message(channel_id, "(Unsupported content type)")
self.api_send_message(channel_id, "(Unsupported content type)", None)
.await?;
}
}
Ok(())
}
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text, Some(thread_id))
.await?;
}
_ => {
self.api_send_message(channel_id, "(Unsupported content type)", Some(thread_id))
.await?;
}
}
@@ -335,9 +411,11 @@ async fn parse_slack_event(
event: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_channels: &[String],
active_threads: &Arc<DashMap<String, Instant>>,
auto_thread_reply: bool,
) -> Option<ChannelMessage> {
let event_type = event["type"].as_str()?;
if event_type != "message" {
if event_type != "message" && event_type != "app_mention" {
return None;
}
@@ -413,6 +491,50 @@ async fn parse_slack_event(
ChannelContent::Text(text.to_string())
};
// Extract thread_id: threaded replies have `thread_ts`, top-level messages
// use their own `ts` so the reply will start a thread under the original.
let thread_id = msg_data["thread_ts"]
.as_str()
.or_else(|| event["thread_ts"].as_str())
.map(|s| s.to_string())
.or_else(|| Some(ts.to_string()));
// Check if the bot was @-mentioned (for group_policy = "mention_only")
let mut metadata = HashMap::new();
if event_type == "app_mention" {
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
}
// Determine the real thread_ts from the event (None for top-level messages).
let real_thread_ts = msg_data["thread_ts"]
.as_str()
.or_else(|| event["thread_ts"].as_str());
let mut explicitly_mentioned = false;
if let Some(ref bid) = *bot_user_id.read().await {
let mention_tag = format!("<@{bid}>");
if text.contains(&mention_tag) {
explicitly_mentioned = true;
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
// Track thread for auto-reply on subsequent messages.
if let Some(tts) = real_thread_ts {
active_threads.insert(tts.to_string(), Instant::now());
}
}
}
// Auto-reply to follow-up messages in tracked threads.
if !explicitly_mentioned && auto_thread_reply {
if let Some(tts) = real_thread_ts {
if let Some(mut entry) = active_threads.get_mut(tts) {
// Refresh TTL and mark as mentioned so dispatch proceeds.
*entry = Instant::now();
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
}
}
Some(ChannelMessage {
channel: ChannelType::Slack,
platform_message_id: ts.to_string(),
@@ -425,8 +547,8 @@ async fn parse_slack_event(
target_agent: None,
timestamp,
is_group: true,
thread_id: None,
metadata: HashMap::new(),
thread_id,
metadata,
})
}
@@ -445,7 +567,9 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Slack);
assert_eq!(msg.sender.platform_id, "C789");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!"));
@@ -463,7 +587,7 @@ mod tests {
"bot_id": "B999"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -478,7 +602,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -494,12 +618,25 @@ mod tests {
});
// Not in allowed channels
let msg =
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()]).await;
let msg = parse_slack_event(
&event,
&bot_id,
&["C111".to_string(), "C222".to_string()],
&Arc::new(DashMap::new()),
true,
)
.await;
assert!(msg.is_none());
// In allowed channels
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()]).await;
let msg = parse_slack_event(
&event,
&bot_id,
&["C789".to_string()],
&Arc::new(DashMap::new()),
true,
)
.await;
assert!(msg.is_some());
}
@@ -516,7 +653,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -531,7 +668,9 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
.await
.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -556,7 +695,9 @@ mod tests {
"ts": "1700000001.000200"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Slack);
assert_eq!(msg.sender.platform_id, "C789");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text"));
@@ -568,8 +709,37 @@ mod tests {
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec!["C123".to_string()],
true,
24,
true,
);
assert_eq!(adapter.name(), "slack");
assert_eq!(adapter.channel_type(), ChannelType::Slack);
}
#[test]
fn test_slack_adapter_unfurl_links_enabled() {
let adapter = SlackAdapter::new(
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec![],
true,
24,
true,
);
assert!(adapter.unfurl_links);
}
#[test]
fn test_slack_adapter_unfurl_links_disabled() {
let adapter = SlackAdapter::new(
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec![],
true,
24,
false,
);
assert!(!adapter.unfurl_links);
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -49,6 +49,13 @@ pub enum ChannelContent {
url: String,
filename: String,
},
/// Local file data (bytes read from disk). Used by the proactive `channel_send`
/// tool when `file_path` is provided instead of `file_url`.
FileData {
data: Vec<u8>,
filename: String,
mime_type: String,
},
Voice {
url: String,
duration_seconds: u32,
@@ -261,6 +268,15 @@ pub trait ChannelAdapter: Send + Sync {
) -> Result<(), Box<dyn std::error::Error>> {
self.send(user, content).await
}
/// Whether this adapter should suppress sending internal agent errors back to the user.
///
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
/// an error message would create a public status update. Errors are always
/// logged regardless of this setting.
fn suppress_error_responses(&self) -> bool {
false
}
}
/// Split a message into chunks of at most `max_len` characters,
+691
View File
@@ -0,0 +1,691 @@
//! WeCom (WeChat Work) channel adapter.
//!
//! Uses the WeCom Work API for sending messages and a webhook HTTP server for
//! receiving inbound events. Authentication is performed via an access token
//! obtained from `https://qyapi.weixin.qq.com/cgi-bin/gettoken`.
//! The token is cached and refreshed automatically.
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use axum::response::IntoResponse;
use chrono::Utc;
use futures::Stream;
use sha1::{Digest, Sha1};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, warn};
use zeroize::Zeroizing;
/// WeCom token endpoint.
const WECOM_TOKEN_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
/// WeCom send message endpoint.
const WECOM_SEND_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/message/send";
/// Maximum WeCom message text length (characters).
const MAX_MESSAGE_LEN: usize = 2048;
/// Token refresh buffer — refresh 5 minutes before actual expiry.
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
fn decrypt_aes_cbc(key: &[u8], encrypted_base64: &str) -> Result<Vec<u8>, String> {
use base64::Engine;
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
// Decode base64
let mut encrypted = base64::engine::general_purpose::STANDARD
.decode(encrypted_base64)
.map_err(|e| format!("base64 decode error: {}", e))?;
// IV is first 16 bytes of key
type Aes256CbcDecrypt = cbc::Decryptor<aes::Aes256>;
let iv = &key[..16];
let cipher = Aes256CbcDecrypt::new(key.into(), iv.into());
let decrypted = cipher
.decrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut encrypted)
.map_err(|e| format!("decrypt error: {}", e))?;
let decrypted = decrypted.to_vec();
let pad = decrypted
.last()
.copied()
.ok_or_else(|| "decrypted payload is empty".to_string())? as usize;
if pad == 0 || pad > 32 || decrypted.len() < pad {
return Err(format!("invalid WeCom PKCS7 padding length: {pad}"));
}
if !decrypted[decrypted.len() - pad..]
.iter()
.all(|byte| *byte as usize == pad)
{
return Err("invalid WeCom PKCS7 padding bytes".to_string());
}
Ok(decrypted[..decrypted.len() - pad].to_vec())
}
fn is_valid_wecom_signature(
token: &str,
timestamp: &str,
nonce: &str,
encrypted_payload: &str,
msg_signature: &str,
) -> bool {
let mut parts = [token, timestamp, nonce, encrypted_payload];
parts.sort_unstable();
let mut hasher = Sha1::new();
hasher.update(parts.concat().as_bytes());
hex::encode(hasher.finalize()) == msg_signature
}
fn decode_wecom_payload(encoding_aes_key: &str, encrypted_payload: &str) -> Result<String, String> {
use base64::{
alphabet,
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
Engine,
};
let aes_key_engine = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
.with_decode_allow_trailing_bits(true),
);
let aes_key = aes_key_engine
.decode(encoding_aes_key)
.map_err(|e| format!("aes key decode error: {e}"))?;
let decrypted = decrypt_aes_cbc(&aes_key, encrypted_payload)?;
if decrypted.len() < 20 {
return Err("decrypted payload too short".to_string());
}
let msg_len =
u32::from_be_bytes([decrypted[16], decrypted[17], decrypted[18], decrypted[19]]) as usize;
if decrypted.len() < 20 + msg_len {
return Err("decrypted payload shorter than declared echostr".to_string());
}
String::from_utf8(decrypted[20..20 + msg_len].to_vec())
.map_err(|e| format!("echostr is not valid utf-8: {e}"))
}
fn parse_wecom_xml_fields(xml: &str) -> Result<HashMap<String, String>, String> {
let doc = roxmltree::Document::parse(xml).map_err(|e| format!("invalid xml: {e}"))?;
let root = doc.root_element();
if root.tag_name().name() != "xml" {
return Err("root element is not <xml>".to_string());
}
let mut fields = HashMap::new();
for child in root.children().filter(|node| node.is_element()) {
let value = child
.children()
.filter_map(|node| node.text())
.collect::<String>()
.trim()
.to_string();
fields.insert(child.tag_name().name().to_string(), value);
}
Ok(fields)
}
fn decode_wecom_post_body(
body: &str,
params: &HashMap<String, String>,
token: Option<&str>,
encoding_aes_key: Option<&str>,
) -> Result<HashMap<String, String>, String> {
let parsed = parse_wecom_xml_fields(body)?;
let Some(encrypted_payload) = parsed.get("Encrypt") else {
return Ok(parsed);
};
let token = token.ok_or_else(|| "missing WeCom callback token".to_string())?;
let timestamp = params
.get("timestamp")
.ok_or_else(|| "missing timestamp".to_string())?;
let nonce = params
.get("nonce")
.ok_or_else(|| "missing nonce".to_string())?;
let msg_signature = params
.get("msg_signature")
.ok_or_else(|| "missing msg_signature".to_string())?;
if !is_valid_wecom_signature(token, timestamp, nonce, encrypted_payload, msg_signature) {
return Err("invalid WeCom callback signature".to_string());
}
let aes_key = encoding_aes_key
.filter(|key| !key.is_empty())
.ok_or_else(|| "missing WeCom encoding_aes_key".to_string())?;
let decrypted_xml = decode_wecom_payload(aes_key, encrypted_payload)?;
parse_wecom_xml_fields(&decrypted_xml)
}
fn wecom_success_response() -> axum::response::Response {
(
axum::http::StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
"success",
)
.into_response()
}
/// WeCom adapter.
pub struct WeComAdapter {
/// WeCom corp ID.
corp_id: String,
/// WeCom application agent ID.
agent_id: String,
/// WeCom application secret, zeroized on drop.
secret: Zeroizing<String>,
/// Encoding AES key for callback verification (optional).
encoding_aes_key: Option<String>,
/// Token for callback verification (optional).
token: Option<String>,
/// Port on which the inbound webhook HTTP server listens.
webhook_port: u16,
/// HTTP client for API calls.
client: reqwest::Client,
/// Shutdown signal.
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Cached access token and its expiry instant.
cached_token: Arc<RwLock<Option<(String, Instant)>>>,
}
impl WeComAdapter {
/// Create a new WeCom adapter.
pub fn new(corp_id: String, agent_id: String, secret: String, webhook_port: u16) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
corp_id,
agent_id,
secret: Zeroizing::new(secret),
encoding_aes_key: None,
token: None,
webhook_port,
client: reqwest::Client::new(),
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
cached_token: Arc::new(RwLock::new(None)),
}
}
/// Create a new WeCom adapter with callback verification.
pub fn with_verification(
corp_id: String,
agent_id: String,
secret: String,
webhook_port: u16,
encoding_aes_key: Option<String>,
token: Option<String>,
) -> Self {
let mut adapter = Self::new(corp_id, agent_id, secret, webhook_port);
adapter.encoding_aes_key = encoding_aes_key;
adapter.token = token;
adapter
}
/// Obtain a valid access token, refreshing if expired or missing.
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error>> {
let mut cached = self.cached_token.write().await;
// Check if we have a valid cached token
if let Some((token, expiry)) = cached.as_ref() {
let now = Instant::now();
let buffer = Duration::from_secs(TOKEN_REFRESH_BUFFER_SECS);
if now + buffer < *expiry {
return Ok(token.clone());
}
}
// Fetch new token
let url = format!(
"{}?corpid={}&corpsecret={}",
WECOM_TOKEN_URL,
self.corp_id,
self.secret.as_str()
);
let response = self.client.get(&url).send().await?;
let json: serde_json::Value = response.json().await?;
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(format!(
"WeCom API error: {} - {}",
errcode,
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
)
.into());
}
}
let token = json["access_token"]
.as_str()
.ok_or("Missing access_token in response")?
.to_string();
let expires_in = json["expires_in"].as_i64().unwrap_or(7200) as u64;
let expiry = Instant::now() + Duration::from_secs(expires_in);
*cached = Some((token.clone(), expiry));
info!("WeCom access token refreshed, expires in {}s", expires_in);
Ok(token)
}
/// Send a text message to a user.
async fn send_text(
&self,
user_id: &str,
content: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let token = self.get_token().await?;
let url = format!("{}?access_token={}", WECOM_SEND_URL, token);
let payload = serde_json::json!({
"touser": user_id,
"msgtype": "text",
"agentid": self.agent_id,
"text": {
"content": content
}
});
let response = self.client.post(&url).json(&payload).send().await?;
let json: serde_json::Value = response.json().await?;
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(format!(
"WeCom send error: {} - {}",
errcode,
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
)
.into());
}
}
Ok(())
}
/// Validate credentials by getting the token.
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
let _token = self.get_token().await?;
// Token obtained successfully means credentials are valid
Ok(format!("corp_id={}", self.corp_id))
}
}
#[async_trait]
impl ChannelAdapter for WeComAdapter {
fn name(&self) -> &str {
"wecom"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Custom("wecom".to_string())
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
// Validate credentials
let _ = self.validate().await?;
info!("WeCom adapter initialized");
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let port = self.webhook_port;
let token = self.token.clone();
let encoding_aes_key = self.encoding_aes_key.clone();
let mut shutdown_rx = self.shutdown_rx.clone();
tokio::spawn(async move {
let token = Arc::new(token);
let encoding_aes_key = Arc::new(encoding_aes_key);
let tx = Arc::new(tx);
let app = axum::Router::new().route(
"/wecom/webhook",
axum::routing::get({
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let token = Arc::clone(&token);
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| {
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let token = Arc::clone(&token);
async move {
// Handle callback verification (URL validation GET request)
// WeChat Work sends GET with msg_signature, timestamp, nonce, echostr
if let (Some(echostr_encoded), Some(msg_sig), Some(timestamp), Some(nonce)) = (
params.get("echostr"),
params.get("msg_signature"),
params.get("timestamp"),
params.get("nonce"),
) {
let Some(token_str) = token.as_deref() else {
return (
axum::http::StatusCode::BAD_REQUEST,
"missing WeCom callback token",
)
.into_response();
};
if !is_valid_wecom_signature(
token_str,
timestamp,
nonce,
echostr_encoded,
msg_sig,
) {
return (
axum::http::StatusCode::FORBIDDEN,
"invalid WeCom callback signature",
)
.into_response();
}
let body = match encoding_aes_key.as_deref() {
Some(aes_key) if !aes_key.is_empty() => {
match decode_wecom_payload(aes_key, echostr_encoded) {
Ok(echostr_plain) => echostr_plain,
Err(err) => {
warn!(error = %err, "Failed to decrypt WeCom echostr");
return (
axum::http::StatusCode::BAD_REQUEST,
"invalid WeCom echostr",
)
.into_response();
}
}
}
_ => echostr_encoded.clone(),
};
return (
axum::http::StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
body,
)
.into_response();
}
(
axum::http::StatusCode::BAD_REQUEST,
"missing WeCom verification parameters",
)
.into_response()
}
}
}).post({
let token = Arc::clone(&token);
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let tx = Arc::clone(&tx);
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>, body: String| {
let token = Arc::clone(&token);
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let tx = Arc::clone(&tx);
async move {
let fields = match decode_wecom_post_body(
&body,
&params,
token.as_deref(),
encoding_aes_key.as_deref(),
) {
Ok(fields) => fields,
Err(err) => {
warn!(error = %err, "Failed to parse WeCom callback body");
return (
axum::http::StatusCode::BAD_REQUEST,
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
"invalid WeCom callback body",
)
.into_response();
}
};
let msg_type = fields.get("MsgType").map(String::as_str).unwrap_or("");
let user_id = fields
.get("FromUserName")
.cloned()
.unwrap_or_default();
let event = fields.get("Event").map(String::as_str).unwrap_or("");
info!(
msg_type = msg_type,
event = event,
from_user = %user_id,
"Received WeCom callback"
);
if msg_type == "event" {
if (event == "subscribe" || event == "enter_agent")
&& !user_id.is_empty()
{
let msg = ChannelMessage {
channel: ChannelType::Custom("wecom".to_string()),
platform_message_id: String::new(),
sender: ChannelUser {
platform_id: user_id.clone(),
display_name: user_id.clone(),
openfang_user: None,
},
content: ChannelContent::Text(String::new()),
target_agent: None,
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: HashMap::new(),
};
let _ = tx.send(msg).await;
}
return wecom_success_response();
}
if msg_type == "text" {
let content = fields.get("Content").cloned().unwrap_or_default();
let msg_id = fields.get("MsgId").cloned().unwrap_or_default();
if !user_id.is_empty() && !content.is_empty() {
let msg = ChannelMessage {
channel: ChannelType::Custom("wecom".to_string()),
platform_message_id: msg_id,
sender: ChannelUser {
platform_id: user_id.clone(),
display_name: user_id.clone(),
openfang_user: None,
},
content: ChannelContent::Text(content),
target_agent: None,
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: HashMap::new(),
};
let _ = tx.send(msg).await;
}
}
wecom_success_response()
}
}
}),
);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
info!("WeCom webhook server listening on http://0.0.0.0:{}", port);
let server = axum::serve(listener, app);
tokio::select! {
result = server => {
if let Err(e) = result {
warn!("WeCom webhook server error: {}", e);
}
}
_ = shutdown_rx.changed() => {
info!("WeCom adapter shutting down");
}
}
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let user_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
// Split long messages
for chunk in split_message(&text, MAX_MESSAGE_LEN) {
self.send_text(user_id, chunk).await?;
}
}
ChannelContent::Command { name: _, args: _ } => {
// WeCom doesn't support commands natively
warn!("WeCom: commands not supported");
}
_ => {
warn!("WeCom: unsupported content type");
}
}
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_adapter_name() {
let adapter = WeComAdapter::new(
"corp_id".to_string(),
"agent_id".to_string(),
"secret".to_string(),
8080,
);
assert_eq!(adapter.name(), "wecom");
}
#[test]
fn test_adapter_channel_type() {
let adapter = WeComAdapter::new(
"corp_id".to_string(),
"agent_id".to_string(),
"secret".to_string(),
8080,
);
assert_eq!(
adapter.channel_type(),
ChannelType::Custom("wecom".to_string())
);
}
#[test]
fn test_adapter_with_verification() {
let adapter = WeComAdapter::with_verification(
"corp_id".to_string(),
"agent_id".to_string(),
"secret".to_string(),
8080,
Some("encoding_aes_key".to_string()),
Some("token".to_string()),
);
assert_eq!(adapter.name(), "wecom");
}
#[test]
fn test_max_message_length() {
// MAX_MESSAGE_LEN should be 2048 for WeCom
assert_eq!(MAX_MESSAGE_LEN, 2048);
}
#[test]
fn test_token_refresh_buffer() {
// Token refresh buffer should be 5 minutes
assert_eq!(TOKEN_REFRESH_BUFFER_SECS, 300);
}
#[test]
fn test_wecom_signature_validation() {
assert!(is_valid_wecom_signature(
"token",
"1710000000",
"nonce",
"echostr",
"bf56bf867459f80e3ceb854596f39f02a5ac5e13",
));
assert!(!is_valid_wecom_signature(
"token",
"1710000000",
"nonce",
"echostr",
"bad-signature",
));
}
#[test]
fn test_decode_wecom_payload() {
let plain = decode_wecom_payload(
"ShlNaJ0PrdXQAuCDVqMki7c2JLNnY6mebvQodTv9qoV",
"/gKbXNFpvlyYNTCneTag1rGm1P4Q5fExE3OPzdYlEyUVDgi55PHVIbo+mHMXWatdW8H8RTQJCly0HBNrWry2Uw==",
)
.expect("echostr should decrypt");
assert_eq!(plain, "openfang-wecom-check");
}
#[test]
fn test_parse_wecom_xml_fields() {
let fields = parse_wecom_xml_fields(
r#"<xml>
<ToUserName><![CDATA[wwcorp]]></ToUserName>
<FromUserName><![CDATA[user123]]></FromUserName>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[hello]]></Content>
<MsgId>123456</MsgId>
</xml>"#,
)
.expect("xml should parse");
assert_eq!(
fields.get("FromUserName").map(String::as_str),
Some("user123")
);
assert_eq!(fields.get("MsgType").map(String::as_str), Some("text"));
assert_eq!(fields.get("Content").map(String::as_str), Some("hello"));
assert_eq!(fields.get("MsgId").map(String::as_str), Some("123456"));
}
}
+3 -5
View File
@@ -222,11 +222,9 @@ impl ChannelAdapter for WhatsAppAdapter {
if let Some(ref gw) = self.gateway_url {
let text = match &content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Image { caption, .. } => {
caption
.clone()
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string())
}
ChannelContent::Image { caption, .. } => caption
.clone()
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()),
ChannelContent::File { filename, .. } => {
format!("(File: {filename} — not supported in Web mode)")
}
+104 -26
View File
@@ -7,34 +7,112 @@
/// Returns all bundled agent templates as `(name, toml_content)` pairs.
pub fn bundled_agents() -> Vec<(&'static str, &'static str)> {
vec![
("analyst", include_str!("../../../agents/analyst/agent.toml")),
("architect", include_str!("../../../agents/architect/agent.toml")),
("assistant", include_str!("../../../agents/assistant/agent.toml")),
(
"analyst",
include_str!("../../../agents/analyst/agent.toml"),
),
(
"architect",
include_str!("../../../agents/architect/agent.toml"),
),
(
"assistant",
include_str!("../../../agents/assistant/agent.toml"),
),
("coder", include_str!("../../../agents/coder/agent.toml")),
("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")),
("customer-support", include_str!("../../../agents/customer-support/agent.toml")),
("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")),
("debugger", include_str!("../../../agents/debugger/agent.toml")),
("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")),
("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")),
("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")),
("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")),
("hello-world", include_str!("../../../agents/hello-world/agent.toml")),
("home-automation", include_str!("../../../agents/home-automation/agent.toml")),
("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")),
("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")),
(
"code-reviewer",
include_str!("../../../agents/code-reviewer/agent.toml"),
),
(
"customer-support",
include_str!("../../../agents/customer-support/agent.toml"),
),
(
"data-scientist",
include_str!("../../../agents/data-scientist/agent.toml"),
),
(
"debugger",
include_str!("../../../agents/debugger/agent.toml"),
),
(
"devops-lead",
include_str!("../../../agents/devops-lead/agent.toml"),
),
(
"doc-writer",
include_str!("../../../agents/doc-writer/agent.toml"),
),
(
"email-assistant",
include_str!("../../../agents/email-assistant/agent.toml"),
),
(
"health-tracker",
include_str!("../../../agents/health-tracker/agent.toml"),
),
(
"hello-world",
include_str!("../../../agents/hello-world/agent.toml"),
),
(
"home-automation",
include_str!("../../../agents/home-automation/agent.toml"),
),
(
"legal-assistant",
include_str!("../../../agents/legal-assistant/agent.toml"),
),
(
"meeting-assistant",
include_str!("../../../agents/meeting-assistant/agent.toml"),
),
("ops", include_str!("../../../agents/ops/agent.toml")),
("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")),
("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")),
("planner", include_str!("../../../agents/planner/agent.toml")),
("recruiter", include_str!("../../../agents/recruiter/agent.toml")),
("researcher", include_str!("../../../agents/researcher/agent.toml")),
("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")),
("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")),
("social-media", include_str!("../../../agents/social-media/agent.toml")),
("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")),
("translator", include_str!("../../../agents/translator/agent.toml")),
("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")),
(
"orchestrator",
include_str!("../../../agents/orchestrator/agent.toml"),
),
(
"personal-finance",
include_str!("../../../agents/personal-finance/agent.toml"),
),
(
"planner",
include_str!("../../../agents/planner/agent.toml"),
),
(
"recruiter",
include_str!("../../../agents/recruiter/agent.toml"),
),
(
"researcher",
include_str!("../../../agents/researcher/agent.toml"),
),
(
"sales-assistant",
include_str!("../../../agents/sales-assistant/agent.toml"),
),
(
"security-auditor",
include_str!("../../../agents/security-auditor/agent.toml"),
),
(
"social-media",
include_str!("../../../agents/social-media/agent.toml"),
),
(
"test-engineer",
include_str!("../../../agents/test-engineer/agent.toml"),
),
(
"translator",
include_str!("../../../agents/translator/agent.toml"),
),
(
"travel-planner",
include_str!("../../../agents/travel-planner/agent.toml"),
),
("tutor", include_str!("../../../agents/tutor/agent.toml")),
("writer", include_str!("../../../agents/writer/agent.toml")),
]
+256 -42
View File
@@ -113,7 +113,11 @@ enum Commands {
quick: bool,
},
/// Start the OpenFang kernel daemon (API server + kernel).
Start,
Start {
/// Auto-approve all tool calls (no confirmation prompts).
#[arg(long)]
yolo: bool,
},
/// Stop the running daemon.
Stop,
/// Manage agents (new, list, chat, kill, spawn) [*].
@@ -520,6 +524,23 @@ enum WorkflowCommands {
/// Path to a JSON file describing the workflow.
file: PathBuf,
},
/// Get a workflow by ID.
Get {
/// Workflow ID (UUID).
workflow_id: String,
},
/// Update a workflow from a JSON file.
Update {
/// Workflow ID (UUID).
workflow_id: String,
/// Path to a JSON file with the updated workflow definition.
file: PathBuf,
},
/// Delete a workflow by ID.
Delete {
/// Workflow ID (UUID).
workflow_id: String,
},
/// Run a workflow by ID.
Run {
/// Workflow ID (UUID).
@@ -777,11 +798,36 @@ enum SystemCommands {
},
}
fn config_log_level() -> String {
let config_path = if let Ok(home) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(home).join("config.toml")
} else {
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
.join("config.toml")
};
if let Ok(content) = std::fs::read_to_string(config_path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("log_level") {
if let Some(val) = trimmed.split('=').nth(1) {
let level = val.trim().trim_matches('"').trim_matches('\'');
if !level.is_empty() {
return level.to_string();
}
}
}
}
}
"info".to_string()
}
fn init_tracing_stderr() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
)
.init();
}
@@ -807,7 +853,7 @@ fn init_tracing_file() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
)
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
@@ -876,7 +922,7 @@ fn main() {
}
Some(Commands::Tui) => tui::run(cli.config),
Some(Commands::Init { quick }) => cmd_init(quick),
Some(Commands::Start) => cmd_start(cli.config),
Some(Commands::Start { yolo }) => cmd_start(cli.config, yolo),
Some(Commands::Stop) => cmd_stop(),
Some(Commands::Agent(sub)) => match sub {
AgentCommands::New { template } => cmd_agent_new(cli.config, template),
@@ -893,6 +939,11 @@ fn main() {
Some(Commands::Workflow(sub)) => match sub {
WorkflowCommands::List => cmd_workflow_list(),
WorkflowCommands::Create { file } => cmd_workflow_create(file),
WorkflowCommands::Get { workflow_id } => cmd_workflow_get(&workflow_id),
WorkflowCommands::Update { workflow_id, file } => {
cmd_workflow_update(&workflow_id, file)
}
WorkflowCommands::Delete { workflow_id } => cmd_workflow_delete(&workflow_id),
WorkflowCommands::Run { workflow_id, input } => cmd_workflow_run(&workflow_id, &input),
},
Some(Commands::Trigger(sub)) => match sub {
@@ -966,7 +1017,7 @@ fn main() {
ModelsCommands::Set { model } => cmd_models_set(model),
},
Some(Commands::Gateway(sub)) => match sub {
GatewayCommands::Start => cmd_start(cli.config),
GatewayCommands::Start => cmd_start(cli.config, false),
GatewayCommands::Stop => cmd_stop(),
GatewayCommands::Status { json } => cmd_status(cli.config, json),
},
@@ -1021,7 +1072,10 @@ fn main() {
SystemCommands::Version { json } => cmd_system_version(json),
},
Some(Commands::Reset { confirm }) => cmd_reset(confirm),
Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config),
Some(Commands::Uninstall {
confirm,
keep_config,
}) => cmd_uninstall(confirm, keep_config),
}
}
@@ -1078,8 +1132,8 @@ pub(crate) fn find_daemon() -> Option<String> {
/// includes a `Authorization: Bearer <key>` header on every request.
/// When api_key is empty or missing, no auth header is sent.
pub(crate) fn daemon_client() -> reqwest::blocking::Client {
let mut builder = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120));
let mut builder =
reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(120));
if let Some(key) = read_api_key() {
let mut headers = reqwest::header::HeaderMap::new();
@@ -1411,7 +1465,7 @@ decay_rate = 0.05
}
}
fn cmd_start(config: Option<PathBuf>) {
fn cmd_start(config: Option<PathBuf>, yolo: bool) {
if let Some(base) = find_daemon() {
ui::error_with_fix(
&format!("Daemon already running at {base}"),
@@ -1427,7 +1481,12 @@ fn cmd_start(config: Option<PathBuf>) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let kernel = match OpenFangKernel::boot(config.as_deref()) {
let mut kernel_config = openfang_kernel::config::load_config(config.as_deref());
if yolo {
kernel_config.approval.auto_approve = true;
kernel_config.approval.apply_shorthands();
}
let kernel = match OpenFangKernel::boot_with_config(kernel_config) {
Ok(k) => k,
Err(e) => {
boot_kernel_error(&e);
@@ -1480,15 +1539,26 @@ fn cmd_start(config: Option<PathBuf>) {
/// Returns `None` when the key is missing, empty, or whitespace-only —
/// meaning the daemon is running in public (unauthenticated) mode.
fn read_api_key() -> Option<String> {
// 1. Config file takes precedence
let config_path = cli_openfang_home().join("config.toml");
let text = std::fs::read_to_string(config_path).ok()?;
let table: toml::Value = text.parse().ok()?;
let key = table.get("api_key")?.as_str()?.trim();
if key.is_empty() {
None
} else {
Some(key.to_string())
if let Ok(text) = std::fs::read_to_string(config_path) {
if let Ok(table) = text.parse::<toml::Value>() {
if let Some(key) = table.get("api_key").and_then(|v| v.as_str()) {
let key = key.trim();
if !key.is_empty() {
return Some(key.to_string());
}
}
}
}
// 2. Fall back to OPENFANG_API_KEY env var
if let Ok(key) = std::env::var("OPENFANG_API_KEY") {
let key = key.trim().to_string();
if !key.is_empty() {
return Some(key);
}
}
None
}
fn cmd_stop() {
@@ -2183,7 +2253,9 @@ decay_rate = 0.05
if !json {
ui::check_ok(&format!("Port {api_listen} is available"));
}
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
checks.push(
serde_json::json!({"check": "port", "status": "ok", "address": api_listen}),
);
}
Err(_) => {
if !json {
@@ -3111,6 +3183,104 @@ fn cmd_workflow_run(workflow_id: &str, input: &str) {
}
}
fn cmd_workflow_get(workflow_id: &str) {
let base = require_daemon("workflow get");
let client = daemon_client();
let body = daemon_json(
client
.get(format!("{base}/api/workflows/{workflow_id}"))
.send(),
);
if body.get("error").is_some() {
eprintln!(
"Workflow not found: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
println!("Workflow: {}", body["name"].as_str().unwrap_or("?"));
println!(" ID: {}", body["id"].as_str().unwrap_or("?"));
println!(
" Description: {}",
body["description"].as_str().unwrap_or("")
);
println!(
" Created: {}",
body["created_at"].as_str().unwrap_or("?")
);
if let Some(steps) = body["steps"].as_array() {
println!(" Steps ({}):", steps.len());
for (i, s) in steps.iter().enumerate() {
let name = s["name"].as_str().unwrap_or("step");
let agent = s["agent"]
.get("name")
.or_else(|| s["agent"].get("id"))
.and_then(|v| v.as_str())
.unwrap_or("?");
println!(" #{}: {} -> {}", i + 1, name, agent);
}
}
}
fn cmd_workflow_update(workflow_id: &str, file: PathBuf) {
let base = require_daemon("workflow update");
if !file.exists() {
eprintln!("Workflow file not found: {}", file.display());
std::process::exit(1);
}
let contents = std::fs::read_to_string(&file).unwrap_or_else(|e| {
eprintln!("Error reading workflow file: {e}");
std::process::exit(1);
});
let json_body: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Invalid JSON: {e}");
std::process::exit(1);
});
let client = daemon_client();
let body = daemon_json(
client
.put(format!("{base}/api/workflows/{workflow_id}"))
.json(&json_body)
.send(),
);
if body["status"].as_str() == Some("updated") {
println!("Workflow updated successfully!");
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
} else {
eprintln!(
"Failed to update workflow: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
fn cmd_workflow_delete(workflow_id: &str) {
let base = require_daemon("workflow delete");
let client = daemon_client();
let body = daemon_json(
client
.delete(format!("{base}/api/workflows/{workflow_id}"))
.send(),
);
if body["status"].as_str() == Some("removed") {
println!("Workflow deleted successfully!");
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
} else {
eprintln!(
"Failed to delete workflow: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
// ---------------------------------------------------------------------------
// Trigger commands
// ---------------------------------------------------------------------------
@@ -3985,7 +4155,10 @@ fn cmd_hand_install(path: &str) {
body["name"].as_str().unwrap_or("?"),
body["id"].as_str().unwrap_or("?"),
);
println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?"));
println!(
"Use `openfang hand activate {}` to start it.",
body["id"].as_str().unwrap_or("?")
);
}
fn cmd_hand_list() {
@@ -4010,10 +4183,7 @@ fn cmd_hand_list() {
println!("No hands available.");
return;
}
println!(
"{:<14} {:<20} {:<10} DESCRIPTION",
"ID", "NAME", "CATEGORY"
);
println!("{:<14} {:<20} {:<10} DESCRIPTION", "ID", "NAME", "CATEGORY");
println!("{}", "-".repeat(72));
for h in arr {
println!(
@@ -4021,7 +4191,12 @@ fn cmd_hand_list() {
h["id"].as_str().unwrap_or("?"),
h["name"].as_str().unwrap_or("?"),
h["category"].as_str().unwrap_or("?"),
h["description"].as_str().unwrap_or("").chars().take(40).collect::<String>(),
h["description"]
.as_str()
.unwrap_or("")
.chars()
.take(40)
.collect::<String>(),
);
}
println!("\nUse `openfang hand activate <id>` to activate a hand.");
@@ -4043,10 +4218,7 @@ fn cmd_hand_active() {
println!("No active hands.");
return;
}
println!(
"{:<38} {:<14} {:<10} AGENT",
"INSTANCE", "HAND", "STATUS"
);
println!("{:<38} {:<14} {:<10} AGENT", "INSTANCE", "HAND", "STATUS");
println!("{}", "-".repeat(72));
for i in &arr {
println!(
@@ -4134,10 +4306,7 @@ fn cmd_hand_info(id: &str) {
let client = daemon_client();
let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send());
if body.get("error").is_some() {
eprintln!(
"Hand not found: {}",
body["error"].as_str().unwrap_or(id)
);
eprintln!("Hand not found: {}", body["error"].as_str().unwrap_or(id));
std::process::exit(1);
}
println!(
@@ -4566,6 +4735,8 @@ fn cmd_config_set(key: &str, value: &str) {
std::process::exit(1);
});
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
ui::error(&format!("Failed to write config: {e}"));
std::process::exit(1);
@@ -4632,6 +4803,8 @@ fn cmd_config_unset(key: &str) {
std::process::exit(1);
});
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
ui::error(&format!("Failed to write config: {e}"));
std::process::exit(1);
@@ -4650,6 +4823,10 @@ fn cmd_config_set_key(provider: &str) {
return;
}
// Try vault first (best-effort)
save_credential_prefer_vault(&env_var, &key);
// Always save to dotenv as fallback
match dotenv::save_env_key(&env_var, &key) {
Ok(()) => {
ui::success(&format!("Saved {env_var} to ~/.openfang/.env"));
@@ -4672,6 +4849,18 @@ fn cmd_config_set_key(provider: &str) {
fn cmd_config_delete_key(provider: &str) {
let env_var = provider_to_env_var(provider);
// Remove from vault (best-effort)
{
let home = openfang_home();
let vault_path = home.join("vault.enc");
if vault_path.exists() {
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
if vault.unlock().is_ok() {
let _ = vault.remove(&env_var);
}
}
}
match dotenv::remove_env_key(&env_var) {
Ok(()) => ui::success(&format!("Removed {env_var} from ~/.openfang/.env")),
Err(e) => {
@@ -4701,6 +4890,26 @@ fn cmd_config_test_key(provider: &str) {
}
}
/// Try to store a credential in the vault first; silently falls through if vault
/// is not initialized or cannot be unlocked. The caller should always also
/// write to dotenv as a fallback.
fn save_credential_prefer_vault(env_var: &str, value: &str) {
use zeroize::Zeroizing;
let home = openfang_home();
let vault_path = home.join("vault.enc");
if !vault_path.exists() {
return;
}
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
if vault.unlock().is_err() {
return;
}
if let Ok(()) = vault.set(env_var.to_string(), Zeroizing::new(value.to_string())) {
println!(" {}", "Also stored in encrypted vault".dimmed());
}
}
// ---------------------------------------------------------------------------
// Quick chat (OpenClaw alias)
// ---------------------------------------------------------------------------
@@ -5456,7 +5665,15 @@ fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
format!(
"{}-{}",
agent,
if short_prompt.is_empty() {
"job"
} else {
&short_prompt
}
)
};
let body = daemon_json(
@@ -6210,10 +6427,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
} else {
match std::fs::remove_dir_all(&openfang_dir) {
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
Err(e) => ui::error(&format!(
"Failed to remove {}: {e}",
openfang_dir.display()
)),
Err(e) => ui::error(&format!("Failed to remove {}: {e}", openfang_dir.display())),
}
}
}
@@ -6222,10 +6436,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
match std::fs::remove_file(&cargo_bin) {
Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())),
Err(e) => ui::error(&format!(
"Failed to remove {}: {e}",
cargo_bin.display()
)),
Err(e) => ui::error(&format!("Failed to remove {}: {e}", cargo_bin.display())),
}
}
@@ -6465,7 +6676,10 @@ fn remove_self_binary(exe_path: &std::path::Path) {
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.spawn();
ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display()));
ui::success(&format!(
"Removed {} (deferred cleanup)",
exe_path.display()
));
}
}
+5 -1
View File
@@ -329,7 +329,11 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
_ => {
// Unknown method — always respond with error
Some(jsonrpc_error(rid, -32601, &format!("Method not found: {method}")))
Some(jsonrpc_error(
rid,
-32601,
&format!("Method not found: {method}"),
))
}
}
}
+8 -18
View File
@@ -394,10 +394,7 @@ impl StandaloneChat {
.as_str()
.unwrap_or("")
.to_string(),
provider: m["provider"]
.as_str()
.unwrap_or("")
.to_string(),
provider: m["provider"].as_str().unwrap_or("").to_string(),
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
})
.collect()
@@ -459,16 +456,13 @@ impl StandaloneChat {
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let provider =
body["model_provider"].as_str().unwrap_or("?");
let provider = body["model_provider"].as_str().unwrap_or("?");
let model = body["model_name"].as_str().unwrap_or("?");
self.chat.model_label = format!("{provider}/{model}");
}
}
self.chat.push_message(
Role::System,
format!("Switched to {model_id}"),
);
self.chat
.push_message(Role::System, format!("Switched to {model_id}"));
}
_ => {
self.chat.push_message(
@@ -506,16 +500,12 @@ impl StandaloneChat {
.unwrap_or_else(|| "?".to_string())
});
self.chat.model_label = format!("{prov_label}/{model_id}");
self.chat.push_message(
Role::System,
format!("Switched to {model_id}"),
);
self.chat
.push_message(Role::System, format!("Switched to {model_id}"));
}
Err(e) => {
self.chat.push_message(
Role::System,
format!("Switch failed: {e}"),
);
self.chat
.push_message(Role::System, format!("Switch failed: {e}"));
}
}
}
+1 -1
View File
@@ -303,7 +303,7 @@ pub fn spawn_inprocess_stream(
// send_message_streaming() finds the reactor.
let _guard = rt.enter();
match kernel.send_message_streaming(agent_id, &message, None) {
match kernel.send_message_streaming(agent_id, &message, None, None, None) {
Ok((mut rx, handle)) => {
rt.block_on(async {
while let Some(ev) = rx.recv().await {
+6 -16
View File
@@ -516,8 +516,7 @@ impl App {
}
AppEvent::CommsEventsLoaded(events) => {
self.comms.events = events;
if !self.comms.events.is_empty()
&& self.comms.event_list_state.selected().is_none()
if !self.comms.events.is_empty() && self.comms.event_list_state.selected().is_none()
{
self.comms.event_list_state.select(Some(0));
}
@@ -1869,14 +1868,8 @@ impl App {
.as_str()
.unwrap_or("")
.to_string(),
provider: m["provider"]
.as_str()
.unwrap_or("")
.to_string(),
tier: m["tier"]
.as_str()
.unwrap_or("Balanced")
.to_string(),
provider: m["provider"].as_str().unwrap_or("").to_string(),
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
})
.collect()
})
@@ -1935,8 +1928,7 @@ impl App {
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let provider =
body["model_provider"].as_str().unwrap_or("?");
let provider = body["model_provider"].as_str().unwrap_or("?");
let model = body["model_name"].as_str().unwrap_or("?");
self.chat.model_label = format!("{provider}/{model}");
}
@@ -1988,10 +1980,8 @@ impl App {
);
}
Err(e) => {
self.chat.push_message(
chat::Role::System,
format!("Switch failed: {e}"),
);
self.chat
.push_message(chat::Role::System, format!("Switch failed: {e}"));
}
}
}
@@ -1524,6 +1524,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+4 -1
View File
@@ -341,6 +341,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -200,7 +200,18 @@ const CHANNEL_DEFS: &[ChannelDef] = &[
display_name: "DingTalk",
category: "Enterprise",
env_vars: &["DINGTALK_ACCESS_TOKEN", "DINGTALK_SECRET"],
description: "DingTalk Robot API adapter",
description: "DingTalk Robot API adapter (webhook mode)",
},
ChannelDef {
name: "dingtalk_stream",
display_name: "DingTalk Stream",
category: "Enterprise",
env_vars: &[
"DINGTALK_APP_KEY",
"DINGTALK_APP_SECRET",
"DINGTALK_ROBOT_CODE",
],
description: "DingTalk Stream Mode (WebSocket long-connection)",
},
ChannelDef {
name: "pumble",
+11 -4
View File
@@ -483,8 +483,7 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
return; // Too small to show picker
}
let popup_w = area.width.clamp(30, 54);
let popup_h = (filtered.len() as u16 + 4)
.clamp(5, area.height.saturating_sub(2));
let popup_h = (filtered.len() as u16 + 4).clamp(5, area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
@@ -548,7 +547,12 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
let mut lines: Vec<Line> = Vec::new();
let max_name = (chunks[1].width as usize).saturating_sub(14);
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
for (i, entry) in filtered
.iter()
.enumerate()
.skip(scroll_start)
.take(visible_h)
{
let selected = i == state.model_picker_idx;
let indicator = if selected { "\u{25b6} " } else { " " };
@@ -882,6 +886,9 @@ fn truncate_line(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max_len.saturating_sub(1))
)
}
}
+15 -16
View File
@@ -158,11 +158,7 @@ impl CommsState {
KeyCode::Up | KeyCode::Char('k') => {
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
let i = self.event_list_state.selected().unwrap_or(0);
let next = if i == 0 {
self.events.len() - 1
} else {
i - 1
};
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
self.event_list_state.select(Some(next));
}
}
@@ -339,12 +335,12 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
f.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(2), // header
Constraint::Length(1), // separator
Constraint::Length(2), // header
Constraint::Length(1), // separator
Constraint::Percentage(35), // topology
Constraint::Length(1), // separator
Constraint::Min(4), // event list
Constraint::Length(1), // hints
Constraint::Length(1), // separator
Constraint::Min(4), // event list
Constraint::Length(1), // hints
])
.split(inner);
@@ -441,10 +437,7 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
if state.nodes.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(
" No agents running.",
theme::dim_style(),
)),
Paragraph::new(Span::styled(" No agents running.", theme::dim_style())),
area,
);
return;
@@ -491,7 +484,10 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
Span::styled(" ", Style::default()),
Span::styled(branch, theme::dim_style()),
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
Span::styled(
format!(" {} ", child.name),
Style::default().fg(theme::TEXT),
),
Span::styled(format!("({})", child.model), theme::dim_style()),
]));
}
@@ -679,7 +675,10 @@ fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
rows[3],
);
f.render_widget(
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
Paragraph::new(Span::styled(
"Assign to (agent ID, optional):",
field_style(2),
)),
rows[4],
);
f.render_widget(
@@ -273,6 +273,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -950,7 +950,9 @@ fn handle_migration_key(
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".openfang")
};
let tx = migrate_tx.clone();
std::thread::spawn(move || {
+4 -1
View File
@@ -405,6 +405,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -317,10 +317,7 @@ fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|a| {
let id_short = if a.id.len() > 12 {
format!(
"{}\u{2026}",
openfang_types::truncate_str(&a.id, 12)
)
format!("{}\u{2026}", openfang_types::truncate_str(&a.id, 12))
} else {
a.id.clone()
};
@@ -408,10 +405,7 @@ fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|kv| {
let val_display = if kv.value.len() > 40 {
format!(
"{}\u{2026}",
openfang_types::truncate_str(&kv.value, 39)
)
format!("{}\u{2026}", openfang_types::truncate_str(&kv.value, 39))
} else {
kv.value.clone()
};
@@ -555,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+1 -1
View File
@@ -1,8 +1,8 @@
pub mod agents;
pub mod audit;
pub mod channels;
pub mod comms;
pub mod chat;
pub mod comms;
pub mod dashboard;
pub mod extensions;
pub mod hands;
+5 -5
View File
@@ -149,10 +149,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
.iter()
.map(|p| {
let id_short = if p.node_id.len() > 12 {
format!(
"{}\u{2026}",
openfang_types::truncate_str(&p.node_id, 12)
)
format!("{}\u{2026}", openfang_types::truncate_str(&p.node_id, 12))
} else {
p.node_id.clone()
};
@@ -211,6 +208,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -251,10 +251,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
.map(|&idx| {
let s = &state.sessions[idx];
let id_short = if s.id.len() > 12 {
format!(
"{}\u{2026}",
openfang_types::truncate_str(&s.id, 12)
)
format!("{}\u{2026}", openfang_types::truncate_str(&s.id, 12))
} else {
s.id.clone()
};
@@ -311,6 +308,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -604,7 +604,10 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -612,7 +612,10 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -399,6 +399,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -549,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+4 -1
View File
@@ -439,6 +439,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -697,6 +697,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+72 -7
View File
@@ -6,6 +6,7 @@
use openfang_api::server::build_router;
use openfang_kernel::OpenFangKernel;
use std::net::{SocketAddr, TcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info};
@@ -20,24 +21,40 @@ pub struct ServerHandle {
shutdown_tx: watch::Sender<bool>,
/// Join handle for the background server thread.
server_thread: Option<std::thread::JoinHandle<()>>,
/// Track whether shutdown has already been initiated to prevent double shutdown.
shutdown_initiated: Arc<AtomicBool>,
}
impl ServerHandle {
/// Signal the server to shut down and wait for the background thread.
pub fn shutdown(mut self) {
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
// Only proceed if shutdown hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
}
impl Drop for ServerHandle {
fn drop(&mut self) {
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
// Only send shutdown signal if it hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
}
}
}
@@ -47,6 +64,11 @@ impl Drop for ServerHandle {
/// any Tauri window is created. The actual axum server runs on a dedicated
/// thread with its own tokio runtime.
pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
// Load .env and secrets.env into process environment (same as CLI).
// Without this, API keys stored in ~/.openfang/.env are invisible to
// the kernel's provider detection and credential resolver.
load_dotenv_files();
// Boot kernel (sync — no tokio needed)
let kernel = OpenFangKernel::boot(None)?;
let kernel = Arc::new(kernel);
@@ -61,6 +83,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let kernel_clone = kernel.clone();
let shutdown_initiated = Arc::new(AtomicBool::new(false));
let server_thread = std::thread::Builder::new()
.name("openfang-server".into())
@@ -83,6 +106,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
kernel,
shutdown_tx,
server_thread: Some(server_thread),
shutdown_initiated,
})
}
@@ -126,3 +150,44 @@ async fn run_embedded_server(
}
}
}
/// Load ~/.openfang/.env and ~/.openfang/secrets.env into the process environment.
/// System env vars take priority — existing vars are NOT overridden.
fn load_dotenv_files() {
let home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
let user_home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
if user_home.is_empty() {
return;
}
std::path::PathBuf::from(user_home).join(".openfang")
};
for filename in &[".env", "secrets.env"] {
let path = home.join(filename);
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let mut value = value.trim().to_string();
if ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
&& value.len() >= 2
{
value = value[1..value.len() - 1].to_string();
}
if !key.is_empty() && std::env::var(key).is_err() {
std::env::set_var(key, &value);
}
}
}
}
}
}
@@ -126,6 +126,17 @@ impl CredentialResolver {
))
}
}
/// Remove a credential from the vault (if available).
pub fn remove_from_vault(&mut self, key: &str) -> ExtensionResult<bool> {
if let Some(ref mut vault) = self.vault {
vault.remove(key)
} else {
Err(crate::ExtensionError::Vault(
"No vault configured".to_string(),
))
}
}
}
/// Load a dotenv file into a HashMap.
+1 -3
View File
@@ -27,9 +27,7 @@ pub fn default_client_ids() -> HashMap<&'static str, &'static str> {
}
/// Resolve OAuth client IDs with config overrides applied on top of defaults.
pub fn resolve_client_ids(
config: &openfang_types::config::OAuthConfig,
) -> HashMap<String, String> {
pub fn resolve_client_ids(config: &openfang_types::config::OAuthConfig) -> HashMap<String, String> {
let defaults = default_client_ids();
let mut resolved: HashMap<String, String> = defaults
.into_iter()
@@ -35,7 +35,8 @@ key = "chromium"
label = "Chromium or Google Chrome must be installed"
requirement_type = "binary"
check_value = "chromium"
description = "A Chromium-based browser is required. Google Chrome, Chromium, or any Chromium derivative will work. You can also set the CHROME_PATH environment variable to point to your browser binary."
optional = true
description = "A Chromium-based browser is recommended. Playwright can install its own bundled browser if none is found. Google Chrome, Chromium, or any Chromium derivative will also work. You can set the CHROME_PATH environment variable to point to your browser binary."
[requires.install]
macos = "brew install --cask google-chrome"
+26 -5
View File
@@ -1,6 +1,6 @@
//! Compile-time embedded Hand definitions.
use crate::{HandDefinition, HandError};
use crate::{parse_hand_toml, HandDefinition, HandError};
/// Returns all bundled hand definitions as (id, HAND.toml content, SKILL.md content).
pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> {
@@ -55,7 +55,7 @@ pub fn parse_bundled(
skill_content: &str,
) -> Result<HandDefinition, HandError> {
let mut def: HandDefinition =
toml::from_str(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?;
parse_hand_toml(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?;
if !skill_content.is_empty() {
def.skill_content = Some(skill_content.to_string());
}
@@ -241,7 +241,14 @@ mod tests {
#[test]
fn all_einstein_hands_have_schedules() {
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"];
let einstein_ids = [
"lead",
"collector",
"predictor",
"researcher",
"twitter",
"trader",
];
for (id, toml_content, skill_content) in bundled_hands() {
if einstein_ids.contains(&id) {
let def = parse_bundled(id, toml_content, skill_content).unwrap();
@@ -266,7 +273,14 @@ mod tests {
#[test]
fn all_einstein_hands_have_memory() {
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"];
let einstein_ids = [
"lead",
"collector",
"predictor",
"researcher",
"twitter",
"trader",
];
for (id, toml_content, skill_content) in bundled_hands() {
if einstein_ids.contains(&id) {
let def = parse_bundled(id, toml_content, skill_content).unwrap();
@@ -286,7 +300,14 @@ mod tests {
#[test]
fn all_einstein_hands_have_knowledge_graph() {
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter", "trader"];
let einstein_ids = [
"lead",
"collector",
"predictor",
"researcher",
"twitter",
"trader",
];
for (id, toml_content, skill_content) in bundled_hands() {
if einstein_ids.contains(&id) {
let def = parse_bundled(id, toml_content, skill_content).unwrap();
+67
View File
@@ -117,6 +117,13 @@ pub struct HandRequirement {
/// Human-readable description of why this is needed.
#[serde(default)]
pub description: Option<String>,
/// Whether this requirement is optional (non-critical).
///
/// Optional requirements do not block activation. When an active hand has
/// unmet optional requirements it is reported as "degraded" rather than
/// "requirements not met".
#[serde(default)]
pub optional: bool,
/// Platform-specific installation instructions.
#[serde(default)]
pub install: Option<HandInstallInfo>,
@@ -299,6 +306,20 @@ fn default_temperature() -> f32 {
0.7
}
#[derive(Deserialize)]
struct HandTomlWrapper {
hand: HandDefinition,
}
/// Parse HAND.toml content, supporting both flat format and `[hand]` table format.
pub fn parse_hand_toml(content: &str) -> Result<HandDefinition, toml::de::Error> {
if let Ok(def) = toml::from_str::<HandDefinition>(content) {
return Ok(def);
}
let wrapper: HandTomlWrapper = toml::from_str(content)?;
Ok(wrapper.hand)
}
/// Complete Hand definition — parsed from HAND.toml.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandDefinition {
@@ -791,4 +812,50 @@ metrics = []
assert!(install.macos.is_none());
assert!(install.windows.is_none());
}
#[test]
fn parse_hand_toml_flat_format() {
let toml_str = r#"
id = "test"
name = "Test Hand"
description = "A test hand"
category = "content"
tools = ["shell_exec"]
[agent]
name = "test-hand"
description = "Test agent"
system_prompt = "You are a test agent."
[dashboard]
metrics = []
"#;
let def = parse_hand_toml(toml_str).unwrap();
assert_eq!(def.id, "test");
assert_eq!(def.name, "Test Hand");
}
#[test]
fn parse_hand_toml_wrapped_format() {
let toml_str = r#"
[hand]
id = "test"
name = "Test Hand"
description = "A test hand"
category = "content"
tools = ["shell_exec"]
[hand.agent]
name = "test-hand"
description = "Test agent"
system_prompt = "You are a test agent."
[hand.dashboard]
metrics = []
"#;
let def = parse_hand_toml(toml_str).unwrap();
assert_eq!(def.id, "test");
assert_eq!(def.name, "Test Hand");
assert_eq!(def.agent.name, "test-hand");
}
}
+236 -7
View File
@@ -168,9 +168,28 @@ impl HandRegistry {
Ok(def)
}
/// Install or update a hand from raw TOML + skill content.
///
/// Unlike `install_from_content`, this overwrites an existing definition
/// with the same ID. Active instances are NOT automatically restarted —
/// the caller should deactivate + reactivate to pick up the new definition.
pub fn upsert_from_content(
&self,
toml_content: &str,
skill_content: &str,
) -> HandResult<HandDefinition> {
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
let existed = self.definitions.contains_key(&def.id);
let verb = if existed { "Updated" } else { "Installed" };
info!(hand = %def.id, name = %def.name, "{verb} hand from content");
self.definitions.insert(def.id.clone(), def.clone());
Ok(def)
}
/// List all known hand definitions.
pub fn list_definitions(&self) -> Vec<HandDefinition> {
let mut defs: Vec<HandDefinition> = self.definitions.iter().map(|r| r.value().clone()).collect();
let mut defs: Vec<HandDefinition> =
self.definitions.iter().map(|r| r.value().clone()).collect();
defs.sort_by(|a, b| a.name.cmp(&b.name));
defs
}
@@ -352,6 +371,47 @@ impl HandRegistry {
entry.updated_at = chrono::Utc::now();
Ok(())
}
/// Compute readiness for a hand, cross-referencing requirements with
/// active instance state.
///
/// Returns `None` if the hand definition does not exist.
pub fn readiness(&self, hand_id: &str) -> Option<HandReadiness> {
let reqs = self.check_requirements(hand_id).ok()?;
let requirements_met = reqs.iter().all(|(_, ok)| *ok);
// A hand is active if at least one instance is in Active status.
let active = self
.instances
.iter()
.any(|entry| entry.hand_id == hand_id && entry.status == HandStatus::Active);
// Degraded: active, but at least one non-optional requirement is unmet
// OR any optional requirement is unmet. In practice, the most useful
// definition is: active + any requirement unsatisfied.
let degraded = active && reqs.iter().any(|(_, ok)| !ok);
Some(HandReadiness {
requirements_met,
active,
degraded,
})
}
}
/// Readiness snapshot for a hand definition — combines requirement checks
/// with runtime activation state so the API can report unambiguous status.
#[derive(Debug, Clone, Serialize)]
pub struct HandReadiness {
/// Whether all declared requirements are currently satisfied.
pub requirements_met: bool,
/// Whether the hand currently has a running (Active-status) instance.
pub active: bool,
/// Whether the hand is active but some requirements are unmet.
/// This means the hand is running in a degraded mode — some features
/// may not work (e.g. browser hand without chromium).
pub degraded: bool,
}
impl Default for HandRegistry {
@@ -375,12 +435,7 @@ fn check_requirement(req: &HandRequirement) -> bool {
return true;
}
if req.check_value == "chromium" {
// Try common Chromium/Chrome binary names across platforms
return which_binary("chromium-browser")
|| which_binary("google-chrome")
|| which_binary("google-chrome-stable")
|| which_binary("chrome")
|| std::env::var("CHROME_PATH").map(|v| !v.is_empty()).unwrap_or(false);
return check_chromium_available();
}
false
}
@@ -431,6 +486,95 @@ fn run_returns_python3(cmd: &str) -> bool {
}
}
/// Check if Chromium (or Chrome) is available anywhere on the system.
///
/// Checks in order:
/// 1. CHROME_PATH / CHROMIUM_PATH env vars
/// 2. Common binary names on PATH (chromium, chromium-browser, google-chrome, etc.)
/// 3. Well-known install paths (Windows Program Files, macOS Applications, Linux /usr)
/// 4. Playwright cache (~/.cache/ms-playwright/chromium-*)
fn check_chromium_available() -> bool {
// 1. Env vars
for var in &["CHROME_PATH", "CHROMIUM_PATH"] {
if let Ok(p) = std::env::var(var) {
if !p.is_empty() && std::path::Path::new(&p).exists() {
return true;
}
}
}
// 2. Common binary names on PATH
let names = [
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"chrome",
];
for name in &names {
if which_binary(name) {
return true;
}
}
// 3. Well-known install paths
let known_paths: Vec<std::path::PathBuf> = if cfg!(windows) {
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into());
let pf86 =
std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into());
let local = std::env::var("LOCALAPPDATA").unwrap_or_default();
vec![
std::path::PathBuf::from(&pf).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&pf86).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe"),
std::path::PathBuf::from(&pf).join(r"Chromium\Application\chrome.exe"),
std::path::PathBuf::from(&local).join(r"Chromium\Application\chrome.exe"),
std::path::PathBuf::from(&pf).join(r"Microsoft\Edge\Application\msedge.exe"),
]
} else if cfg!(target_os = "macos") {
vec![
std::path::PathBuf::from(
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
),
std::path::PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
]
} else {
vec![
std::path::PathBuf::from("/usr/bin/chromium"),
std::path::PathBuf::from("/usr/bin/chromium-browser"),
std::path::PathBuf::from("/usr/bin/google-chrome"),
std::path::PathBuf::from("/usr/bin/google-chrome-stable"),
std::path::PathBuf::from("/snap/bin/chromium"),
]
};
for p in &known_paths {
if p.exists() {
return true;
}
}
// 4. Playwright cache
if let Some(home) = std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())
{
let pw_cache = std::path::Path::new(&home).join(".cache/ms-playwright");
if pw_cache.is_dir() {
if let Ok(entries) = std::fs::read_dir(&pw_cache) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("chromium-") && entry.path().is_dir() {
return true;
}
}
}
}
}
false
}
/// Check if a binary is on PATH (cross-platform).
fn which_binary(name: &str) -> bool {
let path_var = std::env::var("PATH").unwrap_or_default();
@@ -638,6 +782,7 @@ mod tests {
requirement_type: RequirementType::EnvVar,
check_value: "OPENFANG_TEST_HAND_REQ".to_string(),
description: None,
optional: false,
install: None,
};
assert!(check_requirement(&req));
@@ -648,9 +793,93 @@ mod tests {
requirement_type: RequirementType::EnvVar,
check_value: "OPENFANG_NONEXISTENT_VAR_12345".to_string(),
description: None,
optional: false,
install: None,
};
assert!(!check_requirement(&req_missing));
std::env::remove_var("OPENFANG_TEST_HAND_REQ");
}
#[test]
fn readiness_nonexistent_hand() {
let reg = HandRegistry::new();
assert!(reg.readiness("nonexistent").is_none());
}
#[test]
fn readiness_inactive_hand() {
let reg = HandRegistry::new();
reg.load_bundled();
// Lead hand has no requirements, so requirements_met = true
let r = reg.readiness("lead").unwrap();
assert!(r.requirements_met);
assert!(!r.active);
assert!(!r.degraded);
}
#[test]
fn readiness_active_hand_all_met() {
let reg = HandRegistry::new();
reg.load_bundled();
// Lead hand has no requirements — activate it
let instance = reg.activate("lead", HashMap::new()).unwrap();
let r = reg.readiness("lead").unwrap();
assert!(r.requirements_met);
assert!(r.active);
assert!(!r.degraded); // all met, so not degraded
reg.deactivate(instance.instance_id).unwrap();
}
#[test]
fn readiness_active_hand_degraded() {
let reg = HandRegistry::new();
reg.load_bundled();
// Browser hand requires python3 + chromium. Activate it — if either
// requirement is unmet on this machine, it will show as degraded.
let instance = reg.activate("browser", HashMap::new()).unwrap();
let r = reg.readiness("browser").unwrap();
assert!(r.active);
// If any requirement is not satisfied, degraded should be true
if !r.requirements_met {
assert!(r.degraded);
} else {
assert!(!r.degraded);
}
reg.deactivate(instance.instance_id).unwrap();
}
#[test]
fn readiness_paused_hand_not_active() {
let reg = HandRegistry::new();
reg.load_bundled();
let instance = reg.activate("lead", HashMap::new()).unwrap();
reg.pause(instance.instance_id).unwrap();
let r = reg.readiness("lead").unwrap();
assert!(!r.active); // Paused is not Active
assert!(!r.degraded);
reg.deactivate(instance.instance_id).unwrap();
}
#[test]
fn optional_field_defaults_false() {
let req = HandRequirement {
key: "test".to_string(),
label: "test".to_string(),
requirement_type: RequirementType::Binary,
check_value: "test".to_string(),
description: None,
optional: false,
install: None,
};
assert!(!req.optional);
}
}
+1
View File
@@ -34,6 +34,7 @@ rand = { workspace = true }
hex = { workspace = true }
reqwest = { workspace = true }
cron = "0.15"
zeroize = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+6 -2
View File
@@ -242,7 +242,8 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
}
if field_changed(&old.provider_api_keys, &new.provider_api_keys) {
plan.noop_changes.push("provider_api_keys changed (takes effect on next driver init)".to_string());
plan.noop_changes
.push("provider_api_keys changed (takes effect on next driver init)".to_string());
}
// ----- No-op fields -----
@@ -415,7 +416,10 @@ mod tests {
let mut b = default_cfg();
b.default_model.model = "gpt-4".to_string();
let plan = build_reload_plan(&a, &b);
assert!(!plan.restart_required, "default_model should be hot-reloadable");
assert!(
!plan.restart_required,
"default_model should be hot-reloadable"
);
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
}
+11 -6
View File
@@ -234,9 +234,12 @@ impl CronScheduler {
if !entry.value().job.enabled {
// Re-enable jobs that were auto-disabled due to the stale
// agent ID causing repeated failures.
if entry.value().last_status.as_deref().is_some_and(|s| {
s.contains("not found") || s.contains("No such agent")
}) {
if entry
.value()
.last_status
.as_deref()
.is_some_and(|s| s.contains("not found") || s.contains("No such agent"))
{
entry.value_mut().job.enabled = true;
entry.value_mut().job.next_run =
Some(compute_next_run(&entry.value().job.schedule));
@@ -348,8 +351,7 @@ impl CronScheduler {
);
meta.job.enabled = false;
} else {
meta.job.next_run =
Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
}
}
}
@@ -1094,7 +1096,10 @@ mod tests {
sched.reassign_agent_jobs(old_agent, new_agent);
let meta = sched.get_meta(id).unwrap();
assert!(meta.job.enabled, "Job should be re-enabled after reassignment");
assert!(
meta.job.enabled,
"Job should be re-enabled after reassignment"
);
assert_eq!(meta.consecutive_errors, 0);
assert_eq!(meta.job.agent_id, new_agent);
}
+120 -8
View File
@@ -4,9 +4,14 @@
//! each running agent's `last_active` timestamp. If an agent hasn't been active
//! for longer than 2x its heartbeat interval, a `HealthCheckFailed` event is
//! published to the event bus.
//!
//! Crashed agents are tracked for auto-recovery: the heartbeat will attempt to
//! reset crashed agents back to Running up to `max_recovery_attempts` times.
//! After exhausting attempts, agents are marked as Terminated (dead).
use crate::registry::AgentRegistry;
use chrono::Utc;
use dashmap::DashMap;
use openfang_types::agent::{AgentId, AgentState};
use tracing::{debug, warn};
@@ -17,6 +22,12 @@ const DEFAULT_CHECK_INTERVAL_SECS: u64 = 30;
/// multiples of its heartbeat interval.
const UNRESPONSIVE_MULTIPLIER: u64 = 2;
/// Default maximum recovery attempts before giving up.
const DEFAULT_MAX_RECOVERY_ATTEMPTS: u32 = 3;
/// Default cooldown between recovery attempts (seconds).
const DEFAULT_RECOVERY_COOLDOWN_SECS: u64 = 60;
/// Result of a heartbeat check.
#[derive(Debug, Clone)]
pub struct HeartbeatStatus {
@@ -28,6 +39,8 @@ pub struct HeartbeatStatus {
pub inactive_secs: i64,
/// Whether the agent is considered unresponsive.
pub unresponsive: bool,
/// Current agent state.
pub state: AgentState,
}
/// Heartbeat monitor configuration.
@@ -38,18 +51,82 @@ pub struct HeartbeatConfig {
/// Default threshold for unresponsiveness (seconds).
/// Overridden per-agent by AutonomousConfig.heartbeat_interval_secs.
pub default_timeout_secs: u64,
/// Maximum recovery attempts before marking agent as Terminated.
pub max_recovery_attempts: u32,
/// Minimum seconds between recovery attempts for the same agent.
pub recovery_cooldown_secs: u64,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
check_interval_secs: DEFAULT_CHECK_INTERVAL_SECS,
default_timeout_secs: DEFAULT_CHECK_INTERVAL_SECS * UNRESPONSIVE_MULTIPLIER,
// 180s default: browser tasks and complex LLM calls can take 1-3 minutes
default_timeout_secs: 180,
max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
recovery_cooldown_secs: DEFAULT_RECOVERY_COOLDOWN_SECS,
}
}
}
/// Check all running agents and return their heartbeat status.
/// Tracks per-agent recovery state across heartbeat cycles.
#[derive(Debug)]
pub struct RecoveryTracker {
/// Per-agent recovery state: (consecutive_failures, last_attempt_epoch_secs).
state: DashMap<AgentId, (u32, u64)>,
}
impl RecoveryTracker {
/// Create a new recovery tracker.
pub fn new() -> Self {
Self {
state: DashMap::new(),
}
}
/// Record a recovery attempt for an agent.
/// Returns the current attempt number (1-indexed).
pub fn record_attempt(&self, agent_id: AgentId) -> u32 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut entry = self.state.entry(agent_id).or_insert((0, 0));
entry.0 += 1;
entry.1 = now;
entry.0
}
/// Check if enough time has passed since the last recovery attempt.
pub fn can_attempt(&self, agent_id: AgentId, cooldown_secs: u64) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
match self.state.get(&agent_id) {
Some(entry) => now.saturating_sub(entry.1) >= cooldown_secs,
None => true, // No prior attempts
}
}
/// Get the current failure count for an agent.
pub fn failure_count(&self, agent_id: AgentId) -> u32 {
self.state.get(&agent_id).map(|e| e.0).unwrap_or(0)
}
/// Reset recovery state for an agent (e.g. after successful recovery).
pub fn reset(&self, agent_id: AgentId) {
self.state.remove(&agent_id);
}
}
impl Default for RecoveryTracker {
fn default() -> Self {
Self::new()
}
}
/// Check all running and crashed agents and return their heartbeat status.
///
/// This is a pure function — it doesn't start a background task.
/// The caller (kernel) can run this periodically or in a background task.
@@ -58,9 +135,10 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
let mut statuses = Vec::new();
for entry_ref in registry.list() {
// Only check running agents
if entry_ref.state != AgentState::Running {
continue;
// Check Running agents (for unresponsiveness) and Crashed agents (for recovery)
match entry_ref.state {
AgentState::Running | AgentState::Crashed => {}
_ => continue,
}
let inactive_secs = (now - entry_ref.last_active).num_seconds();
@@ -73,15 +151,22 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
.map(|a| a.heartbeat_interval_secs * UNRESPONSIVE_MULTIPLIER)
.unwrap_or(config.default_timeout_secs) as i64;
let unresponsive = inactive_secs > timeout_secs;
// Crashed agents are always considered unresponsive
let unresponsive = entry_ref.state == AgentState::Crashed || inactive_secs > timeout_secs;
if unresponsive {
if unresponsive && entry_ref.state == AgentState::Running {
warn!(
agent = %entry_ref.name,
inactive_secs,
timeout_secs,
"Agent is unresponsive"
);
} else if entry_ref.state == AgentState::Crashed {
warn!(
agent = %entry_ref.name,
inactive_secs,
"Agent is crashed — eligible for recovery"
);
} else {
debug!(
agent = %entry_ref.name,
@@ -95,6 +180,7 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
name: entry_ref.name.clone(),
inactive_secs,
unresponsive,
state: entry_ref.state,
});
}
@@ -201,7 +287,7 @@ mod tests {
fn test_heartbeat_config_default() {
let config = HeartbeatConfig::default();
assert_eq!(config.check_interval_secs, 30);
assert_eq!(config.default_timeout_secs, 60);
assert_eq!(config.default_timeout_secs, 180);
}
#[test]
@@ -220,18 +306,21 @@ mod tests {
name: "agent-1".to_string(),
inactive_secs: 10,
unresponsive: false,
state: AgentState::Running,
},
HeartbeatStatus {
agent_id: AgentId::new(),
name: "agent-2".to_string(),
inactive_secs: 120,
unresponsive: true,
state: AgentState::Running,
},
HeartbeatStatus {
agent_id: AgentId::new(),
name: "agent-3".to_string(),
inactive_secs: 5,
unresponsive: false,
state: AgentState::Running,
},
];
@@ -242,4 +331,27 @@ mod tests {
assert_eq!(summary.unresponsive_agents.len(), 1);
assert_eq!(summary.unresponsive_agents[0].name, "agent-2");
}
#[test]
fn test_recovery_tracker() {
let tracker = RecoveryTracker::new();
let agent_id = AgentId::new();
assert_eq!(tracker.failure_count(agent_id), 0);
assert!(tracker.can_attempt(agent_id, 60));
let attempt = tracker.record_attempt(agent_id);
assert_eq!(attempt, 1);
assert_eq!(tracker.failure_count(agent_id), 1);
// Just recorded — cooldown should block (unless cooldown is 0)
assert!(!tracker.can_attempt(agent_id, 60));
assert!(tracker.can_attempt(agent_id, 0));
let attempt = tracker.record_attempt(agent_id);
assert_eq!(attempt, 2);
tracker.reset(agent_id);
assert_eq!(tracker.failure_count(agent_id), 0);
}
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -356,6 +356,14 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
return (0.20, 0.90);
}
// ── NVIDIA NIM ──────────────────────────────────────────────
if model.contains("nemotron-4-340b") {
return (4.20, 4.20);
}
if model.contains("nemotron") {
return (0.88, 0.88);
}
// ── Open-source (Groq, Together, etc.) ─────────────────────
if model.contains("llama-4-maverick") {
return (0.50, 0.77);
+6 -2
View File
@@ -248,8 +248,12 @@ impl AgentRegistry {
/// Update an agent's name (also updates the name index).
pub fn update_name(&self, id: AgentId, new_name: String) -> OpenFangResult<()> {
if self.name_index.contains_key(&new_name) {
return Err(OpenFangError::AgentAlreadyExists(new_name));
if let Some(existing_id) = self.name_index.get(&new_name).as_deref().copied() {
if existing_id != id {
return Err(OpenFangError::AgentAlreadyExists(new_name));
}
// Same agent owns this name — no-op
return Ok(());
}
let mut entry = self
.agents
+14 -2
View File
@@ -88,8 +88,7 @@ impl AgentScheduler {
// Reset the window if an hour has passed
tracker.reset_if_expired();
if quota.max_llm_tokens_per_hour > 0
&& tracker.total_tokens > quota.max_llm_tokens_per_hour
if quota.max_llm_tokens_per_hour > 0 && tracker.total_tokens > quota.max_llm_tokens_per_hour
{
return Err(OpenFangError::QuotaExceeded(format!(
"Token limit exceeded: {} / {}",
@@ -130,6 +129,19 @@ impl AgentScheduler {
.get(&agent_id)
.map(|t| (t.total_tokens, t.tool_calls))
}
/// Returns remaining token headroom before quota is hit.
/// Returns `None` if no token quota is configured (unlimited).
pub fn token_headroom(&self, agent_id: AgentId) -> Option<u64> {
let quota = self.quotas.get(&agent_id)?;
if quota.max_llm_tokens_per_hour == 0 {
return None;
}
let mut tracker = self.usage.get_mut(&agent_id)?;
tracker.reset_if_expired();
let used = tracker.total_tokens;
Some(quota.max_llm_tokens_per_hour.saturating_sub(used))
}
}
impl Default for AgentScheduler {
+223
View File
@@ -143,6 +143,105 @@ impl TriggerEngine {
}
}
/// Take all triggers for an agent, removing them from the engine.
///
/// Returns the extracted triggers so they can be restored under a
/// different agent ID via [`restore_triggers`]. This is used during
/// hand reactivation: triggers must be saved before `kill_agent`
/// destroys them, then restored with the new agent ID after spawn.
pub fn take_agent_triggers(&self, agent_id: AgentId) -> Vec<Trigger> {
let trigger_ids = self
.agent_triggers
.remove(&agent_id)
.map(|(_, ids)| ids)
.unwrap_or_default();
let mut taken = Vec::with_capacity(trigger_ids.len());
for id in trigger_ids {
if let Some((_, t)) = self.triggers.remove(&id) {
taken.push(t);
}
}
if !taken.is_empty() {
info!(
agent = %agent_id,
count = taken.len(),
"Took triggers for agent (pending reassignment)"
);
}
taken
}
/// Restore previously taken triggers under a new agent ID.
///
/// Each trigger keeps its original pattern, prompt template, fire count,
/// and max_fires, but is re-keyed to `new_agent_id`. New trigger IDs are
/// generated so there are no stale references.
///
/// Returns the number of triggers restored.
pub fn restore_triggers(&self, new_agent_id: AgentId, triggers: Vec<Trigger>) -> usize {
let count = triggers.len();
for old in triggers {
let new_id = TriggerId::new();
let trigger = Trigger {
id: new_id,
agent_id: new_agent_id,
pattern: old.pattern,
prompt_template: old.prompt_template,
enabled: old.enabled,
created_at: old.created_at,
fire_count: old.fire_count,
max_fires: old.max_fires,
};
self.triggers.insert(new_id, trigger);
self.agent_triggers
.entry(new_agent_id)
.or_default()
.push(new_id);
}
if count > 0 {
info!(
agent = %new_agent_id,
count,
"Restored triggers under new agent"
);
}
count
}
/// Reassign all triggers from one agent to another in place.
///
/// Used during cold boot when the old agent ID (from persisted state) no
/// longer exists and a new agent was spawned. Updates the `agent_id` field
/// on each trigger and moves the index entry.
///
/// Returns the number of triggers reassigned.
pub fn reassign_agent_triggers(&self, old_agent_id: AgentId, new_agent_id: AgentId) -> usize {
let trigger_ids = self
.agent_triggers
.remove(&old_agent_id)
.map(|(_, ids)| ids)
.unwrap_or_default();
let count = trigger_ids.len();
for id in &trigger_ids {
if let Some(mut t) = self.triggers.get_mut(id) {
t.agent_id = new_agent_id;
}
}
if !trigger_ids.is_empty() {
self.agent_triggers
.entry(new_agent_id)
.or_default()
.extend(trigger_ids);
info!(
old_agent = %old_agent_id,
new_agent = %new_agent_id,
count,
"Reassigned triggers to new agent"
);
}
count
}
/// Enable or disable a trigger. Returns true if the trigger was found.
pub fn set_enabled(&self, trigger_id: TriggerId, enabled: bool) -> bool {
if let Some(mut t) = self.triggers.get_mut(&trigger_id) {
@@ -508,4 +607,128 @@ mod tests {
);
assert_eq!(engine.evaluate(&event).len(), 1);
}
// -- reassign_agent_triggers (#519) ------------------------------------
#[test]
fn test_reassign_agent_triggers_basic() {
let engine = TriggerEngine::new();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
engine.register(old_agent, TriggerPattern::All, "a".to_string(), 0);
engine.register(old_agent, TriggerPattern::System, "b".to_string(), 0);
let count = engine.reassign_agent_triggers(old_agent, new_agent);
assert_eq!(count, 2);
assert_eq!(engine.list_agent_triggers(old_agent).len(), 0);
assert_eq!(engine.list_agent_triggers(new_agent).len(), 2);
// Verify triggers actually fire for the new agent
let event = Event::new(
AgentId::new(),
EventTarget::Broadcast,
EventPayload::System(SystemEvent::HealthCheck {
status: "ok".to_string(),
}),
);
let matches = engine.evaluate(&event);
assert_eq!(matches.len(), 2);
assert!(matches.iter().all(|(id, _)| *id == new_agent));
}
#[test]
fn test_reassign_agent_triggers_no_match_returns_zero() {
let engine = TriggerEngine::new();
let agent_a = AgentId::new();
engine.register(agent_a, TriggerPattern::All, "a".to_string(), 0);
let count = engine.reassign_agent_triggers(AgentId::new(), AgentId::new());
assert_eq!(count, 0);
// Original triggers untouched
assert_eq!(engine.list_agent_triggers(agent_a).len(), 1);
}
#[test]
fn test_reassign_does_not_touch_other_agents() {
let engine = TriggerEngine::new();
let agent_a = AgentId::new();
let agent_b = AgentId::new();
let agent_c = AgentId::new();
engine.register(agent_a, TriggerPattern::All, "a".to_string(), 0);
engine.register(agent_b, TriggerPattern::System, "b".to_string(), 0);
let count = engine.reassign_agent_triggers(agent_a, agent_c);
assert_eq!(count, 1);
// agent_b untouched
assert_eq!(engine.list_agent_triggers(agent_b).len(), 1);
assert_eq!(engine.list_agent_triggers(agent_c).len(), 1);
}
// -- take / restore triggers (#519) ------------------------------------
#[test]
fn test_take_and_restore_triggers() {
let engine = TriggerEngine::new();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
engine.register(
old_agent,
TriggerPattern::ContentMatch {
substring: "deploy".to_string(),
},
"Deploy alert: {{event}}".to_string(),
5,
);
engine.register(old_agent, TriggerPattern::Lifecycle, "lc".to_string(), 0);
// Take triggers — engine should be empty for old agent
let taken = engine.take_agent_triggers(old_agent);
assert_eq!(taken.len(), 2);
assert_eq!(engine.list_agent_triggers(old_agent).len(), 0);
assert_eq!(engine.list_all().len(), 0);
// Restore under new agent
let restored = engine.restore_triggers(new_agent, taken);
assert_eq!(restored, 2);
assert_eq!(engine.list_agent_triggers(new_agent).len(), 2);
// Verify patterns and max_fires are preserved
let triggers = engine.list_agent_triggers(new_agent);
let has_content_match = triggers.iter().any(|t| {
matches!(&t.pattern, TriggerPattern::ContentMatch { substring } if substring == "deploy")
&& t.max_fires == 5
});
assert!(
has_content_match,
"ContentMatch trigger with max_fires=5 should be preserved"
);
}
#[test]
fn test_take_empty_returns_empty() {
let engine = TriggerEngine::new();
let taken = engine.take_agent_triggers(AgentId::new());
assert!(taken.is_empty());
}
#[test]
fn test_restore_preserves_enabled_state() {
let engine = TriggerEngine::new();
let old_agent = AgentId::new();
let new_agent = AgentId::new();
let tid = engine.register(old_agent, TriggerPattern::All, "a".to_string(), 0);
engine.set_enabled(tid, false);
let taken = engine.take_agent_triggers(old_agent);
assert_eq!(taken.len(), 1);
assert!(!taken[0].enabled);
engine.restore_triggers(new_agent, taken);
let restored = engine.list_agent_triggers(new_agent);
assert_eq!(restored.len(), 1);
assert!(
!restored[0].enabled,
"Disabled state should survive take/restore"
);
}
}
+9 -10
View File
@@ -10,10 +10,8 @@ use std::sync::Arc;
use tracing::{info, warn};
/// Gateway source files embedded at compile time.
const GATEWAY_INDEX_JS: &str =
include_str!("../../../packages/whatsapp-gateway/index.js");
const GATEWAY_PACKAGE_JSON: &str =
include_str!("../../../packages/whatsapp-gateway/package.json");
const GATEWAY_INDEX_JS: &str = include_str!("../../../packages/whatsapp-gateway/index.js");
const GATEWAY_PACKAGE_JSON: &str = include_str!("../../../packages/whatsapp-gateway/package.json");
/// Default port for the WhatsApp Web gateway.
const DEFAULT_GATEWAY_PORT: u16 = 3009;
@@ -69,8 +67,8 @@ async fn ensure_gateway_installed() -> Result<PathBuf, String> {
let package_path = dir.join("package.json");
// Write files only if content changed (avoids unnecessary npm install)
let index_changed =
write_if_changed(&index_path, GATEWAY_INDEX_JS).map_err(|e| format!("Write index.js: {e}"))?;
let index_changed = write_if_changed(&index_path, GATEWAY_INDEX_JS)
.map_err(|e| format!("Write index.js: {e}"))?;
let package_changed = write_if_changed(&package_path, GATEWAY_PACKAGE_JSON)
.map_err(|e| format!("Write package.json: {e}"))?;
@@ -164,7 +162,10 @@ pub async fn start_whatsapp_gateway(kernel: &Arc<super::kernel::OpenFangKernel>)
.to_string();
// Auto-set the env var so the rest of the system finds the gateway
std::env::set_var("WHATSAPP_WEB_GATEWAY_URL", format!("http://127.0.0.1:{port}"));
std::env::set_var(
"WHATSAPP_WEB_GATEWAY_URL",
format!("http://127.0.0.1:{port}"),
);
info!("WHATSAPP_WEB_GATEWAY_URL set to http://127.0.0.1:{port}");
// Spawn with crash monitoring
@@ -247,9 +248,7 @@ pub async fn start_whatsapp_gateway(kernel: &Arc<super::kernel::OpenFangKernel>)
restarts += 1;
if restarts >= MAX_RESTARTS {
warn!(
"WhatsApp gateway exceeded max restarts ({MAX_RESTARTS}), giving up"
);
warn!("WhatsApp gateway exceeded max restarts ({MAX_RESTARTS}), giving up");
return;
}
+18
View File
@@ -237,6 +237,24 @@ impl WorkflowEngine {
self.workflows.write().await.remove(&id).is_some()
}
/// Update an existing workflow definition.
///
/// Preserves the original `id` and `created_at`. Replaces `name`,
/// `description`, and `steps`. Returns `true` if the workflow was
/// found and updated.
pub async fn update_workflow(&self, id: WorkflowId, updated: Workflow) -> bool {
let mut workflows = self.workflows.write().await;
if let Some(existing) = workflows.get_mut(&id) {
existing.name = updated.name;
existing.description = updated.description;
existing.steps = updated.steps;
info!(workflow_id = %id, "Workflow updated");
true
} else {
false
}
}
/// Maximum number of retained workflow runs. Oldest completed/failed
/// runs are evicted when this limit is exceeded.
const MAX_RETAINED_RUNS: usize = 200;
@@ -303,7 +303,7 @@ async fn test_wasm_agent_streaming_fallback() {
let agent_id = kernel.spawn_agent(manifest).unwrap();
let (mut rx, handle) = kernel
.send_message_streaming(agent_id, "Hi!", None)
.send_message_streaming(agent_id, "Hi!", None, None, None)
.expect("Streaming should start");
// Collect all stream events
+4 -2
View File
@@ -557,10 +557,12 @@ impl SessionStore {
MessageContent::Blocks(blocks) => {
for block in blocks {
match block {
ContentBlock::Text { text } => {
ContentBlock::Text { text, .. } => {
text_parts.push(text.clone());
}
ContentBlock::ToolUse { id, name, input, .. } => {
ContentBlock::ToolUse {
id, name, input, ..
} => {
tool_parts.push(serde_json::json!({
"type": "tool_use",
"id": id,
+22 -15
View File
@@ -99,13 +99,12 @@ impl StructuredStore {
let mut pairs = Vec::new();
for row in rows {
let (key, blob) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?;
let value: serde_json::Value = serde_json::from_slice(&blob)
.unwrap_or_else(|_| {
// Fallback: try as UTF-8 string
String::from_utf8(blob)
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null)
});
let value: serde_json::Value = serde_json::from_slice(&blob).unwrap_or_else(|_| {
// Fallback: try as UTF-8 string
String::from_utf8(blob)
.map(serde_json::Value::String)
.unwrap_or(serde_json::Value::Null)
});
pairs.push((key, value));
}
Ok(pairs)
@@ -192,7 +191,14 @@ impl StructuredStore {
} else {
None
};
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str))
Ok((
name,
manifest_blob,
state_str,
created_str,
session_id_str,
identity_str,
))
});
match result {
@@ -307,13 +313,14 @@ impl StructuredStore {
let mut repair_queue: Vec<(String, Vec<u8>, String)> = Vec::new();
for row in rows {
let (id_str, name, manifest_blob, state_str, created_str, session_id_str, identity_str) = match row {
Ok(r) => r,
Err(e) => {
tracing::warn!("Skipping agent row with read error: {e}");
continue;
}
};
let (id_str, name, manifest_blob, state_str, created_str, session_id_str, identity_str) =
match row {
Ok(r) => r,
Err(e) => {
tracing::warn!("Skipping agent row with read error: {e}");
continue;
}
};
// Deduplicate: skip agents with names we've already seen
let name_lower = name.to_lowercase();
+10
View File
@@ -149,6 +149,16 @@ impl MemorySubstrate {
self.sessions.save_session(session)
}
/// Save a session asynchronously — runs the SQLite write in a blocking
/// thread so the tokio runtime stays responsive.
pub async fn save_session_async(&self, session: &Session) -> OpenFangResult<()> {
let sessions = self.sessions.clone();
let session = session.clone();
tokio::task::spawn_blocking(move || sessions.save_session(&session))
.await
.map_err(|e| OpenFangError::Internal(e.to_string()))?
}
/// Create a new empty session for an agent.
pub fn create_session(&self, agent_id: AgentId) -> OpenFangResult<Session> {
self.sessions.create_session(agent_id)
+369 -30
View File
@@ -125,17 +125,21 @@ struct OpenClawAgentTools {
/// Extract a profile name from a Value (string or {name: "..."} object).
fn extract_profile(val: &serde_json::Value) -> Option<String> {
val.as_str()
.map(|s| s.to_string())
.or_else(|| val.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()))
val.as_str().map(|s| s.to_string()).or_else(|| {
val.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
}
/// Extract a list of strings from a Value (array of strings, single string, or object keys).
fn extract_string_list(val: &serde_json::Value) -> Vec<String> {
match val {
serde_json::Value::Array(arr) => {
arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect()
}
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect(),
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Object(map) => map.keys().cloned().collect(),
_ => vec![],
@@ -517,8 +521,7 @@ fn build_channel_table(
let allow_list = allow_from.map(extract_string_list).unwrap_or_default();
// Add overrides sub-table if any policy is set
let has_overrides =
dm_policy.is_some() || group_policy.is_some() || !allow_list.is_empty();
let has_overrides = dm_policy.is_some() || group_policy.is_some() || !allow_list.is_empty();
if has_overrides {
let mut overrides = toml::map::Map::new();
@@ -549,15 +552,43 @@ fn build_channel_table(
toml::Value::Table(table)
}
#[derive(Debug, Clone)]
struct ResolvedModelRef {
provider: String,
model: String,
base_url: Option<String>,
}
/// Split an OpenClaw model reference like `"provider/model"` into `(provider, model)`.
/// If there's no slash, returns `("anthropic", input)` as a fallback.
#[cfg(test)]
fn split_model_ref(model_ref: &str) -> (String, String) {
let resolved = split_model_ref_with_context(model_ref, None);
(resolved.provider, resolved.model)
}
/// Split a model ref and resolve provider/base URL using optional OpenClaw
/// `models.providers` metadata for higher-fidelity migration.
fn split_model_ref_with_context(
model_ref: &str,
provider_catalog: Option<&serde_json::Map<String, serde_json::Value>>,
) -> ResolvedModelRef {
if let Some(pos) = model_ref.find('/') {
let provider = &model_ref[..pos];
let raw_provider = &model_ref[..pos];
let model = &model_ref[pos + 1..];
(map_provider(provider), model.to_string())
let (provider, base_url) =
resolve_provider_with_models_context(raw_provider, provider_catalog);
ResolvedModelRef {
provider,
model: model.to_string(),
base_url,
}
} else {
("anthropic".to_string(), model_ref.to_string())
ResolvedModelRef {
provider: "anthropic".to_string(),
model: model_ref.to_string(),
base_url: None,
}
}
}
@@ -663,10 +694,27 @@ fn map_provider(openclaw_provider: &str) -> String {
"together" => "together".to_string(),
"mistral" => "mistral".to_string(),
"fireworks" => "fireworks".to_string(),
// Gemini aliases
"google" | "gemini" => "google".to_string(),
// Chinese provider aliases (including common OpenClaw custom IDs)
"qwen" | "dashscope" | "qwencode" => "qwen".to_string(),
"moonshot" | "kimi" | "kimicode" => "moonshot".to_string(),
"minimax" => "minimax".to_string(),
"zhipu" | "glm" => "zhipu".to_string(),
"zhipu_coding" | "codegeex" => "zhipu_coding".to_string(),
"qianfan" | "baidu" => "qianfan".to_string(),
"xai" | "grok" => "xai".to_string(),
"cerebras" => "cerebras".to_string(),
"sambanova" => "sambanova".to_string(),
// Additional OpenFang-supported providers and aliases
"perplexity" => "perplexity".to_string(),
"cohere" => "cohere".to_string(),
"ai21" => "ai21".to_string(),
"huggingface" => "huggingface".to_string(),
"replicate" => "replicate".to_string(),
"github-copilot" | "copilot" => "github-copilot".to_string(),
"vllm" => "vllm".to_string(),
"lmstudio" => "lmstudio".to_string(),
other => other.to_string(),
}
}
@@ -683,14 +731,156 @@ fn default_api_key_env(provider: &str) -> String {
"mistral" => "MISTRAL_API_KEY".to_string(),
"fireworks" => "FIREWORKS_API_KEY".to_string(),
"google" => "GOOGLE_API_KEY".to_string(),
"qwen" => "DASHSCOPE_API_KEY".to_string(),
"moonshot" => "MOONSHOT_API_KEY".to_string(),
"minimax" => "MINIMAX_API_KEY".to_string(),
"zhipu" | "zhipu_coding" => "ZHIPU_API_KEY".to_string(),
"qianfan" => "QIANFAN_API_KEY".to_string(),
"xai" => "XAI_API_KEY".to_string(),
"cerebras" => "CEREBRAS_API_KEY".to_string(),
"sambanova" => "SAMBANOVA_API_KEY".to_string(),
"perplexity" => "PERPLEXITY_API_KEY".to_string(),
"cohere" => "COHERE_API_KEY".to_string(),
"ai21" => "AI21_API_KEY".to_string(),
"huggingface" => "HF_API_KEY".to_string(),
"replicate" => "REPLICATE_API_TOKEN".to_string(),
"github-copilot" => "GITHUB_TOKEN".to_string(),
"ollama" => String::new(), // Ollama doesn't need an API key
// Keep explicit env names for local OpenAI-compatible providers to avoid
// falling back to default_model.api_key_env in kernel driver resolution.
"vllm" => "VLLM_API_KEY".to_string(),
"lmstudio" => "LMSTUDIO_API_KEY".to_string(),
_ => format!("{}_API_KEY", provider.to_uppercase()),
}
}
fn is_known_openfang_provider_id(provider: &str) -> bool {
matches!(
provider,
"anthropic"
| "claude"
| "openai"
| "gpt"
| "groq"
| "grok"
| "openrouter"
| "deepseek"
| "together"
| "mistral"
| "fireworks"
| "google"
| "gemini"
| "ollama"
| "vllm"
| "lmstudio"
| "perplexity"
| "cohere"
| "ai21"
| "cerebras"
| "sambanova"
| "huggingface"
| "xai"
| "replicate"
| "github-copilot"
| "copilot"
| "moonshot"
| "kimi"
| "qwen"
| "dashscope"
| "minimax"
| "zhipu"
| "glm"
| "zhipu_coding"
| "codegeex"
| "qianfan"
| "baidu"
)
}
fn json_get_string_case_insensitive(
obj: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Option<String> {
obj.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
}
fn lookup_json5_provider_entry<'a>(
catalog: Option<&'a serde_json::Map<String, serde_json::Value>>,
provider: &str,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
let catalog = catalog?;
if let Some(exact) = catalog
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(provider))
.and_then(|(_, v)| v.as_object())
{
return Some(exact);
}
// Alias/canonical fallback: allow model refs that use aliases (e.g. `gpt`)
// while provider catalog keys use canonical IDs (e.g. `openai`), and vice versa.
let canonical = map_provider(&provider.to_lowercase());
catalog
.iter()
.find(|(k, _)| map_provider(&k.to_lowercase()) == canonical)
.and_then(|(_, v)| v.as_object())
}
fn resolve_provider_with_models_context(
openclaw_provider: &str,
provider_catalog: Option<&serde_json::Map<String, serde_json::Value>>,
) -> (String, Option<String>) {
let raw = openclaw_provider.to_lowercase();
let mapped = map_provider(&raw);
let Some(entry) = lookup_json5_provider_entry(provider_catalog, openclaw_provider) else {
return (mapped, None);
};
let base_url = json_get_string_case_insensitive(entry, "baseUrl")
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let api_hint = json_get_string_case_insensitive(entry, "api")
.unwrap_or_default()
.to_lowercase();
let raw_is_known = is_known_openfang_provider_id(&raw);
let mapped_is_known = is_known_openfang_provider_id(&mapped);
let provider = if api_hint.contains("anthropic") {
"anthropic".to_string()
} else if api_hint.contains("gemini") || api_hint.contains("google") {
"google".to_string()
} else if api_hint.contains("openai") {
if raw_is_known {
mapped.clone()
} else if base_url.is_some() {
// Preserve custom IDs only when runtime can route them via base_url.
raw.clone()
} else if mapped_is_known {
mapped.clone()
} else {
// Unknown custom ID without base_url would fail at runtime; default to
// OpenAI-compatible driver.
"openai".to_string()
}
} else if raw_is_known {
mapped.clone()
} else if base_url.is_some() {
// Unknown API type + explicit custom provider metadata:
// keep custom provider id and rely on base_url for runtime routing.
raw.clone()
} else if mapped_is_known {
mapped.clone()
} else {
// No API hint and no base URL; choose a known OpenAI-compatible driver.
"openai".to_string()
};
(provider, base_url)
}
/// Derive capability grants from the tool list.
fn derive_capabilities(tools: &[String]) -> AgentCapabilities {
let mut caps = AgentCapabilities::default();
@@ -816,15 +1006,21 @@ fn scan_from_json5(base: &Path, config_path: &Path, result: &mut ScanResult) {
Err(_) => return,
};
let provider_catalog = root.models.as_ref().and_then(|m| m.providers.as_ref());
// Agents from JSON config
if let Some(ref agents) = root.agents {
for entry in &agents.list {
let id = entry.id.clone();
let name = entry.name.clone().unwrap_or_else(|| id.clone());
let (provider, model) = extract_primary_model(entry, agents.defaults.as_ref())
.map(|m| split_model_ref(&m))
.unwrap_or_else(|| ("anthropic".to_string(), String::new()));
let resolved = extract_primary_model(entry, agents.defaults.as_ref())
.map(|m| split_model_ref_with_context(&m, provider_catalog))
.unwrap_or(ResolvedModelRef {
provider: "anthropic".to_string(),
model: String::new(),
base_url: None,
});
let tool_count = entry
.tools
@@ -853,8 +1049,8 @@ fn scan_from_json5(base: &Path, config_path: &Path, result: &mut ScanResult) {
result.agents.push(ScannedAgent {
name,
description: String::new(),
provider,
model,
provider: resolved.provider,
model: resolved.model,
tool_count,
has_memory,
has_sessions,
@@ -1159,8 +1355,10 @@ fn migrate_config_from_json(
dry_run: bool,
report: &mut MigrationReport,
) -> Result<(), MigrateError> {
let provider_catalog = root.models.as_ref().and_then(|m| m.providers.as_ref());
// Extract default model from agents.defaults.model
let (provider, model) = root
let resolved = root
.agents
.as_ref()
.and_then(|a| a.defaults.as_ref())
@@ -1169,25 +1367,24 @@ fn migrate_config_from_json(
OpenClawAgentModel::Simple(s) => Some(s.clone()),
OpenClawAgentModel::Detailed(d) => d.primary.clone(),
})
.map(|m| split_model_ref(&m))
.unwrap_or_else(|| {
(
"anthropic".to_string(),
"claude-sonnet-4-20250514".to_string(),
)
.map(|m| split_model_ref_with_context(&m, provider_catalog))
.unwrap_or_else(|| ResolvedModelRef {
provider: "anthropic".to_string(),
model: "claude-sonnet-4-20250514".to_string(),
base_url: None,
});
let api_key_env = default_api_key_env(&provider);
let api_key_env = default_api_key_env(&resolved.provider);
// Extract channels (writes secrets.env)
let channels = migrate_channels_from_json(root, target, dry_run, report);
let of_config = OpenFangConfig {
default_model: OpenFangModelConfig {
provider,
model,
provider: resolved.provider,
model: resolved.model,
api_key_env,
base_url: None,
base_url: resolved.base_url,
},
memory: OpenFangMemorySection { decay_rate: 0.05 },
network: OpenFangNetworkSection {
@@ -1731,6 +1928,7 @@ fn migrate_agents_from_json(
};
let defaults = agents.defaults.as_ref();
let provider_catalog = root.models.as_ref().and_then(|m| m.providers.as_ref());
for entry in &agents.list {
let id = &entry.id;
@@ -1738,7 +1936,7 @@ fn migrate_agents_from_json(
continue;
}
match convert_agent_from_json(entry, defaults) {
match convert_agent_from_json(entry, defaults, provider_catalog) {
Ok((toml_str, unmapped_tools)) => {
let dest_dir = target.join("agents").join(id);
let dest_file = dest_dir.join("agent.toml");
@@ -1779,6 +1977,7 @@ fn migrate_agents_from_json(
fn convert_agent_from_json(
entry: &OpenClawAgentEntry,
defaults: Option<&OpenClawAgentDefaults>,
provider_catalog: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<(String, Vec<String>), MigrateError> {
let id = &entry.id;
let display_name = entry.name.clone().unwrap_or_else(|| id.clone());
@@ -1786,7 +1985,11 @@ fn convert_agent_from_json(
// Resolve model
let primary_ref = extract_primary_model(entry, defaults)
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".to_string());
let (provider, model) = split_model_ref(&primary_ref);
let ResolvedModelRef {
provider,
model,
base_url: primary_base_url,
} = split_model_ref_with_context(&primary_ref, provider_catalog);
// Resolve fallback models
let fallbacks = extract_fallback_models(entry, defaults);
@@ -1879,10 +2082,15 @@ fn convert_agent_from_json(
if let Some(ref api_key) = api_key_env {
toml_str.push_str(&format!("api_key_env = \"{api_key}\"\n"));
}
if let Some(base_url) = primary_base_url {
toml_str.push_str(&format!("base_url = \"{base_url}\"\n"));
}
// Fallback models
for fb in &fallbacks {
let (fb_provider, fb_model) = split_model_ref(fb);
let fallback = split_model_ref_with_context(fb, provider_catalog);
let fb_provider = fallback.provider;
let fb_model = fallback.model;
let fb_api_key = default_api_key_env(&fb_provider);
toml_str.push_str("\n[[fallback_models]]\n");
toml_str.push_str(&format!("provider = \"{fb_provider}\"\n"));
@@ -1890,6 +2098,9 @@ fn convert_agent_from_json(
if !fb_api_key.is_empty() {
toml_str.push_str(&format!("api_key_env = \"{fb_api_key}\"\n"));
}
if let Some(base_url) = fallback.base_url {
toml_str.push_str(&format!("base_url = \"{base_url}\"\n"));
}
}
// Capabilities section
@@ -3070,6 +3281,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), std::io::Error> {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
// ===== Helper: create legacy YAML workspace =====
@@ -3807,6 +4019,102 @@ mod tests {
assert_eq!(m, "");
}
#[test]
fn test_model_ref_split_with_catalog_preserves_custom_openai_provider() {
let providers = json!({
"qwencode": {
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
"api": "openai-completions"
}
});
let catalog = providers.as_object().unwrap();
let resolved = split_model_ref_with_context("qwencode/glm-5", Some(catalog));
assert_eq!(resolved.provider, "qwencode");
assert_eq!(resolved.model, "glm-5");
assert_eq!(
resolved.base_url.as_deref(),
Some("https://coding.dashscope.aliyuncs.com/v1")
);
}
#[test]
fn test_model_ref_split_with_catalog_uses_api_hint_for_driver() {
let providers = json!({
"kimicode": {
"baseUrl": "https://api.kimi.com/coding",
"api": "anthropic-messages"
}
});
let catalog = providers.as_object().unwrap();
let resolved = split_model_ref_with_context("kimicode/kimi-k2.5", Some(catalog));
assert_eq!(resolved.provider, "anthropic");
assert_eq!(resolved.model, "kimi-k2.5");
assert_eq!(
resolved.base_url.as_deref(),
Some("https://api.kimi.com/coding")
);
}
#[test]
fn test_model_ref_split_with_catalog_unknown_openai_without_base_url_falls_back_to_openai() {
let providers = json!({
"mycompany": {
"api": "openai-completions"
}
});
let catalog = providers.as_object().unwrap();
let resolved = split_model_ref_with_context("mycompany/custom-model", Some(catalog));
assert_eq!(resolved.provider, "openai");
assert_eq!(resolved.model, "custom-model");
assert_eq!(resolved.base_url, None);
}
#[test]
fn test_model_ref_split_with_catalog_alias_gpt_maps_to_openai() {
let providers = json!({
"gpt": {
"baseUrl": "https://api.openai.com/v1",
"api": "openai-completions"
}
});
let catalog = providers.as_object().unwrap();
let resolved = split_model_ref_with_context("gpt/gpt-4.1-mini", Some(catalog));
assert_eq!(resolved.provider, "openai");
assert_eq!(resolved.model, "gpt-4.1-mini");
assert_eq!(
resolved.base_url.as_deref(),
Some("https://api.openai.com/v1")
);
}
#[test]
fn test_model_ref_split_with_catalog_alias_key_mismatch_uses_catalog_metadata() {
let providers = json!({
"openai": {
"baseUrl": "https://proxy.example/v1",
"api": "openai-completions"
}
});
let catalog = providers.as_object().unwrap();
let resolved = split_model_ref_with_context("gpt/gpt-4.1-mini", Some(catalog));
assert_eq!(resolved.provider, "openai");
assert_eq!(resolved.model, "gpt-4.1-mini");
assert_eq!(
resolved.base_url.as_deref(),
Some("https://proxy.example/v1")
);
}
#[test]
fn test_json5_unknown_provider_passthrough() {
let source = TempDir::new().unwrap();
@@ -3954,6 +4262,37 @@ mod tests {
assert_eq!(map_provider("gemini"), "google");
assert_eq!(map_provider("xai"), "xai");
assert_eq!(map_provider("grok"), "xai");
assert_eq!(map_provider("qwen"), "qwen");
assert_eq!(map_provider("dashscope"), "qwen");
assert_eq!(map_provider("qwencode"), "qwen");
assert_eq!(map_provider("moonshot"), "moonshot");
assert_eq!(map_provider("kimi"), "moonshot");
assert_eq!(map_provider("kimicode"), "moonshot");
assert_eq!(map_provider("zhipu"), "zhipu");
assert_eq!(map_provider("glm"), "zhipu");
assert_eq!(map_provider("codegeex"), "zhipu_coding");
assert_eq!(map_provider("baidu"), "qianfan");
assert_eq!(map_provider("copilot"), "github-copilot");
assert_eq!(map_provider("github-copilot"), "github-copilot");
}
#[test]
fn test_default_api_key_env_mapping() {
assert_eq!(default_api_key_env("qwen"), "DASHSCOPE_API_KEY");
assert_eq!(default_api_key_env("moonshot"), "MOONSHOT_API_KEY");
assert_eq!(default_api_key_env("minimax"), "MINIMAX_API_KEY");
assert_eq!(default_api_key_env("zhipu"), "ZHIPU_API_KEY");
assert_eq!(default_api_key_env("zhipu_coding"), "ZHIPU_API_KEY");
assert_eq!(default_api_key_env("qianfan"), "QIANFAN_API_KEY");
assert_eq!(default_api_key_env("perplexity"), "PERPLEXITY_API_KEY");
assert_eq!(default_api_key_env("cohere"), "COHERE_API_KEY");
assert_eq!(default_api_key_env("ai21"), "AI21_API_KEY");
assert_eq!(default_api_key_env("huggingface"), "HF_API_KEY");
assert_eq!(default_api_key_env("replicate"), "REPLICATE_API_TOKEN");
assert_eq!(default_api_key_env("github-copilot"), "GITHUB_TOKEN");
assert!(default_api_key_env("ollama").is_empty());
assert_eq!(default_api_key_env("vllm"), "VLLM_API_KEY");
assert_eq!(default_api_key_env("lmstudio"), "LMSTUDIO_API_KEY");
}
#[test]
@@ -0,0 +1,137 @@
use openfang_migrate::{run_migration, MigrateOptions, MigrateSource};
use tempfile::TempDir;
fn migrate_with_json5(json5_content: &str) -> (TempDir, TempDir) {
let source = TempDir::new().expect("create source tempdir");
let target = TempDir::new().expect("create target tempdir");
std::fs::write(source.path().join("openclaw.json"), json5_content)
.expect("write openclaw.json");
let options = MigrateOptions {
source: MigrateSource::OpenClaw,
source_dir: source.path().to_path_buf(),
target_dir: target.path().to_path_buf(),
dry_run: false,
};
run_migration(&options).expect("run migration");
(source, target)
}
#[test]
fn json5_agent_provider_and_fallback_api_key_env_mapping() {
let json5_content = r#"{
agents: {
list: [
{
id: "provider-case",
model: {
primary: "qwencode/glm-5",
fallbacks: [
"kimicode/kimi-k2.5",
"copilot/gpt-4.1",
"vllm/llama3"
]
}
}
]
}
}"#;
let (_source, target) = migrate_with_json5(json5_content);
let agent_toml = std::fs::read_to_string(
target
.path()
.join("agents")
.join("provider-case")
.join("agent.toml"),
)
.expect("read migrated agent.toml");
let parsed: toml::Value = toml::from_str(&agent_toml).expect("parse agent.toml");
let model = parsed
.get("model")
.and_then(toml::Value::as_table)
.expect("[model] exists");
assert_eq!(
model.get("provider").and_then(toml::Value::as_str),
Some("qwen")
);
assert_eq!(
model.get("api_key_env").and_then(toml::Value::as_str),
Some("DASHSCOPE_API_KEY")
);
let fallback_models = parsed
.get("fallback_models")
.and_then(toml::Value::as_array)
.expect("[[fallback_models]] exists");
assert_eq!(fallback_models.len(), 3);
let expected = [
("moonshot", "MOONSHOT_API_KEY"),
("github-copilot", "GITHUB_TOKEN"),
("vllm", "VLLM_API_KEY"),
];
for (index, (expected_provider, expected_api_key_env)) in expected.iter().enumerate() {
let fallback = fallback_models[index]
.as_table()
.expect("fallback entry is table");
assert_eq!(
fallback.get("provider").and_then(toml::Value::as_str),
Some(*expected_provider)
);
assert_eq!(
fallback.get("api_key_env").and_then(toml::Value::as_str),
Some(*expected_api_key_env)
);
}
}
#[test]
fn json5_lmstudio_provider_api_key_env_mapping() {
let json5_content = r#"{
agents: {
list: [
{
id: "lmstudio-case",
model: "lmstudio/llama3"
}
]
}
}"#;
let (_source, target) = migrate_with_json5(json5_content);
let agent_toml = std::fs::read_to_string(
target
.path()
.join("agents")
.join("lmstudio-case")
.join("agent.toml"),
)
.expect("read migrated lmstudio agent.toml");
let parsed: toml::Value = toml::from_str(&agent_toml).expect("parse agent.toml");
let model = parsed
.get("model")
.and_then(toml::Value::as_table)
.expect("[model] exists");
assert_eq!(
model.get("provider").and_then(toml::Value::as_str),
Some("lmstudio")
);
assert_eq!(
model.get("api_key_env").and_then(toml::Value::as_str),
Some("LMSTUDIO_API_KEY")
);
}
@@ -0,0 +1,64 @@
use openfang_migrate::{openclaw, MigrateOptions, MigrateSource};
use tempfile::TempDir;
fn assert_default_model_mapping(
model_ref: &str,
expected_provider: &str,
expected_api_key_env: &str,
) {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let openclaw_json = format!(
r#"{{
agents: {{
defaults: {{ model: "{model_ref}" }},
list: [
{{ id: "tester" }}
]
}}
}}"#
);
std::fs::write(source.path().join("openclaw.json"), openclaw_json).unwrap();
let options = MigrateOptions {
source: MigrateSource::OpenClaw,
source_dir: source.path().to_path_buf(),
target_dir: target.path().to_path_buf(),
dry_run: false,
};
openclaw::migrate(&options).unwrap();
let config_toml = std::fs::read_to_string(target.path().join("config.toml")).unwrap();
let parsed: toml::Value = toml::from_str(&config_toml).unwrap();
let default_model = parsed
.get("default_model")
.and_then(toml::Value::as_table)
.expect("config.toml should contain [default_model]");
assert_eq!(
default_model.get("provider").and_then(toml::Value::as_str),
Some(expected_provider)
);
assert_eq!(
default_model
.get("api_key_env")
.and_then(toml::Value::as_str),
Some(expected_api_key_env)
);
}
#[test]
fn json5_default_model_qwencode_maps_to_qwen_and_dashscope_key() {
assert_default_model_mapping("qwencode/glm-5", "qwen", "DASHSCOPE_API_KEY");
}
#[test]
fn json5_default_model_kimicode_maps_to_moonshot_and_moonshot_key() {
assert_default_model_mapping("kimicode/kimi-k2.5", "moonshot", "MOONSHOT_API_KEY");
}
#[test]
fn json5_default_model_copilot_maps_to_github_copilot_and_github_token() {
assert_default_model_mapping("copilot/gpt-4.1", "github-copilot", "GITHUB_TOKEN");
}

Some files were not shown because too many files have changed in this diff Show More