mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bc26cf591 | ||
|
|
911e057ccd | ||
|
|
815416cd62 | ||
|
|
f0bd4f5b88 | ||
|
|
c3343aa015 | ||
|
|
a0f61b39d2 |
-132
@@ -7,138 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.22.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.21.0...ironclaw-v0.22.0) - 2026-03-25
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- *(agent)* thread per-tool reasoning through provider, session, and all surfaces ([#1513](https://github.com/nearai/ironclaw/pull/1513))
|
|
||||||
- *(cli)* show credential auth status in tool info ([#1572](https://github.com/nearai/ironclaw/pull/1572))
|
|
||||||
- multi-tenant auth with per-user workspace isolation ([#1118](https://github.com/nearai/ironclaw/pull/1118))
|
|
||||||
- *(cli)* add ironclaw models subcommands (list/status/set/set-provider) ([#1043](https://github.com/nearai/ironclaw/pull/1043))
|
|
||||||
- *(workspace)* multi-scope workspace reads ([#1117](https://github.com/nearai/ironclaw/pull/1117))
|
|
||||||
- *(ux)* complete UX overhaul — design system, onboarding, web polish ([#1277](https://github.com/nearai/ironclaw/pull/1277))
|
|
||||||
- *(gemini_oauth)* full Gemini CLI OAuth integration with Cloud Code API ([#1356](https://github.com/nearai/ironclaw/pull/1356))
|
|
||||||
- *(shell)* add Low/Medium/High risk levels for graduated command approval (closes #172) ([#368](https://github.com/nearai/ironclaw/pull/368))
|
|
||||||
- *(agent)* queue and merge messages during active turns ([#1412](https://github.com/nearai/ironclaw/pull/1412))
|
|
||||||
- *(cli)* add `ironclaw hooks list` subcommand ([#1023](https://github.com/nearai/ironclaw/pull/1023))
|
|
||||||
- *(extensions)* support text setup fields in web configure modal ([#496](https://github.com/nearai/ironclaw/pull/496))
|
|
||||||
- *(llm)* add GitHub Copilot as LLM provider ([#1512](https://github.com/nearai/ironclaw/pull/1512))
|
|
||||||
- *(workspace)* layered memory with sensitivity-based privacy redirect ([#1112](https://github.com/nearai/ironclaw/pull/1112))
|
|
||||||
- *(webhooks)* add public webhook trigger endpoint for routines ([#736](https://github.com/nearai/ironclaw/pull/736))
|
|
||||||
- *(llm)* Add OpenAI Codex (ChatGPT subscription) as LLM provider ([#1461](https://github.com/nearai/ironclaw/pull/1461))
|
|
||||||
- *(web)* add light theme with dark/light/system toggle ([#1457](https://github.com/nearai/ironclaw/pull/1457))
|
|
||||||
- *(agent)* activate stuck_threshold for time-based stuck job detection ([#1234](https://github.com/nearai/ironclaw/pull/1234))
|
|
||||||
- chat onboarding and routine advisor ([#927](https://github.com/nearai/ironclaw/pull/927))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- ensure LLM calls always end with user message (closes #763) ([#1259](https://github.com/nearai/ironclaw/pull/1259))
|
|
||||||
- restore owner-scoped gateway startup ([#1625](https://github.com/nearai/ironclaw/pull/1625))
|
|
||||||
- remove stale stream_token gate from channel-relay activation ([#1623](https://github.com/nearai/ironclaw/pull/1623))
|
|
||||||
- *(agent)* case-insensitive channel match and user_id filter for event triggers ([#1211](https://github.com/nearai/ironclaw/pull/1211))
|
|
||||||
- *(routines)* normalize status display across web and CLI ([#1469](https://github.com/nearai/ironclaw/pull/1469))
|
|
||||||
- *(tunnel)* managed tunnels target wrong port and die from SIGPIPE ([#1093](https://github.com/nearai/ironclaw/pull/1093))
|
|
||||||
- *(agent)* persist /model selection to .env, TOML, and DB ([#1581](https://github.com/nearai/ironclaw/pull/1581))
|
|
||||||
- post-merge review sweep — 8 fixes across security, perf, and correctness ([#1550](https://github.com/nearai/ironclaw/pull/1550))
|
|
||||||
- generate Mistral-compatible 9-char alphanumeric tool call IDs ([#1242](https://github.com/nearai/ironclaw/pull/1242))
|
|
||||||
- *(mcp)* handle empty 202 notification acknowledgements ([#1539](https://github.com/nearai/ironclaw/pull/1539))
|
|
||||||
- *(tests)* eliminate env mutex poison cascade ([#1558](https://github.com/nearai/ironclaw/pull/1558))
|
|
||||||
- *(safety)* escape tool output XML content and remove misleading sanitized attr ([#1067](https://github.com/nearai/ironclaw/pull/1067))
|
|
||||||
- *(oauth)* reject malformed ic2.* states in decode_hosted_oauth_state ([#1441](https://github.com/nearai/ironclaw/pull/1441)) ([#1454](https://github.com/nearai/ironclaw/pull/1454))
|
|
||||||
- parameter coercion and validation for oneOf/anyOf/allOf schemas ([#1397](https://github.com/nearai/ironclaw/pull/1397))
|
|
||||||
- persist startup-loaded MCP clients in ExtensionManager ([#1509](https://github.com/nearai/ironclaw/pull/1509))
|
|
||||||
- *(deps)* patch rustls-webpki vulnerability (RUSTSEC-2026-0049)
|
|
||||||
- *(routines)* add missing extension_manager field in trigger_manual EngineContext
|
|
||||||
- *(ci)* serialize env-mutating OAuth wildcard tests with ENV_MUTEX ([#1280](https://github.com/nearai/ironclaw/pull/1280)) ([#1468](https://github.com/nearai/ironclaw/pull/1468))
|
|
||||||
- *(setup)* remove redundant LLM config and API keys from bootstrap .env ([#1448](https://github.com/nearai/ironclaw/pull/1448))
|
|
||||||
- resolve wasm broadcast merge conflicts with staging ([#395](https://github.com/nearai/ironclaw/pull/395)) ([#1460](https://github.com/nearai/ironclaw/pull/1460))
|
|
||||||
- skip credential validation for Bedrock backend ([#1011](https://github.com/nearai/ironclaw/pull/1011))
|
|
||||||
- register sandbox jobs in ContextManager for query tool visibility ([#1426](https://github.com/nearai/ironclaw/pull/1426))
|
|
||||||
- prefer execution-local message routing metadata ([#1449](https://github.com/nearai/ironclaw/pull/1449))
|
|
||||||
- *(security)* validate embedding base URLs to prevent SSRF ([#1221](https://github.com/nearai/ironclaw/pull/1221))
|
|
||||||
- f32→f64 precision artifact in temperature causes provider 400 errors ([#1450](https://github.com/nearai/ironclaw/pull/1450))
|
|
||||||
- *(routines)* surface errors when sandbox unavailable for full_job routines ([#769](https://github.com/nearai/ironclaw/pull/769))
|
|
||||||
- restore libSQL vector search with dynamic dimensions ([#1393](https://github.com/nearai/ironclaw/pull/1393))
|
|
||||||
- staging CI triage — consolidate retry parsing, fix flaky tests, add docs ([#1427](https://github.com/nearai/ironclaw/pull/1427))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- Merge branch 'main' into staging-promote/455f543b-23329172268
|
|
||||||
- Merge pull request #1655 from nearai/codex/fix-staging-promotion-1451-version-bumps
|
|
||||||
- Merge pull request #1499 from nearai/staging-promote/9603fefd-23364438978
|
|
||||||
- Fix libsql prompt scope regressions ([#1651](https://github.com/nearai/ironclaw/pull/1651))
|
|
||||||
- Normalize cron schedules on routine create ([#1648](https://github.com/nearai/ironclaw/pull/1648))
|
|
||||||
- Fix MCP lifecycle trace user scope ([#1646](https://github.com/nearai/ironclaw/pull/1646))
|
|
||||||
- Fix REPL single-message hang and cap CI test duration ([#1643](https://github.com/nearai/ironclaw/pull/1643))
|
|
||||||
- extract AppEvent to crates/ironclaw_common ([#1615](https://github.com/nearai/ironclaw/pull/1615))
|
|
||||||
- Fix hosted OAuth refresh via proxy ([#1602](https://github.com/nearai/ironclaw/pull/1602))
|
|
||||||
- *(agent)* optimize approval thread resolution (UUID parsing + lock contention) ([#1592](https://github.com/nearai/ironclaw/pull/1592))
|
|
||||||
- *(tools)* auto-compact WASM tool schemas, add descriptions, improve credential prompts ([#1525](https://github.com/nearai/ironclaw/pull/1525))
|
|
||||||
- Default new lightweight routines to tools-enabled ([#1573](https://github.com/nearai/ironclaw/pull/1573))
|
|
||||||
- Google OAuth URL broken when initiated from Telegram channel ([#1165](https://github.com/nearai/ironclaw/pull/1165))
|
|
||||||
- add gitcgr code graph badge ([#1563](https://github.com/nearai/ironclaw/pull/1563))
|
|
||||||
- Fix owner-scoped message routing fallbacks ([#1574](https://github.com/nearai/ironclaw/pull/1574))
|
|
||||||
- *(tools)* remove unconditional params clone in shared execution (fix #893) ([#926](https://github.com/nearai/ironclaw/pull/926))
|
|
||||||
- *(llm)* move transcription module into src/llm/ ([#1559](https://github.com/nearai/ironclaw/pull/1559))
|
|
||||||
- *(agent)* avoid preview allocations for non-truncated strings (fix #894) ([#924](https://github.com/nearai/ironclaw/pull/924))
|
|
||||||
- Expand AGENTS.md with coding agents guidance ([#1392](https://github.com/nearai/ironclaw/pull/1392))
|
|
||||||
- Fix CI approval flows and stale fixtures ([#1478](https://github.com/nearai/ironclaw/pull/1478))
|
|
||||||
- Use live owner tool scope for autonomous routines and jobs ([#1453](https://github.com/nearai/ironclaw/pull/1453))
|
|
||||||
- use Arc in embedding cache to avoid clones on miss path ([#1438](https://github.com/nearai/ironclaw/pull/1438))
|
|
||||||
- Add owner-scoped permissions for full-job routines ([#1440](https://github.com/nearai/ironclaw/pull/1440))
|
|
||||||
|
|
||||||
## [0.21.0](https://github.com/nearai/ironclaw/compare/v0.20.0...v0.21.0) - 2026-03-20
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- structured fallback deliverables for failed/stuck jobs ([#236](https://github.com/nearai/ironclaw/pull/236))
|
|
||||||
- LRU embedding cache for workspace search ([#1423](https://github.com/nearai/ironclaw/pull/1423))
|
|
||||||
- receive relay events via webhook callbacks ([#1254](https://github.com/nearai/ironclaw/pull/1254))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- bump Feishu channel version for promotion
|
|
||||||
- *(approval)* make "always" auto-approve work for credentialed HTTP requests ([#1257](https://github.com/nearai/ironclaw/pull/1257))
|
|
||||||
- skip NEAR AI session check when backend is not nearai ([#1413](https://github.com/nearai/ironclaw/pull/1413))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- Make hosted OAuth and MCP auth generic ([#1375](https://github.com/nearai/ironclaw/pull/1375))
|
|
||||||
|
|
||||||
## [0.20.0](https://github.com/nearai/ironclaw/compare/v0.19.0...v0.20.0) - 2026-03-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- *(self-repair)* wire stuck_threshold, store, and builder ([#712](https://github.com/nearai/ironclaw/pull/712))
|
|
||||||
- *(testing)* add FaultInjector framework for StubLlm ([#1233](https://github.com/nearai/ironclaw/pull/1233))
|
|
||||||
- *(gateway)* unified settings page with subtabs ([#1191](https://github.com/nearai/ironclaw/pull/1191))
|
|
||||||
- upgrade MiniMax default model to M2.7 ([#1357](https://github.com/nearai/ironclaw/pull/1357))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- navigate telegram E2E tests to channels subtab ([#1408](https://github.com/nearai/ironclaw/pull/1408))
|
|
||||||
- add missing `builder` field and update E2E extensions tab navigation ([#1400](https://github.com/nearai/ironclaw/pull/1400))
|
|
||||||
- remove debug_assert guards that panic on valid error paths ([#1385](https://github.com/nearai/ironclaw/pull/1385))
|
|
||||||
- address valid review comments from PR #1359 ([#1380](https://github.com/nearai/ironclaw/pull/1380))
|
|
||||||
- full_job routine runs stay running until linked job completion ([#1374](https://github.com/nearai/ironclaw/pull/1374))
|
|
||||||
- full_job routine concurrency tracks linked job lifetime ([#1372](https://github.com/nearai/ironclaw/pull/1372))
|
|
||||||
- remove -x from coverage pytest to prevent suite-blocking failures ([#1360](https://github.com/nearai/ironclaw/pull/1360))
|
|
||||||
- add debug_assert invariant guards to critical code paths ([#1312](https://github.com/nearai/ironclaw/pull/1312))
|
|
||||||
- *(mcp)* retry after missing session id errors ([#1355](https://github.com/nearai/ironclaw/pull/1355))
|
|
||||||
- *(telegram)* preserve polling after secret-blocked updates ([#1353](https://github.com/nearai/ironclaw/pull/1353))
|
|
||||||
- *(llm)* cap retry-after delays ([#1351](https://github.com/nearai/ironclaw/pull/1351))
|
|
||||||
- *(setup)* remove nonexistent webhook secret command hint ([#1349](https://github.com/nearai/ironclaw/pull/1349))
|
|
||||||
- Rate limiter returns retry after None instead of a duration ([#1269](https://github.com/nearai/ironclaw/pull/1269))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- bump telegram channel version to 0.2.5 ([#1410](https://github.com/nearai/ironclaw/pull/1410))
|
|
||||||
- *(ci)* enforce test requirement for state machine and resilience changes ([#1230](https://github.com/nearai/ironclaw/pull/1230)) ([#1304](https://github.com/nearai/ironclaw/pull/1304))
|
|
||||||
- Fix duplicate LLM responses for matched event routines ([#1275](https://github.com/nearai/ironclaw/pull/1275))
|
|
||||||
- add Japanese README ([#1306](https://github.com/nearai/ironclaw/pull/1306))
|
|
||||||
- *(ci)* add coverage gates via codecov.yml ([#1228](https://github.com/nearai/ironclaw/pull/1228)) ([#1291](https://github.com/nearai/ironclaw/pull/1291))
|
|
||||||
- Redesign routine create requests for LLMs ([#1147](https://github.com/nearai/ironclaw/pull/1147))
|
|
||||||
|
|
||||||
## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
|
## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+26
-147
@@ -121,15 +121,6 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ansi_term"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
|
|
||||||
dependencies = [
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "0.6.21"
|
version = "0.6.21"
|
||||||
@@ -166,7 +157,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -177,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -401,17 +392,6 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "atty"
|
|
||||||
version = "0.2.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
|
||||||
dependencies = [
|
|
||||||
"hermit-abi 0.1.19",
|
|
||||||
"libc",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -961,29 +941,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bindgen"
|
|
||||||
version = "0.59.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"cexpr",
|
|
||||||
"clang-sys",
|
|
||||||
"clap 2.34.0",
|
|
||||||
"env_logger",
|
|
||||||
"lazy_static",
|
|
||||||
"lazycell",
|
|
||||||
"log",
|
|
||||||
"peeking_take_while",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"regex",
|
|
||||||
"rustc-hash 1.1.0",
|
|
||||||
"shlex",
|
|
||||||
"which",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.66.1"
|
version = "0.66.1"
|
||||||
@@ -1403,21 +1360,6 @@ dependencies = [
|
|||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "clap"
|
|
||||||
version = "2.34.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
|
|
||||||
dependencies = [
|
|
||||||
"ansi_term",
|
|
||||||
"atty",
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"strsim 0.8.0",
|
|
||||||
"textwrap",
|
|
||||||
"unicode-width 0.1.14",
|
|
||||||
"vec_map",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.60"
|
version = "4.5.60"
|
||||||
@@ -1437,7 +1379,7 @@ dependencies = [
|
|||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"clap_lex",
|
"clap_lex",
|
||||||
"strsim 0.11.1",
|
"strsim",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1446,7 +1388,7 @@ version = "4.5.66"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031"
|
checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap 4.5.60",
|
"clap",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1755,7 +1697,7 @@ dependencies = [
|
|||||||
"anes",
|
"anes",
|
||||||
"cast",
|
"cast",
|
||||||
"ciborium",
|
"ciborium",
|
||||||
"clap 4.5.60",
|
"clap",
|
||||||
"criterion-plot",
|
"criterion-plot",
|
||||||
"is-terminal",
|
"is-terminal",
|
||||||
"itertools 0.10.5",
|
"itertools 0.10.5",
|
||||||
@@ -1998,7 +1940,7 @@ dependencies = [
|
|||||||
"ident_case",
|
"ident_case",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim 0.11.1",
|
"strsim",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2194,7 +2136,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.2",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2368,19 +2310,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_logger"
|
|
||||||
version = "0.9.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
|
|
||||||
dependencies = [
|
|
||||||
"atty",
|
|
||||||
"humantime",
|
|
||||||
"log",
|
|
||||||
"regex",
|
|
||||||
"termcolor",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -2394,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2919,15 +2848,6 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hermit-abi"
|
|
||||||
version = "0.1.19"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -3088,12 +3008,6 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "humantime"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "0.14.32"
|
version = "0.14.32"
|
||||||
@@ -3236,7 +3150,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.6.3",
|
"socket2 0.5.10",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -3476,9 +3390,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.22.0"
|
version = "0.19.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -3493,7 +3406,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"clap 4.5.60",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"criterion",
|
"criterion",
|
||||||
"cron",
|
"cron",
|
||||||
@@ -3545,7 +3458,6 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yml",
|
"serde_yml",
|
||||||
"sha2",
|
"sha2",
|
||||||
"silk-rs",
|
|
||||||
"subtle",
|
"subtle",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -3584,7 +3496,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw_safety"
|
name = "ironclaw_safety"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -3609,9 +3521,9 @@ version = "0.4.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hermit-abi 0.5.2",
|
"hermit-abi",
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3845,7 +3757,7 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf"
|
checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen 0.66.1",
|
"bindgen",
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4231,7 +4143,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4319,7 +4231,7 @@ version = "1.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hermit-abi 0.5.2",
|
"hermit-abi",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4797,7 +4709,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"hermit-abi 0.5.2",
|
"hermit-abi",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -5017,7 +4929,7 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash 2.1.1",
|
"rustc-hash 2.1.1",
|
||||||
"rustls 0.23.37",
|
"rustls 0.23.37",
|
||||||
"socket2 0.6.3",
|
"socket2 0.5.10",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -5054,9 +4966,9 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.6.3",
|
"socket2 0.5.10",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5569,7 +5481,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6195,18 +6107,6 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "silk-rs"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "014e6619f35a385ff848570e73a0b8c36b31031e0ee11cac70192b50097b1cfe"
|
|
||||||
dependencies = [
|
|
||||||
"bindgen 0.59.2",
|
|
||||||
"bytes",
|
|
||||||
"cc",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
@@ -6263,7 +6163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6335,12 +6235,6 @@ dependencies = [
|
|||||||
"unicode-properties",
|
"unicode-properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "strsim"
|
|
||||||
version = "0.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -6494,7 +6388,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6581,15 +6475,6 @@ dependencies = [
|
|||||||
"testcontainers",
|
"testcontainers",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "textwrap"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
|
|
||||||
dependencies = [
|
|
||||||
"unicode-width 0.1.14",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -7303,7 +7188,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7456,12 +7341,6 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "vec_map"
|
|
||||||
version = "0.8.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -8159,7 +8038,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+2
-4
@@ -20,7 +20,7 @@ exclude = [
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.22.0"
|
version = "0.19.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
@@ -104,7 +104,7 @@ cron = "0.13"
|
|||||||
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
|
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
|
||||||
|
|
||||||
# Safety/sanitization
|
# Safety/sanitization
|
||||||
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
|
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.1.0" }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
aho-corasick = "1"
|
aho-corasick = "1"
|
||||||
|
|
||||||
@@ -138,7 +138,6 @@ wasmtime-wasi = "28" # WASI support for component model
|
|||||||
wasmparser = "0.220" # WASM binary parsing for validation
|
wasmparser = "0.220" # WASM binary parsing for validation
|
||||||
|
|
||||||
# Cryptography for secrets management
|
# Cryptography for secrets management
|
||||||
aes = "0.8"
|
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
@@ -175,7 +174,6 @@ base64 = "0.22.1"
|
|||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
clap_complete = "4.5.0"
|
clap_complete = "4.5.0"
|
||||||
lru = "0.16.3"
|
lru = "0.16.3"
|
||||||
silk-rs = "0.2.0"
|
|
||||||
|
|
||||||
# HTML to Markdown conversion (feature gated)
|
# HTML to Markdown conversion (feature gated)
|
||||||
html-to-markdown-rs = { version = "2.3", optional = true }
|
html-to-markdown-rs = { version = "2.3", optional = true }
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
||||||
| Feishu/Lark | ✅ | 🚧 | P3 | WASM channel with Event Subscription v2.0; Bitable/Docx tools planned |
|
| Feishu/Lark | ✅ | 🚧 | P3 | WASM channel with Event Subscription v2.0; Bitable/Docx tools planned |
|
||||||
| LINE | ✅ | ❌ | P3 | |
|
| LINE | ✅ | ❌ | P3 | |
|
||||||
| WeChat (iLink bot) | ✅ | 🚧 | P2 | Extension-first channel (`channels-src/wechat`), single-account DM flow with QR login, typing, image send/receive, inbound file extraction, and inbound voice handling with SILK-to-WAV fallback; multi-account plus video and outbound file parity follow-up |
|
|
||||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
|
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
/target
|
|
||||||
/*.wasm
|
|
||||||
Generated
-568
@@ -1,568 +0,0 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
|
||||||
# It is not intended for manual editing.
|
|
||||||
version = 4
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aes"
|
|
||||||
version = "0.8.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cipher",
|
|
||||||
"cpufeatures",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ahash"
|
|
||||||
version = "0.8.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"once_cell",
|
|
||||||
"version_check",
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anyhow"
|
|
||||||
version = "1.0.102"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.22.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bitflags"
|
|
||||||
version = "2.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "block-buffer"
|
|
||||||
version = "0.10.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
|
||||||
dependencies = [
|
|
||||||
"generic-array",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg-if"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cipher"
|
|
||||||
version = "0.4.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
|
||||||
dependencies = [
|
|
||||||
"crypto-common",
|
|
||||||
"inout",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cpufeatures"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crypto-common"
|
|
||||||
version = "0.1.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
|
||||||
dependencies = [
|
|
||||||
"generic-array",
|
|
||||||
"typenum",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "digest"
|
|
||||||
version = "0.10.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|
||||||
dependencies = [
|
|
||||||
"block-buffer",
|
|
||||||
"crypto-common",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "equivalent"
|
|
||||||
version = "1.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "generic-array"
|
|
||||||
version = "0.14.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|
||||||
dependencies = [
|
|
||||||
"typenum",
|
|
||||||
"version_check",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"wasi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.14.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.16.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "heck"
|
|
||||||
version = "0.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "id-arena"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "indexmap"
|
|
||||||
version = "2.13.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
|
||||||
dependencies = [
|
|
||||||
"equivalent",
|
|
||||||
"hashbrown 0.16.1",
|
|
||||||
"serde",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "inout"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
|
||||||
dependencies = [
|
|
||||||
"generic-array",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "itoa"
|
|
||||||
version = "1.0.18"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "leb128"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libc"
|
|
||||||
version = "0.2.183"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "log"
|
|
||||||
version = "0.4.29"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "md-5"
|
|
||||||
version = "0.10.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"digest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "memchr"
|
|
||||||
version = "2.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell"
|
|
||||||
version = "1.21.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ppv-lite86"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prettyplease"
|
|
||||||
version = "0.2.37"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro2"
|
|
||||||
version = "1.0.106"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
|
||||||
dependencies = [
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quote"
|
|
||||||
version = "1.0.45"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.8.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"rand_chacha",
|
|
||||||
"rand_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_chacha"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
|
||||||
dependencies = [
|
|
||||||
"ppv-lite86",
|
|
||||||
"rand_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.6.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "semver"
|
|
||||||
version = "1.0.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde"
|
|
||||||
version = "1.0.228"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
|
||||||
dependencies = [
|
|
||||||
"serde_core",
|
|
||||||
"serde_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_core"
|
|
||||||
version = "1.0.228"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
|
||||||
dependencies = [
|
|
||||||
"serde_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_derive"
|
|
||||||
version = "1.0.228"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_json"
|
|
||||||
version = "1.0.149"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
|
||||||
dependencies = [
|
|
||||||
"itoa",
|
|
||||||
"memchr",
|
|
||||||
"serde",
|
|
||||||
"serde_core",
|
|
||||||
"zmij",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "smallvec"
|
|
||||||
version = "1.15.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "spdx"
|
|
||||||
version = "0.10.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
|
|
||||||
dependencies = [
|
|
||||||
"smallvec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "syn"
|
|
||||||
version = "2.0.117"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typenum"
|
|
||||||
version = "1.19.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-ident"
|
|
||||||
version = "1.0.24"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-xid"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "version_check"
|
|
||||||
version = "0.9.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasi"
|
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-encoder"
|
|
||||||
version = "0.220.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
|
|
||||||
dependencies = [
|
|
||||||
"leb128",
|
|
||||||
"wasmparser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-metadata"
|
|
||||||
version = "0.220.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"indexmap",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"spdx",
|
|
||||||
"wasm-encoder",
|
|
||||||
"wasmparser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmparser"
|
|
||||||
version = "0.220.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
|
|
||||||
dependencies = [
|
|
||||||
"ahash",
|
|
||||||
"bitflags",
|
|
||||||
"hashbrown 0.14.5",
|
|
||||||
"indexmap",
|
|
||||||
"semver",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wechat-channel"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"aes",
|
|
||||||
"base64",
|
|
||||||
"cipher",
|
|
||||||
"md-5",
|
|
||||||
"rand",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"wit-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
|
|
||||||
dependencies = [
|
|
||||||
"wit-bindgen-rt",
|
|
||||||
"wit-bindgen-rust-macro",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-core"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"heck",
|
|
||||||
"wit-parser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-rt"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-rust"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"heck",
|
|
||||||
"indexmap",
|
|
||||||
"prettyplease",
|
|
||||||
"syn",
|
|
||||||
"wasm-metadata",
|
|
||||||
"wit-bindgen-core",
|
|
||||||
"wit-component",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-bindgen-rust-macro"
|
|
||||||
version = "0.36.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"prettyplease",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
"wit-bindgen-core",
|
|
||||||
"wit-bindgen-rust",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-component"
|
|
||||||
version = "0.220.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"bitflags",
|
|
||||||
"indexmap",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"wasm-encoder",
|
|
||||||
"wasm-metadata",
|
|
||||||
"wasmparser",
|
|
||||||
"wit-parser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-parser"
|
|
||||||
version = "0.220.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"id-arena",
|
|
||||||
"indexmap",
|
|
||||||
"log",
|
|
||||||
"semver",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"unicode-xid",
|
|
||||||
"wasmparser",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy"
|
|
||||||
version = "0.8.47"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
|
||||||
dependencies = [
|
|
||||||
"zerocopy-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerocopy-derive"
|
|
||||||
version = "0.8.47"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zmij"
|
|
||||||
version = "1.0.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "wechat-channel"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "WeChat iLink Bot channel for IronClaw"
|
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
wit-bindgen = "0.36"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
base64 = "0.22"
|
|
||||||
aes = "0.8"
|
|
||||||
cipher = "0.4"
|
|
||||||
md-5 = "0.10"
|
|
||||||
rand = "0.8"
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = "s"
|
|
||||||
lto = true
|
|
||||||
strip = true
|
|
||||||
codegen-units = 1
|
|
||||||
|
|
||||||
[workspace]
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
echo "Building WeChat channel WASM component..."
|
|
||||||
|
|
||||||
cargo build --release --target wasm32-wasip2
|
|
||||||
|
|
||||||
WASM_PATH="target/wasm32-wasip2/release/wechat_channel.wasm"
|
|
||||||
|
|
||||||
if [ -f "$WASM_PATH" ]; then
|
|
||||||
if command -v wasm-tools >/dev/null 2>&1; then
|
|
||||||
wasm-tools component new "$WASM_PATH" -o wechat.wasm 2>/dev/null || cp "$WASM_PATH" wechat.wasm
|
|
||||||
wasm-tools strip wechat.wasm -o wechat.wasm
|
|
||||||
else
|
|
||||||
cp "$WASM_PATH" wechat.wasm
|
|
||||||
echo "wasm-tools not found; copied raw wasm output without component conversion/strip"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Built: wechat.wasm ($(du -h wechat.wasm | cut -f1))"
|
|
||||||
echo ""
|
|
||||||
echo "To install:"
|
|
||||||
echo " mkdir -p ~/.ironclaw/channels"
|
|
||||||
echo " cp wechat.wasm wechat.capabilities.json ~/.ironclaw/channels/"
|
|
||||||
else
|
|
||||||
echo "Error: WASM output not found at $WASM_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
use crate::near::agent::channel_host;
|
|
||||||
use crate::types::{
|
|
||||||
BaseInfo, GetConfigRequest, GetConfigResponse, GetUpdatesRequest, GetUpdatesResponse,
|
|
||||||
GetUploadUrlRequest, GetUploadUrlResponse, MessageItem, OutboundWechatMessage,
|
|
||||||
SendMessageRequest, SendTypingRequest, SendTypingResponse, TextItem, WechatConfig,
|
|
||||||
MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn base_info() -> BaseInfo {
|
|
||||||
BaseInfo {
|
|
||||||
channel_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_trailing_slash(base_url: &str) -> String {
|
|
||||||
if base_url.ends_with('/') {
|
|
||||||
base_url.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{base_url}/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn random_wechat_uin() -> String {
|
|
||||||
let seed = (channel_host::now_millis() % u32::MAX as u64) as u32;
|
|
||||||
base64::engine::general_purpose::STANDARD.encode(seed.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_headers(body: &[u8]) -> String {
|
|
||||||
serde_json::json!({
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"AuthorizationType": "ilink_bot_token",
|
|
||||||
"Authorization": "Bearer {WECHAT_BOT_TOKEN}",
|
|
||||||
"Content-Length": body.len().to_string(),
|
|
||||||
"X-WECHAT-UIN": random_wechat_uin(),
|
|
||||||
})
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn summarize_body_preview(bytes: &[u8], limit: usize) -> String {
|
|
||||||
let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(limit)]);
|
|
||||||
let normalized = preview.replace(['\n', '\r'], " ");
|
|
||||||
if bytes.len() > limit {
|
|
||||||
format!("{normalized}...")
|
|
||||||
} else {
|
|
||||||
normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_updates(
|
|
||||||
config: &WechatConfig,
|
|
||||||
get_updates_buf: &str,
|
|
||||||
) -> Result<GetUpdatesResponse, String> {
|
|
||||||
get_updates_with_timeout(config, get_updates_buf, config.long_poll_timeout_ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_updates_with_timeout(
|
|
||||||
config: &WechatConfig,
|
|
||||||
get_updates_buf: &str,
|
|
||||||
timeout_ms: u32,
|
|
||||||
) -> Result<GetUpdatesResponse, String> {
|
|
||||||
let body = serde_json::to_vec(&GetUpdatesRequest {
|
|
||||||
get_updates_buf: get_updates_buf.to_string(),
|
|
||||||
base_info: base_info(),
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("Failed to encode getUpdates request: {e}"))?;
|
|
||||||
let headers = request_headers(&body);
|
|
||||||
let url = format!(
|
|
||||||
"{}ilink/bot/getupdates",
|
|
||||||
ensure_trailing_slash(&config.base_url)
|
|
||||||
);
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Info,
|
|
||||||
&format!(
|
|
||||||
"WeChat getUpdates request: cursor_len={} timeout_ms={}",
|
|
||||||
get_updates_buf.len(),
|
|
||||||
config.long_poll_timeout_ms
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let response =
|
|
||||||
channel_host::http_request("POST", &url, &headers, Some(&body), Some(timeout_ms))
|
|
||||||
.map_err(|e| format!("getUpdates request failed: {e}"))?;
|
|
||||||
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Info,
|
|
||||||
&format!(
|
|
||||||
"WeChat getUpdates response: status={} bytes={} has_image_marker={} has_aeskey_marker={} preview={}",
|
|
||||||
response.status,
|
|
||||||
response.body.len(),
|
|
||||||
response
|
|
||||||
.body
|
|
||||||
.windows(b"image_item".len())
|
|
||||||
.any(|window| window == b"image_item"),
|
|
||||||
response
|
|
||||||
.body
|
|
||||||
.windows(b"aeskey".len())
|
|
||||||
.any(|window| window == b"aeskey"),
|
|
||||||
summarize_body_preview(&response.body, 160)
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if response.status != 200 {
|
|
||||||
let body = String::from_utf8_lossy(&response.body);
|
|
||||||
return Err(format!("getUpdates returned {}: {}", response.status, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: GetUpdatesResponse = serde_json::from_slice(&response.body)
|
|
||||||
.map_err(|e| format!("Failed to parse getUpdates response: {e}"))?;
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Info,
|
|
||||||
&format!(
|
|
||||||
"WeChat getUpdates parsed: ret={:?} errcode={:?} msg_count={} next_cursor_len={}",
|
|
||||||
parsed.ret,
|
|
||||||
parsed.errcode,
|
|
||||||
parsed.msgs.len(),
|
|
||||||
parsed.get_updates_buf.as_deref().unwrap_or_default().len()
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Ok(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_text_message(
|
|
||||||
config: &WechatConfig,
|
|
||||||
to_user_id: &str,
|
|
||||||
text: &str,
|
|
||||||
context_token: Option<&str>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let message = SendMessageRequest {
|
|
||||||
msg: OutboundWechatMessage {
|
|
||||||
from_user_id: String::new(),
|
|
||||||
to_user_id: to_user_id.to_string(),
|
|
||||||
client_id: format!("wechat-{}", channel_host::now_millis()),
|
|
||||||
message_type: MESSAGE_TYPE_BOT,
|
|
||||||
message_state: MESSAGE_STATE_FINISH,
|
|
||||||
item_list: vec![MessageItem {
|
|
||||||
r#type: Some(MESSAGE_ITEM_TEXT),
|
|
||||||
text_item: Some(TextItem {
|
|
||||||
text: text.to_string(),
|
|
||||||
}),
|
|
||||||
image_item: None,
|
|
||||||
voice_item: None,
|
|
||||||
file_item: None,
|
|
||||||
}],
|
|
||||||
context_token: context_token.map(str::to_string),
|
|
||||||
},
|
|
||||||
base_info: base_info(),
|
|
||||||
};
|
|
||||||
|
|
||||||
send_message_request(config, &message)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_message_request(
|
|
||||||
config: &WechatConfig,
|
|
||||||
message: &SendMessageRequest,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let body = serde_json::to_vec(message)
|
|
||||||
.map_err(|e| format!("Failed to encode sendMessage request: {e}"))?;
|
|
||||||
let headers = request_headers(&body);
|
|
||||||
let url = format!(
|
|
||||||
"{}ilink/bot/sendmessage",
|
|
||||||
ensure_trailing_slash(&config.base_url)
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(15_000))
|
|
||||||
.map_err(|e| format!("sendMessage request failed: {e}"))?;
|
|
||||||
|
|
||||||
if response.status != 200 {
|
|
||||||
let body = String::from_utf8_lossy(&response.body);
|
|
||||||
return Err(format!(
|
|
||||||
"sendMessage returned {}: {}",
|
|
||||||
response.status, body
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_upload_url(
|
|
||||||
config: &WechatConfig,
|
|
||||||
request: &GetUploadUrlRequest,
|
|
||||||
) -> Result<GetUploadUrlResponse, String> {
|
|
||||||
let body = serde_json::to_vec(request)
|
|
||||||
.map_err(|e| format!("Failed to encode getUploadUrl request: {e}"))?;
|
|
||||||
let headers = request_headers(&body);
|
|
||||||
let url = format!(
|
|
||||||
"{}ilink/bot/getuploadurl",
|
|
||||||
ensure_trailing_slash(&config.base_url)
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(15_000))
|
|
||||||
.map_err(|e| format!("getUploadUrl request failed: {e}"))?;
|
|
||||||
|
|
||||||
if response.status != 200 {
|
|
||||||
let body = String::from_utf8_lossy(&response.body);
|
|
||||||
return Err(format!(
|
|
||||||
"getUploadUrl returned {}: {}",
|
|
||||||
response.status, body
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::from_slice(&response.body)
|
|
||||||
.map_err(|e| format!("Failed to parse getUploadUrl response: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_config(
|
|
||||||
config: &WechatConfig,
|
|
||||||
ilink_user_id: &str,
|
|
||||||
context_token: Option<&str>,
|
|
||||||
) -> Result<GetConfigResponse, String> {
|
|
||||||
let body = serde_json::to_vec(&GetConfigRequest {
|
|
||||||
ilink_user_id: ilink_user_id.to_string(),
|
|
||||||
context_token: context_token.map(str::to_string),
|
|
||||||
base_info: base_info(),
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("Failed to encode getConfig request: {e}"))?;
|
|
||||||
let headers = request_headers(&body);
|
|
||||||
let url = format!(
|
|
||||||
"{}ilink/bot/getconfig",
|
|
||||||
ensure_trailing_slash(&config.base_url)
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000))
|
|
||||||
.map_err(|e| format!("getConfig request failed: {e}"))?;
|
|
||||||
|
|
||||||
if response.status != 200 {
|
|
||||||
let body = String::from_utf8_lossy(&response.body);
|
|
||||||
return Err(format!("getConfig returned {}: {}", response.status, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::from_slice(&response.body)
|
|
||||||
.map_err(|e| format!("Failed to parse getConfig response: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_typing(
|
|
||||||
config: &WechatConfig,
|
|
||||||
ilink_user_id: &str,
|
|
||||||
typing_ticket: &str,
|
|
||||||
status: i32,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let body = serde_json::to_vec(&SendTypingRequest {
|
|
||||||
ilink_user_id: ilink_user_id.to_string(),
|
|
||||||
typing_ticket: typing_ticket.to_string(),
|
|
||||||
status,
|
|
||||||
base_info: base_info(),
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("Failed to encode sendTyping request: {e}"))?;
|
|
||||||
let headers = request_headers(&body);
|
|
||||||
let url = format!(
|
|
||||||
"{}ilink/bot/sendtyping",
|
|
||||||
ensure_trailing_slash(&config.base_url)
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000))
|
|
||||||
.map_err(|e| format!("sendTyping request failed: {e}"))?;
|
|
||||||
|
|
||||||
if response.status != 200 {
|
|
||||||
let body = String::from_utf8_lossy(&response.body);
|
|
||||||
return Err(format!("sendTyping returned {}: {}", response.status, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: SendTypingResponse = serde_json::from_slice(&response.body)
|
|
||||||
.map_err(|e| format!("Failed to parse sendTyping response: {e}"))?;
|
|
||||||
|
|
||||||
if parsed.ret.unwrap_or(0) != 0 {
|
|
||||||
let errmsg = parsed
|
|
||||||
.errmsg
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("unknown WeChat sendTyping error");
|
|
||||||
return Err(format!(
|
|
||||||
"sendTyping returned ret={} errmsg={errmsg}",
|
|
||||||
parsed.ret.unwrap_or(-1)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub const TOKEN_SECRET_NAME: &str = "wechat_bot_token";
|
|
||||||
pub const CONFIG_PATH: &str = "config.json";
|
|
||||||
pub const GET_UPDATES_BUF_PATH: &str = "state/get_updates_buf.json";
|
|
||||||
pub const CONTEXT_TOKENS_PATH: &str = "state/context_tokens.json";
|
|
||||||
pub const TYPING_TICKETS_PATH: &str = "state/typing_tickets.json";
|
|
||||||
pub const PENDING_INBOUND_PATH: &str = "state/pending_inbound.json";
|
|
||||||
@@ -1,942 +0,0 @@
|
|||||||
wit_bindgen::generate!({
|
|
||||||
world: "sandboxed-channel",
|
|
||||||
path: "../../wit/channel.wit",
|
|
||||||
});
|
|
||||||
|
|
||||||
mod api;
|
|
||||||
mod auth;
|
|
||||||
mod media;
|
|
||||||
mod state;
|
|
||||||
mod types;
|
|
||||||
|
|
||||||
use exports::near::agent::channel::{
|
|
||||||
AgentResponse, ChannelConfig, Guest, PollConfig, StatusType, StatusUpdate,
|
|
||||||
};
|
|
||||||
use near::agent::channel_host::{self, EmittedMessage};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::auth::TOKEN_SECRET_NAME;
|
|
||||||
use crate::state::{
|
|
||||||
load_config, load_context_tokens, load_get_updates_buf, load_pending_inbound_bundles,
|
|
||||||
load_typing_tickets, persist_config, persist_context_tokens, persist_get_updates_buf,
|
|
||||||
persist_pending_inbound_bundles, persist_typing_tickets, PendingInboundBundle,
|
|
||||||
StoredInboundAttachment, TypingTicketEntry,
|
|
||||||
};
|
|
||||||
use crate::types::{
|
|
||||||
OutboundMetadata, WechatConfig, WechatMessage, MESSAGE_ITEM_TEXT, MESSAGE_TYPE_USER,
|
|
||||||
TYPING_STATUS_CANCEL, TYPING_STATUS_TYPING,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYPING_TICKET_TTL_MS: u64 = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum WechatStatusAction {
|
|
||||||
Typing,
|
|
||||||
Cancel,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WechatChannel;
|
|
||||||
|
|
||||||
impl Guest for WechatChannel {
|
|
||||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
|
||||||
let config = serde_json::from_str::<WechatConfig>(&config_json)
|
|
||||||
.map_err(|e| format!("Failed to parse WeChat config: {e}"))?;
|
|
||||||
persist_config(&config)?;
|
|
||||||
|
|
||||||
Ok(ChannelConfig {
|
|
||||||
display_name: "WeChat".to_string(),
|
|
||||||
http_endpoints: Vec::new(),
|
|
||||||
poll: Some(PollConfig {
|
|
||||||
interval_ms: config.poll_interval_ms.max(30_000),
|
|
||||||
enabled: true,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_http_request(
|
|
||||||
_req: exports::near::agent::channel::IncomingHttpRequest,
|
|
||||||
) -> exports::near::agent::channel::OutgoingHttpResponse {
|
|
||||||
exports::near::agent::channel::OutgoingHttpResponse {
|
|
||||||
status: 404,
|
|
||||||
headers_json: "{}".to_string(),
|
|
||||||
body: b"{\"error\":\"wechat channel does not expose webhooks\"}".to_vec(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_poll() {
|
|
||||||
if !channel_host::secret_exists(TOKEN_SECRET_NAME) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
"WeChat bot token is missing; skipping poll",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = load_config();
|
|
||||||
let cursor = load_get_updates_buf();
|
|
||||||
let mut current_cursor = cursor.clone();
|
|
||||||
let mut context_tokens = load_context_tokens();
|
|
||||||
let mut pending_inbound = match load_pending_inbound_bundles() {
|
|
||||||
Ok(bundles) => bundles,
|
|
||||||
Err(error) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("Failed to load WeChat pending inbound bundles: {error}"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut pending_inbound_changed = false;
|
|
||||||
|
|
||||||
for bundle in take_due_pending_bundles(&mut pending_inbound, channel_host::now_millis()) {
|
|
||||||
pending_inbound_changed = true;
|
|
||||||
emit_buffered_bundle(bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
match api::get_updates(&config, ¤t_cursor) {
|
|
||||||
Ok(response) => {
|
|
||||||
if response.errcode == Some(-14) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
"WeChat getUpdates returned errcode=-14; reconnect the channel",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.ret.unwrap_or(0) != 0 {
|
|
||||||
let errmsg = response
|
|
||||||
.errmsg
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("unknown WeChat polling error");
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!(
|
|
||||||
"WeChat getUpdates returned ret={} errmsg={errmsg}",
|
|
||||||
response.ret.unwrap_or(-1)
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(next_cursor) = response.get_updates_buf.as_deref() {
|
|
||||||
if next_cursor != current_cursor {
|
|
||||||
current_cursor = next_cursor.to_string();
|
|
||||||
if let Err(error) = persist_get_updates_buf(next_cursor) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!("Failed to persist WeChat polling cursor: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut context_tokens_changed = false;
|
|
||||||
for message in response.msgs {
|
|
||||||
if let Some(from_user_id) = message.from_user_id.as_deref() {
|
|
||||||
if let Some(context_token) = message.context_token.as_deref() {
|
|
||||||
let changed = context_tokens
|
|
||||||
.insert(from_user_id.to_string(), context_token.to_string())
|
|
||||||
.as_deref()
|
|
||||||
!= Some(context_token);
|
|
||||||
context_tokens_changed |= changed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match incoming_bundle_from_message(&config, message) {
|
|
||||||
Ok(Some(bundle)) => {
|
|
||||||
let emitted = process_incoming_bundle(
|
|
||||||
&mut pending_inbound,
|
|
||||||
bundle,
|
|
||||||
&mut pending_inbound_changed,
|
|
||||||
channel_host::now_millis(),
|
|
||||||
u64::from(config.inbound_merge_window_ms),
|
|
||||||
);
|
|
||||||
for emitted_bundle in emitted {
|
|
||||||
emit_buffered_bundle(emitted_bundle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {}
|
|
||||||
Err(error) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("Failed to map WeChat inbound message: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
collect_follow_up_bundles(
|
|
||||||
&config,
|
|
||||||
&mut current_cursor,
|
|
||||||
&mut context_tokens,
|
|
||||||
&mut context_tokens_changed,
|
|
||||||
&mut pending_inbound,
|
|
||||||
&mut pending_inbound_changed,
|
|
||||||
);
|
|
||||||
|
|
||||||
for bundle in
|
|
||||||
take_due_pending_bundles(&mut pending_inbound, channel_host::now_millis())
|
|
||||||
{
|
|
||||||
pending_inbound_changed = true;
|
|
||||||
emit_buffered_bundle(bundle);
|
|
||||||
}
|
|
||||||
|
|
||||||
if context_tokens_changed {
|
|
||||||
if let Err(error) = persist_context_tokens(&context_tokens) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!("Failed to persist WeChat context tokens: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pending_inbound_changed {
|
|
||||||
if let Err(error) = persist_pending_inbound_bundles(&pending_inbound) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!("Failed to persist WeChat pending inbound bundles: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("WeChat polling failed: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
|
||||||
let metadata = serde_json::from_str::<OutboundMetadata>(&response.metadata_json)
|
|
||||||
.map_err(|e| format!("Invalid WeChat response metadata: {e}"))?;
|
|
||||||
let config = load_config();
|
|
||||||
let context_tokens = load_context_tokens();
|
|
||||||
let context_token = metadata
|
|
||||||
.context_token
|
|
||||||
.clone()
|
|
||||||
.or_else(|| context_tokens.get(&metadata.from_user_id).cloned());
|
|
||||||
if let Err(error) = send_typing_indicator(
|
|
||||||
&config,
|
|
||||||
&metadata,
|
|
||||||
context_token.as_deref(),
|
|
||||||
TYPING_STATUS_CANCEL,
|
|
||||||
false,
|
|
||||||
) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Debug,
|
|
||||||
&format!("Failed to cancel WeChat typing indicator before reply: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
send_response(&config, &metadata, &response, context_token.as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_status(update: StatusUpdate) {
|
|
||||||
let Some(action) = classify_status_update(&update) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let metadata = match serde_json::from_str::<OutboundMetadata>(&update.metadata_json) {
|
|
||||||
Ok(metadata) => metadata,
|
|
||||||
Err(_) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Debug,
|
|
||||||
"on_status: no valid WeChat metadata, skipping typing update",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let config = load_config();
|
|
||||||
let context_tokens = load_context_tokens();
|
|
||||||
let context_token = resolve_context_token(&metadata, &context_tokens);
|
|
||||||
|
|
||||||
let (typing_status, allow_ticket_fetch) = match action {
|
|
||||||
WechatStatusAction::Typing => (TYPING_STATUS_TYPING, true),
|
|
||||||
WechatStatusAction::Cancel => (TYPING_STATUS_CANCEL, false),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(error) = send_typing_indicator(
|
|
||||||
&config,
|
|
||||||
&metadata,
|
|
||||||
context_token.as_deref(),
|
|
||||||
typing_status,
|
|
||||||
allow_ticket_fetch,
|
|
||||||
) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Debug,
|
|
||||||
&format!("WeChat typing update failed: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_shutdown() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn incoming_bundle_from_message(
|
|
||||||
config: &WechatConfig,
|
|
||||||
message: WechatMessage,
|
|
||||||
) -> Result<Option<PendingInboundBundle>, String> {
|
|
||||||
if message.message_type != Some(MESSAGE_TYPE_USER) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let from_user_id = match message.from_user_id.as_deref() {
|
|
||||||
Some(user_id) => user_id,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let text = extract_text(&message);
|
|
||||||
let attachments = media::extract_inbound_attachments(config, &message)?
|
|
||||||
.into_iter()
|
|
||||||
.map(StoredInboundAttachment::from)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if text.trim().is_empty() && attachments.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(PendingInboundBundle {
|
|
||||||
from_user_id: from_user_id.to_string(),
|
|
||||||
to_user_id: message.to_user_id,
|
|
||||||
session_id: message.session_id,
|
|
||||||
context_token: message.context_token,
|
|
||||||
message_id: message.message_id,
|
|
||||||
flush_at_ms: 0,
|
|
||||||
text,
|
|
||||||
attachments,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn process_incoming_bundle(
|
|
||||||
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
|
|
||||||
mut bundle: PendingInboundBundle,
|
|
||||||
pending_inbound_changed: &mut bool,
|
|
||||||
now_ms: u64,
|
|
||||||
inbound_merge_window_ms: u64,
|
|
||||||
) -> Vec<PendingInboundBundle> {
|
|
||||||
let key = bundle.from_user_id.clone();
|
|
||||||
let bundle_has_text = !bundle.text.trim().is_empty();
|
|
||||||
let bundle_has_attachments = !bundle.attachments.is_empty();
|
|
||||||
|
|
||||||
if let Some(mut pending) = pending_inbound.remove(&key) {
|
|
||||||
*pending_inbound_changed = true;
|
|
||||||
|
|
||||||
if bundle_has_text {
|
|
||||||
let incoming_metadata = bundle.clone();
|
|
||||||
pending.text = merge_text(&pending.text, &bundle.text);
|
|
||||||
pending.attachments.extend(bundle.attachments);
|
|
||||||
merge_bundle_metadata(&mut pending, &incoming_metadata);
|
|
||||||
return vec![pending];
|
|
||||||
}
|
|
||||||
|
|
||||||
let incoming_metadata = bundle.clone();
|
|
||||||
pending.attachments.extend(bundle.attachments);
|
|
||||||
merge_bundle_metadata(&mut pending, &incoming_metadata);
|
|
||||||
pending.flush_at_ms = next_flush_deadline(now_ms, inbound_merge_window_ms);
|
|
||||||
pending_inbound.insert(key, pending);
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
if bundle_has_attachments && !bundle_has_text {
|
|
||||||
*pending_inbound_changed = true;
|
|
||||||
bundle.flush_at_ms = next_flush_deadline(now_ms, inbound_merge_window_ms);
|
|
||||||
pending_inbound.insert(key, bundle);
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
vec![bundle]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_follow_up_bundles(
|
|
||||||
config: &WechatConfig,
|
|
||||||
current_cursor: &mut String,
|
|
||||||
context_tokens: &mut std::collections::HashMap<String, String>,
|
|
||||||
context_tokens_changed: &mut bool,
|
|
||||||
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
|
|
||||||
pending_inbound_changed: &mut bool,
|
|
||||||
) {
|
|
||||||
while !pending_inbound.is_empty() {
|
|
||||||
let now_ms = channel_host::now_millis();
|
|
||||||
let Some(timeout_ms) = next_follow_up_timeout_ms(pending_inbound, now_ms) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
if timeout_ms == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout_ms_u32 = timeout_ms.min(u64::from(u32::MAX)) as u32;
|
|
||||||
let response = match api::get_updates_with_timeout(config, current_cursor, timeout_ms_u32) {
|
|
||||||
Ok(response) => response,
|
|
||||||
Err(_) => break,
|
|
||||||
};
|
|
||||||
|
|
||||||
if response.errcode == Some(-14) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
"WeChat getUpdates returned errcode=-14 during follow-up merge window; reconnect the channel",
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.ret.unwrap_or(0) != 0 {
|
|
||||||
let errmsg = response
|
|
||||||
.errmsg
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("unknown WeChat polling error");
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!(
|
|
||||||
"WeChat getUpdates returned ret={} errmsg={errmsg} during follow-up merge window",
|
|
||||||
response.ret.unwrap_or(-1)
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(next_cursor) = response.get_updates_buf.as_deref() {
|
|
||||||
if next_cursor != current_cursor {
|
|
||||||
*current_cursor = next_cursor.to_string();
|
|
||||||
if let Err(error) = persist_get_updates_buf(next_cursor) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!("Failed to persist WeChat polling cursor: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut saw_relevant_message = false;
|
|
||||||
for message in response.msgs {
|
|
||||||
if let Some(from_user_id) = message.from_user_id.as_deref() {
|
|
||||||
if let Some(context_token) = message.context_token.as_deref() {
|
|
||||||
let changed = context_tokens
|
|
||||||
.insert(from_user_id.to_string(), context_token.to_string())
|
|
||||||
.as_deref()
|
|
||||||
!= Some(context_token);
|
|
||||||
*context_tokens_changed |= changed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match incoming_bundle_from_message(config, message) {
|
|
||||||
Ok(Some(bundle)) => {
|
|
||||||
let emitted = process_incoming_bundle(
|
|
||||||
pending_inbound,
|
|
||||||
bundle,
|
|
||||||
pending_inbound_changed,
|
|
||||||
channel_host::now_millis(),
|
|
||||||
u64::from(config.inbound_merge_window_ms),
|
|
||||||
);
|
|
||||||
for emitted_bundle in emitted {
|
|
||||||
saw_relevant_message = true;
|
|
||||||
emit_buffered_bundle(emitted_bundle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {}
|
|
||||||
Err(error) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("Failed to map WeChat inbound message: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !saw_relevant_message && pending_inbound.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_flush_deadline(now_ms: u64, inbound_merge_window_ms: u64) -> u64 {
|
|
||||||
now_ms.saturating_add(inbound_merge_window_ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_follow_up_timeout_ms(
|
|
||||||
pending_inbound: &std::collections::HashMap<String, PendingInboundBundle>,
|
|
||||||
now_ms: u64,
|
|
||||||
) -> Option<u64> {
|
|
||||||
pending_inbound
|
|
||||||
.values()
|
|
||||||
.map(|bundle| bundle.flush_at_ms.saturating_sub(now_ms))
|
|
||||||
.min()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take_due_pending_bundles(
|
|
||||||
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
|
|
||||||
now_ms: u64,
|
|
||||||
) -> Vec<PendingInboundBundle> {
|
|
||||||
let due_keys = pending_inbound
|
|
||||||
.iter()
|
|
||||||
.filter_map(|(key, bundle)| (bundle.flush_at_ms <= now_ms).then_some(key.clone()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
due_keys
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|key| pending_inbound.remove(&key))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_buffered_bundle(bundle: PendingInboundBundle) {
|
|
||||||
let metadata = json!({
|
|
||||||
"from_user_id": bundle.from_user_id,
|
|
||||||
"to_user_id": bundle.to_user_id,
|
|
||||||
"message_id": bundle.message_id,
|
|
||||||
"session_id": bundle.session_id,
|
|
||||||
"context_token": bundle.context_token,
|
|
||||||
});
|
|
||||||
|
|
||||||
channel_host::emit_message(&EmittedMessage {
|
|
||||||
user_id: bundle.from_user_id.clone(),
|
|
||||||
user_name: None,
|
|
||||||
content: bundle.text,
|
|
||||||
thread_id: Some(format!("wechat:{}", bundle.from_user_id)),
|
|
||||||
metadata_json: metadata.to_string(),
|
|
||||||
attachments: bundle.attachments.into_iter().map(Into::into).collect(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge_bundle_metadata(target: &mut PendingInboundBundle, incoming: &PendingInboundBundle) {
|
|
||||||
if incoming.to_user_id.is_some() {
|
|
||||||
target.to_user_id = incoming.to_user_id.clone();
|
|
||||||
}
|
|
||||||
if incoming.session_id.is_some() {
|
|
||||||
target.session_id = incoming.session_id.clone();
|
|
||||||
}
|
|
||||||
if incoming.context_token.is_some() {
|
|
||||||
target.context_token = incoming.context_token.clone();
|
|
||||||
}
|
|
||||||
if incoming.message_id.is_some() {
|
|
||||||
target.message_id = incoming.message_id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge_text(existing: &str, incoming: &str) -> String {
|
|
||||||
let existing = existing.trim();
|
|
||||||
let incoming = incoming.trim();
|
|
||||||
match (existing.is_empty(), incoming.is_empty()) {
|
|
||||||
(true, true) => String::new(),
|
|
||||||
(true, false) => incoming.to_string(),
|
|
||||||
(false, true) => existing.to_string(),
|
|
||||||
(false, false) => format!("{existing}\n\n{incoming}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_response(
|
|
||||||
config: &WechatConfig,
|
|
||||||
metadata: &OutboundMetadata,
|
|
||||||
response: &AgentResponse,
|
|
||||||
context_token: Option<&str>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut remaining_text = response.content.trim().to_string();
|
|
||||||
let mut sent_attachment = false;
|
|
||||||
|
|
||||||
for attachment in &response.attachments {
|
|
||||||
if !attachment.mime_type.starts_with("image/") {
|
|
||||||
return Err(format!(
|
|
||||||
"WeChat currently supports image attachments only, got {} ({})",
|
|
||||||
attachment.filename, attachment.mime_type
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let caption = if sent_attachment {
|
|
||||||
""
|
|
||||||
} else {
|
|
||||||
remaining_text.as_str()
|
|
||||||
};
|
|
||||||
media::send_image_attachment(
|
|
||||||
config,
|
|
||||||
&metadata.from_user_id,
|
|
||||||
attachment,
|
|
||||||
context_token,
|
|
||||||
caption,
|
|
||||||
)?;
|
|
||||||
sent_attachment = true;
|
|
||||||
remaining_text.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !remaining_text.is_empty() || !sent_attachment {
|
|
||||||
api::send_text_message(
|
|
||||||
config,
|
|
||||||
&metadata.from_user_id,
|
|
||||||
&remaining_text,
|
|
||||||
context_token,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_text(message: &WechatMessage) -> String {
|
|
||||||
message
|
|
||||||
.item_list
|
|
||||||
.iter()
|
|
||||||
.find_map(|item| {
|
|
||||||
if item.r#type == Some(MESSAGE_ITEM_TEXT) {
|
|
||||||
item.text_item.as_ref().map(|item| item.text.clone())
|
|
||||||
} else if item.r#type == Some(crate::types::MESSAGE_ITEM_VOICE) {
|
|
||||||
item.voice_item
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|item| item.text.as_ref())
|
|
||||||
.cloned()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_terminal_text_status(message: &str) -> bool {
|
|
||||||
let trimmed = message.trim();
|
|
||||||
trimmed.eq_ignore_ascii_case("done")
|
|
||||||
|| trimmed.eq_ignore_ascii_case("interrupted")
|
|
||||||
|| trimmed.eq_ignore_ascii_case("awaiting approval")
|
|
||||||
|| trimmed.eq_ignore_ascii_case("rejected")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify_status_update(update: &StatusUpdate) -> Option<WechatStatusAction> {
|
|
||||||
match update.status {
|
|
||||||
StatusType::Thinking => Some(WechatStatusAction::Typing),
|
|
||||||
StatusType::Done
|
|
||||||
| StatusType::Interrupted
|
|
||||||
| StatusType::ApprovalNeeded
|
|
||||||
| StatusType::AuthRequired => Some(WechatStatusAction::Cancel),
|
|
||||||
StatusType::Status if is_terminal_text_status(&update.message) => {
|
|
||||||
Some(WechatStatusAction::Cancel)
|
|
||||||
}
|
|
||||||
StatusType::ToolStarted
|
|
||||||
| StatusType::ToolCompleted
|
|
||||||
| StatusType::ToolResult
|
|
||||||
| StatusType::Status
|
|
||||||
| StatusType::JobStarted
|
|
||||||
| StatusType::AuthCompleted => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_context_token(
|
|
||||||
metadata: &OutboundMetadata,
|
|
||||||
context_tokens: &std::collections::HashMap<String, String>,
|
|
||||||
) -> Option<String> {
|
|
||||||
metadata
|
|
||||||
.context_token
|
|
||||||
.clone()
|
|
||||||
.or_else(|| context_tokens.get(&metadata.from_user_id).cloned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cached_typing_ticket(user_id: &str) -> Option<String> {
|
|
||||||
let tickets = load_typing_tickets();
|
|
||||||
let ticket = tickets.get(user_id)?;
|
|
||||||
let trimmed = ticket.ticket.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let age_ms = channel_host::now_millis().saturating_sub(ticket.fetched_at_ms);
|
|
||||||
if age_ms >= TYPING_TICKET_TTL_MS {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn persist_typing_ticket(user_id: &str, ticket: &str) -> Result<(), String> {
|
|
||||||
let mut tickets = load_typing_tickets();
|
|
||||||
tickets.insert(
|
|
||||||
user_id.to_string(),
|
|
||||||
TypingTicketEntry {
|
|
||||||
ticket: ticket.to_string(),
|
|
||||||
fetched_at_ms: channel_host::now_millis(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
persist_typing_tickets(&tickets)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_typing_ticket(user_id: &str) -> Result<(), String> {
|
|
||||||
let mut tickets = load_typing_tickets();
|
|
||||||
if tickets.remove(user_id).is_some() {
|
|
||||||
persist_typing_tickets(&tickets)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_typing_ticket(
|
|
||||||
config: &WechatConfig,
|
|
||||||
user_id: &str,
|
|
||||||
context_token: Option<&str>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
if let Some(ticket) = cached_typing_ticket(user_id) {
|
|
||||||
return Ok(Some(ticket));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = api::get_config(config, user_id, context_token)?;
|
|
||||||
if response.ret.unwrap_or(0) != 0 {
|
|
||||||
let errmsg = response
|
|
||||||
.errmsg
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("unknown WeChat getConfig error");
|
|
||||||
return Err(format!(
|
|
||||||
"WeChat getConfig returned ret={} errmsg={errmsg}",
|
|
||||||
response.ret.unwrap_or(-1)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(ticket) = response
|
|
||||||
.typing_ticket
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|ticket| !ticket.is_empty())
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(error) = persist_typing_ticket(user_id, ticket) {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!("Failed to persist WeChat typing ticket: {error}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(ticket.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_typing_indicator(
|
|
||||||
config: &WechatConfig,
|
|
||||||
metadata: &OutboundMetadata,
|
|
||||||
context_token: Option<&str>,
|
|
||||||
status: i32,
|
|
||||||
allow_ticket_fetch: bool,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let ticket = if allow_ticket_fetch {
|
|
||||||
resolve_typing_ticket(config, &metadata.from_user_id, context_token)?
|
|
||||||
} else {
|
|
||||||
cached_typing_ticket(&metadata.from_user_id)
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(ticket) = ticket else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(error) = api::send_typing(config, &metadata.from_user_id, &ticket, status) {
|
|
||||||
let _ = clear_typing_ticket(&metadata.from_user_id);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
export!(WechatChannel);
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
classify_status_update, extract_text, merge_text, process_incoming_bundle,
|
|
||||||
take_due_pending_bundles, PendingInboundBundle, StoredInboundAttachment,
|
|
||||||
WechatStatusAction,
|
|
||||||
};
|
|
||||||
use crate::exports::near::agent::channel::{StatusType, StatusUpdate};
|
|
||||||
use crate::types::{MessageItem, VoiceItem, WechatMessage, MESSAGE_ITEM_VOICE};
|
|
||||||
|
|
||||||
fn make_bundle(user_id: &str, text: &str, image_count: usize) -> PendingInboundBundle {
|
|
||||||
PendingInboundBundle {
|
|
||||||
from_user_id: user_id.to_string(),
|
|
||||||
to_user_id: Some("bot".to_string()),
|
|
||||||
session_id: Some("session-1".to_string()),
|
|
||||||
context_token: Some("ctx-1".to_string()),
|
|
||||||
message_id: Some(1),
|
|
||||||
flush_at_ms: 0,
|
|
||||||
text: text.to_string(),
|
|
||||||
attachments: (0..image_count)
|
|
||||||
.map(|index| StoredInboundAttachment {
|
|
||||||
id: format!("att-{index}"),
|
|
||||||
mime_type: "image/jpeg".to_string(),
|
|
||||||
filename: Some(format!("photo-{index}.jpg")),
|
|
||||||
size_bytes: Some(128),
|
|
||||||
source_url: Some("https://example.com/image.jpg".to_string()),
|
|
||||||
storage_key: None,
|
|
||||||
extracted_text: None,
|
|
||||||
extras_json: "{}".to_string(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_classify_status_update_thinking_starts_typing() {
|
|
||||||
let update = StatusUpdate {
|
|
||||||
status: StatusType::Thinking,
|
|
||||||
message: "Thinking...".to_string(),
|
|
||||||
metadata_json: "{}".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
classify_status_update(&update),
|
|
||||||
Some(WechatStatusAction::Typing)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_classify_status_update_done_cancels_typing() {
|
|
||||||
let update = StatusUpdate {
|
|
||||||
status: StatusType::Done,
|
|
||||||
message: "Done".to_string(),
|
|
||||||
metadata_json: "{}".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
classify_status_update(&update),
|
|
||||||
Some(WechatStatusAction::Cancel)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_classify_status_update_approval_needed_cancels_typing() {
|
|
||||||
let update = StatusUpdate {
|
|
||||||
status: StatusType::ApprovalNeeded,
|
|
||||||
message: "Approval needed".to_string(),
|
|
||||||
metadata_json: "{}".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
classify_status_update(&update),
|
|
||||||
Some(WechatStatusAction::Cancel)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_classify_status_update_tool_started_is_ignored() {
|
|
||||||
let update = StatusUpdate {
|
|
||||||
status: StatusType::ToolStarted,
|
|
||||||
message: "Tool started".to_string(),
|
|
||||||
metadata_json: "{}".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(classify_status_update(&update), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_classify_status_update_terminal_text_status_cancels_typing() {
|
|
||||||
let update = StatusUpdate {
|
|
||||||
status: StatusType::Status,
|
|
||||||
message: "Awaiting approval".to_string(),
|
|
||||||
metadata_json: "{}".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
classify_status_update(&update),
|
|
||||||
Some(WechatStatusAction::Cancel)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_classify_status_update_progress_status_is_ignored() {
|
|
||||||
let update = StatusUpdate {
|
|
||||||
status: StatusType::Status,
|
|
||||||
message: "Context compaction started".to_string(),
|
|
||||||
metadata_json: "{}".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(classify_status_update(&update), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_merge_text_joins_non_empty_segments() {
|
|
||||||
assert_eq!(merge_text("", "hello"), "hello");
|
|
||||||
assert_eq!(merge_text("look", "what is this"), "look\n\nwhat is this");
|
|
||||||
assert_eq!(merge_text("look", ""), "look");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_text_uses_voice_transcript_when_present() {
|
|
||||||
let message = WechatMessage {
|
|
||||||
message_id: Some(1),
|
|
||||||
from_user_id: Some("user-1".to_string()),
|
|
||||||
to_user_id: Some("bot-1".to_string()),
|
|
||||||
session_id: None,
|
|
||||||
message_type: None,
|
|
||||||
context_token: None,
|
|
||||||
item_list: vec![MessageItem {
|
|
||||||
r#type: Some(MESSAGE_ITEM_VOICE),
|
|
||||||
text_item: None,
|
|
||||||
image_item: None,
|
|
||||||
voice_item: Some(VoiceItem {
|
|
||||||
media: None,
|
|
||||||
encode_type: Some(6),
|
|
||||||
playtime: Some(1500),
|
|
||||||
text: Some("voice transcript".to_string()),
|
|
||||||
}),
|
|
||||||
file_item: None,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(extract_text(&message), "voice transcript");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_process_incoming_bundle_merges_buffered_image_with_follow_up_text() {
|
|
||||||
let mut pending = HashMap::new();
|
|
||||||
let mut changed = false;
|
|
||||||
|
|
||||||
let emitted = process_incoming_bundle(
|
|
||||||
&mut pending,
|
|
||||||
make_bundle("u1", "", 1),
|
|
||||||
&mut changed,
|
|
||||||
100,
|
|
||||||
5_000,
|
|
||||||
);
|
|
||||||
assert!(emitted.is_empty());
|
|
||||||
assert!(changed);
|
|
||||||
assert_eq!(pending.len(), 1);
|
|
||||||
assert_eq!(pending["u1"].flush_at_ms, 5100);
|
|
||||||
|
|
||||||
changed = false;
|
|
||||||
let emitted = process_incoming_bundle(
|
|
||||||
&mut pending,
|
|
||||||
make_bundle("u1", "What is in this image?", 0),
|
|
||||||
&mut changed,
|
|
||||||
200,
|
|
||||||
5_000,
|
|
||||||
);
|
|
||||||
assert!(changed);
|
|
||||||
assert!(pending.is_empty());
|
|
||||||
assert_eq!(emitted.len(), 1);
|
|
||||||
assert_eq!(emitted[0].text, "What is in this image?");
|
|
||||||
assert_eq!(emitted[0].attachments.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_process_incoming_bundle_emits_text_and_images_together_without_buffering() {
|
|
||||||
let mut pending = HashMap::new();
|
|
||||||
let mut changed = false;
|
|
||||||
|
|
||||||
let emitted = process_incoming_bundle(
|
|
||||||
&mut pending,
|
|
||||||
make_bundle("u1", "Look at this image", 1),
|
|
||||||
&mut changed,
|
|
||||||
100,
|
|
||||||
5_000,
|
|
||||||
);
|
|
||||||
assert!(!changed);
|
|
||||||
assert!(pending.is_empty());
|
|
||||||
assert_eq!(emitted.len(), 1);
|
|
||||||
assert_eq!(emitted[0].text, "Look at this image");
|
|
||||||
assert_eq!(emitted[0].attachments.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_take_due_pending_bundles_emits_only_expired_entries() {
|
|
||||||
let mut pending = HashMap::new();
|
|
||||||
let mut expired = make_bundle("u1", "", 1);
|
|
||||||
expired.flush_at_ms = 100;
|
|
||||||
let mut fresh = make_bundle("u2", "", 1);
|
|
||||||
fresh.flush_at_ms = 300;
|
|
||||||
pending.insert(expired.from_user_id.clone(), expired);
|
|
||||||
pending.insert(fresh.from_user_id.clone(), fresh);
|
|
||||||
|
|
||||||
let due = take_due_pending_bundles(&mut pending, 200);
|
|
||||||
assert_eq!(due.len(), 1);
|
|
||||||
assert_eq!(due[0].from_user_id, "u1");
|
|
||||||
assert_eq!(pending.len(), 1);
|
|
||||||
assert!(pending.contains_key("u2"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,718 +0,0 @@
|
|||||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
|
||||||
use aes::Aes128;
|
|
||||||
use base64::Engine as _;
|
|
||||||
use md5::{Digest, Md5};
|
|
||||||
use rand::RngCore;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::exports::near::agent::channel::Attachment;
|
|
||||||
use crate::near::agent::channel_host::{self, InboundAttachment};
|
|
||||||
use crate::types::{
|
|
||||||
CdnMedia, FileItem, ImageItem, MessageItem, SendMessageRequest, WechatConfig,
|
|
||||||
MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE, MESSAGE_STATE_FINISH,
|
|
||||||
MESSAGE_TYPE_BOT, UPLOAD_MEDIA_TYPE_IMAGE,
|
|
||||||
};
|
|
||||||
|
|
||||||
const AES_BLOCK_SIZE: usize = 16;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct UploadImage {
|
|
||||||
pub download_encrypted_query_param: String,
|
|
||||||
pub aes_key_base64: String,
|
|
||||||
pub file_size_ciphertext: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn extract_inbound_attachments(
|
|
||||||
config: &WechatConfig,
|
|
||||||
message: &crate::types::WechatMessage,
|
|
||||||
) -> Result<Vec<InboundAttachment>, String> {
|
|
||||||
message
|
|
||||||
.item_list
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(index, item)| {
|
|
||||||
map_inbound_attachment(config, message, item, index).transpose()
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn send_image_attachment(
|
|
||||||
config: &WechatConfig,
|
|
||||||
to_user_id: &str,
|
|
||||||
attachment: &Attachment,
|
|
||||||
context_token: Option<&str>,
|
|
||||||
text: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if attachment.data.is_empty() {
|
|
||||||
return Err(format!(
|
|
||||||
"WeChat image attachment '{}' has no data",
|
|
||||||
attachment.filename
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let upload = upload_image(config, to_user_id, attachment)?;
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
crate::api::send_text_message(config, to_user_id, text.trim(), context_token)?;
|
|
||||||
}
|
|
||||||
let request = SendMessageRequest {
|
|
||||||
msg: crate::types::OutboundWechatMessage {
|
|
||||||
from_user_id: String::new(),
|
|
||||||
to_user_id: to_user_id.to_string(),
|
|
||||||
client_id: format!("wechat-{}", channel_host::now_millis()),
|
|
||||||
message_type: MESSAGE_TYPE_BOT,
|
|
||||||
message_state: MESSAGE_STATE_FINISH,
|
|
||||||
item_list: vec![MessageItem {
|
|
||||||
r#type: Some(MESSAGE_ITEM_IMAGE),
|
|
||||||
text_item: None,
|
|
||||||
image_item: Some(ImageItem {
|
|
||||||
media: Some(CdnMedia {
|
|
||||||
encrypt_query_param: Some(upload.download_encrypted_query_param.clone()),
|
|
||||||
aes_key: Some(upload.aes_key_base64.clone()),
|
|
||||||
encrypt_type: Some(1),
|
|
||||||
}),
|
|
||||||
aeskey: None,
|
|
||||||
mid_size: Some(upload.file_size_ciphertext),
|
|
||||||
}),
|
|
||||||
voice_item: None,
|
|
||||||
file_item: None,
|
|
||||||
}],
|
|
||||||
context_token: context_token.map(str::to_string),
|
|
||||||
},
|
|
||||||
base_info: crate::api::base_info(),
|
|
||||||
};
|
|
||||||
|
|
||||||
crate::api::send_message_request(config, &request)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_inbound_attachment(
|
|
||||||
config: &WechatConfig,
|
|
||||||
message: &crate::types::WechatMessage,
|
|
||||||
item: &MessageItem,
|
|
||||||
index: usize,
|
|
||||||
) -> Result<Option<InboundAttachment>, String> {
|
|
||||||
if item.r#type == Some(MESSAGE_ITEM_IMAGE) {
|
|
||||||
return map_image_attachment(config, message, item, index);
|
|
||||||
}
|
|
||||||
if item.r#type == Some(MESSAGE_ITEM_VOICE) {
|
|
||||||
return map_voice_attachment(config, message, item, index);
|
|
||||||
}
|
|
||||||
if item.r#type == Some(MESSAGE_ITEM_FILE) {
|
|
||||||
return map_file_attachment(config, message, item, index);
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_image_attachment(
|
|
||||||
config: &WechatConfig,
|
|
||||||
message: &crate::types::WechatMessage,
|
|
||||||
item: &MessageItem,
|
|
||||||
index: usize,
|
|
||||||
) -> Result<Option<InboundAttachment>, String> {
|
|
||||||
if item.r#type != Some(MESSAGE_ITEM_IMAGE) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let image = item.image_item.as_ref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat image message {:?} is missing image_item payload",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let media = image.media.as_ref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat image message {:?} is missing media payload",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat image message {:?} is missing encrypt_query_param",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let message_id = message
|
|
||||||
.message_id
|
|
||||||
.ok_or_else(|| "WeChat image message is missing message_id".to_string())?;
|
|
||||||
let aes_key = preferred_image_aes_key(image, media).map(str::to_string);
|
|
||||||
|
|
||||||
Ok(Some(InboundAttachment {
|
|
||||||
id: format!("wechat-image-{}-{}", message_id, index),
|
|
||||||
mime_type: "image/jpeg".to_string(),
|
|
||||||
filename: Some(format!("wechat-image-{}-{}.jpg", message_id, index)),
|
|
||||||
size_bytes: image.mid_size,
|
|
||||||
source_url: Some(build_cdn_download_url(
|
|
||||||
&config.cdn_base_url,
|
|
||||||
encrypt_query_param,
|
|
||||||
)),
|
|
||||||
storage_key: None,
|
|
||||||
extracted_text: None,
|
|
||||||
extras_json: json!({ "wechat_aes_key": aes_key }).to_string(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_file_attachment(
|
|
||||||
config: &WechatConfig,
|
|
||||||
message: &crate::types::WechatMessage,
|
|
||||||
item: &MessageItem,
|
|
||||||
index: usize,
|
|
||||||
) -> Result<Option<InboundAttachment>, String> {
|
|
||||||
if item.r#type != Some(MESSAGE_ITEM_FILE) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = item.file_item.as_ref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat file message {:?} is missing file_item payload",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let media = file.media.as_ref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat file message {:?} is missing media payload",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat file message {:?} is missing encrypt_query_param",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let aes_key = media
|
|
||||||
.aes_key
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat file message {:?} is missing aes_key",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let message_id = message
|
|
||||||
.message_id
|
|
||||||
.ok_or_else(|| "WeChat file message is missing message_id".to_string())?;
|
|
||||||
let filename = inbound_file_name(file, message_id, index);
|
|
||||||
let size_bytes = file.len.as_deref().and_then(parse_file_size);
|
|
||||||
|
|
||||||
Ok(Some(InboundAttachment {
|
|
||||||
id: format!("wechat-file-{}-{}", message_id, index),
|
|
||||||
mime_type: infer_file_mime_type(&filename),
|
|
||||||
filename: Some(filename),
|
|
||||||
size_bytes,
|
|
||||||
source_url: Some(build_cdn_download_url(
|
|
||||||
&config.cdn_base_url,
|
|
||||||
encrypt_query_param,
|
|
||||||
)),
|
|
||||||
storage_key: None,
|
|
||||||
extracted_text: None,
|
|
||||||
extras_json: json!({ "wechat_aes_key": aes_key }).to_string(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_voice_attachment(
|
|
||||||
config: &WechatConfig,
|
|
||||||
message: &crate::types::WechatMessage,
|
|
||||||
item: &MessageItem,
|
|
||||||
index: usize,
|
|
||||||
) -> Result<Option<InboundAttachment>, String> {
|
|
||||||
if item.r#type != Some(MESSAGE_ITEM_VOICE) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let voice = item.voice_item.as_ref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat voice message {:?} is missing voice_item payload",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let media = voice.media.as_ref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat voice message {:?} is missing media payload",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat voice message {:?} is missing encrypt_query_param",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let aes_key = media
|
|
||||||
.aes_key
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"WeChat voice message {:?} is missing aes_key",
|
|
||||||
message.message_id
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let message_id = message
|
|
||||||
.message_id
|
|
||||||
.ok_or_else(|| "WeChat voice message is missing message_id".to_string())?;
|
|
||||||
let (mime_type, extension) = infer_voice_media_type(voice.encode_type);
|
|
||||||
let duration_secs = voice.playtime.map(|millis| (millis / 1000) as u32);
|
|
||||||
|
|
||||||
Ok(Some(InboundAttachment {
|
|
||||||
id: format!("wechat-voice-{}-{}", message_id, index),
|
|
||||||
mime_type: mime_type.to_string(),
|
|
||||||
filename: Some(format!(
|
|
||||||
"wechat-voice-{}-{}.{}",
|
|
||||||
message_id, index, extension
|
|
||||||
)),
|
|
||||||
size_bytes: None,
|
|
||||||
source_url: Some(build_cdn_download_url(
|
|
||||||
&config.cdn_base_url,
|
|
||||||
encrypt_query_param,
|
|
||||||
)),
|
|
||||||
storage_key: None,
|
|
||||||
extracted_text: voice
|
|
||||||
.text
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string),
|
|
||||||
extras_json: build_voice_extras_json(aes_key, duration_secs),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn preferred_image_aes_key<'a>(image: &'a ImageItem, media: &'a CdnMedia) -> Option<&'a str> {
|
|
||||||
image
|
|
||||||
.aeskey
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.or_else(|| {
|
|
||||||
media
|
|
||||||
.aes_key
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn inbound_file_name(file: &FileItem, message_id: i64, index: usize) -> String {
|
|
||||||
file.file_name
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.unwrap_or_else(|| format!("wechat-file-{}-{}.bin", message_id, index))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_file_size(raw: &str) -> Option<u64> {
|
|
||||||
raw.trim().parse::<u64>().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn infer_voice_media_type(encode_type: Option<i32>) -> (&'static str, &'static str) {
|
|
||||||
match encode_type {
|
|
||||||
Some(7) => ("audio/mpeg", "mp3"),
|
|
||||||
Some(8) => ("audio/ogg", "ogg"),
|
|
||||||
Some(5) => ("audio/amr", "amr"),
|
|
||||||
Some(6) => ("audio/silk", "silk"),
|
|
||||||
_ => ("audio/silk", "silk"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_voice_extras_json(aes_key: &str, duration_secs: Option<u32>) -> String {
|
|
||||||
let mut extras = serde_json::Map::new();
|
|
||||||
extras.insert("wechat_aes_key".to_string(), json!(aes_key));
|
|
||||||
if let Some(duration_secs) = duration_secs {
|
|
||||||
extras.insert("duration_secs".to_string(), json!(duration_secs));
|
|
||||||
}
|
|
||||||
serde_json::Value::Object(extras).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn infer_file_mime_type(filename: &str) -> String {
|
|
||||||
let extension = filename
|
|
||||||
.rsplit_once('.')
|
|
||||||
.map(|(_, ext)| ext.trim().to_ascii_lowercase());
|
|
||||||
|
|
||||||
match extension.as_deref() {
|
|
||||||
Some("pdf") => "application/pdf",
|
|
||||||
Some("doc") => "application/msword",
|
|
||||||
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
Some("xls") => "application/vnd.ms-excel",
|
|
||||||
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
||||||
Some("ppt") => "application/vnd.ms-powerpoint",
|
|
||||||
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
||||||
Some("txt") => "text/plain",
|
|
||||||
Some("csv") => "text/csv",
|
|
||||||
Some("json") => "application/json",
|
|
||||||
Some("xml") => "application/xml",
|
|
||||||
Some("md") => "text/markdown",
|
|
||||||
Some("zip") => "application/zip",
|
|
||||||
Some("tar") => "application/x-tar",
|
|
||||||
Some("gz") => "application/gzip",
|
|
||||||
Some("mp3") => "audio/mpeg",
|
|
||||||
Some("ogg") => "audio/ogg",
|
|
||||||
Some("wav") => "audio/wav",
|
|
||||||
Some("mp4") => "video/mp4",
|
|
||||||
Some("mov") => "video/quicktime",
|
|
||||||
Some("webm") => "video/webm",
|
|
||||||
Some("mkv") => "video/x-matroska",
|
|
||||||
Some("avi") => "video/x-msvideo",
|
|
||||||
Some("png") => "image/png",
|
|
||||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
|
||||||
Some("gif") => "image/gif",
|
|
||||||
Some("webp") => "image/webp",
|
|
||||||
Some("bmp") => "image/bmp",
|
|
||||||
_ => "application/octet-stream",
|
|
||||||
}
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn upload_image(
|
|
||||||
config: &WechatConfig,
|
|
||||||
to_user_id: &str,
|
|
||||||
attachment: &Attachment,
|
|
||||||
) -> Result<UploadImage, String> {
|
|
||||||
let plaintext = &attachment.data;
|
|
||||||
let raw_size = plaintext.len() as u64;
|
|
||||||
let raw_md5 = hex_lower(md5_bytes(plaintext));
|
|
||||||
let file_size_ciphertext = padded_size(raw_size);
|
|
||||||
let filekey = hex_lower(random_bytes(16)?);
|
|
||||||
let aes_key = random_bytes(16)?;
|
|
||||||
let aes_key_hex = hex_lower(aes_key.clone());
|
|
||||||
|
|
||||||
let upload_url = crate::api::get_upload_url(
|
|
||||||
config,
|
|
||||||
&crate::types::GetUploadUrlRequest {
|
|
||||||
filekey: filekey.clone(),
|
|
||||||
media_type: UPLOAD_MEDIA_TYPE_IMAGE,
|
|
||||||
to_user_id: to_user_id.to_string(),
|
|
||||||
rawsize: raw_size,
|
|
||||||
rawfilemd5: raw_md5,
|
|
||||||
filesize: file_size_ciphertext,
|
|
||||||
no_need_thumb: true,
|
|
||||||
aeskey: aes_key_hex,
|
|
||||||
base_info: crate::api::base_info(),
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let upload_param = upload_url
|
|
||||||
.upload_param
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.ok_or_else(|| "WeChat getUploadUrl returned no upload_param".to_string())?;
|
|
||||||
|
|
||||||
if upload_url.thumb_upload_param.is_some() {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Debug,
|
|
||||||
"WeChat image upload returned thumb_upload_param; ignoring for single-image flow",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ciphertext = encrypt_aes_ecb_pkcs7(plaintext, &aes_key)?;
|
|
||||||
let upload_response = channel_host::http_request(
|
|
||||||
"POST",
|
|
||||||
&build_cdn_upload_url(&config.cdn_base_url, upload_param, &filekey),
|
|
||||||
r#"{"Content-Type":"application/octet-stream"}"#,
|
|
||||||
Some(&ciphertext),
|
|
||||||
Some(15_000),
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("WeChat CDN upload failed: {e}"))?;
|
|
||||||
|
|
||||||
if upload_response.status != 200 {
|
|
||||||
let body = String::from_utf8_lossy(&upload_response.body);
|
|
||||||
return Err(format!(
|
|
||||||
"WeChat CDN upload returned {}: {}",
|
|
||||||
upload_response.status, body
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let headers: std::collections::HashMap<String, String> =
|
|
||||||
serde_json::from_str(&upload_response.headers_json)
|
|
||||||
.map_err(|e| format!("Failed to parse WeChat CDN upload headers: {e}"))?;
|
|
||||||
let download_encrypted_query_param = headers
|
|
||||||
.iter()
|
|
||||||
.find_map(|(key, value)| {
|
|
||||||
if key.eq_ignore_ascii_case("x-encrypted-param") {
|
|
||||||
Some(value.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.ok_or_else(|| "WeChat CDN upload response missing x-encrypted-param".to_string())?;
|
|
||||||
|
|
||||||
Ok(UploadImage {
|
|
||||||
download_encrypted_query_param,
|
|
||||||
aes_key_base64: base64::engine::general_purpose::STANDARD.encode(aes_key),
|
|
||||||
file_size_ciphertext,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_cdn_download_url(cdn_base_url: &str, encrypted_query_param: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/download?encrypted_query_param={}",
|
|
||||||
cdn_base_url.trim_end_matches('/'),
|
|
||||||
percent_encode(encrypted_query_param)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_cdn_upload_url(cdn_base_url: &str, upload_param: &str, filekey: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/upload?encrypted_query_param={}&filekey={}",
|
|
||||||
cdn_base_url.trim_end_matches('/'),
|
|
||||||
percent_encode(upload_param),
|
|
||||||
percent_encode(filekey)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn percent_encode(value: &str) -> String {
|
|
||||||
let mut encoded = String::with_capacity(value.len());
|
|
||||||
for byte in value.bytes() {
|
|
||||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
|
||||||
encoded.push(byte as char);
|
|
||||||
} else {
|
|
||||||
encoded.push('%');
|
|
||||||
encoded.push(nibble_to_hex(byte >> 4));
|
|
||||||
encoded.push(nibble_to_hex(byte & 0x0F));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nibble_to_hex(nibble: u8) -> char {
|
|
||||||
match nibble {
|
|
||||||
0..=9 => (b'0' + nibble) as char,
|
|
||||||
10..=15 => (b'A' + (nibble - 10)) as char,
|
|
||||||
_ => '0',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_hex(bytes: &[u8]) -> String {
|
|
||||||
let mut out = String::with_capacity(bytes.len() * 2);
|
|
||||||
for byte in bytes {
|
|
||||||
out.push(nibble_to_hex(byte >> 4));
|
|
||||||
out.push(nibble_to_hex(byte & 0x0F));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hex_lower(bytes: Vec<u8>) -> String {
|
|
||||||
encode_hex(&bytes).to_ascii_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
|
|
||||||
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
|
|
||||||
let mut padded = plaintext.to_vec();
|
|
||||||
let pad_len = AES_BLOCK_SIZE - (padded.len() % AES_BLOCK_SIZE);
|
|
||||||
padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
|
|
||||||
|
|
||||||
for chunk in padded.chunks_exact_mut(AES_BLOCK_SIZE) {
|
|
||||||
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(padded)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn md5_bytes(bytes: &[u8]) -> Vec<u8> {
|
|
||||||
Md5::digest(bytes).to_vec()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn random_bytes(len: usize) -> Result<Vec<u8>, String> {
|
|
||||||
let mut bytes = vec![0u8; len];
|
|
||||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
|
||||||
if bytes.iter().all(|byte| *byte == 0) {
|
|
||||||
return Err("OS RNG returned all-zero bytes unexpectedly".to_string());
|
|
||||||
}
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn padded_size(raw_size: u64) -> u64 {
|
|
||||||
((raw_size / AES_BLOCK_SIZE as u64) + 1) * AES_BLOCK_SIZE as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
build_voice_extras_json, encode_hex, encrypt_aes_ecb_pkcs7, infer_file_mime_type,
|
|
||||||
infer_voice_media_type, map_file_attachment, map_image_attachment, map_voice_attachment,
|
|
||||||
AES_BLOCK_SIZE,
|
|
||||||
};
|
|
||||||
use crate::types::{
|
|
||||||
CdnMedia, FileItem, ImageItem, MessageItem, VoiceItem, WechatConfig, WechatMessage,
|
|
||||||
MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_encrypt_aes_ecb_pkcs7_is_block_aligned() {
|
|
||||||
let key = [0x11u8; 16];
|
|
||||||
let plaintext = b"wechat image payload".to_vec();
|
|
||||||
let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap();
|
|
||||||
assert_eq!(ciphertext.len() % AES_BLOCK_SIZE, 0);
|
|
||||||
assert_ne!(ciphertext, plaintext);
|
|
||||||
assert_eq!(
|
|
||||||
encode_hex(&ciphertext).to_ascii_lowercase(),
|
|
||||||
"a7464c94a03fb2c5aa783597a1d2f5a461f1cd5d83a7bd92721e8ac1853f881f"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_image_attachment_errors_when_message_id_missing() {
|
|
||||||
let config = WechatConfig::default();
|
|
||||||
let message = WechatMessage {
|
|
||||||
message_id: None,
|
|
||||||
from_user_id: Some("user-1".to_string()),
|
|
||||||
to_user_id: Some("bot-1".to_string()),
|
|
||||||
session_id: None,
|
|
||||||
message_type: None,
|
|
||||||
context_token: None,
|
|
||||||
item_list: vec![MessageItem {
|
|
||||||
r#type: Some(MESSAGE_ITEM_IMAGE),
|
|
||||||
text_item: None,
|
|
||||||
image_item: Some(ImageItem {
|
|
||||||
media: Some(CdnMedia {
|
|
||||||
encrypt_query_param: Some("enc".to_string()),
|
|
||||||
aes_key: Some("aes".to_string()),
|
|
||||||
encrypt_type: Some(1),
|
|
||||||
}),
|
|
||||||
aeskey: None,
|
|
||||||
mid_size: Some(128),
|
|
||||||
}),
|
|
||||||
voice_item: None,
|
|
||||||
file_item: None,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
let error = map_image_attachment(&config, &message, &message.item_list[0], 0)
|
|
||||||
.expect_err("missing message_id should error");
|
|
||||||
assert!(error.contains("missing message_id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_file_attachment_uses_filename_and_size_metadata() {
|
|
||||||
let config = WechatConfig::default();
|
|
||||||
let message = WechatMessage {
|
|
||||||
message_id: Some(42),
|
|
||||||
from_user_id: Some("user-1".to_string()),
|
|
||||||
to_user_id: Some("bot-1".to_string()),
|
|
||||||
session_id: None,
|
|
||||||
message_type: None,
|
|
||||||
context_token: None,
|
|
||||||
item_list: vec![MessageItem {
|
|
||||||
r#type: Some(MESSAGE_ITEM_FILE),
|
|
||||||
text_item: None,
|
|
||||||
image_item: None,
|
|
||||||
voice_item: None,
|
|
||||||
file_item: Some(FileItem {
|
|
||||||
media: Some(CdnMedia {
|
|
||||||
encrypt_query_param: Some("enc".to_string()),
|
|
||||||
aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()),
|
|
||||||
encrypt_type: Some(1),
|
|
||||||
}),
|
|
||||||
file_name: Some("report.PDF".to_string()),
|
|
||||||
len: Some("256".to_string()),
|
|
||||||
}),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
let attachment = map_file_attachment(&config, &message, &message.item_list[0], 0)
|
|
||||||
.expect("file attachment should map")
|
|
||||||
.expect("file attachment should be present");
|
|
||||||
assert_eq!(attachment.id, "wechat-file-42-0");
|
|
||||||
assert_eq!(attachment.mime_type, "application/pdf");
|
|
||||||
assert_eq!(attachment.filename.as_deref(), Some("report.PDF"));
|
|
||||||
assert_eq!(attachment.size_bytes, Some(256));
|
|
||||||
assert!(attachment.extras_json.contains("wechat_aes_key"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_file_attachment_errors_when_message_id_missing() {
|
|
||||||
let config = WechatConfig::default();
|
|
||||||
let message = WechatMessage {
|
|
||||||
message_id: None,
|
|
||||||
from_user_id: Some("user-1".to_string()),
|
|
||||||
to_user_id: Some("bot-1".to_string()),
|
|
||||||
session_id: None,
|
|
||||||
message_type: None,
|
|
||||||
context_token: None,
|
|
||||||
item_list: vec![MessageItem {
|
|
||||||
r#type: Some(MESSAGE_ITEM_FILE),
|
|
||||||
text_item: None,
|
|
||||||
image_item: None,
|
|
||||||
voice_item: None,
|
|
||||||
file_item: Some(FileItem {
|
|
||||||
media: Some(CdnMedia {
|
|
||||||
encrypt_query_param: Some("enc".to_string()),
|
|
||||||
aes_key: Some("aes".to_string()),
|
|
||||||
encrypt_type: Some(1),
|
|
||||||
}),
|
|
||||||
file_name: Some("report.pdf".to_string()),
|
|
||||||
len: Some("256".to_string()),
|
|
||||||
}),
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
let error = map_file_attachment(&config, &message, &message.item_list[0], 0)
|
|
||||||
.expect_err("missing message_id should error");
|
|
||||||
assert!(error.contains("missing message_id"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_infer_file_mime_type_defaults_to_octet_stream() {
|
|
||||||
assert_eq!(
|
|
||||||
infer_file_mime_type("archive.unknown"),
|
|
||||||
"application/octet-stream"
|
|
||||||
);
|
|
||||||
assert_eq!(infer_file_mime_type("README"), "application/octet-stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_infer_voice_media_type_defaults_to_silk() {
|
|
||||||
assert_eq!(infer_voice_media_type(Some(6)), ("audio/silk", "silk"));
|
|
||||||
assert_eq!(infer_voice_media_type(Some(8)), ("audio/ogg", "ogg"));
|
|
||||||
assert_eq!(infer_voice_media_type(None), ("audio/silk", "silk"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_build_voice_extras_json_includes_duration() {
|
|
||||||
let extras = build_voice_extras_json("aes-key", Some(9));
|
|
||||||
assert!(extras.contains("wechat_aes_key"));
|
|
||||||
assert!(extras.contains("duration_secs"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_voice_attachment_sets_audio_metadata() {
|
|
||||||
let config = WechatConfig::default();
|
|
||||||
let message = WechatMessage {
|
|
||||||
message_id: Some(77),
|
|
||||||
from_user_id: Some("user-1".to_string()),
|
|
||||||
to_user_id: Some("bot-1".to_string()),
|
|
||||||
session_id: None,
|
|
||||||
message_type: None,
|
|
||||||
context_token: None,
|
|
||||||
item_list: vec![MessageItem {
|
|
||||||
r#type: Some(MESSAGE_ITEM_VOICE),
|
|
||||||
text_item: None,
|
|
||||||
image_item: None,
|
|
||||||
voice_item: Some(VoiceItem {
|
|
||||||
media: Some(CdnMedia {
|
|
||||||
encrypt_query_param: Some("enc".to_string()),
|
|
||||||
aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()),
|
|
||||||
encrypt_type: Some(1),
|
|
||||||
}),
|
|
||||||
encode_type: Some(8),
|
|
||||||
playtime: Some(4200),
|
|
||||||
text: Some("hello from voice".to_string()),
|
|
||||||
}),
|
|
||||||
file_item: None,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
let attachment = map_voice_attachment(&config, &message, &message.item_list[0], 0)
|
|
||||||
.expect("voice attachment should map")
|
|
||||||
.expect("voice attachment should be present");
|
|
||||||
assert_eq!(attachment.id, "wechat-voice-77-0");
|
|
||||||
assert_eq!(attachment.mime_type, "audio/ogg");
|
|
||||||
assert_eq!(
|
|
||||||
attachment.filename.as_deref(),
|
|
||||||
Some("wechat-voice-77-0.ogg")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
attachment.extracted_text.as_deref(),
|
|
||||||
Some("hello from voice")
|
|
||||||
);
|
|
||||||
assert!(attachment.extras_json.contains("duration_secs"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::auth::{
|
|
||||||
CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, PENDING_INBOUND_PATH,
|
|
||||||
TYPING_TICKETS_PATH,
|
|
||||||
};
|
|
||||||
use crate::near::agent::channel_host;
|
|
||||||
use crate::types::WechatConfig;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct TypingTicketEntry {
|
|
||||||
pub ticket: String,
|
|
||||||
pub fetched_at_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
pub struct StoredInboundAttachment {
|
|
||||||
pub id: String,
|
|
||||||
pub mime_type: String,
|
|
||||||
pub filename: Option<String>,
|
|
||||||
pub size_bytes: Option<u64>,
|
|
||||||
pub source_url: Option<String>,
|
|
||||||
pub storage_key: Option<String>,
|
|
||||||
pub extracted_text: Option<String>,
|
|
||||||
pub extras_json: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<channel_host::InboundAttachment> for StoredInboundAttachment {
|
|
||||||
fn from(value: channel_host::InboundAttachment) -> Self {
|
|
||||||
Self {
|
|
||||||
id: value.id,
|
|
||||||
mime_type: value.mime_type,
|
|
||||||
filename: value.filename,
|
|
||||||
size_bytes: value.size_bytes,
|
|
||||||
source_url: value.source_url,
|
|
||||||
storage_key: value.storage_key,
|
|
||||||
extracted_text: value.extracted_text,
|
|
||||||
extras_json: value.extras_json,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<StoredInboundAttachment> for channel_host::InboundAttachment {
|
|
||||||
fn from(value: StoredInboundAttachment) -> Self {
|
|
||||||
Self {
|
|
||||||
id: value.id,
|
|
||||||
mime_type: value.mime_type,
|
|
||||||
filename: value.filename,
|
|
||||||
size_bytes: value.size_bytes,
|
|
||||||
source_url: value.source_url,
|
|
||||||
storage_key: value.storage_key,
|
|
||||||
extracted_text: value.extracted_text,
|
|
||||||
extras_json: value.extras_json,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
pub struct PendingInboundBundle {
|
|
||||||
pub from_user_id: String,
|
|
||||||
pub to_user_id: Option<String>,
|
|
||||||
pub session_id: Option<String>,
|
|
||||||
pub context_token: Option<String>,
|
|
||||||
pub message_id: Option<i64>,
|
|
||||||
pub flush_at_ms: u64,
|
|
||||||
pub text: String,
|
|
||||||
pub attachments: Vec<StoredInboundAttachment>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_config() -> WechatConfig {
|
|
||||||
channel_host::workspace_read(CONFIG_PATH)
|
|
||||||
.and_then(|raw| serde_json::from_str::<WechatConfig>(&raw).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn persist_config(config: &WechatConfig) -> Result<(), String> {
|
|
||||||
let serialized =
|
|
||||||
serde_json::to_string(config).map_err(|e| format!("Failed to serialize config: {e}"))?;
|
|
||||||
channel_host::workspace_write(CONFIG_PATH, &serialized).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_get_updates_buf() -> String {
|
|
||||||
channel_host::workspace_read(GET_UPDATES_BUF_PATH)
|
|
||||||
.and_then(|raw| serde_json::from_str::<String>(&raw).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn persist_get_updates_buf(value: &str) -> Result<(), String> {
|
|
||||||
let serialized =
|
|
||||||
serde_json::to_string(value).map_err(|e| format!("Failed to serialize cursor: {e}"))?;
|
|
||||||
channel_host::workspace_write(GET_UPDATES_BUF_PATH, &serialized).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_context_tokens() -> HashMap<String, String> {
|
|
||||||
channel_host::workspace_read(CONTEXT_TOKENS_PATH)
|
|
||||||
.and_then(|raw| serde_json::from_str::<HashMap<String, String>>(&raw).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn persist_context_tokens(tokens: &HashMap<String, String>) -> Result<(), String> {
|
|
||||||
let serialized =
|
|
||||||
serde_json::to_string(tokens).map_err(|e| format!("Failed to serialize tokens: {e}"))?;
|
|
||||||
channel_host::workspace_write(CONTEXT_TOKENS_PATH, &serialized).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_typing_tickets() -> HashMap<String, TypingTicketEntry> {
|
|
||||||
channel_host::workspace_read(TYPING_TICKETS_PATH)
|
|
||||||
.and_then(|raw| serde_json::from_str::<HashMap<String, TypingTicketEntry>>(&raw).ok())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn persist_typing_tickets(tickets: &HashMap<String, TypingTicketEntry>) -> Result<(), String> {
|
|
||||||
let serialized =
|
|
||||||
serde_json::to_string(tickets).map_err(|e| format!("Failed to serialize tickets: {e}"))?;
|
|
||||||
channel_host::workspace_write(TYPING_TICKETS_PATH, &serialized).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_pending_inbound_bundles() -> Result<HashMap<String, PendingInboundBundle>, String> {
|
|
||||||
parse_pending_inbound_bundles(channel_host::workspace_read(PENDING_INBOUND_PATH).as_deref())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn persist_pending_inbound_bundles(
|
|
||||||
bundles: &HashMap<String, PendingInboundBundle>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let serialized =
|
|
||||||
serde_json::to_string(bundles).map_err(|e| format!("Failed to serialize bundles: {e}"))?;
|
|
||||||
channel_host::workspace_write(PENDING_INBOUND_PATH, &serialized).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_pending_inbound_bundles(
|
|
||||||
raw: Option<&str>,
|
|
||||||
) -> Result<HashMap<String, PendingInboundBundle>, String> {
|
|
||||||
match raw {
|
|
||||||
None => Ok(HashMap::new()),
|
|
||||||
Some(raw) => serde_json::from_str(raw)
|
|
||||||
.map_err(|e| format!("Failed to parse pending inbound bundles: {e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_pending_inbound_bundles_missing_file_returns_empty_map() {
|
|
||||||
let bundles = parse_pending_inbound_bundles(None).expect("missing state should be empty");
|
|
||||||
assert!(bundles.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_pending_inbound_bundles_invalid_json_returns_error() {
|
|
||||||
let error =
|
|
||||||
parse_pending_inbound_bundles(Some("{not json")).expect_err("invalid json should err");
|
|
||||||
assert!(error.contains("Failed to parse pending inbound bundles"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct WechatConfig {
|
|
||||||
#[serde(default = "default_base_url")]
|
|
||||||
pub base_url: String,
|
|
||||||
#[serde(default = "default_cdn_base_url")]
|
|
||||||
pub cdn_base_url: String,
|
|
||||||
#[serde(default = "default_bot_type")]
|
|
||||||
pub bot_type: String,
|
|
||||||
#[serde(default = "default_poll_interval_ms")]
|
|
||||||
pub poll_interval_ms: u32,
|
|
||||||
#[serde(default = "default_long_poll_timeout_ms")]
|
|
||||||
pub long_poll_timeout_ms: u32,
|
|
||||||
#[serde(default = "default_inbound_merge_window_ms")]
|
|
||||||
pub inbound_merge_window_ms: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_base_url() -> String {
|
|
||||||
"https://ilinkai.weixin.qq.com".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_cdn_base_url() -> String {
|
|
||||||
"https://novac2c.cdn.weixin.qq.com/c2c".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_bot_type() -> String {
|
|
||||||
"3".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_poll_interval_ms() -> u32 {
|
|
||||||
30_000
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_long_poll_timeout_ms() -> u32 {
|
|
||||||
35_000
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_inbound_merge_window_ms() -> u32 {
|
|
||||||
5_000
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WechatConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
base_url: default_base_url(),
|
|
||||||
cdn_base_url: default_cdn_base_url(),
|
|
||||||
bot_type: default_bot_type(),
|
|
||||||
poll_interval_ms: default_poll_interval_ms(),
|
|
||||||
long_poll_timeout_ms: default_long_poll_timeout_ms(),
|
|
||||||
inbound_merge_window_ms: default_inbound_merge_window_ms(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct BaseInfo {
|
|
||||||
pub channel_version: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct GetUploadUrlRequest {
|
|
||||||
pub filekey: String,
|
|
||||||
pub media_type: i32,
|
|
||||||
pub to_user_id: String,
|
|
||||||
pub rawsize: u64,
|
|
||||||
pub rawfilemd5: String,
|
|
||||||
pub filesize: u64,
|
|
||||||
pub no_need_thumb: bool,
|
|
||||||
pub aeskey: String,
|
|
||||||
pub base_info: BaseInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct GetUpdatesRequest {
|
|
||||||
pub get_updates_buf: String,
|
|
||||||
pub base_info: BaseInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct GetConfigRequest {
|
|
||||||
pub ilink_user_id: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub context_token: Option<String>,
|
|
||||||
pub base_info: BaseInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct GetUpdatesResponse {
|
|
||||||
pub ret: Option<i32>,
|
|
||||||
pub errcode: Option<i32>,
|
|
||||||
pub errmsg: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub msgs: Vec<WechatMessage>,
|
|
||||||
pub get_updates_buf: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct GetUploadUrlResponse {
|
|
||||||
pub upload_param: Option<String>,
|
|
||||||
pub thumb_upload_param: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct SendMessageRequest {
|
|
||||||
pub msg: OutboundWechatMessage,
|
|
||||||
pub base_info: BaseInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct SendTypingRequest {
|
|
||||||
pub ilink_user_id: String,
|
|
||||||
pub typing_ticket: String,
|
|
||||||
pub status: i32,
|
|
||||||
pub base_info: BaseInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct OutboundWechatMessage {
|
|
||||||
pub from_user_id: String,
|
|
||||||
pub to_user_id: String,
|
|
||||||
pub client_id: String,
|
|
||||||
pub message_type: i32,
|
|
||||||
pub message_state: i32,
|
|
||||||
pub item_list: Vec<MessageItem>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub context_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct WechatMessage {
|
|
||||||
pub message_id: Option<i64>,
|
|
||||||
pub from_user_id: Option<String>,
|
|
||||||
pub to_user_id: Option<String>,
|
|
||||||
pub session_id: Option<String>,
|
|
||||||
pub message_type: Option<i32>,
|
|
||||||
pub context_token: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub item_list: Vec<MessageItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct GetConfigResponse {
|
|
||||||
pub ret: Option<i32>,
|
|
||||||
pub errmsg: Option<String>,
|
|
||||||
pub typing_ticket: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct SendTypingResponse {
|
|
||||||
pub ret: Option<i32>,
|
|
||||||
pub errmsg: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct MessageItem {
|
|
||||||
pub r#type: Option<i32>,
|
|
||||||
pub text_item: Option<TextItem>,
|
|
||||||
pub image_item: Option<ImageItem>,
|
|
||||||
pub voice_item: Option<VoiceItem>,
|
|
||||||
pub file_item: Option<FileItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct TextItem {
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct CdnMedia {
|
|
||||||
pub encrypt_query_param: Option<String>,
|
|
||||||
pub aes_key: Option<String>,
|
|
||||||
pub encrypt_type: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct ImageItem {
|
|
||||||
pub media: Option<CdnMedia>,
|
|
||||||
pub aeskey: Option<String>,
|
|
||||||
pub mid_size: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct VoiceItem {
|
|
||||||
pub media: Option<CdnMedia>,
|
|
||||||
pub encode_type: Option<i32>,
|
|
||||||
pub playtime: Option<u64>,
|
|
||||||
pub text: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct FileItem {
|
|
||||||
pub media: Option<CdnMedia>,
|
|
||||||
pub file_name: Option<String>,
|
|
||||||
pub len: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct OutboundMetadata {
|
|
||||||
pub from_user_id: String,
|
|
||||||
pub to_user_id: Option<String>,
|
|
||||||
pub message_id: Option<i64>,
|
|
||||||
pub session_id: Option<String>,
|
|
||||||
pub context_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const MESSAGE_TYPE_USER: i32 = 1;
|
|
||||||
pub const MESSAGE_TYPE_BOT: i32 = 2;
|
|
||||||
pub const MESSAGE_STATE_FINISH: i32 = 2;
|
|
||||||
pub const MESSAGE_ITEM_TEXT: i32 = 1;
|
|
||||||
pub const MESSAGE_ITEM_IMAGE: i32 = 2;
|
|
||||||
pub const MESSAGE_ITEM_VOICE: i32 = 3;
|
|
||||||
pub const MESSAGE_ITEM_FILE: i32 = 4;
|
|
||||||
pub const TYPING_STATUS_TYPING: i32 = 1;
|
|
||||||
pub const TYPING_STATUS_CANCEL: i32 = 2;
|
|
||||||
pub const UPLOAD_MEDIA_TYPE_IMAGE: i32 = 1;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.1.0",
|
|
||||||
"wit_version": "0.3.0",
|
|
||||||
"type": "channel",
|
|
||||||
"name": "wechat",
|
|
||||||
"description": "WeChat iLink Bot channel for direct-message chat via long polling",
|
|
||||||
"setup": {
|
|
||||||
"required_secrets": [
|
|
||||||
{
|
|
||||||
"name": "wechat_bot_token",
|
|
||||||
"prompt": "Connect this channel from the WeChat setup flow. IronClaw stores the bot token after QR login succeeds.",
|
|
||||||
"optional": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"setup_url": "https://ilinkai.weixin.qq.com"
|
|
||||||
},
|
|
||||||
"capabilities": {
|
|
||||||
"http": {
|
|
||||||
"allowlist": [
|
|
||||||
{ "host": "ilinkai.weixin.qq.com", "path_prefix": "/" },
|
|
||||||
{ "host": "novac2c.cdn.weixin.qq.com", "path_prefix": "/c2c/" }
|
|
||||||
],
|
|
||||||
"rate_limit": {
|
|
||||||
"requests_per_minute": 60,
|
|
||||||
"requests_per_hour": 1200
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secrets": {
|
|
||||||
"allowed_names": ["wechat_*"]
|
|
||||||
},
|
|
||||||
"channel": {
|
|
||||||
"allowed_paths": [],
|
|
||||||
"allow_polling": true,
|
|
||||||
"min_poll_interval_ms": 30000,
|
|
||||||
"workspace_prefix": "channels/wechat/",
|
|
||||||
"callback_timeout_secs": 45,
|
|
||||||
"emit_rate_limit": {
|
|
||||||
"messages_per_minute": 100,
|
|
||||||
"messages_per_hour": 5000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"base_url": "https://ilinkai.weixin.qq.com",
|
|
||||||
"cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c",
|
|
||||||
"bot_type": "3",
|
|
||||||
"poll_interval_ms": 30000,
|
|
||||||
"long_poll_timeout_ms": 35000,
|
|
||||||
"inbound_merge_window_ms": 5000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+1
-1
@@ -269,7 +269,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whatsapp-channel"
|
name = "whatsapp-channel"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ authors = ["NEAR AI <[email protected]>"]
|
|||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
homepage = "https://github.com/nearai/ironclaw"
|
homepage = "https://github.com/nearai/ironclaw"
|
||||||
repository = "https://github.com/nearai/ironclaw"
|
repository = "https://github.com/nearai/ironclaw"
|
||||||
|
publish = false
|
||||||
|
|
||||||
[package.metadata.dist]
|
[package.metadata.dist]
|
||||||
dist = false
|
dist = false
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ironclaw_safety"
|
name = "ironclaw_safety"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
description = "Prompt injection defense, input validation, secret leak detection, and safety policy enforcement"
|
description = "Prompt injection defense, input validation, secret leak detection, and safety policy enforcement"
|
||||||
@@ -8,6 +8,7 @@ authors = ["NEAR AI <[email protected]>"]
|
|||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
homepage = "https://github.com/nearai/ironclaw"
|
homepage = "https://github.com/nearai/ironclaw"
|
||||||
repository = "https://github.com/nearai/ironclaw"
|
repository = "https://github.com/nearai/ironclaw"
|
||||||
|
publish = false
|
||||||
|
|
||||||
[package.metadata.dist]
|
[package.metadata.dist]
|
||||||
dist = false
|
dist = false
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
# WeChat Integration Design
|
|
||||||
|
|
||||||
**Date:** 2026-03-25
|
|
||||||
**Status:** Ready for implementation
|
|
||||||
**Goal:** Add WeChat support to IronClaw using the same upstream iLink Bot protocol as `@tencent-weixin/openclaw-weixin`, while keeping the implementation aligned with IronClaw's extension-first channel architecture.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Upstream Baseline
|
|
||||||
|
|
||||||
The current upstream npm package is `@tencent-weixin/openclaw-weixin` version `2.0.1`.
|
|
||||||
|
|
||||||
From the package README and source, the upstream WeChat channel does all of the following:
|
|
||||||
|
|
||||||
- logs in by QR code against `https://ilinkai.weixin.qq.com`
|
|
||||||
- receives inbound messages by long-polling `ilink/bot/getupdates`
|
|
||||||
- sends outbound messages through `ilink/bot/sendmessage`
|
|
||||||
- uses `ilink/bot/getconfig` and `ilink/bot/sendtyping` for typing indicators
|
|
||||||
- uses `ilink/bot/getuploadurl` for media uploads
|
|
||||||
- persists `get_updates_buf` for long-poll resume
|
|
||||||
- persists `context_token` so replies stay attached to the right WeChat session
|
|
||||||
- supports multiple logged-in WeChat bot accounts
|
|
||||||
- treats WeChat as a direct-message-only channel
|
|
||||||
- block-sends replies instead of token streaming
|
|
||||||
|
|
||||||
This design treats that upstream behavior as the capability boundary. We should not add scope based on features the upstream plugin does not have.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Direction
|
|
||||||
|
|
||||||
IronClaw should **not** try to load the upstream OpenClaw plugin directly.
|
|
||||||
|
|
||||||
Instead, IronClaw should implement a **native channel extension** under `channels-src/wechat/` and only extend the host/runtime where that support is generic and reusable.
|
|
||||||
|
|
||||||
### Why not host the npm plugin directly
|
|
||||||
|
|
||||||
- The upstream package depends on `openclaw/plugin-sdk/*` APIs and runtime contracts that IronClaw does not have.
|
|
||||||
- It assumes OpenClaw-specific lifecycle concepts such as `gateway.startAccount`.
|
|
||||||
- Recreating an OpenClaw-compatible Node plugin host inside IronClaw would be more work and more fragile than implementing the protocol directly.
|
|
||||||
|
|
||||||
### Why `channels-src/wechat/`
|
|
||||||
|
|
||||||
- It matches the existing layering used by other platform channels.
|
|
||||||
- It keeps platform protocol logic out of host-owned core modules.
|
|
||||||
- It leaves room for the channel to move outside this repo later without changing the host model.
|
|
||||||
|
|
||||||
Recommended layout:
|
|
||||||
|
|
||||||
```text
|
|
||||||
channels-src/
|
|
||||||
wechat/
|
|
||||||
Cargo.toml
|
|
||||||
build.sh
|
|
||||||
wechat.capabilities.json
|
|
||||||
src/
|
|
||||||
lib.rs
|
|
||||||
api.rs
|
|
||||||
auth.rs
|
|
||||||
state.rs
|
|
||||||
types.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 Scope
|
|
||||||
|
|
||||||
Phase 1 is a **single-account** implementation of the upstream WeChat channel.
|
|
||||||
|
|
||||||
The point of this phase is to keep the channel aligned with upstream behavior while removing the one biggest source of host/runtime complexity: multi-account lifecycle.
|
|
||||||
|
|
||||||
### Must-have in Phase 1
|
|
||||||
|
|
||||||
- QR code login
|
|
||||||
- one connected WeChat bot account
|
|
||||||
- direct-message text receive/send
|
|
||||||
- `getupdates` long-poll loop
|
|
||||||
- `sendmessage` outbound replies
|
|
||||||
- typing indicators via `getconfig` and `sendtyping`
|
|
||||||
- inbound image download/decrypt for vision
|
|
||||||
- outbound image upload/send via `getuploadurl`
|
|
||||||
- `context_token` persistence
|
|
||||||
- `get_updates_buf` persistence
|
|
||||||
- login persistence across restart
|
|
||||||
- extension-first packaging under `channels-src/wechat/`
|
|
||||||
|
|
||||||
### Explicit simplification from upstream
|
|
||||||
|
|
||||||
- multi-account support is deferred
|
|
||||||
|
|
||||||
### Follow-up after Phase 1
|
|
||||||
|
|
||||||
These are upstream features, so they belong on the roadmap, but they do not need to block the first implementation cut:
|
|
||||||
|
|
||||||
- broader media parity beyond the current image + inbound-file + inbound-voice path (outbound files and video)
|
|
||||||
|
|
||||||
We should not spend time listing non-goals that come from outside the upstream capability boundary.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Architecture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
A["Core WASM channel host"] --> B["WeChat channel extension"]
|
|
||||||
C["Generic login UI/API"] --> D["QR login session"]
|
|
||||||
D --> E["Secret storage"]
|
|
||||||
E --> B
|
|
||||||
B --> F["getupdates long-poll"]
|
|
||||||
F --> G["IncomingMessage"]
|
|
||||||
G --> H["ChannelManager -> Agent"]
|
|
||||||
H --> I["sendmessage reply"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Extension responsibilities
|
|
||||||
|
|
||||||
`channels-src/wechat/` should own:
|
|
||||||
|
|
||||||
- iLink API request/response types
|
|
||||||
- QR login protocol calls
|
|
||||||
- long-polling `getupdates`
|
|
||||||
- `context_token` storage and lookup
|
|
||||||
- outbound `sendmessage`
|
|
||||||
- WeChat-specific status/error mapping
|
|
||||||
|
|
||||||
### Host responsibilities
|
|
||||||
|
|
||||||
IronClaw core should only own reusable pieces:
|
|
||||||
|
|
||||||
- installing and activating the WASM channel
|
|
||||||
- generic secret persistence
|
|
||||||
- generic QR/device-login session handling for channels
|
|
||||||
- exposing login flow through authenticated UI/API
|
|
||||||
- starting and polling the channel runtime
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data And State Model
|
|
||||||
|
|
||||||
Phase 1 is single-account, so state should stay simple.
|
|
||||||
|
|
||||||
### Secrets
|
|
||||||
|
|
||||||
- `wechat_bot_token`
|
|
||||||
|
|
||||||
This is written after QR login succeeds and reused on restart.
|
|
||||||
|
|
||||||
### Channel state
|
|
||||||
|
|
||||||
Under the channel workspace prefix, persist:
|
|
||||||
|
|
||||||
- `state/get_updates_buf.json`
|
|
||||||
- `state/context_tokens.json`
|
|
||||||
|
|
||||||
`context_tokens.json` maps the WeChat peer to its latest `context_token`.
|
|
||||||
|
|
||||||
### Inbound message mapping
|
|
||||||
|
|
||||||
For each inbound WeChat DM:
|
|
||||||
|
|
||||||
- `channel = "wechat"`
|
|
||||||
- `user_id = <wechat sender id>` or owner scope if it is the bound owner
|
|
||||||
- `thread_id = Some("wechat:<sender_id>")`
|
|
||||||
- `conversation_scope_id = Some("wechat:<sender_id>")`
|
|
||||||
|
|
||||||
`metadata_json` should include:
|
|
||||||
|
|
||||||
- `from_user_id`
|
|
||||||
- `to_user_id`
|
|
||||||
- `message_id`
|
|
||||||
- `context_token`
|
|
||||||
|
|
||||||
That is enough for `on_respond()` to send the reply back to the right peer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Minimal Host Uplift
|
|
||||||
|
|
||||||
The current extension host is close, but Phase 1 still needs one important addition: a generic interactive login flow for channels.
|
|
||||||
|
|
||||||
Minimum host support needed:
|
|
||||||
|
|
||||||
1. Start a channel login session.
|
|
||||||
2. Return QR payload plus a session identifier.
|
|
||||||
3. Poll login session status.
|
|
||||||
4. On success, write the returned token to channel secrets.
|
|
||||||
5. Reload or reactivate the channel so polling starts automatically.
|
|
||||||
|
|
||||||
This should be added as a generic channel-auth capability, not as WeChat-specific core logic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Flow
|
|
||||||
|
|
||||||
Phase 1 should be **web-first**, because the target user is a normal WeChat user rather than a CLI-only operator.
|
|
||||||
|
|
||||||
1. Install or enable the `wechat` channel extension.
|
|
||||||
2. Click "Connect WeChat".
|
|
||||||
3. Web UI requests a login session from the host.
|
|
||||||
4. Web UI displays the QR code.
|
|
||||||
5. User scans and confirms on their phone.
|
|
||||||
6. Host stores `wechat_bot_token`.
|
|
||||||
7. Channel reloads and starts polling.
|
|
||||||
8. User sends a DM in WeChat and receives IronClaw replies there.
|
|
||||||
|
|
||||||
CLI support can still exist for development, but it should not be the primary Phase 1 UX.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Message Handling Semantics
|
|
||||||
|
|
||||||
### Inbound
|
|
||||||
|
|
||||||
On each poll:
|
|
||||||
|
|
||||||
1. load `get_updates_buf`
|
|
||||||
2. call `getupdates`
|
|
||||||
3. persist the new cursor if present
|
|
||||||
4. normalize inbound text messages into `IncomingMessage`
|
|
||||||
5. persist the latest `context_token` for that peer
|
|
||||||
6. emit the message to the agent
|
|
||||||
|
|
||||||
### Outbound
|
|
||||||
|
|
||||||
On response:
|
|
||||||
|
|
||||||
1. read peer routing info from `metadata_json`
|
|
||||||
2. load the latest `context_token`
|
|
||||||
3. convert the response to plain text if needed
|
|
||||||
4. send one coalesced text reply via `sendmessage`
|
|
||||||
|
|
||||||
This matches the upstream channel's block-send behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Plan
|
|
||||||
|
|
||||||
### Unit tests
|
|
||||||
|
|
||||||
- QR login response parsing
|
|
||||||
- `get_updates_buf` round-trip
|
|
||||||
- `context_token` round-trip
|
|
||||||
- inbound message normalization
|
|
||||||
- outbound metadata routing
|
|
||||||
|
|
||||||
### Integration tests
|
|
||||||
|
|
||||||
Use a mock iLink server to cover:
|
|
||||||
|
|
||||||
- QR login success and expiry
|
|
||||||
- restart without re-login
|
|
||||||
- inbound poll -> agent -> outbound text reply
|
|
||||||
- cursor resume after restart
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2
|
|
||||||
|
|
||||||
After Phase 1 is stable, add the upstream features we intentionally deferred:
|
|
||||||
|
|
||||||
- multi-account support
|
|
||||||
- media upload/send
|
|
||||||
@@ -15,14 +15,13 @@
|
|||||||
},
|
},
|
||||||
"messaging": {
|
"messaging": {
|
||||||
"display_name": "Messaging Channels",
|
"display_name": "Messaging Channels",
|
||||||
"description": "Discord, Telegram, Slack, WhatsApp, and WeChat channels",
|
"description": "Discord, Telegram, Slack, and WhatsApp channels",
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"channels/discord",
|
"channels/discord",
|
||||||
"channels/telegram",
|
"channels/telegram",
|
||||||
"channels/slack",
|
"channels/slack",
|
||||||
"channels/whatsapp",
|
"channels/whatsapp",
|
||||||
"channels/feishu",
|
"channels/feishu"
|
||||||
"channels/wechat"
|
|
||||||
],
|
],
|
||||||
"shared_auth": null
|
"shared_auth": null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "feishu",
|
"name": "feishu",
|
||||||
"display_name": "Feishu / Lark Channel",
|
"display_name": "Feishu / Lark Channel",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
"version": "0.1.3",
|
"version": "0.1.1",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Talk to your agent through a Feishu or Lark bot",
|
"description": "Talk to your agent through a Feishu or Lark bot",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"sha256": "a66ff0dafb67d2216d8161bb7e96e724a94acb0ab993b85d2782d30412f8fe94",
|
"sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714",
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/channel-feishu-0.1.3-wasm32-wasip2.tar.gz"
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/v0.20.0/channel-telegram-0.2.5-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "1ef20a538f55b379e049356e4d6758006251846bc3365ceaa1c87eba8379a329"
|
"sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "wechat",
|
|
||||||
"display_name": "WeChat Channel",
|
|
||||||
"kind": "channel",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"wit_version": "0.3.0",
|
|
||||||
"description": "Talk to your agent through a WeChat iLink bot account",
|
|
||||||
"keywords": [
|
|
||||||
"messaging",
|
|
||||||
"chat",
|
|
||||||
"wechat",
|
|
||||||
"wechat",
|
|
||||||
"qr"
|
|
||||||
],
|
|
||||||
"source": {
|
|
||||||
"dir": "channels-src/wechat",
|
|
||||||
"capabilities": "wechat.capabilities.json",
|
|
||||||
"crate_name": "wechat-channel"
|
|
||||||
},
|
|
||||||
"auth_summary": {
|
|
||||||
"method": "interactive",
|
|
||||||
"provider": "WeChat",
|
|
||||||
"secrets": [
|
|
||||||
"wechat_bot_token"
|
|
||||||
],
|
|
||||||
"shared_auth": null,
|
|
||||||
"setup_url": "https://ilinkai.weixin.qq.com"
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"messaging"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "github",
|
"name": "github",
|
||||||
"display_name": "GitHub",
|
"display_name": "GitHub",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.2",
|
"version": "0.2.1",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "GitHub integration for issues, PRs, repos, and code search",
|
"description": "GitHub integration for issues, PRs, repos, and code search",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-github-0.2.2-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "70b55af593193d8fa495c0f702ea23284d83a624124f8a5f7564916ec5032c3f"
|
"sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "gmail",
|
"name": "gmail",
|
||||||
"display_name": "Gmail",
|
"display_name": "Gmail",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Read, send, and manage Gmail messages and threads",
|
"description": "Read, send, and manage Gmail messages and threads",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-gmail-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/gmail-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "79025b40ee70ce1120acc4320bae50da095d7afb0ef67bd56d99b064b72ea779"
|
"sha256": "ee9574e02e92bc1d481f1310eb88afd99ee52bf6971074ab33bd76bf99b34b1d"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "google-calendar",
|
"name": "google-calendar",
|
||||||
"display_name": "Google Calendar",
|
"display_name": "Google Calendar",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Create, read, update, and delete Google Calendar events",
|
"description": "Create, read, update, and delete Google Calendar events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-calendar-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-calendar-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "86bcc075010b08f5ab2f98f504cec1c6c9e0ca144857d185cbecf72a11f504bf"
|
"sha256": "2fa47150ea222e787c122182ad6f4dfa2ffaf5fe490d05e8de887a76445f8d2d"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "google-docs",
|
"name": "google-docs",
|
||||||
"display_name": "Google Docs",
|
"display_name": "Google Docs",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Create and edit Google Docs documents",
|
"description": "Create and edit Google Docs documents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-docs-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-docs-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "39d476029764949498a53a6a223f9952b5f4df151be7b8b19bf3fe4d401a57cd"
|
"sha256": "40e134a1c1564f832ca861c3396895d4e33ec67b99313fc1f97baf8d971423a9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "google-drive",
|
"name": "google-drive",
|
||||||
"display_name": "Google Drive",
|
"display_name": "Google Drive",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Upload, download, search, and manage Google Drive files and folders",
|
"description": "Upload, download, search, and manage Google Drive files and folders",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-drive-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-drive-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "6e9a700fab93865c852af718666af64c5b534ad6a419fb4b736e07740188f494"
|
"sha256": "002a341a1d58125563a7c69561b26fbc2629b04ea723cade744102bdc0fbb71f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "google-sheets",
|
"name": "google-sheets",
|
||||||
"display_name": "Google Sheets",
|
"display_name": "Google Sheets",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Read and write Google Sheets spreadsheet data",
|
"description": "Read and write Google Sheets spreadsheet data",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-sheets-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-sheets-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "1f8c381799a916be83263cac9d497d52946e21b1b588592a3a42ca94a73b7051"
|
"sha256": "8aa2c9d52f033edea3a6c2311b0ec694ccb6d0a54ef07e94d72bf8be1ce8009a"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "google-slides",
|
"name": "google-slides",
|
||||||
"display_name": "Google Slides",
|
"display_name": "Google Slides",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Create and edit Google Slides presentations",
|
"description": "Create and edit Google Slides presentations",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-slides-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-slides-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "e2528be5da02f1b8cfc8ee9b0cdd849516c53d412e2f75c6175b3bded7f512cb"
|
"sha256": "e931a97d4fd0b0b938e464dc7c7f2be6ea6b4d1508f5ea3cd931d44db23f05f5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "llm-context",
|
"name": "llm-context",
|
||||||
"display_name": "LLM Context",
|
"display_name": "LLM Context",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Fetch pre-extracted web content from Brave Search for grounding LLM answers (RAG, fact-checking)",
|
"description": "Fetch pre-extracted web content from Brave Search for grounding LLM answers (RAG, fact-checking)",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-llm-context-0.1.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "9b19e2fd05dbbbe3c8bd55309a91db09124e8415eb0f767828b6e10b55771e63"
|
"sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "slack-tool",
|
"name": "slack-tool",
|
||||||
"display_name": "Slack Tool",
|
"display_name": "Slack Tool",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Your agent uses Slack to post and read messages in your workspace",
|
"description": "Your agent uses Slack to post and read messages in your workspace",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-slack-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "927519e5b7734beeb022d3b8bbd152e0e6b9f67c9452a8ad47809d3c4221a137"
|
"sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "telegram-mtproto",
|
"name": "telegram-mtproto",
|
||||||
"display_name": "Telegram Tool",
|
"display_name": "Telegram Tool",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.1",
|
"version": "0.2.0",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Your agent uses your Telegram account to read and send messages",
|
"description": "Your agent uses your Telegram account to read and send messages",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-telegram-0.2.1-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "1e57d0755fc9c7b3ec013d079f30168898b484a6919f9edd105f0cd80131c1cd"
|
"sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "web-search",
|
"name": "web-search",
|
||||||
"display_name": "Web Search",
|
"display_name": "Web Search",
|
||||||
"kind": "tool",
|
"kind": "tool",
|
||||||
"version": "0.2.2",
|
"version": "0.2.1",
|
||||||
"wit_version": "0.3.0",
|
"wit_version": "0.3.0",
|
||||||
"description": "Search the web using Brave Search API",
|
"description": "Search the web using Brave Search API",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-web-search-0.2.2-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "47382b50c1ea7525b20d59dc02fab04e336d018665826c2f24710bdf460779ae"
|
"sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -1,2 +1,7 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
git_release_enable = false
|
git_release_enable = false
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ironclaw_safety"
|
||||||
|
publish = false
|
||||||
|
release = false
|
||||||
|
|||||||
+37
-136
@@ -13,7 +13,7 @@ use futures::StreamExt;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::agent::context_monitor::ContextMonitor;
|
use crate::agent::context_monitor::ContextMonitor;
|
||||||
use crate::agent::heartbeat::{spawn_heartbeat, spawn_multi_user_heartbeat};
|
use crate::agent::heartbeat::spawn_heartbeat;
|
||||||
use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
|
use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
|
||||||
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
|
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
|
||||||
use crate::agent::session::ThreadState;
|
use crate::agent::session::ThreadState;
|
||||||
@@ -182,8 +182,6 @@ pub struct AgentDeps {
|
|||||||
/// Resolved LLM backend identifier (e.g., "nearai", "openai", "groq").
|
/// Resolved LLM backend identifier (e.g., "nearai", "openai", "groq").
|
||||||
/// Used by `/model` persistence to determine which env var to update.
|
/// Used by `/model` persistence to determine which env var to update.
|
||||||
pub llm_backend: String,
|
pub llm_backend: String,
|
||||||
/// Per-tenant rate limiting registry (lazily creates rate state per user).
|
|
||||||
pub tenant_rates: Arc<crate::tenant::TenantRateRegistry>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main agent that coordinates all components.
|
/// The main agent that coordinates all components.
|
||||||
@@ -246,10 +244,7 @@ impl Agent {
|
|||||||
SchedulerDeps {
|
SchedulerDeps {
|
||||||
tools: deps.tools.clone(),
|
tools: deps.tools.clone(),
|
||||||
extension_manager: deps.extension_manager.clone(),
|
extension_manager: deps.extension_manager.clone(),
|
||||||
store: deps
|
store: deps.store.clone(),
|
||||||
.store
|
|
||||||
.as_ref()
|
|
||||||
.map(|db| crate::tenant::AdminScope::new(Arc::clone(db))),
|
|
||||||
hooks: deps.hooks.clone(),
|
hooks: deps.hooks.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -330,50 +325,6 @@ impl Agent {
|
|||||||
&self.deps.cost_guard
|
&self.deps.cost_guard
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a tenant-scoped execution context for the given user.
|
|
||||||
///
|
|
||||||
/// This is the standard entry point for per-user operations. The returned
|
|
||||||
/// [`TenantCtx`] provides a [`TenantScope`] that auto-binds `user_id` on
|
|
||||||
/// every database operation and a per-user rate limiter.
|
|
||||||
pub(super) async fn tenant_ctx(&self, user_id: &str) -> crate::tenant::TenantCtx {
|
|
||||||
let rate = self.deps.tenant_rates.get_or_create(user_id).await;
|
|
||||||
|
|
||||||
let store = self
|
|
||||||
.deps
|
|
||||||
.store
|
|
||||||
.as_ref()
|
|
||||||
.map(|db| crate::tenant::TenantScope::new(user_id, Arc::clone(db)));
|
|
||||||
|
|
||||||
// Reuse the owner workspace if user matches, otherwise create per-user.
|
|
||||||
let workspace = match &self.deps.workspace {
|
|
||||||
Some(ws) if ws.user_id() == user_id => Some(Arc::clone(ws)),
|
|
||||||
_ => self
|
|
||||||
.deps
|
|
||||||
.store
|
|
||||||
.as_ref()
|
|
||||||
.map(|db| Arc::new(Workspace::new_with_db(user_id, Arc::clone(db)))),
|
|
||||||
};
|
|
||||||
|
|
||||||
crate::tenant::TenantCtx::new(
|
|
||||||
user_id,
|
|
||||||
store,
|
|
||||||
workspace,
|
|
||||||
Arc::clone(&self.deps.cost_guard),
|
|
||||||
rate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get an admin-scoped database accessor for cross-tenant operations.
|
|
||||||
///
|
|
||||||
/// Only for system-level components (heartbeat, routine engine, self-repair,
|
|
||||||
/// scheduler). Handler code should use [`tenant_ctx()`](Self::tenant_ctx) instead.
|
|
||||||
pub(super) fn admin_store(&self) -> Option<crate::tenant::AdminScope> {
|
|
||||||
self.deps
|
|
||||||
.store
|
|
||||||
.as_ref()
|
|
||||||
.map(|db| crate::tenant::AdminScope::new(Arc::clone(db)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn skill_registry(&self) -> Option<&Arc<std::sync::RwLock<SkillRegistry>>> {
|
pub(super) fn skill_registry(&self) -> Option<&Arc<std::sync::RwLock<SkillRegistry>>> {
|
||||||
self.deps.skill_registry.as_ref()
|
self.deps.skill_registry.as_ref()
|
||||||
}
|
}
|
||||||
@@ -459,8 +410,8 @@ impl Agent {
|
|||||||
self.config.stuck_threshold,
|
self.config.stuck_threshold,
|
||||||
self.config.max_repair_attempts,
|
self.config.max_repair_attempts,
|
||||||
);
|
);
|
||||||
if let Some(admin) = self.admin_store() {
|
if let Some(ref store) = self.deps.store {
|
||||||
self_repair = self_repair.with_store(admin);
|
self_repair = self_repair.with_store(Arc::clone(store));
|
||||||
}
|
}
|
||||||
if let Some(ref builder) = self.deps.builder {
|
if let Some(ref builder) = self.deps.builder {
|
||||||
self_repair = self_repair.with_builder(Arc::clone(builder), Arc::clone(self.tools()));
|
self_repair = self_repair.with_builder(Arc::clone(builder), Arc::clone(self.tools()));
|
||||||
@@ -567,7 +518,6 @@ impl Agent {
|
|||||||
.with_interval(std::time::Duration::from_secs(hb_config.interval_secs));
|
.with_interval(std::time::Duration::from_secs(hb_config.interval_secs));
|
||||||
config.quiet_hours_start = hb_config.quiet_hours_start;
|
config.quiet_hours_start = hb_config.quiet_hours_start;
|
||||||
config.quiet_hours_end = hb_config.quiet_hours_end;
|
config.quiet_hours_end = hb_config.quiet_hours_end;
|
||||||
config.multi_tenant = hb_config.multi_tenant;
|
|
||||||
config.timezone = hb_config
|
config.timezone = hb_config
|
||||||
.timezone
|
.timezone
|
||||||
.clone()
|
.clone()
|
||||||
@@ -597,52 +547,30 @@ impl Agent {
|
|||||||
.await;
|
.await;
|
||||||
let notify_user = heartbeat_notify_user;
|
let notify_user = heartbeat_notify_user;
|
||||||
let channels = self.channels.clone();
|
let channels = self.channels.clone();
|
||||||
let is_multi_tenant = hb_config.multi_tenant;
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(response) = notify_rx.recv().await {
|
while let Some(response) = notify_rx.recv().await {
|
||||||
// In multi-tenant mode, extract the owning user_id from
|
|
||||||
// the response metadata so notifications reach the
|
|
||||||
// correct user rather than the agent's owner.
|
|
||||||
// This intentionally overrides the configured notify_target
|
|
||||||
// because each user's heartbeat should notify that user.
|
|
||||||
let effective_user = if is_multi_tenant {
|
|
||||||
response
|
|
||||||
.metadata
|
|
||||||
.get("owner_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(String::from)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try the configured channel first, fall back to
|
// Try the configured channel first, fall back to
|
||||||
// broadcasting on all channels.
|
// broadcasting on all channels.
|
||||||
let targeted_ok = if let Some(ref channel) = notify_channel {
|
let targeted_ok = if let Some(ref channel) = notify_channel
|
||||||
let target = effective_user.as_deref().or(notify_target.as_deref());
|
&& let Some(ref user) = notify_target
|
||||||
if let Some(user) = target {
|
{
|
||||||
channels
|
channels
|
||||||
.broadcast(channel, user, response.clone())
|
.broadcast(channel, user, response.clone())
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
if !targeted_ok {
|
if !targeted_ok && let Some(ref user) = notify_user {
|
||||||
let fallback = effective_user.as_deref().or(notify_user.as_deref());
|
let results = channels.broadcast_all(user, response).await;
|
||||||
if let Some(user) = fallback {
|
for (ch, result) in results {
|
||||||
let results = channels.broadcast_all(user, response).await;
|
if let Err(e) = result {
|
||||||
for (ch, result) in results {
|
tracing::warn!(
|
||||||
if let Err(e) = result {
|
"Failed to broadcast heartbeat to {}: {}",
|
||||||
tracing::warn!(
|
ch,
|
||||||
"Failed to broadcast heartbeat to {}: {}",
|
e
|
||||||
ch,
|
);
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,29 +583,14 @@ impl Agent {
|
|||||||
.map(|h| h.to_workspace_config())
|
.map(|h| h.to_workspace_config())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if config.multi_tenant {
|
Some(spawn_heartbeat(
|
||||||
if let Some(admin) = self.admin_store() {
|
config,
|
||||||
Some(spawn_multi_user_heartbeat(
|
hygiene,
|
||||||
config,
|
workspace.clone(),
|
||||||
hygiene,
|
self.cheap_llm().clone(),
|
||||||
self.cheap_llm().clone(),
|
Some(notify_tx),
|
||||||
Some(notify_tx),
|
self.store().map(Arc::clone),
|
||||||
admin,
|
))
|
||||||
))
|
|
||||||
} else {
|
|
||||||
tracing::warn!("Multi-tenant heartbeat requires a database store");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Some(spawn_heartbeat(
|
|
||||||
config,
|
|
||||||
hygiene,
|
|
||||||
workspace.clone(),
|
|
||||||
self.cheap_llm().clone(),
|
|
||||||
Some(notify_tx),
|
|
||||||
self.admin_store(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("Heartbeat enabled but no workspace available");
|
tracing::warn!("Heartbeat enabled but no workspace available");
|
||||||
None
|
None
|
||||||
@@ -699,7 +612,7 @@ impl Agent {
|
|||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
rt_config.clone(),
|
rt_config.clone(),
|
||||||
crate::tenant::AdminScope::new(Arc::clone(store)),
|
Arc::clone(store),
|
||||||
self.llm().clone(),
|
self.llm().clone(),
|
||||||
Arc::clone(workspace),
|
Arc::clone(workspace),
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -1260,22 +1173,13 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build per-tenant execution context once; threaded through all handlers.
|
|
||||||
let tenant = self.tenant_ctx(&message.user_id).await;
|
|
||||||
|
|
||||||
let session_for_empty_exit = Arc::clone(&session);
|
let session_for_empty_exit = Arc::clone(&session);
|
||||||
|
|
||||||
// Process based on submission type
|
// Process based on submission type
|
||||||
let result = match submission {
|
let result = match submission {
|
||||||
Submission::UserInput { content } => {
|
Submission::UserInput { content } => {
|
||||||
let mut result = self
|
let mut result = self
|
||||||
.process_user_input(
|
.process_user_input(message, session.clone(), thread_id, &content)
|
||||||
message,
|
|
||||||
tenant.clone(),
|
|
||||||
session.clone(),
|
|
||||||
thread_id,
|
|
||||||
&content,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Drain any messages queued during processing.
|
// Drain any messages queued during processing.
|
||||||
@@ -1342,13 +1246,7 @@ impl Agent {
|
|||||||
let mut queued_msg = message.clone();
|
let mut queued_msg = message.clone();
|
||||||
queued_msg.attachments.clear();
|
queued_msg.attachments.clear();
|
||||||
result = self
|
result = self
|
||||||
.process_user_input(
|
.process_user_input(&queued_msg, session.clone(), thread_id, &next_content)
|
||||||
&queued_msg,
|
|
||||||
tenant.clone(),
|
|
||||||
session.clone(),
|
|
||||||
thread_id,
|
|
||||||
&next_content,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// If processing failed, re-queue the drained content so it
|
// If processing failed, re-queue the drained content so it
|
||||||
@@ -1396,7 +1294,7 @@ impl Agent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Authorization checks (including restart channel check) are enforced in handle_system_command
|
// Authorization checks (including restart channel check) are enforced in handle_system_command
|
||||||
self.handle_system_command(&command, &args, &message.channel, &tenant)
|
self.handle_system_command(&command, &args, &message.channel)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
Submission::Undo => self.process_undo(session, thread_id).await,
|
Submission::Undo => self.process_undo(session, thread_id).await,
|
||||||
@@ -1409,9 +1307,12 @@ impl Agent {
|
|||||||
Submission::Summarize => self.process_summarize(session, thread_id).await,
|
Submission::Summarize => self.process_summarize(session, thread_id).await,
|
||||||
Submission::Suggest => self.process_suggest(session, thread_id).await,
|
Submission::Suggest => self.process_suggest(session, thread_id).await,
|
||||||
Submission::JobStatus { job_id } => {
|
Submission::JobStatus { job_id } => {
|
||||||
self.process_job_status(&tenant, job_id.as_deref()).await
|
self.process_job_status(&message.user_id, job_id.as_deref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Submission::JobCancel { job_id } => {
|
||||||
|
self.process_job_cancel(&message.user_id, &job_id).await
|
||||||
}
|
}
|
||||||
Submission::JobCancel { job_id } => self.process_job_cancel(&tenant, &job_id).await,
|
|
||||||
Submission::Quit => return Ok(None),
|
Submission::Quit => return Ok(None),
|
||||||
Submission::SwitchThread { thread_id: target } => {
|
Submission::SwitchThread { thread_id: target } => {
|
||||||
self.process_switch_thread(message, target).await
|
self.process_switch_thread(message, target).await
|
||||||
|
|||||||
+1
-125
@@ -10,7 +10,7 @@ use std::borrow::Cow;
|
|||||||
|
|
||||||
use crate::agent::session::PendingApproval;
|
use crate::agent::session::PendingApproval;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::llm::{ChatMessage, FinishReason, Reasoning, ReasoningContext, RespondResult};
|
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
|
||||||
|
|
||||||
/// Signal from the delegate indicating how the loop should proceed.
|
/// Signal from the delegate indicating how the loop should proceed.
|
||||||
pub enum LoopSignal {
|
pub enum LoopSignal {
|
||||||
@@ -134,9 +134,6 @@ pub async fn run_agentic_loop(
|
|||||||
config: &AgenticLoopConfig,
|
config: &AgenticLoopConfig,
|
||||||
) -> Result<LoopOutcome, Error> {
|
) -> Result<LoopOutcome, Error> {
|
||||||
let mut consecutive_tool_intent_nudges: u32 = 0;
|
let mut consecutive_tool_intent_nudges: u32 = 0;
|
||||||
// Accumulates across all iterations (not reset by text responses) so
|
|
||||||
// non-consecutive truncations still escalate to force_text.
|
|
||||||
let mut truncation_count: u32 = 0;
|
|
||||||
|
|
||||||
for iteration in 1..=config.max_iterations {
|
for iteration in 1..=config.max_iterations {
|
||||||
// Check for external signals (stop, cancellation, user messages)
|
// Check for external signals (stop, cancellation, user messages)
|
||||||
@@ -218,35 +215,7 @@ pub async fn run_agentic_loop(
|
|||||||
tool_calls,
|
tool_calls,
|
||||||
content,
|
content,
|
||||||
} => {
|
} => {
|
||||||
// If the response was truncated, tool call parameters are likely
|
|
||||||
// incomplete. Discard them and tell the LLM to try a different
|
|
||||||
// approach rather than executing malformed tool calls.
|
|
||||||
if output.finish_reason == FinishReason::Length {
|
|
||||||
truncation_count += 1;
|
|
||||||
let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
|
|
||||||
tracing::warn!(
|
|
||||||
iteration,
|
|
||||||
tools = ?names,
|
|
||||||
truncation_count,
|
|
||||||
"Discarding truncated tool calls (finish_reason=Length)"
|
|
||||||
);
|
|
||||||
if let Some(ref text) = content {
|
|
||||||
reason_ctx.messages.push(ChatMessage::assistant(text));
|
|
||||||
}
|
|
||||||
reason_ctx
|
|
||||||
.messages
|
|
||||||
.push(ChatMessage::user(crate::llm::TRUNCATED_TOOL_CALL_NOTICE));
|
|
||||||
// After repeated truncations, force text-only mode so the LLM
|
|
||||||
// stops attempting tool calls it can't fit in the output budget.
|
|
||||||
if truncation_count >= 3 {
|
|
||||||
reason_ctx.force_text = true;
|
|
||||||
}
|
|
||||||
delegate.after_iteration(iteration).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
consecutive_tool_intent_nudges = 0;
|
consecutive_tool_intent_nudges = 0;
|
||||||
truncation_count = 0;
|
|
||||||
|
|
||||||
if let Some(outcome) = delegate
|
if let Some(outcome) = delegate
|
||||||
.execute_tool_calls(tool_calls, content, reason_ctx)
|
.execute_tool_calls(tool_calls, content, reason_ctx)
|
||||||
@@ -302,7 +271,6 @@ mod tests {
|
|||||||
RespondOutput {
|
RespondOutput {
|
||||||
result: RespondResult::Text(text.to_string()),
|
result: RespondResult::Text(text.to_string()),
|
||||||
usage: zero_usage(),
|
usage: zero_usage(),
|
||||||
finish_reason: FinishReason::Stop,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +281,6 @@ mod tests {
|
|||||||
content: None,
|
content: None,
|
||||||
},
|
},
|
||||||
usage: zero_usage(),
|
usage: zero_usage(),
|
||||||
finish_reason: FinishReason::ToolUse,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,95 +622,4 @@ mod tests {
|
|||||||
let result = truncate_for_preview("café", 4);
|
let result = truncate_for_preview("café", 4);
|
||||||
assert_eq!(result, "caf...");
|
assert_eq!(result, "caf...");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_truncated_tool_calls_discarded_on_length() {
|
|
||||||
let truncated_tool_call = ToolCall {
|
|
||||||
id: "call_1".to_string(),
|
|
||||||
name: "memory_write".to_string(),
|
|
||||||
arguments: serde_json::json!({}), // empty — truncated
|
|
||||||
reasoning: None,
|
|
||||||
};
|
|
||||||
let truncated_output = RespondOutput {
|
|
||||||
result: RespondResult::ToolCalls {
|
|
||||||
tool_calls: vec![truncated_tool_call],
|
|
||||||
content: Some("I'll write the report.".to_string()),
|
|
||||||
},
|
|
||||||
usage: zero_usage(),
|
|
||||||
finish_reason: FinishReason::Length, // response was truncated
|
|
||||||
};
|
|
||||||
let delegate = MockDelegate::new(vec![truncated_output, text_output("Summarized it.")]);
|
|
||||||
let reasoning = stub_reasoning();
|
|
||||||
let mut ctx = ReasoningContext::new();
|
|
||||||
let config = AgenticLoopConfig {
|
|
||||||
max_iterations: 5,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Tool calls should NOT have been executed
|
|
||||||
assert_eq!(delegate.tool_exec_count.load(Ordering::SeqCst), 0);
|
|
||||||
// The loop should have continued and returned the text response
|
|
||||||
assert!(matches!(outcome, LoopOutcome::Response(ref t) if t == "Summarized it."));
|
|
||||||
// A truncation notice should have been injected into context
|
|
||||||
assert!(
|
|
||||||
ctx.messages
|
|
||||||
.iter()
|
|
||||||
.any(|m| m.role == crate::llm::Role::User && m.content.contains("truncated")),
|
|
||||||
"Should inject truncation notice into context"
|
|
||||||
);
|
|
||||||
// The partial assistant content should have been preserved
|
|
||||||
assert!(
|
|
||||||
ctx.messages
|
|
||||||
.iter()
|
|
||||||
.any(|m| m.role == crate::llm::Role::Assistant
|
|
||||||
&& m.content.contains("write the report")),
|
|
||||||
"Should preserve partial assistant content"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_repeated_truncations_force_text_mode() {
|
|
||||||
let make_truncated = || RespondOutput {
|
|
||||||
result: RespondResult::ToolCalls {
|
|
||||||
tool_calls: vec![ToolCall {
|
|
||||||
id: "call_1".to_string(),
|
|
||||||
name: "memory_write".to_string(),
|
|
||||||
arguments: serde_json::json!({}),
|
|
||||||
reasoning: None,
|
|
||||||
}],
|
|
||||||
content: None,
|
|
||||||
},
|
|
||||||
usage: zero_usage(),
|
|
||||||
finish_reason: FinishReason::Length,
|
|
||||||
};
|
|
||||||
// Three truncated responses, then a text response
|
|
||||||
let delegate = MockDelegate::new(vec![
|
|
||||||
make_truncated(),
|
|
||||||
make_truncated(),
|
|
||||||
make_truncated(),
|
|
||||||
text_output("Gave up on tool calls."),
|
|
||||||
]);
|
|
||||||
let reasoning = stub_reasoning();
|
|
||||||
let mut ctx = ReasoningContext::new();
|
|
||||||
let config = AgenticLoopConfig {
|
|
||||||
max_iterations: 5,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(outcome, LoopOutcome::Response(_)));
|
|
||||||
assert_eq!(delegate.tool_exec_count.load(Ordering::SeqCst), 0);
|
|
||||||
// After 3 truncations, force_text should be set
|
|
||||||
assert!(
|
|
||||||
ctx.force_text,
|
|
||||||
"Should escalate to force_text after repeated truncations"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-88
@@ -33,7 +33,6 @@ impl Agent {
|
|||||||
&self,
|
&self,
|
||||||
intent: MessageIntent,
|
intent: MessageIntent,
|
||||||
message: &IncomingMessage,
|
message: &IncomingMessage,
|
||||||
tenant: &crate::tenant::TenantCtx,
|
|
||||||
) -> Result<SubmissionResult, Error> {
|
) -> Result<SubmissionResult, Error> {
|
||||||
// Send thinking status for non-trivial operations
|
// Send thinking status for non-trivial operations
|
||||||
if let MessageIntent::CreateJob { .. } = &intent {
|
if let MessageIntent::CreateJob { .. } = &intent {
|
||||||
@@ -53,18 +52,24 @@ impl Agent {
|
|||||||
description,
|
description,
|
||||||
category,
|
category,
|
||||||
} => {
|
} => {
|
||||||
self.handle_create_job(tenant, title, description, category)
|
self.handle_create_job(&message.user_id, title, description, category)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
MessageIntent::CheckJobStatus { job_id } => {
|
MessageIntent::CheckJobStatus { job_id } => {
|
||||||
self.handle_check_status(tenant, job_id).await?
|
self.handle_check_status(&message.user_id, job_id).await?
|
||||||
|
}
|
||||||
|
MessageIntent::CancelJob { job_id } => {
|
||||||
|
self.handle_cancel_job(&message.user_id, &job_id).await?
|
||||||
|
}
|
||||||
|
MessageIntent::ListJobs { filter } => {
|
||||||
|
self.handle_list_jobs(&message.user_id, filter).await?
|
||||||
|
}
|
||||||
|
MessageIntent::HelpJob { job_id } => {
|
||||||
|
self.handle_help_job(&message.user_id, &job_id).await?
|
||||||
}
|
}
|
||||||
MessageIntent::CancelJob { job_id } => self.handle_cancel_job(tenant, &job_id).await?,
|
|
||||||
MessageIntent::ListJobs { filter } => self.handle_list_jobs(tenant, filter).await?,
|
|
||||||
MessageIntent::HelpJob { job_id } => self.handle_help_job(tenant, &job_id).await?,
|
|
||||||
MessageIntent::Command { command, args } => {
|
MessageIntent::Command { command, args } => {
|
||||||
match self
|
match self
|
||||||
.handle_command(&command, &args, &message.channel, tenant)
|
.handle_command(&command, &args, &message.channel)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
@@ -78,14 +83,14 @@ impl Agent {
|
|||||||
|
|
||||||
async fn handle_create_job(
|
async fn handle_create_job(
|
||||||
&self,
|
&self,
|
||||||
tenant: &crate::tenant::TenantCtx,
|
user_id: &str,
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
let job_id = self
|
let job_id = self
|
||||||
.scheduler
|
.scheduler
|
||||||
.dispatch_job(tenant.user_id(), &title, &description, None)
|
.dispatch_job(user_id, &title, &description, None)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Set the dedicated category field (not stored in metadata)
|
// Set the dedicated category field (not stored in metadata)
|
||||||
@@ -108,7 +113,7 @@ impl Agent {
|
|||||||
|
|
||||||
async fn handle_check_status(
|
async fn handle_check_status(
|
||||||
&self,
|
&self,
|
||||||
tenant: &crate::tenant::TenantCtx,
|
user_id: &str,
|
||||||
job_id: Option<String>,
|
job_id: Option<String>,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
match job_id {
|
match job_id {
|
||||||
@@ -117,8 +122,7 @@ impl Agent {
|
|||||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||||
|
|
||||||
// Try DB first for persistent state, fall back to ContextManager.
|
// Try DB first for persistent state, fall back to ContextManager.
|
||||||
// TenantScope.get_job() auto-filters by ownership — no manual check needed.
|
if let Some(store) = self.store()
|
||||||
if let Some(store) = tenant.store()
|
|
||||||
&& let Ok(Some(ctx)) = store.get_job(uuid).await
|
&& let Ok(Some(ctx)) = store.get_job(uuid).await
|
||||||
{
|
{
|
||||||
return Ok(format!(
|
return Ok(format!(
|
||||||
@@ -134,7 +138,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ctx = self.context_manager.get_context(uuid).await?;
|
let ctx = self.context_manager.get_context(uuid).await?;
|
||||||
if ctx.user_id != tenant.user_id() {
|
if ctx.user_id != user_id {
|
||||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,8 +155,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// Show summary from DB for consistency with Jobs tab.
|
// Show summary from DB for consistency with Jobs tab.
|
||||||
// TenantScope methods auto-scope to user — no user_id parameter needed.
|
if let Some(store) = self.store() {
|
||||||
if let Some(store) = tenant.store() {
|
|
||||||
let mut total = 0;
|
let mut total = 0;
|
||||||
let mut in_progress = 0;
|
let mut in_progress = 0;
|
||||||
let mut completed = 0;
|
let mut completed = 0;
|
||||||
@@ -180,7 +183,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to ContextManager if no DB.
|
// Fallback to ContextManager if no DB.
|
||||||
let summary = self.context_manager.summary_for(tenant.user_id()).await;
|
let summary = self.context_manager.summary_for(user_id).await;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}",
|
"Jobs summary: Total: {} In Progress: {} Completed: {} Failed: {} Stuck: {}",
|
||||||
summary.total,
|
summary.total,
|
||||||
@@ -193,24 +196,19 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_cancel_job(
|
async fn handle_cancel_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
||||||
&self,
|
|
||||||
tenant: &crate::tenant::TenantCtx,
|
|
||||||
job_id: &str,
|
|
||||||
) -> Result<String, Error> {
|
|
||||||
let uuid = Uuid::parse_str(job_id)
|
let uuid = Uuid::parse_str(job_id)
|
||||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||||
|
|
||||||
let ctx = self.context_manager.get_context(uuid).await?;
|
let ctx = self.context_manager.get_context(uuid).await?;
|
||||||
if ctx.user_id != tenant.user_id() {
|
if ctx.user_id != user_id {
|
||||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.scheduler.stop(uuid).await?;
|
self.scheduler.stop(uuid).await?;
|
||||||
|
|
||||||
// Also update DB so the Jobs tab reflects cancellation immediately.
|
// Also update DB so the Jobs tab reflects cancellation immediately.
|
||||||
// Use TenantScope — ownership already verified above.
|
if let Some(store) = self.store()
|
||||||
if let Some(store) = tenant.store()
|
|
||||||
&& let Err(e) = store
|
&& let Err(e) = store
|
||||||
.update_job_status(uuid, JobState::Cancelled, Some("Cancelled by user"))
|
.update_job_status(uuid, JobState::Cancelled, Some("Cancelled by user"))
|
||||||
.await
|
.await
|
||||||
@@ -223,12 +221,11 @@ impl Agent {
|
|||||||
|
|
||||||
async fn handle_list_jobs(
|
async fn handle_list_jobs(
|
||||||
&self,
|
&self,
|
||||||
tenant: &crate::tenant::TenantCtx,
|
user_id: &str,
|
||||||
_filter: Option<String>,
|
_filter: Option<String>,
|
||||||
) -> Result<String, Error> {
|
) -> Result<String, Error> {
|
||||||
// List from DB for consistency with Jobs tab.
|
// List from DB for consistency with Jobs tab.
|
||||||
// TenantScope methods auto-scope to user.
|
if let Some(store) = self.store() {
|
||||||
if let Some(store) = tenant.store() {
|
|
||||||
let agent_jobs = match store.list_agent_jobs().await {
|
let agent_jobs = match store.list_agent_jobs().await {
|
||||||
Ok(jobs) => jobs,
|
Ok(jobs) => jobs,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -259,7 +256,7 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to ContextManager if no DB.
|
// Fallback to ContextManager if no DB.
|
||||||
let jobs = self.context_manager.all_jobs_for(tenant.user_id()).await;
|
let jobs = self.context_manager.all_jobs_for(user_id).await;
|
||||||
if jobs.is_empty() {
|
if jobs.is_empty() {
|
||||||
return Ok("No jobs found.".to_string());
|
return Ok("No jobs found.".to_string());
|
||||||
}
|
}
|
||||||
@@ -273,16 +270,12 @@ impl Agent {
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_help_job(
|
async fn handle_help_job(&self, user_id: &str, job_id: &str) -> Result<String, Error> {
|
||||||
&self,
|
|
||||||
tenant: &crate::tenant::TenantCtx,
|
|
||||||
job_id: &str,
|
|
||||||
) -> Result<String, Error> {
|
|
||||||
let uuid = Uuid::parse_str(job_id)
|
let uuid = Uuid::parse_str(job_id)
|
||||||
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
.map_err(|_| crate::error::JobError::NotFound { id: Uuid::nil() })?;
|
||||||
|
|
||||||
let ctx = self.context_manager.get_context(uuid).await?;
|
let ctx = self.context_manager.get_context(uuid).await?;
|
||||||
if ctx.user_id != tenant.user_id() {
|
if ctx.user_id != user_id {
|
||||||
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
return Err(crate::error::JobError::NotFound { id: uuid }.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,11 +308,11 @@ impl Agent {
|
|||||||
/// Show job status inline — either all jobs (no id) or a specific job.
|
/// Show job status inline — either all jobs (no id) or a specific job.
|
||||||
pub(super) async fn process_job_status(
|
pub(super) async fn process_job_status(
|
||||||
&self,
|
&self,
|
||||||
tenant: &crate::tenant::TenantCtx,
|
user_id: &str,
|
||||||
job_id: Option<&str>,
|
job_id: Option<&str>,
|
||||||
) -> Result<SubmissionResult, Error> {
|
) -> Result<SubmissionResult, Error> {
|
||||||
match self
|
match self
|
||||||
.handle_check_status(tenant, job_id.map(|s| s.to_string()))
|
.handle_check_status(user_id, job_id.map(|s| s.to_string()))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(text) => Ok(SubmissionResult::response(text)),
|
Ok(text) => Ok(SubmissionResult::response(text)),
|
||||||
@@ -330,10 +323,10 @@ impl Agent {
|
|||||||
/// Cancel a job by ID.
|
/// Cancel a job by ID.
|
||||||
pub(super) async fn process_job_cancel(
|
pub(super) async fn process_job_cancel(
|
||||||
&self,
|
&self,
|
||||||
tenant: &crate::tenant::TenantCtx,
|
user_id: &str,
|
||||||
job_id: &str,
|
job_id: &str,
|
||||||
) -> Result<SubmissionResult, Error> {
|
) -> Result<SubmissionResult, Error> {
|
||||||
match self.handle_cancel_job(tenant, job_id).await {
|
match self.handle_cancel_job(user_id, job_id).await {
|
||||||
Ok(text) => Ok(SubmissionResult::response(text)),
|
Ok(text) => Ok(SubmissionResult::response(text)),
|
||||||
Err(e) => Ok(SubmissionResult::error(format!("Cancel error: {}", e))),
|
Err(e) => Ok(SubmissionResult::error(format!("Cancel error: {}", e))),
|
||||||
}
|
}
|
||||||
@@ -566,7 +559,6 @@ impl Agent {
|
|||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
channel: &str,
|
channel: &str,
|
||||||
tenant: &crate::tenant::TenantCtx,
|
|
||||||
) -> Result<SubmissionResult, Error> {
|
) -> Result<SubmissionResult, Error> {
|
||||||
match command {
|
match command {
|
||||||
"help" => Ok(SubmissionResult::response(concat!(
|
"help" => Ok(SubmissionResult::response(concat!(
|
||||||
@@ -760,32 +752,19 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.config.multi_tenant {
|
match self.llm().set_model(requested) {
|
||||||
// Multi-tenant: only persist to per-user DB settings.
|
Ok(()) => {
|
||||||
// Do NOT call set_model() on the shared provider — that
|
// Persist the model choice so it survives restarts.
|
||||||
// would change the default for all users. The per-request
|
self.persist_selected_model(requested).await;
|
||||||
// model_override in the dispatcher reads from the same
|
Ok(SubmissionResult::response(format!(
|
||||||
// "selected_model" setting and applies it per-user.
|
"Switched model to: {}",
|
||||||
self.persist_selected_model(tenant, requested).await;
|
requested
|
||||||
Ok(SubmissionResult::response(format!(
|
)))
|
||||||
"Model preference set to: {} (per-user)",
|
|
||||||
requested
|
|
||||||
)))
|
|
||||||
} else {
|
|
||||||
match self.llm().set_model(requested) {
|
|
||||||
Ok(()) => {
|
|
||||||
// Persist the model choice so it survives restarts.
|
|
||||||
self.persist_selected_model(tenant, requested).await;
|
|
||||||
Ok(SubmissionResult::response(format!(
|
|
||||||
"Switched model to: {}",
|
|
||||||
requested
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
Err(e) => Ok(SubmissionResult::error(format!(
|
|
||||||
"Failed to switch model: {}",
|
|
||||||
e
|
|
||||||
))),
|
|
||||||
}
|
}
|
||||||
|
Err(e) => Ok(SubmissionResult::error(format!(
|
||||||
|
"Failed to switch model: {}",
|
||||||
|
e
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -927,14 +906,10 @@ impl Agent {
|
|||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
channel: &str,
|
channel: &str,
|
||||||
tenant: &crate::tenant::TenantCtx,
|
|
||||||
) -> Result<Option<String>, Error> {
|
) -> Result<Option<String>, Error> {
|
||||||
// System commands are now handled directly via Submission::SystemCommand,
|
// System commands are now handled directly via Submission::SystemCommand,
|
||||||
// but the router may still send us unknown /commands.
|
// but the router may still send us unknown /commands.
|
||||||
match self
|
match self.handle_system_command(command, args, channel).await? {
|
||||||
.handle_system_command(command, args, channel, tenant)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
SubmissionResult::Response { content } => Ok(Some(content)),
|
SubmissionResult::Response { content } => Ok(Some(content)),
|
||||||
SubmissionResult::Ok { message } => Ok(message),
|
SubmissionResult::Ok { message } => Ok(message),
|
||||||
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
|
||||||
@@ -946,33 +921,23 @@ impl Agent {
|
|||||||
///
|
///
|
||||||
/// Best-effort: logs warnings on failure but does not propagate errors,
|
/// Best-effort: logs warnings on failure but does not propagate errors,
|
||||||
/// since the in-memory model switch already succeeded.
|
/// since the in-memory model switch already succeeded.
|
||||||
///
|
async fn persist_selected_model(&self, model: &str) {
|
||||||
/// In multi-tenant mode, only the per-user DB setting is written — global
|
// 1. Persist to DB if available.
|
||||||
/// .env and TOML files are shared across users and must not be mutated.
|
if let Some(store) = self.store() {
|
||||||
async fn persist_selected_model(&self, tenant: &crate::tenant::TenantCtx, model: &str) {
|
|
||||||
// 1. Persist to DB if available (per-user scoped via TenantScope).
|
|
||||||
if let Some(store) = tenant.store() {
|
|
||||||
let value = serde_json::Value::String(model.to_string());
|
let value = serde_json::Value::String(model.to_string());
|
||||||
if let Err(e) = store.set_setting("selected_model", &value).await {
|
if let Err(e) = store
|
||||||
|
.set_setting(self.owner_id(), "selected_model", &value)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::warn!("Failed to persist model to DB: {}", e);
|
tracing::warn!("Failed to persist model to DB: {}", e);
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!(
|
tracing::debug!("Persisted selected_model to DB: {}", model);
|
||||||
user_id = tenant.user_id(),
|
|
||||||
"Persisted selected_model to DB: {}",
|
|
||||||
model
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("No database store available — model choice will not persist to DB");
|
tracing::warn!("No database store available — model choice will not persist to DB");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. In multi-tenant mode, skip .env/TOML writes — these are global
|
// 2. Update .env and TOML config file (sync I/O in spawn_blocking).
|
||||||
// files shared by all users. The per-user DB setting is sufficient.
|
|
||||||
if self.config.multi_tenant {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Update .env and TOML config file (sync I/O in spawn_blocking).
|
|
||||||
let model_owned = model.to_string();
|
let model_owned = model.to_string();
|
||||||
let backend = self.deps.llm_backend.clone();
|
let backend = self.deps.llm_backend.clone();
|
||||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||||
|
|||||||
+3
-236
@@ -21,9 +21,6 @@ pub struct CostGuardConfig {
|
|||||||
pub max_cost_per_day_cents: Option<u64>,
|
pub max_cost_per_day_cents: Option<u64>,
|
||||||
/// Maximum LLM calls per hour. None = unlimited.
|
/// Maximum LLM calls per hour. None = unlimited.
|
||||||
pub max_actions_per_hour: Option<u64>,
|
pub max_actions_per_hour: Option<u64>,
|
||||||
/// Maximum spend per user per day in cents. None = unlimited.
|
|
||||||
/// Applied independently per user alongside the global budget.
|
|
||||||
pub max_cost_per_user_per_day_cents: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Error returned when a cost limit is exceeded.
|
/// Error returned when a cost limit is exceeded.
|
||||||
@@ -33,12 +30,6 @@ pub enum CostLimitExceeded {
|
|||||||
DailyBudget { spent_cents: u64, limit_cents: u64 },
|
DailyBudget { spent_cents: u64, limit_cents: u64 },
|
||||||
/// Hourly action rate limit reached.
|
/// Hourly action rate limit reached.
|
||||||
HourlyRate { actions: u64, limit: u64 },
|
HourlyRate { actions: u64, limit: u64 },
|
||||||
/// Per-user daily spending cap reached.
|
|
||||||
UserDailyBudget {
|
|
||||||
user_id: String,
|
|
||||||
spent_cents: u64,
|
|
||||||
limit_cents: u64,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for CostLimitExceeded {
|
impl std::fmt::Display for CostLimitExceeded {
|
||||||
@@ -58,17 +49,6 @@ impl std::fmt::Display for CostLimitExceeded {
|
|||||||
"Hourly action limit exceeded: {} actions of {} allowed per hour",
|
"Hourly action limit exceeded: {} actions of {} allowed per hour",
|
||||||
actions, limit
|
actions, limit
|
||||||
),
|
),
|
||||||
Self::UserDailyBudget {
|
|
||||||
user_id,
|
|
||||||
spent_cents,
|
|
||||||
limit_cents,
|
|
||||||
} => write!(
|
|
||||||
f,
|
|
||||||
"User '{}' daily cost limit exceeded: spent ${:.2} of ${:.2} allowed",
|
|
||||||
user_id,
|
|
||||||
*spent_cents as f64 / 100.0,
|
|
||||||
*limit_cents as f64 / 100.0
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,9 +78,6 @@ pub struct CostGuard {
|
|||||||
|
|
||||||
/// Per-model token usage since startup.
|
/// Per-model token usage since startup.
|
||||||
model_tokens: Mutex<HashMap<String, ModelTokens>>,
|
model_tokens: Mutex<HashMap<String, ModelTokens>>,
|
||||||
|
|
||||||
/// Per-user daily cost tracking. Each entry resets independently at midnight UTC.
|
|
||||||
per_user_daily_cost: Mutex<HashMap<String, DailyCost>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DailyCost {
|
struct DailyCost {
|
||||||
@@ -120,7 +97,6 @@ impl CostGuard {
|
|||||||
action_window: Mutex::new(VecDeque::new()),
|
action_window: Mutex::new(VecDeque::new()),
|
||||||
budget_exceeded: AtomicBool::new(false),
|
budget_exceeded: AtomicBool::new(false),
|
||||||
model_tokens: Mutex::new(HashMap::new()),
|
model_tokens: Mutex::new(HashMap::new()),
|
||||||
per_user_daily_cost: Mutex::new(HashMap::new()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,11 +203,6 @@ impl CostGuard {
|
|||||||
daily.reset_date = today;
|
daily.reset_date = today;
|
||||||
self.budget_exceeded.store(false, Ordering::Relaxed);
|
self.budget_exceeded.store(false, Ordering::Relaxed);
|
||||||
tracing::info!("Cost guard: daily counter reset for {}", today);
|
tracing::info!("Cost guard: daily counter reset for {}", today);
|
||||||
|
|
||||||
// Prune per-user entries from previous days to prevent
|
|
||||||
// unbounded HashMap growth in long-lived deployments.
|
|
||||||
let mut per_user = self.per_user_daily_cost.lock().await;
|
|
||||||
per_user.retain(|_, entry| entry.reset_date == today);
|
|
||||||
}
|
}
|
||||||
daily.total += cost;
|
daily.total += cost;
|
||||||
|
|
||||||
@@ -277,85 +248,6 @@ impl CostGuard {
|
|||||||
cost
|
cost
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record an LLM call with per-user attribution.
|
|
||||||
///
|
|
||||||
/// Delegates to `record_llm_call` for global tracking, then additionally
|
|
||||||
/// records the cost against the user's daily budget.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn record_llm_call_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
model: &str,
|
|
||||||
input_tokens: u32,
|
|
||||||
output_tokens: u32,
|
|
||||||
cache_read_input_tokens: u32,
|
|
||||||
cache_creation_input_tokens: u32,
|
|
||||||
cache_read_discount: Decimal,
|
|
||||||
cache_write_multiplier: Decimal,
|
|
||||||
cost_per_token: Option<(Decimal, Decimal)>,
|
|
||||||
) -> Decimal {
|
|
||||||
let cost = self
|
|
||||||
.record_llm_call(
|
|
||||||
model,
|
|
||||||
input_tokens,
|
|
||||||
output_tokens,
|
|
||||||
cache_read_input_tokens,
|
|
||||||
cache_creation_input_tokens,
|
|
||||||
cache_read_discount,
|
|
||||||
cache_write_multiplier,
|
|
||||||
cost_per_token,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Track per-user daily cost
|
|
||||||
{
|
|
||||||
let today = chrono::Utc::now().date_naive();
|
|
||||||
let mut per_user = self.per_user_daily_cost.lock().await;
|
|
||||||
let entry = per_user
|
|
||||||
.entry(user_id.to_string())
|
|
||||||
.or_insert_with(|| DailyCost {
|
|
||||||
total: Decimal::ZERO,
|
|
||||||
reset_date: today,
|
|
||||||
});
|
|
||||||
if today != entry.reset_date {
|
|
||||||
entry.total = Decimal::ZERO;
|
|
||||||
entry.reset_date = today;
|
|
||||||
}
|
|
||||||
entry.total += cost;
|
|
||||||
}
|
|
||||||
|
|
||||||
cost
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether the next action is allowed for a specific user.
|
|
||||||
///
|
|
||||||
/// Checks the global limits first (via `check_allowed`), then additionally
|
|
||||||
/// checks the per-user daily budget if configured.
|
|
||||||
pub async fn check_allowed_for_user(&self, user_id: &str) -> Result<(), CostLimitExceeded> {
|
|
||||||
// Check global limits first
|
|
||||||
self.check_allowed().await?;
|
|
||||||
|
|
||||||
// Check per-user daily budget
|
|
||||||
if let Some(limit_cents) = self.config.max_cost_per_user_per_day_cents {
|
|
||||||
let today = chrono::Utc::now().date_naive();
|
|
||||||
let per_user = self.per_user_daily_cost.lock().await;
|
|
||||||
if let Some(entry) = per_user.get(user_id)
|
|
||||||
&& entry.reset_date == today
|
|
||||||
{
|
|
||||||
let spent_cents = to_cents(entry.total);
|
|
||||||
if spent_cents >= limit_cents {
|
|
||||||
return Err(CostLimitExceeded::UserDailyBudget {
|
|
||||||
user_id: user_id.to_string(),
|
|
||||||
spent_cents,
|
|
||||||
limit_cents,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current daily spend in USD (as Decimal).
|
/// Current daily spend in USD (as Decimal).
|
||||||
pub async fn daily_spend(&self) -> Decimal {
|
pub async fn daily_spend(&self) -> Decimal {
|
||||||
let daily = self.daily_cost.lock().await;
|
let daily = self.daily_cost.lock().await;
|
||||||
@@ -367,16 +259,6 @@ impl CostGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current daily spend for a specific user in USD (as Decimal).
|
|
||||||
pub async fn daily_spend_for_user(&self, user_id: &str) -> Decimal {
|
|
||||||
let today = chrono::Utc::now().date_naive();
|
|
||||||
let per_user = self.per_user_daily_cost.lock().await;
|
|
||||||
match per_user.get(user_id) {
|
|
||||||
Some(entry) if entry.reset_date == today => entry.total,
|
|
||||||
_ => Decimal::ZERO,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Number of actions in the current hourly window.
|
/// Number of actions in the current hourly window.
|
||||||
pub async fn actions_this_hour(&self) -> u64 {
|
pub async fn actions_this_hour(&self) -> u64 {
|
||||||
let mut window = self.action_window.lock().await;
|
let mut window = self.action_window.lock().await;
|
||||||
@@ -432,7 +314,7 @@ mod tests {
|
|||||||
async fn test_daily_budget_enforcement() {
|
async fn test_daily_budget_enforcement() {
|
||||||
let guard = CostGuard::new(CostGuardConfig {
|
let guard = CostGuard::new(CostGuardConfig {
|
||||||
max_cost_per_day_cents: Some(1), // $0.01 limit
|
max_cost_per_day_cents: Some(1), // $0.01 limit
|
||||||
..CostGuardConfig::default()
|
max_actions_per_hour: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
// First call allowed
|
// First call allowed
|
||||||
@@ -468,8 +350,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_hourly_rate_enforcement() {
|
async fn test_hourly_rate_enforcement() {
|
||||||
let guard = CostGuard::new(CostGuardConfig {
|
let guard = CostGuard::new(CostGuardConfig {
|
||||||
|
max_cost_per_day_cents: None,
|
||||||
max_actions_per_hour: Some(3),
|
max_actions_per_hour: Some(3),
|
||||||
..CostGuardConfig::default()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// First 3 actions allowed
|
// First 3 actions allowed
|
||||||
@@ -751,8 +633,8 @@ mod tests {
|
|||||||
// A fresh CostGuard with rate limits should not panic even if
|
// A fresh CostGuard with rate limits should not panic even if
|
||||||
// checked_sub returns None (simulating short uptime).
|
// checked_sub returns None (simulating short uptime).
|
||||||
let guard = CostGuard::new(CostGuardConfig {
|
let guard = CostGuard::new(CostGuardConfig {
|
||||||
|
max_cost_per_day_cents: None,
|
||||||
max_actions_per_hour: Some(100),
|
max_actions_per_hour: Some(100),
|
||||||
..CostGuardConfig::default()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// These must not panic regardless of system uptime
|
// These must not panic regardless of system uptime
|
||||||
@@ -774,119 +656,4 @@ mod tests {
|
|||||||
let result = Instant::now().checked_sub(std::time::Duration::MAX);
|
let result = Instant::now().checked_sub(std::time::Duration::MAX);
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_per_user_daily_budget_enforcement() {
|
|
||||||
let guard = CostGuard::new(CostGuardConfig {
|
|
||||||
max_cost_per_day_cents: None,
|
|
||||||
max_actions_per_hour: None,
|
|
||||||
max_cost_per_user_per_day_cents: Some(1), // $0.01 per user
|
|
||||||
});
|
|
||||||
|
|
||||||
// Both users initially allowed
|
|
||||||
assert!(guard.check_allowed_for_user("alice").await.is_ok());
|
|
||||||
assert!(guard.check_allowed_for_user("bob").await.is_ok());
|
|
||||||
|
|
||||||
// Alice makes an expensive call
|
|
||||||
guard
|
|
||||||
.record_llm_call_for_user(
|
|
||||||
"alice",
|
|
||||||
"gpt-4o",
|
|
||||||
10_000,
|
|
||||||
10_000,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
Decimal::ONE,
|
|
||||||
Decimal::ONE,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Alice should be blocked, Bob should still be allowed
|
|
||||||
let result = guard.check_allowed_for_user("alice").await;
|
|
||||||
assert!(result.is_err());
|
|
||||||
match result.unwrap_err() {
|
|
||||||
CostLimitExceeded::UserDailyBudget {
|
|
||||||
user_id,
|
|
||||||
limit_cents,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
assert_eq!(user_id, "alice");
|
|
||||||
assert_eq!(limit_cents, 1);
|
|
||||||
}
|
|
||||||
other => panic!("Expected UserDailyBudget, got {:?}", other),
|
|
||||||
}
|
|
||||||
assert!(guard.check_allowed_for_user("bob").await.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_per_user_daily_spend_tracking() {
|
|
||||||
let guard = CostGuard::new(CostGuardConfig::default());
|
|
||||||
|
|
||||||
assert_eq!(guard.daily_spend_for_user("alice").await, Decimal::ZERO);
|
|
||||||
assert_eq!(guard.daily_spend_for_user("bob").await, Decimal::ZERO);
|
|
||||||
|
|
||||||
let cost = guard
|
|
||||||
.record_llm_call_for_user(
|
|
||||||
"alice",
|
|
||||||
"gpt-4o",
|
|
||||||
1000,
|
|
||||||
500,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
Decimal::ONE,
|
|
||||||
Decimal::ONE,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(guard.daily_spend_for_user("alice").await, cost);
|
|
||||||
assert_eq!(guard.daily_spend_for_user("bob").await, Decimal::ZERO);
|
|
||||||
// Global spend should also be tracked
|
|
||||||
assert_eq!(guard.daily_spend().await, cost);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_per_user_budget_independent_of_global() {
|
|
||||||
let guard = CostGuard::new(CostGuardConfig {
|
|
||||||
max_cost_per_day_cents: Some(100_000), // $1000 global limit
|
|
||||||
max_actions_per_hour: None,
|
|
||||||
max_cost_per_user_per_day_cents: Some(1), // $0.01 per user
|
|
||||||
});
|
|
||||||
|
|
||||||
// User hits their personal limit
|
|
||||||
guard
|
|
||||||
.record_llm_call_for_user(
|
|
||||||
"alice",
|
|
||||||
"gpt-4o",
|
|
||||||
10_000,
|
|
||||||
10_000,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
Decimal::ONE,
|
|
||||||
Decimal::ONE,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Alice blocked by per-user limit, not global
|
|
||||||
assert!(guard.check_allowed_for_user("alice").await.is_err());
|
|
||||||
// Global limit is far from reached
|
|
||||||
assert!(guard.check_allowed().await.is_ok());
|
|
||||||
// Bob is unaffected
|
|
||||||
assert!(guard.check_allowed_for_user("bob").await.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_user_cost_limit_display() {
|
|
||||||
let limit = CostLimitExceeded::UserDailyBudget {
|
|
||||||
user_id: "alice".to_string(),
|
|
||||||
spent_cents: 150,
|
|
||||||
limit_cents: 100,
|
|
||||||
};
|
|
||||||
let msg = limit.to_string();
|
|
||||||
assert!(msg.contains("alice"));
|
|
||||||
assert!(msg.contains("$1.50"));
|
|
||||||
assert!(msg.contains("$1.00"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-61
@@ -42,7 +42,6 @@ impl Agent {
|
|||||||
pub(super) async fn run_agentic_loop(
|
pub(super) async fn run_agentic_loop(
|
||||||
&self,
|
&self,
|
||||||
message: &IncomingMessage,
|
message: &IncomingMessage,
|
||||||
tenant: crate::tenant::TenantCtx,
|
|
||||||
session: Arc<Mutex<Session>>,
|
session: Arc<Mutex<Session>>,
|
||||||
thread_id: Uuid,
|
thread_id: Uuid,
|
||||||
initial_messages: Vec<ChatMessage>,
|
initial_messages: Vec<ChatMessage>,
|
||||||
@@ -64,12 +63,7 @@ impl Agent {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let system_prompt = if let Some(ws) = self.workspace() {
|
let system_prompt = if let Some(ws) = self.workspace() {
|
||||||
let scoped_workspace = if ws.user_id() == message.user_id {
|
match ws
|
||||||
Arc::clone(ws)
|
|
||||||
} else {
|
|
||||||
Arc::new(ws.scoped_to_user(&message.user_id))
|
|
||||||
};
|
|
||||||
match scoped_workspace
|
|
||||||
.system_prompt_for_context_tz(is_group_chat, user_tz)
|
.system_prompt_for_context_tz(is_group_chat, user_tz)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -169,7 +163,6 @@ impl Agent {
|
|||||||
|
|
||||||
let delegate = ChatDelegate {
|
let delegate = ChatDelegate {
|
||||||
agent: self,
|
agent: self,
|
||||||
tenant,
|
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
thread_id,
|
thread_id,
|
||||||
message,
|
message,
|
||||||
@@ -242,7 +235,6 @@ impl Agent {
|
|||||||
/// auth intercept, and cost tracking.
|
/// auth intercept, and cost tracking.
|
||||||
struct ChatDelegate<'a> {
|
struct ChatDelegate<'a> {
|
||||||
agent: &'a Agent,
|
agent: &'a Agent,
|
||||||
tenant: crate::tenant::TenantCtx,
|
|
||||||
session: Arc<Mutex<Session>>,
|
session: Arc<Mutex<Session>>,
|
||||||
thread_id: Uuid,
|
thread_id: Uuid,
|
||||||
message: &'a IncomingMessage,
|
message: &'a IncomingMessage,
|
||||||
@@ -303,10 +295,9 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
} else {
|
} else {
|
||||||
tool_defs
|
tool_defs
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update context for this iteration
|
// Update context for this iteration
|
||||||
reason_ctx.available_tools = tool_defs;
|
reason_ctx.available_tools = tool_defs;
|
||||||
// Preserve force_text if already set (e.g. by truncation escalation).
|
|
||||||
let force_text = force_text || reason_ctx.force_text;
|
|
||||||
reason_ctx.system_prompt = Some(if force_text {
|
reason_ctx.system_prompt = Some(if force_text {
|
||||||
self.cached_prompt_no_tools.clone()
|
self.cached_prompt_no_tools.clone()
|
||||||
} else {
|
} else {
|
||||||
@@ -340,8 +331,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
reason_ctx: &mut ReasoningContext,
|
reason_ctx: &mut ReasoningContext,
|
||||||
iteration: usize,
|
iteration: usize,
|
||||||
) -> Result<crate::llm::RespondOutput, Error> {
|
) -> Result<crate::llm::RespondOutput, Error> {
|
||||||
// Enforce cost guardrails before the LLM call (global + per-user)
|
// Enforce cost guardrails before the LLM call
|
||||||
if let Err(limit) = self.tenant.check_cost_allowed().await {
|
if let Err(limit) = self.agent.cost_guard().check_allowed().await {
|
||||||
return Err(crate::error::LlmError::InvalidResponse {
|
return Err(crate::error::LlmError::InvalidResponse {
|
||||||
provider: "agent".to_string(),
|
provider: "agent".to_string(),
|
||||||
reason: limit.to_string(),
|
reason: limit.to_string(),
|
||||||
@@ -349,21 +340,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply per-user model override from settings (first iteration only
|
|
||||||
// to avoid repeated DB lookups within the same agentic loop).
|
|
||||||
// Uses "selected_model" — the same key the /model command persists to
|
|
||||||
// via SettingsStore (per-user scoped via TenantScope).
|
|
||||||
if iteration == 0
|
|
||||||
&& let Some(store) = self.tenant.store()
|
|
||||||
&& let Ok(Some(value)) = store.get_setting("selected_model").await
|
|
||||||
&& let Some(model) = value.as_str()
|
|
||||||
{
|
|
||||||
let model = model.trim();
|
|
||||||
if !model.is_empty() {
|
|
||||||
reason_ctx.model_override = Some(model.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = match reasoning.respond_with_tools(reason_ctx).await {
|
let output = match reasoning.respond_with_tools(reason_ctx).await {
|
||||||
Ok(output) => output,
|
Ok(output) => output,
|
||||||
Err(crate::error::LlmError::ContextLengthExceeded { used, limit }) => {
|
Err(crate::error::LlmError::ContextLengthExceeded { used, limit }) => {
|
||||||
@@ -398,22 +374,13 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Record cost and track token usage (global + per-user).
|
// Record cost and track token usage
|
||||||
// When a model override is active, use the override name for attribution
|
let model_name = self.agent.llm().active_model_name();
|
||||||
// and let CostGuard look up pricing via costs::model_cost() instead of
|
|
||||||
// using the default provider's cost_per_token (which reflects the wrong model).
|
|
||||||
let (model_name, cost_per_token) = if let Some(ref ovr) = reason_ctx.model_override {
|
|
||||||
(ovr.clone(), None)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
self.agent.llm().active_model_name(),
|
|
||||||
Some(self.agent.llm().cost_per_token()),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let read_discount = self.agent.llm().cache_read_discount();
|
let read_discount = self.agent.llm().cache_read_discount();
|
||||||
let write_multiplier = self.agent.llm().cache_write_multiplier();
|
let write_multiplier = self.agent.llm().cache_write_multiplier();
|
||||||
let call_cost = self
|
let call_cost = self
|
||||||
.tenant
|
.agent
|
||||||
|
.cost_guard()
|
||||||
.record_llm_call(
|
.record_llm_call(
|
||||||
&model_name,
|
&model_name,
|
||||||
output.usage.input_tokens,
|
output.usage.input_tokens,
|
||||||
@@ -422,7 +389,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
output.usage.cache_creation_input_tokens,
|
output.usage.cache_creation_input_tokens,
|
||||||
read_discount,
|
read_discount,
|
||||||
write_multiplier,
|
write_multiplier,
|
||||||
cost_per_token,
|
Some(self.agent.llm().cost_per_token()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@@ -1333,7 +1300,6 @@ mod tests {
|
|||||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||||
builder: None,
|
builder: None,
|
||||||
llm_backend: "nearai".to_string(),
|
llm_backend: "nearai".to_string(),
|
||||||
tenant_rates: Arc::new(crate::tenant::TenantRateRegistry::new(4, 3)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Agent::new(
|
Agent::new(
|
||||||
@@ -1349,14 +1315,10 @@ mod tests {
|
|||||||
allow_local_tools: false,
|
allow_local_tools: false,
|
||||||
max_cost_per_day_cents: None,
|
max_cost_per_day_cents: None,
|
||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_cost_per_user_per_day_cents: None,
|
|
||||||
max_tool_iterations: 50,
|
max_tool_iterations: 50,
|
||||||
auto_approve_tools: false,
|
auto_approve_tools: false,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_tokens_per_job: 0,
|
max_tokens_per_job: 0,
|
||||||
multi_tenant: false,
|
|
||||||
max_llm_concurrent_per_user: None,
|
|
||||||
max_jobs_concurrent_per_user: None,
|
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
Arc::new(ChannelManager::new()),
|
Arc::new(ChannelManager::new()),
|
||||||
@@ -2214,7 +2176,6 @@ mod tests {
|
|||||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||||
builder: None,
|
builder: None,
|
||||||
llm_backend: "nearai".to_string(),
|
llm_backend: "nearai".to_string(),
|
||||||
tenant_rates: Arc::new(crate::tenant::TenantRateRegistry::new(4, 3)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Agent::new(
|
Agent::new(
|
||||||
@@ -2230,14 +2191,10 @@ mod tests {
|
|||||||
allow_local_tools: false,
|
allow_local_tools: false,
|
||||||
max_cost_per_day_cents: None,
|
max_cost_per_day_cents: None,
|
||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_cost_per_user_per_day_cents: None,
|
|
||||||
max_tool_iterations,
|
max_tool_iterations,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_tokens_per_job: 0,
|
max_tokens_per_job: 0,
|
||||||
multi_tenant: false,
|
|
||||||
max_llm_concurrent_per_user: None,
|
|
||||||
max_jobs_concurrent_per_user: None,
|
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
Arc::new(ChannelManager::new()),
|
Arc::new(ChannelManager::new()),
|
||||||
@@ -2272,14 +2229,13 @@ mod tests {
|
|||||||
|
|
||||||
let message = IncomingMessage::new("test", "test-user", "do something");
|
let message = IncomingMessage::new("test", "test-user", "do something");
|
||||||
let initial_messages = vec![ChatMessage::user("do something")];
|
let initial_messages = vec![ChatMessage::user("do something")];
|
||||||
let tenant = agent.tenant_ctx("test-user").await;
|
|
||||||
|
|
||||||
// The dispatcher must terminate within 5 seconds. If there is an
|
// The dispatcher must terminate within 5 seconds. If there is an
|
||||||
// infinite loop bug (e.g., index not advancing on tool failure), the
|
// infinite loop bug (e.g., index not advancing on tool failure), the
|
||||||
// timeout will fire and the test will fail.
|
// timeout will fire and the test will fail.
|
||||||
let result = tokio::time::timeout(
|
let result = tokio::time::timeout(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
agent.run_agentic_loop(&message, tenant, session, thread_id, initial_messages),
|
agent.run_agentic_loop(&message, session, thread_id, initial_messages),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -2341,7 +2297,6 @@ mod tests {
|
|||||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||||
builder: None,
|
builder: None,
|
||||||
llm_backend: "nearai".to_string(),
|
llm_backend: "nearai".to_string(),
|
||||||
tenant_rates: Arc::new(crate::tenant::TenantRateRegistry::new(4, 3)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Agent::new(
|
Agent::new(
|
||||||
@@ -2357,14 +2312,10 @@ mod tests {
|
|||||||
allow_local_tools: false,
|
allow_local_tools: false,
|
||||||
max_cost_per_day_cents: None,
|
max_cost_per_day_cents: None,
|
||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_cost_per_user_per_day_cents: None,
|
|
||||||
max_tool_iterations: max_iter,
|
max_tool_iterations: max_iter,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_tokens_per_job: 0,
|
max_tokens_per_job: 0,
|
||||||
multi_tenant: false,
|
|
||||||
max_llm_concurrent_per_user: None,
|
|
||||||
max_jobs_concurrent_per_user: None,
|
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
Arc::new(ChannelManager::new()),
|
Arc::new(ChannelManager::new()),
|
||||||
@@ -2384,14 +2335,13 @@ mod tests {
|
|||||||
|
|
||||||
let message = IncomingMessage::new("test", "test-user", "keep calling tools");
|
let message = IncomingMessage::new("test", "test-user", "keep calling tools");
|
||||||
let initial_messages = vec![ChatMessage::user("keep calling tools")];
|
let initial_messages = vec![ChatMessage::user("keep calling tools")];
|
||||||
let tenant = agent.tenant_ctx("test-user").await;
|
|
||||||
|
|
||||||
// Even with an LLM that always wants to call tools, the dispatcher
|
// Even with an LLM that always wants to call tools, the dispatcher
|
||||||
// must terminate within the timeout thanks to force_text at
|
// must terminate within the timeout thanks to force_text at
|
||||||
// max_tool_iterations.
|
// max_tool_iterations.
|
||||||
let result = tokio::time::timeout(
|
let result = tokio::time::timeout(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
agent.run_agentic_loop(&message, tenant, session, thread_id, initial_messages),
|
agent.run_agentic_loop(&message, session, thread_id, initial_messages),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
+7
-184
@@ -31,8 +31,8 @@ use chrono_tz::Tz;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::channels::OutgoingResponse;
|
use crate::channels::OutgoingResponse;
|
||||||
|
use crate::db::Database;
|
||||||
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
|
use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning};
|
||||||
use crate::tenant::AdminScope;
|
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
use crate::workspace::hygiene::HygieneConfig;
|
use crate::workspace::hygiene::HygieneConfig;
|
||||||
|
|
||||||
@@ -57,9 +57,6 @@ pub struct HeartbeatConfig {
|
|||||||
pub quiet_hours_end: Option<u32>,
|
pub quiet_hours_end: Option<u32>,
|
||||||
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
||||||
pub timezone: Option<String>,
|
pub timezone: Option<String>,
|
||||||
/// When true, cycle through all users with routines instead of
|
|
||||||
/// running heartbeat for a single user. Requires a database store.
|
|
||||||
pub multi_tenant: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HeartbeatConfig {
|
impl Default for HeartbeatConfig {
|
||||||
@@ -74,7 +71,6 @@ impl Default for HeartbeatConfig {
|
|||||||
quiet_hours_start: None,
|
quiet_hours_start: None,
|
||||||
quiet_hours_end: None,
|
quiet_hours_end: None,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
multi_tenant: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +178,7 @@ pub struct HeartbeatRunner {
|
|||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||||
store: Option<AdminScope>,
|
store: Option<Arc<dyn Database>>,
|
||||||
consecutive_failures: u32,
|
consecutive_failures: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +207,8 @@ impl HeartbeatRunner {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the admin-scoped database store for persistent heartbeat conversations.
|
/// Set the database store for persistent heartbeat conversations.
|
||||||
pub fn with_store(mut self, store: AdminScope) -> Self {
|
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
||||||
self.store = Some(store);
|
self.store = Some(store);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -400,7 +396,7 @@ impl HeartbeatRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send a notification about heartbeat findings.
|
/// Send a notification about heartbeat findings.
|
||||||
pub(crate) async fn send_notification(&self, message: &str) {
|
async fn send_notification(&self, message: &str) {
|
||||||
let Some(ref tx) = self.response_tx else {
|
let Some(ref tx) = self.response_tx else {
|
||||||
tracing::debug!("No response channel configured for heartbeat notifications");
|
tracing::debug!("No response channel configured for heartbeat notifications");
|
||||||
return;
|
return;
|
||||||
@@ -497,7 +493,7 @@ pub fn spawn_heartbeat(
|
|||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
||||||
store: Option<AdminScope>,
|
store: Option<Arc<dyn Database>>,
|
||||||
) -> tokio::task::JoinHandle<()> {
|
) -> tokio::task::JoinHandle<()> {
|
||||||
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm);
|
let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm);
|
||||||
if let Some(tx) = response_tx {
|
if let Some(tx) = response_tx {
|
||||||
@@ -512,179 +508,6 @@ pub fn spawn_heartbeat(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a multi-user heartbeat runner that cycles through all users that
|
|
||||||
/// own routines (enabled or not). Each tick, it queries the DB for distinct
|
|
||||||
/// user_ids, creates a per-user workspace, and runs a heartbeat check for
|
|
||||||
/// each user concurrently. Per-user failure counts are tracked independently.
|
|
||||||
pub fn spawn_multi_user_heartbeat(
|
|
||||||
config: HeartbeatConfig,
|
|
||||||
hygiene_config: HygieneConfig,
|
|
||||||
llm: Arc<dyn LlmProvider>,
|
|
||||||
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
|
|
||||||
store: AdminScope,
|
|
||||||
) -> tokio::task::JoinHandle<()> {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if !config.enabled {
|
|
||||||
tracing::info!("Multi-user heartbeat is disabled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tick_interval = if config.fire_at.is_none() {
|
|
||||||
let mut iv = tokio::time::interval(config.interval);
|
|
||||||
iv.tick().await; // skip immediate tick
|
|
||||||
Some(iv)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track consecutive failures per user so we can disable heartbeat
|
|
||||||
// for persistently-failing users (same semantics as single-user mode).
|
|
||||||
let mut user_failures: std::collections::HashMap<String, u32> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
|
|
||||||
tracing::info!("Starting multi-user heartbeat loop");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if let Some(fire_at) = config.fire_at {
|
|
||||||
let sleep_dur = duration_until_next_fire(fire_at, config.resolved_tz());
|
|
||||||
tokio::time::sleep(sleep_dur).await;
|
|
||||||
} else if let Some(ref mut iv) = tick_interval {
|
|
||||||
iv.tick().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.is_quiet_hours() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get distinct user_ids from routines
|
|
||||||
let user_ids = match store.list_all_routines().await {
|
|
||||||
Ok(routines) => {
|
|
||||||
let mut ids: Vec<String> = routines
|
|
||||||
.iter()
|
|
||||||
.map(|r| r.user_id.clone())
|
|
||||||
.collect::<std::collections::HashSet<_>>()
|
|
||||||
.into_iter()
|
|
||||||
.collect();
|
|
||||||
ids.sort();
|
|
||||||
ids
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Multi-user heartbeat: failed to list routines: {}", e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run user heartbeats concurrently so one slow LLM call doesn't
|
|
||||||
// block others. Cap concurrency to avoid flooding the LLM provider.
|
|
||||||
const MAX_CONCURRENT_HEARTBEATS: usize = 8;
|
|
||||||
let mut join_set = tokio::task::JoinSet::new();
|
|
||||||
|
|
||||||
for user_id in &user_ids {
|
|
||||||
// Skip users that have exceeded max_failures
|
|
||||||
let failures = user_failures.get(user_id).copied().unwrap_or(0);
|
|
||||||
if failures >= config.max_failures {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspace = Arc::new(Workspace::new_with_db(user_id, Arc::clone(store.db())));
|
|
||||||
|
|
||||||
// Run memory hygiene per user (same as single-user heartbeat).
|
|
||||||
let hygiene_ws = Arc::clone(&workspace);
|
|
||||||
let hygiene_cfg = hygiene_config.clone();
|
|
||||||
let hygiene_user = user_id.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let report =
|
|
||||||
crate::workspace::hygiene::run_if_due(&hygiene_ws, &hygiene_cfg).await;
|
|
||||||
if report.had_work() {
|
|
||||||
tracing::info!(
|
|
||||||
user_id = hygiene_user,
|
|
||||||
daily_logs_deleted = report.daily_logs_deleted,
|
|
||||||
conversation_docs_deleted = report.conversation_docs_deleted,
|
|
||||||
"multi-user heartbeat: memory hygiene deleted stale documents"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drain completed tasks to stay within the concurrency cap.
|
|
||||||
while join_set.len() >= MAX_CONCURRENT_HEARTBEATS {
|
|
||||||
if let Some(join_result) = join_set.join_next().await {
|
|
||||||
collect_heartbeat_result(join_result, &mut user_failures, &config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let uid = user_id.clone();
|
|
||||||
let cfg = config.clone();
|
|
||||||
let hyg = hygiene_config.clone();
|
|
||||||
let llm_clone = llm.clone();
|
|
||||||
let tx = response_tx.clone();
|
|
||||||
let admin = store.clone();
|
|
||||||
|
|
||||||
join_set.spawn(async move {
|
|
||||||
let mut runner = HeartbeatRunner::new(cfg, hyg, workspace, llm_clone);
|
|
||||||
if let Some(tx) = tx {
|
|
||||||
runner = runner.with_response_channel(tx);
|
|
||||||
}
|
|
||||||
runner = runner.with_store(admin);
|
|
||||||
|
|
||||||
let result = runner.check_heartbeat().await;
|
|
||||||
if let HeartbeatResult::NeedsAttention(msg) = &result {
|
|
||||||
runner.send_notification(msg).await;
|
|
||||||
}
|
|
||||||
(uid, result)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect remaining results and update failure counts
|
|
||||||
while let Some(join_result) = join_set.join_next().await {
|
|
||||||
collect_heartbeat_result(join_result, &mut user_failures, &config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process a single JoinSet result from the multi-user heartbeat loop.
|
|
||||||
fn collect_heartbeat_result(
|
|
||||||
join_result: Result<(String, HeartbeatResult), tokio::task::JoinError>,
|
|
||||||
user_failures: &mut std::collections::HashMap<String, u32>,
|
|
||||||
config: &HeartbeatConfig,
|
|
||||||
) {
|
|
||||||
let (uid, result) = match join_result {
|
|
||||||
Ok(pair) => pair,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Multi-user heartbeat task panicked: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
HeartbeatResult::Ok => {
|
|
||||||
tracing::trace!(user_id = uid, "Multi-user heartbeat OK");
|
|
||||||
user_failures.remove(&uid);
|
|
||||||
}
|
|
||||||
HeartbeatResult::NeedsAttention(_) => {
|
|
||||||
tracing::info!(user_id = uid, "Multi-user heartbeat needs attention");
|
|
||||||
user_failures.remove(&uid);
|
|
||||||
}
|
|
||||||
HeartbeatResult::Skipped => {}
|
|
||||||
HeartbeatResult::Failed(err) => {
|
|
||||||
let count = user_failures.entry(uid.clone()).or_insert(0);
|
|
||||||
*count += 1;
|
|
||||||
tracing::error!(
|
|
||||||
user_id = uid,
|
|
||||||
consecutive_failures = *count,
|
|
||||||
"Multi-user heartbeat failed: {}",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
if *count >= config.max_failures {
|
|
||||||
tracing::error!(
|
|
||||||
user_id = uid,
|
|
||||||
"Multi-user heartbeat disabled for user after {} consecutive failures",
|
|
||||||
count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -903,7 +726,7 @@ mod tests {
|
|||||||
Arc<crate::workspace::Workspace>,
|
Arc<crate::workspace::Workspace>,
|
||||||
Arc<dyn crate::llm::LlmProvider>,
|
Arc<dyn crate::llm::LlmProvider>,
|
||||||
Option<tokio::sync::mpsc::Sender<crate::channels::OutgoingResponse>>,
|
Option<tokio::sync::mpsc::Sender<crate::channels::OutgoingResponse>>,
|
||||||
Option<AdminScope>,
|
Option<Arc<dyn crate::db::Database>>,
|
||||||
) -> tokio::task::JoinHandle<()> = spawn_heartbeat;
|
) -> tokio::task::JoinHandle<()> = spawn_heartbeat;
|
||||||
let _ = _fn_ptr;
|
let _ = _fn_ptr;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -36,9 +36,7 @@ pub(crate) use agent_loop::truncate_for_preview;
|
|||||||
pub use agent_loop::{Agent, AgentDeps};
|
pub use agent_loop::{Agent, AgentDeps};
|
||||||
pub use compaction::{CompactionResult, ContextCompactor};
|
pub use compaction::{CompactionResult, ContextCompactor};
|
||||||
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
||||||
pub use heartbeat::{
|
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
||||||
HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat, spawn_multi_user_heartbeat,
|
|
||||||
};
|
|
||||||
pub use router::{MessageIntent, Router};
|
pub use router::{MessageIntent, Router};
|
||||||
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
||||||
pub use routine_engine::{RoutineEngine, SandboxReadiness};
|
pub use routine_engine::{RoutineEngine, SandboxReadiness};
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ use crate::agent::routine::{
|
|||||||
use crate::channels::{IncomingMessage, OutgoingResponse};
|
use crate::channels::{IncomingMessage, OutgoingResponse};
|
||||||
use crate::config::RoutineConfig;
|
use crate::config::RoutineConfig;
|
||||||
use crate::context::{JobContext, JobState};
|
use crate::context::{JobContext, JobState};
|
||||||
|
use crate::db::Database;
|
||||||
use crate::error::RoutineError;
|
use crate::error::RoutineError;
|
||||||
use crate::extensions::ExtensionManager;
|
use crate::extensions::ExtensionManager;
|
||||||
use crate::llm::{
|
use crate::llm::{
|
||||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
||||||
};
|
};
|
||||||
use crate::tenant::AdminScope;
|
|
||||||
use crate::tools::{
|
use crate::tools::{
|
||||||
ToolError, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_message,
|
ToolError, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_message,
|
||||||
prepare_tool_params,
|
prepare_tool_params,
|
||||||
@@ -99,7 +99,7 @@ pub(crate) fn routine_matches_message(routine: &Routine, message: &IncomingMessa
|
|||||||
/// The routine execution engine.
|
/// The routine execution engine.
|
||||||
pub struct RoutineEngine {
|
pub struct RoutineEngine {
|
||||||
config: RoutineConfig,
|
config: RoutineConfig,
|
||||||
store: AdminScope,
|
store: Arc<dyn Database>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
/// Sender for notifications (routed to channel manager).
|
/// Sender for notifications (routed to channel manager).
|
||||||
@@ -128,7 +128,7 @@ impl RoutineEngine {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
config: RoutineConfig,
|
config: RoutineConfig,
|
||||||
store: AdminScope,
|
store: Arc<dyn Database>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||||
@@ -782,22 +782,12 @@ impl RoutineEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-user workspace (same pattern as spawn_fire).
|
|
||||||
let routine_workspace = if routine.user_id == self.workspace.user_id() {
|
|
||||||
self.workspace.clone()
|
|
||||||
} else {
|
|
||||||
Arc::new(Workspace::new_with_db(
|
|
||||||
&routine.user_id,
|
|
||||||
Arc::clone(self.store.db()),
|
|
||||||
))
|
|
||||||
};
|
|
||||||
|
|
||||||
// Execute inline for manual triggers (caller wants to wait)
|
// Execute inline for manual triggers (caller wants to wait)
|
||||||
let engine = EngineContext {
|
let engine = EngineContext {
|
||||||
config: self.config.clone(),
|
config: self.config.clone(),
|
||||||
store: self.store.clone(),
|
store: self.store.clone(),
|
||||||
llm: self.llm.clone(),
|
llm: self.llm.clone(),
|
||||||
workspace: routine_workspace,
|
workspace: self.workspace.clone(),
|
||||||
notify_tx: self.notify_tx.clone(),
|
notify_tx: self.notify_tx.clone(),
|
||||||
running_count: self.running_count.clone(),
|
running_count: self.running_count.clone(),
|
||||||
scheduler: self.scheduler.clone(),
|
scheduler: self.scheduler.clone(),
|
||||||
@@ -920,23 +910,11 @@ impl RoutineEngine {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use per-user workspace so each routine executes in the correct
|
|
||||||
// user's context. Fall back to the engine-wide workspace when the
|
|
||||||
// routine belongs to the same user (avoids unnecessary allocation).
|
|
||||||
let routine_workspace = if routine.user_id == self.workspace.user_id() {
|
|
||||||
self.workspace.clone()
|
|
||||||
} else {
|
|
||||||
Arc::new(Workspace::new_with_db(
|
|
||||||
&routine.user_id,
|
|
||||||
Arc::clone(self.store.db()),
|
|
||||||
))
|
|
||||||
};
|
|
||||||
|
|
||||||
let engine = EngineContext {
|
let engine = EngineContext {
|
||||||
config: self.config.clone(),
|
config: self.config.clone(),
|
||||||
store: self.store.clone(),
|
store: self.store.clone(),
|
||||||
llm: self.llm.clone(),
|
llm: self.llm.clone(),
|
||||||
workspace: routine_workspace,
|
workspace: self.workspace.clone(),
|
||||||
notify_tx: self.notify_tx.clone(),
|
notify_tx: self.notify_tx.clone(),
|
||||||
running_count: self.running_count.clone(),
|
running_count: self.running_count.clone(),
|
||||||
scheduler: self.scheduler.clone(),
|
scheduler: self.scheduler.clone(),
|
||||||
@@ -989,7 +967,7 @@ impl RoutineEngine {
|
|||||||
/// an active state (Pending/InProgress/Stuck). Maps the final `JobState` to
|
/// an active state (Pending/InProgress/Stuck). Maps the final `JobState` to
|
||||||
/// a `RunStatus` for the routine run.
|
/// a `RunStatus` for the routine run.
|
||||||
struct FullJobWatcher {
|
struct FullJobWatcher {
|
||||||
store: AdminScope,
|
store: Arc<dyn Database>,
|
||||||
job_id: Uuid,
|
job_id: Uuid,
|
||||||
routine_name: String,
|
routine_name: String,
|
||||||
}
|
}
|
||||||
@@ -1000,7 +978,7 @@ impl FullJobWatcher {
|
|||||||
/// Safety ceiling: 24 hours, derived from POLL_INTERVAL.
|
/// Safety ceiling: 24 hours, derived from POLL_INTERVAL.
|
||||||
const MAX_POLLS: u32 = (24 * 60 * 60) / Self::POLL_INTERVAL.as_secs() as u32;
|
const MAX_POLLS: u32 = (24 * 60 * 60) / Self::POLL_INTERVAL.as_secs() as u32;
|
||||||
|
|
||||||
fn new(store: AdminScope, job_id: Uuid, routine_name: String) -> Self {
|
fn new(store: Arc<dyn Database>, job_id: Uuid, routine_name: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
job_id,
|
job_id,
|
||||||
@@ -1072,7 +1050,7 @@ impl FullJobWatcher {
|
|||||||
/// Shared context passed to the execution function.
|
/// Shared context passed to the execution function.
|
||||||
struct EngineContext {
|
struct EngineContext {
|
||||||
config: RoutineConfig,
|
config: RoutineConfig,
|
||||||
store: AdminScope,
|
store: Arc<dyn Database>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ use uuid::Uuid;
|
|||||||
use crate::agent::task::{Task, TaskContext, TaskOutput};
|
use crate::agent::task::{Task, TaskContext, TaskOutput};
|
||||||
use crate::config::AgentConfig;
|
use crate::config::AgentConfig;
|
||||||
use crate::context::{ContextManager, JobContext, JobState};
|
use crate::context::{ContextManager, JobContext, JobState};
|
||||||
|
use crate::db::Database;
|
||||||
use crate::error::{Error, JobError};
|
use crate::error::{Error, JobError};
|
||||||
use crate::extensions::ExtensionManager;
|
use crate::extensions::ExtensionManager;
|
||||||
use crate::hooks::HookRegistry;
|
use crate::hooks::HookRegistry;
|
||||||
use crate::llm::LlmProvider;
|
use crate::llm::LlmProvider;
|
||||||
use crate::safety::SafetyLayer;
|
use crate::safety::SafetyLayer;
|
||||||
use crate::tenant::AdminScope;
|
|
||||||
use crate::tools::{
|
use crate::tools::{
|
||||||
ApprovalContext, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_error,
|
ApprovalContext, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_error,
|
||||||
prepare_tool_params,
|
prepare_tool_params,
|
||||||
@@ -52,7 +52,7 @@ struct ScheduledSubtask {
|
|||||||
pub struct SchedulerDeps {
|
pub struct SchedulerDeps {
|
||||||
pub tools: Arc<ToolRegistry>,
|
pub tools: Arc<ToolRegistry>,
|
||||||
pub extension_manager: Option<Arc<ExtensionManager>>,
|
pub extension_manager: Option<Arc<ExtensionManager>>,
|
||||||
pub store: Option<AdminScope>,
|
pub store: Option<Arc<dyn Database>>,
|
||||||
pub hooks: Arc<HookRegistry>,
|
pub hooks: Arc<HookRegistry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ pub struct Scheduler {
|
|||||||
safety: Arc<SafetyLayer>,
|
safety: Arc<SafetyLayer>,
|
||||||
tools: Arc<ToolRegistry>,
|
tools: Arc<ToolRegistry>,
|
||||||
extension_manager: Option<Arc<ExtensionManager>>,
|
extension_manager: Option<Arc<ExtensionManager>>,
|
||||||
store: Option<AdminScope>,
|
store: Option<Arc<dyn Database>>,
|
||||||
hooks: Arc<HookRegistry>,
|
hooks: Arc<HookRegistry>,
|
||||||
/// SSE manager for live job event streaming.
|
/// SSE manager for live job event streaming.
|
||||||
sse_tx: Option<Arc<crate::channels::web::sse::SseManager>>,
|
sse_tx: Option<Arc<crate::channels::web::sse::SseManager>>,
|
||||||
@@ -780,14 +780,10 @@ mod tests {
|
|||||||
allow_local_tools: true,
|
allow_local_tools: true,
|
||||||
max_cost_per_day_cents: None,
|
max_cost_per_day_cents: None,
|
||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_cost_per_user_per_day_cents: None,
|
|
||||||
max_tool_iterations: 10,
|
max_tool_iterations: 10,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_tokens_per_job,
|
max_tokens_per_job,
|
||||||
multi_tenant: false,
|
|
||||||
max_llm_concurrent_per_user: None,
|
|
||||||
max_jobs_concurrent_per_user: None,
|
|
||||||
};
|
};
|
||||||
let cm = Arc::new(ContextManager::new(5));
|
let cm = Arc::new(ContextManager::new(5));
|
||||||
let llm: Arc<dyn LlmProvider> = Arc::new(StubLlm);
|
let llm: Arc<dyn LlmProvider> = Arc::new(StubLlm);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use chrono::{DateTime, Utc};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::context::{ContextManager, JobState};
|
use crate::context::{ContextManager, JobState};
|
||||||
|
use crate::db::Database;
|
||||||
use crate::error::RepairError;
|
use crate::error::RepairError;
|
||||||
use crate::tenant::AdminScope;
|
|
||||||
use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry};
|
use crate::tools::{BuildRequirement, Language, SoftwareBuilder, SoftwareType, ToolRegistry};
|
||||||
|
|
||||||
/// A job that has been detected as stuck.
|
/// A job that has been detected as stuck.
|
||||||
@@ -69,7 +69,7 @@ pub struct DefaultSelfRepair {
|
|||||||
/// Jobs in `InProgress` longer than this are treated as stuck.
|
/// Jobs in `InProgress` longer than this are treated as stuck.
|
||||||
stuck_threshold: Duration,
|
stuck_threshold: Duration,
|
||||||
max_repair_attempts: u32,
|
max_repair_attempts: u32,
|
||||||
store: Option<AdminScope>,
|
store: Option<Arc<dyn Database>>,
|
||||||
builder: Option<Arc<dyn SoftwareBuilder>>,
|
builder: Option<Arc<dyn SoftwareBuilder>>,
|
||||||
tools: Option<Arc<ToolRegistry>>,
|
tools: Option<Arc<ToolRegistry>>,
|
||||||
}
|
}
|
||||||
@@ -91,8 +91,8 @@ impl DefaultSelfRepair {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an admin-scoped store for tool failure tracking.
|
/// Add a Store for tool failure tracking.
|
||||||
pub fn with_store(mut self, store: AdminScope) -> Self {
|
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
||||||
self.store = Some(store);
|
self.store = Some(store);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -806,7 +806,7 @@ mod tests {
|
|||||||
// Create self-repair with zero threshold (detect immediately),
|
// Create self-repair with zero threshold (detect immediately),
|
||||||
// wired with store, builder, and tools.
|
// wired with store, builder, and tools.
|
||||||
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(0), 3)
|
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(0), 3)
|
||||||
.with_store(crate::tenant::AdminScope::new(Arc::clone(&db)))
|
.with_store(Arc::clone(&db))
|
||||||
.with_builder(
|
.with_builder(
|
||||||
Arc::clone(&builder) as Arc<dyn crate::tools::SoftwareBuilder>,
|
Arc::clone(&builder) as Arc<dyn crate::tools::SoftwareBuilder>,
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
+63
-112
@@ -31,58 +31,7 @@ fn requires_preexisting_uuid_thread(channel: &str) -> bool {
|
|||||||
matches!(channel, "gateway" | "test")
|
matches!(channel, "gateway" | "test")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_inbound_text_for_message(
|
|
||||||
safety: &crate::safety::SafetyLayer,
|
|
||||||
content: &str,
|
|
||||||
attachments: &[crate::channels::IncomingAttachment],
|
|
||||||
) -> crate::safety::ValidationResult {
|
|
||||||
if content.trim().is_empty() && !attachments.is_empty() {
|
|
||||||
crate::safety::ValidationResult::ok()
|
|
||||||
} else {
|
|
||||||
safety.validate_input(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
fn reject_unsafe_inbound_user_message(
|
|
||||||
&self,
|
|
||||||
message: &IncomingMessage,
|
|
||||||
content: &str,
|
|
||||||
) -> Option<SubmissionResult> {
|
|
||||||
let validation =
|
|
||||||
validate_inbound_text_for_message(self.safety(), content, &message.attachments);
|
|
||||||
if !validation.is_valid {
|
|
||||||
let details = validation
|
|
||||||
.errors
|
|
||||||
.iter()
|
|
||||||
.map(|e| format!("{}: {}", e.field, e.message))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("; ");
|
|
||||||
return Some(SubmissionResult::error(format!(
|
|
||||||
"Input rejected by safety validation: {details}",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let violations = self.safety().check_policy(content);
|
|
||||||
if violations
|
|
||||||
.iter()
|
|
||||||
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
|
|
||||||
{
|
|
||||||
return Some(SubmissionResult::error("Input rejected by safety policy."));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
|
|
||||||
tracing::warn!(
|
|
||||||
user = %message.user_id,
|
|
||||||
channel = %message.channel,
|
|
||||||
"Inbound message blocked: contains leaked secret"
|
|
||||||
);
|
|
||||||
return Some(SubmissionResult::error(warning));
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hydrate a historical thread from DB into memory if not already present.
|
/// Hydrate a historical thread from DB into memory if not already present.
|
||||||
///
|
///
|
||||||
/// Called before `resolve_thread` so that the session manager finds the
|
/// Called before `resolve_thread` so that the session manager finds the
|
||||||
@@ -226,7 +175,6 @@ impl Agent {
|
|||||||
pub(super) async fn process_user_input(
|
pub(super) async fn process_user_input(
|
||||||
&self,
|
&self,
|
||||||
message: &IncomingMessage,
|
message: &IncomingMessage,
|
||||||
tenant: crate::tenant::TenantCtx,
|
|
||||||
session: Arc<Mutex<Session>>,
|
session: Arc<Mutex<Session>>,
|
||||||
thread_id: Uuid,
|
thread_id: Uuid,
|
||||||
content: &str,
|
content: &str,
|
||||||
@@ -278,11 +226,34 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run the same safety checks that the normal path applies
|
// Run the same safety checks that the normal path applies
|
||||||
// so blocked content is never stored in pending_messages.
|
// (validation, policy, secret scan) so that blocked content
|
||||||
if let Some(rejection) =
|
// is never stored in pending_messages or serialized.
|
||||||
self.reject_unsafe_inbound_user_message(message, content)
|
let validation = self.safety().validate_input(content);
|
||||||
|
if !validation.is_valid {
|
||||||
|
let details = validation
|
||||||
|
.errors
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("{}: {}", e.field, e.message))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
return Ok(SubmissionResult::error(format!(
|
||||||
|
"Input rejected by safety validation: {details}",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let violations = self.safety().check_policy(content);
|
||||||
|
if violations
|
||||||
|
.iter()
|
||||||
|
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
|
||||||
{
|
{
|
||||||
return Ok(rejection);
|
return Ok(SubmissionResult::error("Input rejected by safety policy."));
|
||||||
|
}
|
||||||
|
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
|
||||||
|
tracing::warn!(
|
||||||
|
user = %message.user_id,
|
||||||
|
channel = %message.channel,
|
||||||
|
"Queued message blocked: contains leaked secret"
|
||||||
|
);
|
||||||
|
return Ok(SubmissionResult::error(warning));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !thread.queue_message(content.to_string()) {
|
if !thread.queue_message(content.to_string()) {
|
||||||
@@ -336,11 +307,39 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate inbound content before the turn is created. Attachment-only
|
// Safety validation for user input
|
||||||
// messages are allowed to pass through so multimodal channels can send
|
let validation = self.safety().validate_input(content);
|
||||||
// an empty text body alongside real image/document payloads.
|
if !validation.is_valid {
|
||||||
if let Some(rejection) = self.reject_unsafe_inbound_user_message(message, content) {
|
let details = validation
|
||||||
return Ok(rejection);
|
.errors
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("{}: {}", e.field, e.message))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
return Ok(SubmissionResult::error(format!(
|
||||||
|
"Input rejected by safety validation: {}",
|
||||||
|
details
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let violations = self.safety().check_policy(content);
|
||||||
|
if violations
|
||||||
|
.iter()
|
||||||
|
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
|
||||||
|
{
|
||||||
|
return Ok(SubmissionResult::error("Input rejected by safety policy."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan inbound messages for secrets (API keys, tokens).
|
||||||
|
// Catching them here prevents the LLM from echoing them back, which
|
||||||
|
// would trigger the outbound leak detector and create error loops.
|
||||||
|
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
|
||||||
|
tracing::warn!(
|
||||||
|
user = %message.user_id,
|
||||||
|
channel = %message.channel,
|
||||||
|
"Inbound message blocked: contains leaked secret"
|
||||||
|
);
|
||||||
|
return Ok(SubmissionResult::error(warning));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle explicit commands (starting with /) directly
|
// Handle explicit commands (starting with /) directly
|
||||||
@@ -352,7 +351,7 @@ impl Agent {
|
|||||||
|
|
||||||
if let Some(intent) = self.router.route_command(&temp_message) {
|
if let Some(intent) = self.router.route_command(&temp_message) {
|
||||||
// Explicit command like /status, /job, /list - handle directly
|
// Explicit command like /status, /job, /list - handle directly
|
||||||
return self.handle_job_or_command(intent, message, &tenant).await;
|
return self.handle_job_or_command(intent, message).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Natural language goes through the agentic loop
|
// Natural language goes through the agentic loop
|
||||||
@@ -463,7 +462,7 @@ impl Agent {
|
|||||||
|
|
||||||
// Run the agentic tool execution loop
|
// Run the agentic tool execution loop
|
||||||
let result = self
|
let result = self
|
||||||
.run_agentic_loop(message, tenant, session.clone(), thread_id, turn_messages)
|
.run_agentic_loop(message, session.clone(), thread_id, turn_messages)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Re-acquire lock and check if interrupted
|
// Re-acquire lock and check if interrupted
|
||||||
@@ -1474,13 +1473,7 @@ impl Agent {
|
|||||||
|
|
||||||
// Continue the agentic loop (a tool was already executed this turn)
|
// Continue the agentic loop (a tool was already executed this turn)
|
||||||
let result = self
|
let result = self
|
||||||
.run_agentic_loop(
|
.run_agentic_loop(message, session.clone(), thread_id, context_messages)
|
||||||
message,
|
|
||||||
self.tenant_ctx(&message.user_id).await,
|
|
||||||
session.clone(),
|
|
||||||
thread_id,
|
|
||||||
context_messages,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Handle the result
|
// Handle the result
|
||||||
@@ -1934,9 +1927,6 @@ fn rebuild_chat_messages_from_db(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::channels::{AttachmentKind, IncomingAttachment};
|
|
||||||
use crate::config::SafetyConfig;
|
|
||||||
use crate::safety::SafetyLayer;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rebuild_chat_messages_user_assistant_only() {
|
fn test_rebuild_chat_messages_user_assistant_only() {
|
||||||
@@ -2074,45 +2064,6 @@ mod tests {
|
|||||||
assert_eq!(result[7].content, "Written");
|
assert_eq!(result[7].content, "Written");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_inbound_text_rejects_empty_text_without_attachments() {
|
|
||||||
let safety = SafetyLayer::new(&SafetyConfig {
|
|
||||||
max_output_length: 10_000,
|
|
||||||
injection_check_enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = validate_inbound_text_for_message(&safety, "", &[]);
|
|
||||||
assert!(!result.is_valid);
|
|
||||||
assert_eq!(result.errors.len(), 1);
|
|
||||||
assert_eq!(result.errors[0].field, "input");
|
|
||||||
assert_eq!(result.errors[0].message, "Input cannot be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_inbound_text_allows_empty_text_when_attachments_exist() {
|
|
||||||
let safety = SafetyLayer::new(&SafetyConfig {
|
|
||||||
max_output_length: 10_000,
|
|
||||||
injection_check_enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let attachments = vec![IncomingAttachment {
|
|
||||||
id: "image-1".to_string(),
|
|
||||||
kind: AttachmentKind::Image,
|
|
||||||
mime_type: "image/jpeg".to_string(),
|
|
||||||
filename: Some("photo.jpg".to_string()),
|
|
||||||
size_bytes: Some(128),
|
|
||||||
source_url: Some("https://example.com/photo.jpg".to_string()),
|
|
||||||
storage_key: None,
|
|
||||||
extracted_text: None,
|
|
||||||
data: vec![1, 2, 3],
|
|
||||||
duration_secs: None,
|
|
||||||
}];
|
|
||||||
|
|
||||||
let result = validate_inbound_text_for_message(&safety, "", &attachments);
|
|
||||||
assert!(result.is_valid);
|
|
||||||
assert!(result.errors.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_db_msg(role: &str, content: &str) -> crate::history::ConversationMessage {
|
fn make_db_msg(role: &str, content: &str) -> crate::history::ConversationMessage {
|
||||||
crate::history::ConversationMessage {
|
crate::history::ConversationMessage {
|
||||||
id: uuid::Uuid::new_v4(),
|
id: uuid::Uuid::new_v4(),
|
||||||
|
|||||||
@@ -880,7 +880,6 @@ impl AppBuilder {
|
|||||||
crate::agent::cost_guard::CostGuardConfig {
|
crate::agent::cost_guard::CostGuardConfig {
|
||||||
max_cost_per_day_cents: self.config.agent.max_cost_per_day_cents,
|
max_cost_per_day_cents: self.config.agent.max_cost_per_day_cents,
|
||||||
max_actions_per_hour: self.config.agent.max_actions_per_hour,
|
max_actions_per_hour: self.config.agent.max_actions_per_hour,
|
||||||
max_cost_per_user_per_day_cents: self.config.agent.max_cost_per_user_per_day_cents,
|
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -122,32 +122,18 @@ impl RelayClient {
|
|||||||
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
|
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
|
||||||
/// for validating the callback — no URLs.
|
/// for validating the callback — no URLs.
|
||||||
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
|
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
|
||||||
let url = format!("{}/oauth/slack/auth", self.base_url);
|
|
||||||
tracing::debug!(relay_url = %url, "RelayClient::initiate_oauth: sending request");
|
|
||||||
let mut query: Vec<(&str, &str)> = vec![];
|
let mut query: Vec<(&str, &str)> = vec![];
|
||||||
if let Some(nonce) = state_nonce {
|
if let Some(nonce) = state_nonce {
|
||||||
query.push(("state_nonce", nonce));
|
query.push(("state_nonce", nonce));
|
||||||
}
|
}
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.get(&url)
|
.get(format!("{}/oauth/slack/auth", self.base_url))
|
||||||
.bearer_auth(self.api_key.expose_secret())
|
.bearer_auth(self.api_key.expose_secret())
|
||||||
.query(&query)
|
.query(&query)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||||
tracing::warn!(
|
|
||||||
relay_url = %url,
|
|
||||||
error = %e,
|
|
||||||
"RelayClient::initiate_oauth: network request failed"
|
|
||||||
);
|
|
||||||
RelayError::Network(e.to_string())
|
|
||||||
})?;
|
|
||||||
tracing::debug!(
|
|
||||||
relay_url = %url,
|
|
||||||
status = %resp.status(),
|
|
||||||
"RelayClient::initiate_oauth: received response"
|
|
||||||
);
|
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status.is_redirection() {
|
if status.is_redirection() {
|
||||||
@@ -238,39 +224,20 @@ impl RelayClient {
|
|||||||
method: &str,
|
method: &str,
|
||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
) -> Result<serde_json::Value, RelayError> {
|
) -> Result<serde_json::Value, RelayError> {
|
||||||
let url = format!("{}/proxy/{}/{}", self.base_url, provider, method);
|
|
||||||
tracing::debug!(
|
|
||||||
relay_url = %url,
|
|
||||||
provider = %provider,
|
|
||||||
method = %method,
|
|
||||||
"RelayClient::proxy_provider: sending request"
|
|
||||||
);
|
|
||||||
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
|
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.post(&url)
|
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
|
||||||
.bearer_auth(self.api_key.expose_secret())
|
.bearer_auth(self.api_key.expose_secret())
|
||||||
.query(&query)
|
.query(&query)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||||
tracing::warn!(
|
|
||||||
relay_url = %url,
|
|
||||||
error = %e,
|
|
||||||
"RelayClient::proxy_provider: network request failed"
|
|
||||||
);
|
|
||||||
RelayError::Network(e.to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status().as_u16();
|
let status = resp.status().as_u16();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
tracing::warn!(
|
|
||||||
relay_url = %url,
|
|
||||||
status = status,
|
|
||||||
"RelayClient::proxy_provider: channel-relay returned error"
|
|
||||||
);
|
|
||||||
return Err(RelayError::Api {
|
return Err(RelayError::Api {
|
||||||
status,
|
status,
|
||||||
message: body,
|
message: body,
|
||||||
@@ -288,45 +255,23 @@ impl RelayClient {
|
|||||||
/// 32-byte secret. Called once at activation time; the result is cached in the
|
/// 32-byte secret. Called once at activation time; the result is cached in the
|
||||||
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
|
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
|
||||||
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
|
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
|
||||||
let url = format!("{}/relay/signing-secret", self.base_url);
|
|
||||||
tracing::debug!(
|
|
||||||
relay_url = %url,
|
|
||||||
"RelayClient::get_signing_secret: fetching signing secret"
|
|
||||||
);
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.http
|
.http
|
||||||
.get(&url)
|
.get(format!("{}/relay/signing-secret", self.base_url))
|
||||||
.bearer_auth(self.api_key.expose_secret())
|
.bearer_auth(self.api_key.expose_secret())
|
||||||
.query(&[("team_id", team_id)])
|
.query(&[("team_id", team_id)])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||||
tracing::warn!(
|
|
||||||
relay_url = %url,
|
|
||||||
error = %e,
|
|
||||||
"RelayClient::get_signing_secret: network request failed"
|
|
||||||
);
|
|
||||||
RelayError::Network(e.to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status().as_u16();
|
let status = resp.status().as_u16();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
tracing::warn!(
|
|
||||||
relay_url = %url,
|
|
||||||
status = status,
|
|
||||||
body = %body,
|
|
||||||
"RelayClient::get_signing_secret: channel-relay returned error"
|
|
||||||
);
|
|
||||||
return Err(RelayError::Api {
|
return Err(RelayError::Api {
|
||||||
status,
|
status,
|
||||||
message: body,
|
message: body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
tracing::debug!(
|
|
||||||
relay_url = %url,
|
|
||||||
"RelayClient::get_signing_secret: received successful response"
|
|
||||||
);
|
|
||||||
|
|
||||||
let body: serde_json::Value = resp
|
let body: serde_json::Value = resp
|
||||||
.json()
|
.json()
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use aes::Aes128;
|
|
||||||
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
|
|
||||||
use base64::Engine as _;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use silk_rs::decode_silk;
|
|
||||||
|
|
||||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
|
||||||
use crate::channels::wasm::host::{Attachment, ChannelHostState};
|
|
||||||
|
|
||||||
const AES_BLOCK_SIZE: usize = 16;
|
|
||||||
const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024;
|
|
||||||
const WECHAT_CHANNEL_NAME: &str = "wechat";
|
|
||||||
const WECHAT_SILK_SAMPLE_RATE_HZ: i32 = 24_000;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct WechatAttachmentExtras {
|
|
||||||
wechat_aes_key: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn hydrate_attachment_for_channel(
|
|
||||||
channel_name: &str,
|
|
||||||
capabilities: &ChannelCapabilities,
|
|
||||||
attachment: &mut Attachment,
|
|
||||||
) {
|
|
||||||
if !should_hydrate_wechat_attachment(channel_name, attachment) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(source_url) = attachment.source_url.as_deref() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(encoded_aes_key) = wechat_aes_key(&attachment.extras_json) else {
|
|
||||||
tracing::warn!(
|
|
||||||
channel = %channel_name,
|
|
||||||
attachment_id = %attachment.id,
|
|
||||||
"Skipping WeChat attachment hydration: missing AES key metadata"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
match download_wechat_attachment_bytes(channel_name, capabilities, source_url).await {
|
|
||||||
Ok(ciphertext) => match decrypt_wechat_attachment_bytes(&ciphertext, &encoded_aes_key) {
|
|
||||||
Ok(plaintext) => {
|
|
||||||
attachment.size_bytes = Some(plaintext.len() as u64);
|
|
||||||
attachment.data = plaintext;
|
|
||||||
if attachment.mime_type.starts_with("image/") {
|
|
||||||
attachment.mime_type = detect_image_mime(&attachment.data).to_string();
|
|
||||||
} else if is_wechat_silk_attachment(attachment) {
|
|
||||||
if let Err(error) = maybe_transcode_wechat_silk_attachment(attachment) {
|
|
||||||
tracing::warn!(
|
|
||||||
channel = %channel_name,
|
|
||||||
attachment_id = %attachment.id,
|
|
||||||
error = %error,
|
|
||||||
"Failed to transcode WeChat SILK attachment; preserving raw SILK"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!(
|
|
||||||
channel = %channel_name,
|
|
||||||
attachment_id = %attachment.id,
|
|
||||||
error = %error,
|
|
||||||
"Failed to decrypt WeChat attachment"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(error) => {
|
|
||||||
tracing::warn!(
|
|
||||||
channel = %channel_name,
|
|
||||||
attachment_id = %attachment.id,
|
|
||||||
error = %error,
|
|
||||||
"Failed to download WeChat attachment"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_wechat_silk_attachment(attachment: &Attachment) -> bool {
|
|
||||||
attachment.mime_type.eq_ignore_ascii_case("audio/silk")
|
|
||||||
|| attachment
|
|
||||||
.filename
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|filename| filename.rsplit_once('.').map(|(_, ext)| ext))
|
|
||||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("silk"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_hydrate_wechat_attachment(channel_name: &str, attachment: &Attachment) -> bool {
|
|
||||||
channel_name == WECHAT_CHANNEL_NAME
|
|
||||||
&& attachment.data.is_empty()
|
|
||||||
&& attachment.source_url.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wechat_aes_key(extras_json: &str) -> Option<String> {
|
|
||||||
if extras_json.trim().is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::from_str::<WechatAttachmentExtras>(extras_json)
|
|
||||||
.ok()
|
|
||||||
.and_then(|extras| extras.wechat_aes_key)
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_wechat_attachment_bytes(
|
|
||||||
channel_name: &str,
|
|
||||||
capabilities: &ChannelCapabilities,
|
|
||||||
source_url: &str,
|
|
||||||
) -> Result<Vec<u8>, String> {
|
|
||||||
let host_state = ChannelHostState::new(channel_name, capabilities.clone());
|
|
||||||
host_state.check_http_allowed(source_url, "GET")?;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.connect_timeout(Duration::from_secs(10))
|
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(source_url)
|
|
||||||
.timeout(Duration::from_secs(15))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("WeChat CDN download failed: {e}"))?;
|
|
||||||
|
|
||||||
if response.status() != reqwest::StatusCode::OK {
|
|
||||||
return Err(format!(
|
|
||||||
"WeChat CDN download returned {}",
|
|
||||||
response.status()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = response
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to read WeChat CDN response body: {e}"))?
|
|
||||||
.to_vec();
|
|
||||||
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return Err("WeChat CDN download returned an empty body".to_string());
|
|
||||||
}
|
|
||||||
if bytes.len() > MAX_ATTACHMENT_BYTES {
|
|
||||||
return Err(format!(
|
|
||||||
"WeChat attachment exceeds {MAX_ATTACHMENT_BYTES} bytes"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrypt_wechat_attachment_bytes(
|
|
||||||
ciphertext: &[u8],
|
|
||||||
encoded_aes_key: &str,
|
|
||||||
) -> Result<Vec<u8>, String> {
|
|
||||||
let key = parse_aes_key(encoded_aes_key)?;
|
|
||||||
decrypt_aes_ecb_pkcs7(ciphertext, &key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_aes_key(encoded: &str) -> Result<Vec<u8>, String> {
|
|
||||||
let decoded = if encoded.len() == 32 && encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
|
||||||
decode_hex(encoded)?
|
|
||||||
} else {
|
|
||||||
base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(encoded)
|
|
||||||
.map_err(|e| format!("Failed to decode WeChat AES key: {e}"))?
|
|
||||||
};
|
|
||||||
|
|
||||||
if decoded.len() == AES_BLOCK_SIZE {
|
|
||||||
return Ok(decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
if decoded.len() == 32 && decoded.iter().all(|byte| byte.is_ascii_hexdigit()) {
|
|
||||||
return decode_hex(
|
|
||||||
std::str::from_utf8(&decoded)
|
|
||||||
.map_err(|e| format!("WeChat AES key hex payload is not valid UTF-8: {e}"))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(format!(
|
|
||||||
"WeChat AES key must decode to 16 bytes or a 32-char hex string, got {} bytes",
|
|
||||||
decoded.len()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
|
|
||||||
if !input.len().is_multiple_of(2) {
|
|
||||||
return Err("hex input length must be even".to_string());
|
|
||||||
}
|
|
||||||
let mut bytes = Vec::with_capacity(input.len() / 2);
|
|
||||||
let chars: Vec<u8> = input.as_bytes().to_vec();
|
|
||||||
for idx in (0..chars.len()).step_by(2) {
|
|
||||||
let high = from_hex_digit(chars[idx])?;
|
|
||||||
let low = from_hex_digit(chars[idx + 1])?;
|
|
||||||
bytes.push((high << 4) | low);
|
|
||||||
}
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_hex_digit(value: u8) -> Result<u8, String> {
|
|
||||||
match value {
|
|
||||||
b'0'..=b'9' => Ok(value - b'0'),
|
|
||||||
b'a'..=b'f' => Ok(value - b'a' + 10),
|
|
||||||
b'A'..=b'F' => Ok(value - b'A' + 10),
|
|
||||||
_ => Err(format!("invalid hex digit '{}'", value as char)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrypt_aes_ecb_pkcs7(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
|
|
||||||
if !ciphertext.len().is_multiple_of(AES_BLOCK_SIZE) {
|
|
||||||
return Err("ciphertext length is not a multiple of 16 bytes".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
|
|
||||||
let mut plaintext = ciphertext.to_vec();
|
|
||||||
for chunk in plaintext.chunks_exact_mut(AES_BLOCK_SIZE) {
|
|
||||||
cipher.decrypt_block(GenericArray::from_mut_slice(chunk));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pad_len = *plaintext
|
|
||||||
.last()
|
|
||||||
.ok_or_else(|| "ciphertext decrypted to an empty buffer".to_string())?
|
|
||||||
as usize;
|
|
||||||
if pad_len == 0 || pad_len > AES_BLOCK_SIZE || pad_len > plaintext.len() {
|
|
||||||
return Err("invalid PKCS7 padding".to_string());
|
|
||||||
}
|
|
||||||
if !plaintext[plaintext.len() - pad_len..]
|
|
||||||
.iter()
|
|
||||||
.all(|byte| *byte as usize == pad_len)
|
|
||||||
{
|
|
||||||
return Err("invalid PKCS7 padding bytes".to_string());
|
|
||||||
}
|
|
||||||
plaintext.truncate(plaintext.len() - pad_len);
|
|
||||||
Ok(plaintext)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detect_image_mime(bytes: &[u8]) -> &'static str {
|
|
||||||
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
|
||||||
"image/png"
|
|
||||||
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
|
||||||
"image/jpeg"
|
|
||||||
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
|
||||||
"image/gif"
|
|
||||||
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
|
|
||||||
"image/webp"
|
|
||||||
} else {
|
|
||||||
"image/jpeg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_transcode_wechat_silk_attachment(attachment: &mut Attachment) -> Result<(), String> {
|
|
||||||
if attachment.data.is_empty() {
|
|
||||||
return Err("SILK attachment has no data".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let pcm = decode_silk(&attachment.data, WECHAT_SILK_SAMPLE_RATE_HZ)
|
|
||||||
.map_err(|error| format!("SILK decode failed: {error}"))?;
|
|
||||||
if pcm.is_empty() {
|
|
||||||
return Err("SILK decoder returned empty PCM".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let wav = pcm_s16le_to_wav(&pcm, WECHAT_SILK_SAMPLE_RATE_HZ as u32)?;
|
|
||||||
attachment.data = wav;
|
|
||||||
attachment.size_bytes = Some(attachment.data.len() as u64);
|
|
||||||
attachment.mime_type = "audio/wav".to_string();
|
|
||||||
if let Some(filename) = attachment.filename.as_mut() {
|
|
||||||
replace_attachment_extension(filename, "wav");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pcm_s16le_to_wav(pcm: &[u8], sample_rate_hz: u32) -> Result<Vec<u8>, String> {
|
|
||||||
if !pcm.len().is_multiple_of(2) {
|
|
||||||
return Err("PCM buffer length must be even for 16-bit mono audio".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let data_len = u32::try_from(pcm.len())
|
|
||||||
.map_err(|_| "PCM buffer exceeds WAV container size limits".to_string())?;
|
|
||||||
let total_len = 44u32
|
|
||||||
.checked_add(data_len)
|
|
||||||
.ok_or_else(|| "WAV container size overflowed".to_string())?;
|
|
||||||
let byte_rate = sample_rate_hz
|
|
||||||
.checked_mul(2)
|
|
||||||
.ok_or_else(|| "WAV byte rate overflowed".to_string())?;
|
|
||||||
|
|
||||||
let mut wav = Vec::with_capacity(total_len as usize);
|
|
||||||
wav.extend_from_slice(b"RIFF");
|
|
||||||
wav.extend_from_slice(&(total_len - 8).to_le_bytes());
|
|
||||||
wav.extend_from_slice(b"WAVE");
|
|
||||||
wav.extend_from_slice(b"fmt ");
|
|
||||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
|
||||||
wav.extend_from_slice(&1u16.to_le_bytes());
|
|
||||||
wav.extend_from_slice(&1u16.to_le_bytes());
|
|
||||||
wav.extend_from_slice(&sample_rate_hz.to_le_bytes());
|
|
||||||
wav.extend_from_slice(&byte_rate.to_le_bytes());
|
|
||||||
wav.extend_from_slice(&2u16.to_le_bytes());
|
|
||||||
wav.extend_from_slice(&16u16.to_le_bytes());
|
|
||||||
wav.extend_from_slice(b"data");
|
|
||||||
wav.extend_from_slice(&data_len.to_le_bytes());
|
|
||||||
wav.extend_from_slice(pcm);
|
|
||||||
Ok(wav)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn replace_attachment_extension(filename: &mut String, replacement: &str) {
|
|
||||||
if let Some((stem, _)) = filename.rsplit_once('.') {
|
|
||||||
*filename = format!("{stem}.{replacement}");
|
|
||||||
} else {
|
|
||||||
filename.push('.');
|
|
||||||
filename.push_str(replacement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
|
|
||||||
use aes::cipher::BlockEncrypt;
|
|
||||||
|
|
||||||
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
|
|
||||||
let mut padded = plaintext.to_vec();
|
|
||||||
let pad_len = AES_BLOCK_SIZE - (padded.len() % AES_BLOCK_SIZE);
|
|
||||||
padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
|
|
||||||
|
|
||||||
for chunk in padded.chunks_exact_mut(AES_BLOCK_SIZE) {
|
|
||||||
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(padded)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
Attachment, decrypt_wechat_attachment_bytes, detect_image_mime, encrypt_aes_ecb_pkcs7,
|
|
||||||
hydrate_attachment_for_channel, maybe_transcode_wechat_silk_attachment, pcm_s16le_to_wav,
|
|
||||||
should_hydrate_wechat_attachment,
|
|
||||||
};
|
|
||||||
use crate::channels::wasm::ChannelCapabilities;
|
|
||||||
use base64::Engine as _;
|
|
||||||
|
|
||||||
fn make_attachment() -> Attachment {
|
|
||||||
Attachment {
|
|
||||||
id: "wechat-image-1".to_string(),
|
|
||||||
mime_type: "image/jpeg".to_string(),
|
|
||||||
filename: Some("wechat-image.jpg".to_string()),
|
|
||||||
size_bytes: None,
|
|
||||||
source_url: Some(
|
|
||||||
"https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=test"
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
storage_key: None,
|
|
||||||
extracted_text: None,
|
|
||||||
extras_json: String::new(),
|
|
||||||
data: Vec::new(),
|
|
||||||
duration_secs: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_test_extras_json(aes_key: &str) -> String {
|
|
||||||
serde_json::json!({ "wechat_aes_key": aes_key }).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_wechat_image_bytes_round_trips() {
|
|
||||||
let key = [7u8; 16];
|
|
||||||
let plaintext = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x11];
|
|
||||||
let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap();
|
|
||||||
let encoded_key = base64::engine::general_purpose::STANDARD.encode(key);
|
|
||||||
let decrypted = decrypt_wechat_attachment_bytes(&ciphertext, &encoded_key).unwrap();
|
|
||||||
assert_eq!(decrypted, plaintext);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn detect_image_mime_prefers_magic_bytes() {
|
|
||||||
assert_eq!(detect_image_mime(&[0xFF, 0xD8, 0xFF, 0x00]), "image/jpeg");
|
|
||||||
assert_eq!(
|
|
||||||
detect_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]),
|
|
||||||
"image/png"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wechat_attachment_hydration_applies_to_wechat_encrypted_media() {
|
|
||||||
let mut attachment = make_attachment();
|
|
||||||
attachment.extras_json = encode_test_extras_json("ZmFrZS1rZXk=");
|
|
||||||
assert!(should_hydrate_wechat_attachment("wechat", &attachment));
|
|
||||||
assert!(!should_hydrate_wechat_attachment("telegram", &attachment));
|
|
||||||
|
|
||||||
attachment.mime_type = "application/pdf".to_string();
|
|
||||||
assert!(should_hydrate_wechat_attachment("wechat", &attachment));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn hydration_skips_when_metadata_is_missing() {
|
|
||||||
let mut attachment = make_attachment();
|
|
||||||
let caps = ChannelCapabilities::for_channel("wechat");
|
|
||||||
hydrate_attachment_for_channel("wechat", &caps, &mut attachment).await;
|
|
||||||
assert!(attachment.data.is_empty());
|
|
||||||
assert_eq!(attachment.size_bytes, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pcm_s16le_to_wav_wraps_pcm_with_expected_header() {
|
|
||||||
let wav = pcm_s16le_to_wav(&[0x00, 0x00, 0x01, 0x00], 24_000).expect("wav wrapping");
|
|
||||||
assert!(wav.starts_with(b"RIFF"));
|
|
||||||
assert_eq!(&wav[8..12], b"WAVE");
|
|
||||||
assert_eq!(&wav[12..16], b"fmt ");
|
|
||||||
assert_eq!(&wav[36..40], b"data");
|
|
||||||
assert_eq!(&wav[40..44], &(4u32).to_le_bytes());
|
|
||||||
assert_eq!(&wav[44..], &[0x00, 0x00, 0x01, 0x00]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn silk_transcode_failure_preserves_raw_silk_path_for_callers() {
|
|
||||||
let mut attachment = Attachment {
|
|
||||||
id: "wechat-voice-1".to_string(),
|
|
||||||
mime_type: "audio/silk".to_string(),
|
|
||||||
filename: Some("wechat-voice-1.silk".to_string()),
|
|
||||||
size_bytes: Some(3),
|
|
||||||
source_url: None,
|
|
||||||
storage_key: None,
|
|
||||||
extracted_text: None,
|
|
||||||
extras_json: encode_test_extras_json("ZmFrZS1rZXk="),
|
|
||||||
data: vec![1, 2, 3],
|
|
||||||
duration_secs: Some(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
let original = attachment.data.clone();
|
|
||||||
let error =
|
|
||||||
maybe_transcode_wechat_silk_attachment(&mut attachment).expect_err("invalid SILK");
|
|
||||||
assert!(error.contains("SILK decode failed"));
|
|
||||||
assert_eq!(attachment.mime_type, "audio/silk");
|
|
||||||
assert_eq!(attachment.filename.as_deref(), Some("wechat-voice-1.silk"));
|
|
||||||
assert_eq!(attachment.data, original);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,8 +35,6 @@ pub struct Attachment {
|
|||||||
pub storage_key: Option<String>,
|
pub storage_key: Option<String>,
|
||||||
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
|
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
|
||||||
pub extracted_text: Option<String>,
|
pub extracted_text: Option<String>,
|
||||||
/// Extensible metadata from the channel payload.
|
|
||||||
pub extras_json: String,
|
|
||||||
/// Raw file bytes (for small files downloaded by the channel).
|
/// Raw file bytes (for small files downloaded by the channel).
|
||||||
pub data: Vec<u8>,
|
pub data: Vec<u8>,
|
||||||
/// Duration in seconds (for audio/video).
|
/// Duration in seconds (for audio/video).
|
||||||
@@ -997,7 +995,6 @@ mod tests {
|
|||||||
source_url: None,
|
source_url: None,
|
||||||
storage_key: None,
|
storage_key: None,
|
||||||
extracted_text: None,
|
extracted_text: None,
|
||||||
extras_json: String::new(),
|
|
||||||
data: Vec::new(),
|
data: Vec::new(),
|
||||||
duration_secs: None,
|
duration_secs: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@
|
|||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod attachment_hydration;
|
|
||||||
mod bundled;
|
mod bundled;
|
||||||
mod capabilities;
|
mod capabilities;
|
||||||
mod error;
|
mod error;
|
||||||
|
|||||||
@@ -159,11 +159,6 @@ impl WasmChannelRouter {
|
|||||||
self.channels.read().await.get(channel_name).cloned()
|
self.channels.read().await.get(channel_name).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a registered channel directly by name.
|
|
||||||
pub async fn get_channel_by_name(&self, channel_name: &str) -> Option<Arc<WasmChannel>> {
|
|
||||||
self.channels.read().await.get(channel_name).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate a secret for a channel.
|
/// Validate a secret for a channel.
|
||||||
pub async fn validate_secret(&self, channel_name: &str, provided: &str) -> bool {
|
pub async fn validate_secret(&self, channel_name: &str, provided: &str) -> bool {
|
||||||
let secrets = self.secrets.read().await;
|
let secrets = self.secrets.read().await;
|
||||||
@@ -715,10 +710,6 @@ mod tests {
|
|||||||
// Should not find non-existent path
|
// Should not find non-existent path
|
||||||
let not_found = router.get_channel_for_path("/webhook/telegram").await;
|
let not_found = router.get_channel_for_path("/webhook/telegram").await;
|
||||||
assert!(not_found.is_none());
|
assert!(not_found.is_none());
|
||||||
|
|
||||||
let found_by_name = router.get_channel_by_name("slack").await;
|
|
||||||
assert!(found_by_name.is_some());
|
|
||||||
assert_eq!(found_by_name.unwrap().channel_name(), "slack");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -139,13 +139,6 @@ impl ChannelCapabilitiesFile {
|
|||||||
serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
|
serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this channel declares owner/pairing gating in its config.
|
|
||||||
pub fn requires_binding(&self) -> bool {
|
|
||||||
["owner_id", "dm_policy", "allow_from"]
|
|
||||||
.iter()
|
|
||||||
.any(|key| self.config.contains_key(*key))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the webhook secret header name for this channel.
|
/// Get the webhook secret header name for this channel.
|
||||||
///
|
///
|
||||||
/// Returns the configured header name from capabilities, or a sensible default.
|
/// Returns the configured header name from capabilities, or a sensible default.
|
||||||
@@ -576,34 +569,6 @@ mod tests {
|
|||||||
assert_eq!(caps.workspace_prefix, "integrations/custom/");
|
assert_eq!(caps.workspace_prefix, "integrations/custom/");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_requires_binding_detects_dm_owner_fields() {
|
|
||||||
let telegram = ChannelCapabilitiesFile::from_json(
|
|
||||||
r#"{
|
|
||||||
"name": "telegram",
|
|
||||||
"config": {
|
|
||||||
"owner_id": null,
|
|
||||||
"dm_policy": "pairing",
|
|
||||||
"allow_from": []
|
|
||||||
}
|
|
||||||
}"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(telegram.requires_binding());
|
|
||||||
|
|
||||||
let wechat = ChannelCapabilitiesFile::from_json(
|
|
||||||
r#"{
|
|
||||||
"name": "wechat",
|
|
||||||
"config": {
|
|
||||||
"base_url": "https://ilinkai.weixin.qq.com",
|
|
||||||
"bot_type": "3"
|
|
||||||
}
|
|
||||||
}"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(!wechat.requires_binding());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_emit_rate_limit() {
|
fn test_emit_rate_limit() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
|
|||||||
+2
-107
@@ -190,20 +190,7 @@ async fn register_channel(
|
|||||||
// The credential injection system only replaces placeholders in URLs
|
// The credential injection system only replaces placeholders in URLs
|
||||||
// and headers, so channels like Feishu that exchange app_id + app_secret
|
// and headers, so channels like Feishu that exchange app_id + app_secret
|
||||||
// for a tenant token need the raw values in their config.
|
// for a tenant token need the raw values in their config.
|
||||||
inject_channel_secrets_into_config(
|
inject_channel_secrets_into_config(&channel_name, secrets_store, &mut config_updates).await;
|
||||||
&channel_name,
|
|
||||||
&config.owner_id,
|
|
||||||
secrets_store,
|
|
||||||
&mut config_updates,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
inject_channel_settings_into_config(
|
|
||||||
&channel_name,
|
|
||||||
&config.owner_id,
|
|
||||||
settings_store,
|
|
||||||
&mut config_updates,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if !config_updates.is_empty() {
|
if !config_updates.is_empty() {
|
||||||
channel_arc.update_config(config_updates).await;
|
channel_arc.update_config(config_updates).await;
|
||||||
@@ -409,7 +396,6 @@ pub async fn inject_channel_credentials(
|
|||||||
/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`.
|
/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`.
|
||||||
async fn inject_channel_secrets_into_config(
|
async fn inject_channel_secrets_into_config(
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
owner_id: &str,
|
|
||||||
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||||
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
|
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
|
||||||
) {
|
) {
|
||||||
@@ -427,7 +413,7 @@ async fn inject_channel_secrets_into_config(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for &(config_key, secret_name) in secret_config_mappings {
|
for &(config_key, secret_name) in secret_config_mappings {
|
||||||
match secrets.get_decrypted(owner_id, secret_name).await {
|
match secrets.get_decrypted("default", secret_name).await {
|
||||||
Ok(decrypted) => {
|
Ok(decrypted) => {
|
||||||
config_updates.insert(
|
config_updates.insert(
|
||||||
config_key.to_string(),
|
config_key.to_string(),
|
||||||
@@ -456,94 +442,3 @@ async fn inject_channel_secrets_into_config(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject channel-specific settings into config for channels that persist
|
|
||||||
/// runtime-discovered values (for example a custom API base URL after login).
|
|
||||||
async fn inject_channel_settings_into_config(
|
|
||||||
channel_name: &str,
|
|
||||||
owner_id: &str,
|
|
||||||
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
|
||||||
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
|
|
||||||
) {
|
|
||||||
let Some(store) = settings_store else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let setting_mappings: &[(&str, &str)] = match channel_name {
|
|
||||||
"wechat" => &[("base_url", "extensions.wechat.base_url")],
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
for &(config_key, setting_path) in setting_mappings {
|
|
||||||
if let Ok(Some(serde_json::Value::String(value))) =
|
|
||||||
store.get_setting(owner_id, setting_path).await
|
|
||||||
{
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
config_updates.insert(
|
|
||||||
config_key.to_string(),
|
|
||||||
serde_json::Value::String(trimmed.to_string()),
|
|
||||||
);
|
|
||||||
tracing::debug!(
|
|
||||||
channel = %channel_name,
|
|
||||||
config_key = %config_key,
|
|
||||||
setting_path = %setting_path,
|
|
||||||
"Injected setting into channel config"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::db::{Database, SettingsStore};
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_inject_channel_settings_uses_owner_scope() -> Result<(), String> {
|
|
||||||
let dir = tempfile::tempdir().map_err(|e| format!("tempdir failed: {e}"))?;
|
|
||||||
let db_path = dir.path().join("wechat-settings.db");
|
|
||||||
let db = Arc::new(
|
|
||||||
crate::db::libsql::LibSqlBackend::new_local(&db_path)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("create local libsql backend failed: {e}"))?,
|
|
||||||
);
|
|
||||||
db.run_migrations()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("run libsql migrations failed: {e}"))?;
|
|
||||||
|
|
||||||
db.set_setting(
|
|
||||||
"default",
|
|
||||||
"extensions.wechat.base_url",
|
|
||||||
&serde_json::json!("https://default.example"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("persist default setting failed: {e}"))?;
|
|
||||||
db.set_setting(
|
|
||||||
"owner-123",
|
|
||||||
"extensions.wechat.base_url",
|
|
||||||
&serde_json::json!("https://owner.example"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("persist owner setting failed: {e}"))?;
|
|
||||||
|
|
||||||
let settings_store: Arc<dyn crate::db::SettingsStore> = db;
|
|
||||||
let mut config_updates = std::collections::HashMap::new();
|
|
||||||
super::inject_channel_settings_into_config(
|
|
||||||
"wechat",
|
|
||||||
"owner-123",
|
|
||||||
Some(&settings_store),
|
|
||||||
&mut config_updates,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
config_updates.get("base_url"),
|
|
||||||
Some(&serde_json::json!("https://owner.example"))
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+78
-145
@@ -573,7 +573,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
|
|||||||
source_url: a.source_url,
|
source_url: a.source_url,
|
||||||
storage_key: a.storage_key,
|
storage_key: a.storage_key,
|
||||||
extracted_text: a.extracted_text,
|
extracted_text: a.extracted_text,
|
||||||
extras_json: a.extras_json,
|
|
||||||
data,
|
data,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
}
|
}
|
||||||
@@ -1182,32 +1181,22 @@ impl WasmChannel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_host_state_entries(channel_name: &str, host_state: &mut ChannelHostState) {
|
fn log_on_start_host_state(&self, host_state: &mut ChannelHostState) {
|
||||||
for entry in host_state.take_logs() {
|
for entry in host_state.take_logs() {
|
||||||
match entry.level {
|
match entry.level {
|
||||||
crate::tools::wasm::LogLevel::Trace => {
|
|
||||||
tracing::trace!(channel = %channel_name, "{}", entry.message);
|
|
||||||
}
|
|
||||||
crate::tools::wasm::LogLevel::Debug => {
|
|
||||||
tracing::debug!(channel = %channel_name, "{}", entry.message);
|
|
||||||
}
|
|
||||||
crate::tools::wasm::LogLevel::Info => {
|
|
||||||
tracing::info!(channel = %channel_name, "{}", entry.message);
|
|
||||||
}
|
|
||||||
crate::tools::wasm::LogLevel::Error => {
|
crate::tools::wasm::LogLevel::Error => {
|
||||||
tracing::error!(channel = %channel_name, "{}", entry.message);
|
tracing::error!(channel = %self.name, "{}", entry.message);
|
||||||
}
|
}
|
||||||
crate::tools::wasm::LogLevel::Warn => {
|
crate::tools::wasm::LogLevel::Warn => {
|
||||||
tracing::warn!(channel = %channel_name, "{}", entry.message);
|
tracing::warn!(channel = %self.name, "{}", entry.message);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::debug!(channel = %self.name, "{}", entry.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_on_start_host_state(&self, host_state: &mut ChannelHostState) {
|
|
||||||
Self::log_host_state_entries(&self.name, host_state);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute_on_start_with_state(
|
async fn execute_on_start_with_state(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<(Result<ChannelConfig, WasmChannelError>, ChannelHostState), WasmChannelError> {
|
) -> Result<(Result<ChannelConfig, WasmChannelError>, ChannelHostState), WasmChannelError> {
|
||||||
@@ -1491,20 +1480,18 @@ impl WasmChannel {
|
|||||||
|
|
||||||
// Call on_poll using the generated typed interface
|
// Call on_poll using the generated typed interface
|
||||||
let channel_iface = instance.near_agent_channel();
|
let channel_iface = instance.near_agent_channel();
|
||||||
let poll_result = channel_iface
|
channel_iface
|
||||||
.call_on_poll(&mut store)
|
.call_on_poll(&mut store)
|
||||||
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel));
|
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
||||||
|
|
||||||
let mut host_state =
|
let mut host_state =
|
||||||
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
||||||
|
|
||||||
if poll_result.is_ok() {
|
// Commit pending workspace writes to the persistent store
|
||||||
// Commit pending workspace writes only after a successful callback.
|
let pending_writes = host_state.take_pending_writes();
|
||||||
let pending_writes = host_state.take_pending_writes();
|
workspace_store.commit_writes(&pending_writes);
|
||||||
workspace_store.commit_writes(&pending_writes);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((poll_result, host_state))
|
Ok(((), host_state))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
||||||
@@ -1516,10 +1503,7 @@ impl WasmChannel {
|
|||||||
|
|
||||||
let channel_name = self.name.clone();
|
let channel_name = self.name.clone();
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok((poll_result, mut host_state))) => {
|
Ok(Ok(((), mut host_state))) => {
|
||||||
Self::log_host_state_entries(&channel_name, &mut host_state);
|
|
||||||
poll_result?;
|
|
||||||
|
|
||||||
// Process emitted messages
|
// Process emitted messages
|
||||||
let emitted = host_state.take_emitted_messages();
|
let emitted = host_state.take_emitted_messages();
|
||||||
self.process_emitted_messages(emitted).await?;
|
self.process_emitted_messages(emitted).await?;
|
||||||
@@ -2197,16 +2181,6 @@ impl WasmChannel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for emitted in messages {
|
for emitted in messages {
|
||||||
let EmittedMessage {
|
|
||||||
user_id,
|
|
||||||
user_name,
|
|
||||||
content,
|
|
||||||
thread_id,
|
|
||||||
metadata_json,
|
|
||||||
attachments,
|
|
||||||
..
|
|
||||||
} = emitted;
|
|
||||||
|
|
||||||
// Check rate limit — acquire and release the write lock before send().await
|
// Check rate limit — acquire and release the write lock before send().await
|
||||||
{
|
{
|
||||||
let mut rate_limiter = self.rate_limiter.write().await;
|
let mut rate_limiter = self.rate_limiter.write().await;
|
||||||
@@ -2224,41 +2198,55 @@ impl WasmChannel {
|
|||||||
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
|
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
|
||||||
&self.owner_scope_id,
|
&self.owner_scope_id,
|
||||||
self.owner_actor_id.as_deref(),
|
self.owner_actor_id.as_deref(),
|
||||||
&user_id,
|
&emitted.user_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to IncomingMessage
|
// Convert to IncomingMessage
|
||||||
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &content)
|
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &emitted.content)
|
||||||
.with_owner_id(&self.owner_scope_id)
|
.with_owner_id(&self.owner_scope_id)
|
||||||
.with_sender_id(&user_id);
|
.with_sender_id(&emitted.user_id);
|
||||||
|
|
||||||
if let Some(name) = user_name {
|
if let Some(name) = emitted.user_name {
|
||||||
msg = msg.with_user_name(name);
|
msg = msg.with_user_name(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(thread_id) = thread_id {
|
if let Some(thread_id) = emitted.thread_id {
|
||||||
msg = msg.with_thread(thread_id);
|
msg = msg.with_thread(thread_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert attachments
|
// Convert attachments
|
||||||
if !attachments.is_empty() {
|
if !emitted.attachments.is_empty() {
|
||||||
let incoming_attachments =
|
let incoming_attachments = emitted
|
||||||
convert_emitted_attachments(&self.name, &self.capabilities, attachments).await;
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.map(|a| crate::channels::IncomingAttachment {
|
||||||
|
id: a.id.clone(),
|
||||||
|
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
|
||||||
|
mime_type: a.mime_type.clone(),
|
||||||
|
filename: a.filename.clone(),
|
||||||
|
size_bytes: a.size_bytes,
|
||||||
|
source_url: a.source_url.clone(),
|
||||||
|
storage_key: a.storage_key.clone(),
|
||||||
|
extracted_text: a.extracted_text.clone(),
|
||||||
|
data: a.data.clone(),
|
||||||
|
duration_secs: a.duration_secs,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
msg = msg.with_attachments(incoming_attachments);
|
msg = msg.with_attachments(incoming_attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse metadata JSON
|
// Parse metadata JSON
|
||||||
msg = apply_emitted_metadata(msg, &metadata_json);
|
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
|
||||||
if is_owner_sender {
|
if is_owner_sender {
|
||||||
// Store for owner-target routing (chat_id etc.).
|
// Store for owner-target routing (chat_id etc.).
|
||||||
self.update_broadcast_metadata(&metadata_json).await;
|
self.update_broadcast_metadata(&emitted.metadata_json).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to stream — no locks held across this await
|
// Send to stream — no locks held across this await
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %self.name,
|
channel = %self.name,
|
||||||
user_id = %user_id,
|
user_id = %emitted.user_id,
|
||||||
content_len = content.len(),
|
content_len = emitted.content.len(),
|
||||||
attachment_count = msg.attachments.len(),
|
attachment_count = msg.attachments.len(),
|
||||||
"Sending emitted message to agent"
|
"Sending emitted message to agent"
|
||||||
);
|
);
|
||||||
@@ -2343,7 +2331,6 @@ impl WasmChannel {
|
|||||||
&& let Err(e) = Self::dispatch_emitted_messages(
|
&& let Err(e) = Self::dispatch_emitted_messages(
|
||||||
EmitDispatchContext {
|
EmitDispatchContext {
|
||||||
channel_name: &channel_name,
|
channel_name: &channel_name,
|
||||||
capabilities: &capabilities,
|
|
||||||
owner_scope_id: &owner_scope_id,
|
owner_scope_id: &owner_scope_id,
|
||||||
owner_actor_id: owner_actor_id.as_deref(),
|
owner_actor_id: owner_actor_id.as_deref(),
|
||||||
message_tx: &message_tx,
|
message_tx: &message_tx,
|
||||||
@@ -2429,20 +2416,18 @@ impl WasmChannel {
|
|||||||
|
|
||||||
// Call on_poll using the generated typed interface
|
// Call on_poll using the generated typed interface
|
||||||
let channel_iface = instance.near_agent_channel();
|
let channel_iface = instance.near_agent_channel();
|
||||||
let poll_result = channel_iface
|
channel_iface
|
||||||
.call_on_poll(&mut store)
|
.call_on_poll(&mut store)
|
||||||
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel));
|
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
||||||
|
|
||||||
let mut host_state =
|
let mut host_state =
|
||||||
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
||||||
|
|
||||||
if poll_result.is_ok() {
|
// Commit pending workspace writes to the persistent store
|
||||||
// Commit pending workspace writes only after a successful callback.
|
let pending_writes = host_state.take_pending_writes();
|
||||||
let pending_writes = host_state.take_pending_writes();
|
workspace_store.commit_writes(&pending_writes);
|
||||||
workspace_store.commit_writes(&pending_writes);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((poll_result, host_state))
|
Ok(host_state)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
||||||
@@ -2453,10 +2438,7 @@ impl WasmChannel {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok((poll_result, mut host_state))) => {
|
Ok(Ok(mut host_state)) => {
|
||||||
Self::log_host_state_entries(channel_name, &mut host_state);
|
|
||||||
poll_result?;
|
|
||||||
|
|
||||||
let emitted = host_state.take_emitted_messages();
|
let emitted = host_state.take_emitted_messages();
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
@@ -2502,16 +2484,6 @@ impl WasmChannel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for emitted in messages {
|
for emitted in messages {
|
||||||
let EmittedMessage {
|
|
||||||
user_id,
|
|
||||||
user_name,
|
|
||||||
content,
|
|
||||||
thread_id,
|
|
||||||
metadata_json,
|
|
||||||
attachments,
|
|
||||||
..
|
|
||||||
} = emitted;
|
|
||||||
|
|
||||||
// Check rate limit — acquire and release the write lock before send().await
|
// Check rate limit — acquire and release the write lock before send().await
|
||||||
{
|
{
|
||||||
let mut limiter = dispatch.rate_limiter.write().await;
|
let mut limiter = dispatch.rate_limiter.write().await;
|
||||||
@@ -2526,40 +2498,54 @@ impl WasmChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (resolved_user_id, is_owner_sender) =
|
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
|
||||||
resolve_message_scope(dispatch.owner_scope_id, dispatch.owner_actor_id, &user_id);
|
dispatch.owner_scope_id,
|
||||||
|
dispatch.owner_actor_id,
|
||||||
|
&emitted.user_id,
|
||||||
|
);
|
||||||
|
|
||||||
// Convert to IncomingMessage
|
// Convert to IncomingMessage
|
||||||
let mut msg = IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &content)
|
let mut msg =
|
||||||
.with_owner_id(dispatch.owner_scope_id)
|
IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &emitted.content)
|
||||||
.with_sender_id(&user_id);
|
.with_owner_id(dispatch.owner_scope_id)
|
||||||
|
.with_sender_id(&emitted.user_id);
|
||||||
|
|
||||||
if let Some(name) = user_name {
|
if let Some(name) = emitted.user_name {
|
||||||
msg = msg.with_user_name(name);
|
msg = msg.with_user_name(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(thread_id) = thread_id {
|
if let Some(thread_id) = emitted.thread_id {
|
||||||
msg = msg.with_thread(thread_id);
|
msg = msg.with_thread(thread_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert attachments
|
// Convert attachments
|
||||||
if !attachments.is_empty() {
|
if !emitted.attachments.is_empty() {
|
||||||
let incoming_attachments = convert_emitted_attachments(
|
let incoming_attachments = emitted
|
||||||
dispatch.channel_name,
|
.attachments
|
||||||
dispatch.capabilities,
|
.iter()
|
||||||
attachments,
|
.map(|a| crate::channels::IncomingAttachment {
|
||||||
)
|
id: a.id.clone(),
|
||||||
.await;
|
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
|
||||||
|
mime_type: a.mime_type.clone(),
|
||||||
|
filename: a.filename.clone(),
|
||||||
|
size_bytes: a.size_bytes,
|
||||||
|
source_url: a.source_url.clone(),
|
||||||
|
storage_key: a.storage_key.clone(),
|
||||||
|
extracted_text: a.extracted_text.clone(),
|
||||||
|
data: a.data.clone(),
|
||||||
|
duration_secs: a.duration_secs,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
msg = msg.with_attachments(incoming_attachments);
|
msg = msg.with_attachments(incoming_attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
msg = apply_emitted_metadata(msg, &metadata_json);
|
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
|
||||||
if is_owner_sender {
|
if is_owner_sender {
|
||||||
// Store for owner-target routing (chat_id etc.)
|
// Store for owner-target routing (chat_id etc.)
|
||||||
do_update_broadcast_metadata(
|
do_update_broadcast_metadata(
|
||||||
dispatch.channel_name,
|
dispatch.channel_name,
|
||||||
dispatch.owner_scope_id,
|
dispatch.owner_scope_id,
|
||||||
&metadata_json,
|
&emitted.metadata_json,
|
||||||
dispatch.last_broadcast_metadata,
|
dispatch.last_broadcast_metadata,
|
||||||
dispatch.settings_store,
|
dispatch.settings_store,
|
||||||
)
|
)
|
||||||
@@ -2569,8 +2555,8 @@ impl WasmChannel {
|
|||||||
// Send to stream — no locks held across this await
|
// Send to stream — no locks held across this await
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %dispatch.channel_name,
|
channel = %dispatch.channel_name,
|
||||||
user_id = %user_id,
|
user_id = %emitted.user_id,
|
||||||
content_len = content.len(),
|
content_len = emitted.content.len(),
|
||||||
attachment_count = msg.attachments.len(),
|
attachment_count = msg.attachments.len(),
|
||||||
"Sending polled message to agent"
|
"Sending polled message to agent"
|
||||||
);
|
);
|
||||||
@@ -2595,7 +2581,6 @@ impl WasmChannel {
|
|||||||
|
|
||||||
struct EmitDispatchContext<'a> {
|
struct EmitDispatchContext<'a> {
|
||||||
channel_name: &'a str,
|
channel_name: &'a str,
|
||||||
capabilities: &'a ChannelCapabilities,
|
|
||||||
owner_scope_id: &'a str,
|
owner_scope_id: &'a str,
|
||||||
owner_actor_id: Option<&'a str>,
|
owner_actor_id: Option<&'a str>,
|
||||||
message_tx: &'a RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
message_tx: &'a RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||||
@@ -3272,38 +3257,6 @@ async fn resolve_channel_host_credentials(
|
|||||||
/// Maximum total attachment size (50 MB).
|
/// Maximum total attachment size (50 MB).
|
||||||
const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 50 * 1024 * 1024;
|
const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 50 * 1024 * 1024;
|
||||||
|
|
||||||
async fn convert_emitted_attachments(
|
|
||||||
channel_name: &str,
|
|
||||||
capabilities: &ChannelCapabilities,
|
|
||||||
attachments: Vec<crate::channels::wasm::host::Attachment>,
|
|
||||||
) -> Vec<crate::channels::IncomingAttachment> {
|
|
||||||
let mut hydrated = attachments;
|
|
||||||
for attachment in &mut hydrated {
|
|
||||||
crate::channels::wasm::attachment_hydration::hydrate_attachment_for_channel(
|
|
||||||
channel_name,
|
|
||||||
capabilities,
|
|
||||||
attachment,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrated
|
|
||||||
.into_iter()
|
|
||||||
.map(|attachment| crate::channels::IncomingAttachment {
|
|
||||||
id: attachment.id,
|
|
||||||
kind: crate::channels::AttachmentKind::from_mime_type(&attachment.mime_type),
|
|
||||||
mime_type: attachment.mime_type,
|
|
||||||
filename: attachment.filename,
|
|
||||||
size_bytes: attachment.size_bytes,
|
|
||||||
source_url: attachment.source_url,
|
|
||||||
storage_key: attachment.storage_key,
|
|
||||||
extracted_text: attachment.extracted_text,
|
|
||||||
data: attachment.data,
|
|
||||||
duration_secs: attachment.duration_secs,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detect MIME type from file extension using the `mime_guess` crate.
|
/// Detect MIME type from file extension using the `mime_guess` crate.
|
||||||
fn mime_from_extension(path: &str) -> String {
|
fn mime_from_extension(path: &str) -> String {
|
||||||
mime_guess::from_path(path)
|
mime_guess::from_path(path)
|
||||||
@@ -3516,8 +3469,6 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||||
let capabilities =
|
|
||||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
|
|
||||||
|
|
||||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||||
@@ -3534,7 +3485,6 @@ mod tests {
|
|||||||
let result = WasmChannel::dispatch_emitted_messages(
|
let result = WasmChannel::dispatch_emitted_messages(
|
||||||
EmitDispatchContext {
|
EmitDispatchContext {
|
||||||
channel_name: "test-channel",
|
channel_name: "test-channel",
|
||||||
capabilities: &capabilities,
|
|
||||||
owner_scope_id: "default",
|
owner_scope_id: "default",
|
||||||
owner_actor_id: None,
|
owner_actor_id: None,
|
||||||
message_tx: &message_tx,
|
message_tx: &message_tx,
|
||||||
@@ -3567,8 +3517,6 @@ mod tests {
|
|||||||
|
|
||||||
// No sender available (channel not started)
|
// No sender available (channel not started)
|
||||||
let message_tx = Arc::new(tokio::sync::RwLock::new(None));
|
let message_tx = Arc::new(tokio::sync::RwLock::new(None));
|
||||||
let capabilities =
|
|
||||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
|
|
||||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||||
@@ -3582,7 +3530,6 @@ mod tests {
|
|||||||
let result = WasmChannel::dispatch_emitted_messages(
|
let result = WasmChannel::dispatch_emitted_messages(
|
||||||
EmitDispatchContext {
|
EmitDispatchContext {
|
||||||
channel_name: "test-channel",
|
channel_name: "test-channel",
|
||||||
capabilities: &capabilities,
|
|
||||||
owner_scope_id: "default",
|
owner_scope_id: "default",
|
||||||
owner_actor_id: None,
|
owner_actor_id: None,
|
||||||
message_tx: &message_tx,
|
message_tx: &message_tx,
|
||||||
@@ -4573,8 +4520,6 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||||
let capabilities =
|
|
||||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
|
|
||||||
|
|
||||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||||
@@ -4591,7 +4536,6 @@ mod tests {
|
|||||||
source_url: Some("https://api.telegram.org/file/photo123".to_string()),
|
source_url: Some("https://api.telegram.org/file/photo123".to_string()),
|
||||||
storage_key: None,
|
storage_key: None,
|
||||||
extracted_text: None,
|
extracted_text: None,
|
||||||
extras_json: String::new(),
|
|
||||||
data: Vec::new(),
|
data: Vec::new(),
|
||||||
duration_secs: None,
|
duration_secs: None,
|
||||||
},
|
},
|
||||||
@@ -4603,7 +4547,6 @@ mod tests {
|
|||||||
source_url: None,
|
source_url: None,
|
||||||
storage_key: Some("store/doc456".to_string()),
|
storage_key: Some("store/doc456".to_string()),
|
||||||
extracted_text: Some("Report contents...".to_string()),
|
extracted_text: Some("Report contents...".to_string()),
|
||||||
extras_json: String::new(),
|
|
||||||
data: Vec::new(),
|
data: Vec::new(),
|
||||||
duration_secs: None,
|
duration_secs: None,
|
||||||
},
|
},
|
||||||
@@ -4616,7 +4559,6 @@ mod tests {
|
|||||||
let result = WasmChannel::dispatch_emitted_messages(
|
let result = WasmChannel::dispatch_emitted_messages(
|
||||||
EmitDispatchContext {
|
EmitDispatchContext {
|
||||||
channel_name: "test-channel",
|
channel_name: "test-channel",
|
||||||
capabilities: &capabilities,
|
|
||||||
owner_scope_id: "default",
|
owner_scope_id: "default",
|
||||||
owner_actor_id: None,
|
owner_actor_id: None,
|
||||||
message_tx: &message_tx,
|
message_tx: &message_tx,
|
||||||
@@ -4663,8 +4605,6 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||||
let capabilities =
|
|
||||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("telegram");
|
|
||||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||||
@@ -4680,7 +4620,6 @@ mod tests {
|
|||||||
let result = WasmChannel::dispatch_emitted_messages(
|
let result = WasmChannel::dispatch_emitted_messages(
|
||||||
EmitDispatchContext {
|
EmitDispatchContext {
|
||||||
channel_name: "telegram",
|
channel_name: "telegram",
|
||||||
capabilities: &capabilities,
|
|
||||||
owner_scope_id: "owner-scope",
|
owner_scope_id: "owner-scope",
|
||||||
owner_actor_id: Some("telegram-owner"),
|
owner_actor_id: Some("telegram-owner"),
|
||||||
message_tx: &message_tx,
|
message_tx: &message_tx,
|
||||||
@@ -4709,8 +4648,6 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||||
let capabilities =
|
|
||||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("telegram");
|
|
||||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||||
@@ -4725,7 +4662,6 @@ mod tests {
|
|||||||
let result = WasmChannel::dispatch_emitted_messages(
|
let result = WasmChannel::dispatch_emitted_messages(
|
||||||
EmitDispatchContext {
|
EmitDispatchContext {
|
||||||
channel_name: "telegram",
|
channel_name: "telegram",
|
||||||
capabilities: &capabilities,
|
|
||||||
owner_scope_id: "owner-scope",
|
owner_scope_id: "owner-scope",
|
||||||
owner_actor_id: Some("telegram-owner"),
|
owner_actor_id: Some("telegram-owner"),
|
||||||
message_tx: &message_tx,
|
message_tx: &message_tx,
|
||||||
@@ -4795,8 +4731,6 @@ mod tests {
|
|||||||
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||||
let capabilities =
|
|
||||||
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
|
|
||||||
|
|
||||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||||
@@ -4810,7 +4744,6 @@ mod tests {
|
|||||||
let result = WasmChannel::dispatch_emitted_messages(
|
let result = WasmChannel::dispatch_emitted_messages(
|
||||||
EmitDispatchContext {
|
EmitDispatchContext {
|
||||||
channel_name: "test-channel",
|
channel_name: "test-channel",
|
||||||
capabilities: &capabilities,
|
|
||||||
owner_scope_id: "default",
|
owner_scope_id: "default",
|
||||||
owner_actor_id: None,
|
owner_actor_id: None,
|
||||||
message_tx: &message_tx,
|
message_tx: &message_tx,
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ pub async fn extensions_list_handler(
|
|||||||
&ext,
|
&ext,
|
||||||
has_paired,
|
has_paired,
|
||||||
owner_bound_channels.contains(&ext.name),
|
owner_bound_channels.contains(&ext.name),
|
||||||
ext.requires_binding,
|
|
||||||
)
|
)
|
||||||
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
|
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
|
||||||
Some(if ext.active {
|
Some(if ext.active {
|
||||||
|
|||||||
@@ -54,37 +54,10 @@ fn validate_webhook_secret(
|
|||||||
///
|
///
|
||||||
/// This endpoint is **public** (no gateway auth token required) but protected
|
/// This endpoint is **public** (no gateway auth token required) but protected
|
||||||
/// by the per-routine webhook secret sent via the `X-Webhook-Secret` header.
|
/// by the per-routine webhook secret sent via the `X-Webhook-Secret` header.
|
||||||
///
|
|
||||||
/// **Single-user/backward-compatible**: looks up routines by path across all
|
|
||||||
/// users. For multi-tenant isolation, use the user-scoped endpoint at
|
|
||||||
/// `/api/webhooks/u/{user_id}/{path}` instead.
|
|
||||||
pub async fn webhook_trigger_handler(
|
pub async fn webhook_trigger_handler(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Path(path): Path<String>,
|
Path(path): Path<String>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
fire_webhook_inner(state, &path, None, &headers).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle incoming webhook POST to `/api/webhooks/u/{user_id}/{path}`.
|
|
||||||
///
|
|
||||||
/// User-scoped variant for multi-tenant deployments. The `user_id` in the URL
|
|
||||||
/// restricts the routine lookup to that user only, preventing cross-user
|
|
||||||
/// webhook triggering even when paths collide.
|
|
||||||
pub async fn webhook_trigger_user_scoped_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
Path((user_id, path)): Path<(String, String)>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
fire_webhook_inner(state, &path, Some(&user_id), &headers).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared webhook logic for both scoped and unscoped endpoints.
|
|
||||||
async fn fire_webhook_inner(
|
|
||||||
state: Arc<GatewayState>,
|
|
||||||
path: &str,
|
|
||||||
user_id: Option<&str>,
|
|
||||||
headers: &HeaderMap,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
// Rate limit check
|
// Rate limit check
|
||||||
if !state.webhook_rate_limiter.check() {
|
if !state.webhook_rate_limiter.check() {
|
||||||
@@ -99,9 +72,9 @@ async fn fire_webhook_inner(
|
|||||||
"Database not available".to_string(),
|
"Database not available".to_string(),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
// Targeted query — when user_id is provided, restrict to that user's routines
|
// Targeted query instead of loading all routines
|
||||||
let routine = store
|
let routine = store
|
||||||
.get_webhook_routine_by_path(path, user_id)
|
.get_webhook_routine_by_path(&path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((
|
.ok_or((
|
||||||
@@ -126,7 +99,7 @@ async fn fire_webhook_inner(
|
|||||||
))?
|
))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let run_id = engine.fire_webhook(routine.id, path).await.map_err(|e| {
|
let run_id = engine.fire_webhook(routine.id, &path).await.map_err(|e| {
|
||||||
let status = match &e {
|
let status = match &e {
|
||||||
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
|
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||||
crate::error::RoutineError::Disabled { .. }
|
crate::error::RoutineError::Disabled { .. }
|
||||||
|
|||||||
+9
-969
File diff suppressed because it is too large
Load Diff
+15
-234
@@ -3056,17 +3056,16 @@ function showConfigureModal(name) {
|
|||||||
.then((setup) => {
|
.then((setup) => {
|
||||||
const secrets = Array.isArray(setup.secrets) ? setup.secrets : [];
|
const secrets = Array.isArray(setup.secrets) ? setup.secrets : [];
|
||||||
const setupFields = Array.isArray(setup.fields) ? setup.fields : [];
|
const setupFields = Array.isArray(setup.fields) ? setup.fields : [];
|
||||||
const interactiveLogin = setup.interactive_login || null;
|
if (secrets.length === 0 && setupFields.length === 0) {
|
||||||
if (secrets.length === 0 && setupFields.length === 0 && !interactiveLogin) {
|
showToast('No configuration needed for ' + name, 'info');
|
||||||
showToast(I18n.t('extensions.noConfigNeeded', { name: name }), 'info');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderConfigureModal(name, secrets, setupFields, interactiveLogin);
|
renderConfigureModal(name, secrets, setupFields);
|
||||||
})
|
})
|
||||||
.catch((err) => showToast(I18n.t('error.loadFailed', { message: err.message }), 'error'));
|
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
|
function renderConfigureModal(name, secrets, setupFields) {
|
||||||
closeConfigureModal();
|
closeConfigureModal();
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'configure-overlay';
|
overlay.className = 'configure-overlay';
|
||||||
@@ -3092,13 +3091,6 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
|
|||||||
modal.appendChild(hint);
|
modal.appendChild(hint);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interactiveLogin) {
|
|
||||||
const hint = document.createElement('div');
|
|
||||||
hint.className = 'configure-hint';
|
|
||||||
hint.textContent = interactiveLoginHintText(name, interactiveLogin);
|
|
||||||
modal.appendChild(hint);
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.createElement('div');
|
const form = document.createElement('div');
|
||||||
form.className = 'configure-form';
|
form.className = 'configure-form';
|
||||||
|
|
||||||
@@ -3188,13 +3180,7 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
|
|||||||
fields.push({ kind: 'field', name: setupField.name, input: input });
|
fields.push({ kind: 'field', name: setupField.name, input: input });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.length > 0) {
|
modal.appendChild(form);
|
||||||
modal.appendChild(form);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interactiveLogin) {
|
|
||||||
modal.appendChild(renderInteractiveLoginPanel(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = document.createElement('div');
|
const error = document.createElement('div');
|
||||||
error.className = 'configure-inline-error';
|
error.className = 'configure-inline-error';
|
||||||
@@ -3209,23 +3195,11 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
|
|||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'configure-actions';
|
actions.className = 'configure-actions';
|
||||||
|
|
||||||
if (fields.length > 0) {
|
const submitBtn = document.createElement('button');
|
||||||
const submitBtn = document.createElement('button');
|
submitBtn.className = 'btn-ext activate';
|
||||||
submitBtn.className = 'btn-ext activate';
|
submitBtn.textContent = I18n.t('config.save');
|
||||||
submitBtn.textContent = I18n.t('config.save');
|
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
||||||
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
actions.appendChild(submitBtn);
|
||||||
actions.appendChild(submitBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interactiveLogin) {
|
|
||||||
const loginBtn = document.createElement('button');
|
|
||||||
loginBtn.className = 'btn-ext activate';
|
|
||||||
loginBtn.dataset.defaultLabel = interactiveLoginDefaultLabel(name, interactiveLogin);
|
|
||||||
loginBtn.textContent = loginBtn.dataset.defaultLabel;
|
|
||||||
loginBtn.dataset.interactiveLogin = 'true';
|
|
||||||
loginBtn.addEventListener('click', () => startInteractiveLogin(name, overlay));
|
|
||||||
actions.appendChild(loginBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelBtn = document.createElement('button');
|
const cancelBtn = document.createElement('button');
|
||||||
cancelBtn.className = 'btn-ext remove';
|
cancelBtn.className = 'btn-ext remove';
|
||||||
@@ -3237,200 +3211,7 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
|
|||||||
overlay.appendChild(modal);
|
overlay.appendChild(modal);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
if (fields.length > 0) {
|
if (fields.length > 0) fields[0].input.focus();
|
||||||
fields[0].input.focus();
|
|
||||||
} else {
|
|
||||||
const loginBtn = overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
|
|
||||||
if (loginBtn) loginBtn.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInteractiveLoginPanel(name) {
|
|
||||||
const panel = document.createElement('div');
|
|
||||||
panel.className = 'configure-qr-login';
|
|
||||||
panel.style.display = 'none';
|
|
||||||
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.className = 'configure-verification-title';
|
|
||||||
title.textContent =
|
|
||||||
name === 'wechat' ? I18n.t('config.wechatQrTitle') : I18n.t('auth.connect');
|
|
||||||
panel.appendChild(title);
|
|
||||||
|
|
||||||
const status = document.createElement('div');
|
|
||||||
status.className = 'configure-verification-instructions';
|
|
||||||
status.textContent = interactiveLoginStatusText(name, null);
|
|
||||||
status.dataset.qrStatus = 'true';
|
|
||||||
panel.appendChild(status);
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.className = 'configure-verification-link';
|
|
||||||
link.textContent =
|
|
||||||
name === 'wechat' ? I18n.t('config.wechatQrOpen') : I18n.t('auth.connect');
|
|
||||||
link.target = '_blank';
|
|
||||||
link.rel = 'noreferrer noopener';
|
|
||||||
link.style.display = 'none';
|
|
||||||
link.dataset.qrLink = 'true';
|
|
||||||
panel.appendChild(link);
|
|
||||||
|
|
||||||
return panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function interactiveLoginHintText(name, interactiveLogin) {
|
|
||||||
if (name === 'wechat') return I18n.t('config.wechatHint');
|
|
||||||
return (interactiveLogin && interactiveLogin.instructions) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function interactiveLoginDefaultLabel(name, interactiveLogin) {
|
|
||||||
if (name === 'wechat') return I18n.t('config.wechatConnect');
|
|
||||||
return (interactiveLogin && interactiveLogin.button_label) || I18n.t('auth.connect');
|
|
||||||
}
|
|
||||||
|
|
||||||
function interactiveLoginWaitingLabel(name) {
|
|
||||||
if (name === 'wechat') return I18n.t('config.wechatWaiting');
|
|
||||||
return I18n.t('status.connecting');
|
|
||||||
}
|
|
||||||
|
|
||||||
function interactiveLoginStatusText(name, res) {
|
|
||||||
if (name !== 'wechat') return (res && res.message) || '';
|
|
||||||
if (!res) return I18n.t('config.wechatQrIntro');
|
|
||||||
|
|
||||||
switch (res.status) {
|
|
||||||
case 'pending':
|
|
||||||
return res.qr_code_url ? I18n.t('config.wechatQrReady') : I18n.t('config.wechatQrWaiting');
|
|
||||||
case 'scanned':
|
|
||||||
return I18n.t('config.wechatQrScanned');
|
|
||||||
case 'refreshed':
|
|
||||||
return I18n.t('config.wechatQrRefreshed');
|
|
||||||
case 'succeeded':
|
|
||||||
return I18n.t('config.wechatConnected');
|
|
||||||
case 'failed':
|
|
||||||
return res.message || I18n.t('config.wechatQrFailed');
|
|
||||||
default:
|
|
||||||
return res.message || I18n.t('config.wechatQrIntro');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInteractiveLoginButton(overlay) {
|
|
||||||
return overlay && overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInteractiveLoginPanel(overlay) {
|
|
||||||
return overlay && overlay.querySelector('.configure-qr-login');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateInteractiveLoginPanel(overlay, res) {
|
|
||||||
const panel = getInteractiveLoginPanel(overlay);
|
|
||||||
if (!panel) return;
|
|
||||||
const name = overlay && overlay.dataset ? overlay.dataset.extensionName : '';
|
|
||||||
const status = panel.querySelector('[data-qr-status="true"]');
|
|
||||||
const link = panel.querySelector('[data-qr-link="true"]');
|
|
||||||
|
|
||||||
panel.style.display = '';
|
|
||||||
if (status) {
|
|
||||||
if (name === 'wechat' && res.status === 'refreshed') {
|
|
||||||
status.textContent = I18n.t('config.wechatQrRefreshedHint');
|
|
||||||
} else {
|
|
||||||
status.textContent = interactiveLoginStatusText(name, res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (link && res.qr_code_url) {
|
|
||||||
link.href = res.qr_code_url;
|
|
||||||
link.style.display = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setInteractiveLoginBusy(overlay, busy, label) {
|
|
||||||
const loginBtn = getInteractiveLoginButton(overlay);
|
|
||||||
if (!loginBtn) return;
|
|
||||||
loginBtn.disabled = !!busy;
|
|
||||||
loginBtn.textContent = label || loginBtn.dataset.defaultLabel || I18n.t('auth.connect');
|
|
||||||
}
|
|
||||||
|
|
||||||
function startInteractiveLogin(name, overlay) {
|
|
||||||
if (!overlay || !document.body.contains(overlay)) return;
|
|
||||||
clearConfigureInlineError(overlay);
|
|
||||||
setConfigureInlineStatus(
|
|
||||||
overlay,
|
|
||||||
name === 'wechat' ? I18n.t('config.wechatPreparingQr') : I18n.t('status.connecting'),
|
|
||||||
);
|
|
||||||
setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name));
|
|
||||||
|
|
||||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/start', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { force: true },
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (!overlay || !document.body.contains(overlay)) return;
|
|
||||||
if (!res.success || !res.session_id) {
|
|
||||||
setInteractiveLoginBusy(overlay, false);
|
|
||||||
setConfigureInlineError(
|
|
||||||
overlay,
|
|
||||||
res.message || I18n.t('config.interactiveLoginStartFailed'),
|
|
||||||
);
|
|
||||||
setConfigureInlineStatus(overlay, '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
overlay.dataset.interactiveLoginSessionId = res.session_id;
|
|
||||||
updateInteractiveLoginPanel(overlay, res);
|
|
||||||
setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res));
|
|
||||||
pollInteractiveLogin(name, overlay, res.session_id);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!overlay || !document.body.contains(overlay)) return;
|
|
||||||
setInteractiveLoginBusy(overlay, false);
|
|
||||||
setConfigureInlineError(
|
|
||||||
overlay,
|
|
||||||
err.message || I18n.t('config.interactiveLoginStartFailed'),
|
|
||||||
);
|
|
||||||
setConfigureInlineStatus(overlay, '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function pollInteractiveLogin(name, overlay, sessionId) {
|
|
||||||
if (!overlay || !document.body.contains(overlay)) return;
|
|
||||||
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
|
|
||||||
|
|
||||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/poll', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { session_id: sessionId },
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (!overlay || !document.body.contains(overlay)) return;
|
|
||||||
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
|
|
||||||
|
|
||||||
updateInteractiveLoginPanel(overlay, res);
|
|
||||||
setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res));
|
|
||||||
|
|
||||||
if (res.status === 'pending' || res.status === 'scanned' || res.status === 'refreshed') {
|
|
||||||
if (res.status === 'refreshed') {
|
|
||||||
setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name));
|
|
||||||
}
|
|
||||||
window.setTimeout(function() {
|
|
||||||
pollInteractiveLogin(name, overlay, sessionId);
|
|
||||||
}, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.success && res.activated) {
|
|
||||||
closeConfigureModal(name);
|
|
||||||
showToast(res.message || I18n.t('config.connectedSuccess', { name: name }), 'success');
|
|
||||||
refreshCurrentSettingsTab();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInteractiveLoginBusy(overlay, false);
|
|
||||||
setConfigureInlineError(overlay, res.message || I18n.t('config.interactiveLoginFailed'));
|
|
||||||
setConfigureInlineStatus(overlay, '');
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!overlay || !document.body.contains(overlay)) return;
|
|
||||||
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
|
|
||||||
setInteractiveLoginBusy(overlay, false);
|
|
||||||
setConfigureInlineError(overlay, err.message || I18n.t('config.interactiveLoginFailed'));
|
|
||||||
setConfigureInlineStatus(overlay, '');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTelegramVerificationChallenge(overlay, verification) {
|
function renderTelegramVerificationChallenge(overlay, verification) {
|
||||||
@@ -3736,9 +3517,9 @@ function renderWasmChannelStepper(ext) {
|
|||||||
var status = ext.activation_status || 'installed';
|
var status = ext.activation_status || 'installed';
|
||||||
|
|
||||||
var steps = [
|
var steps = [
|
||||||
{ label: I18n.t('status.installed'), key: 'installed' },
|
{ label: 'Installed', key: 'installed' },
|
||||||
{ label: I18n.t('status.configured'), key: 'configured' },
|
{ label: 'Configured', key: 'configured' },
|
||||||
{ label: status === 'pairing' ? I18n.t('status.pairingShort') : I18n.t('status.active'), key: 'active' },
|
{ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' },
|
||||||
];
|
];
|
||||||
|
|
||||||
var reachedIdx;
|
var reachedIdx;
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ I18n.register('en', {
|
|||||||
'status.restart': 'Restart',
|
'status.restart': 'Restart',
|
||||||
'status.active': 'Active',
|
'status.active': 'Active',
|
||||||
'status.installed': 'Installed',
|
'status.installed': 'Installed',
|
||||||
'status.configured': 'Configured',
|
|
||||||
'status.awaitingPairing': 'Awaiting Pairing',
|
'status.awaitingPairing': 'Awaiting Pairing',
|
||||||
'status.pairingShort': 'Pairing',
|
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
'dashboard.connections': 'Connections',
|
'dashboard.connections': 'Connections',
|
||||||
@@ -361,23 +359,6 @@ I18n.register('en', {
|
|||||||
'config.telegramStartOver': 'Start over',
|
'config.telegramStartOver': 'Start over',
|
||||||
'config.telegramStartOverHint': 'Telegram verification did not complete. Click Start over to generate a new code and try again.',
|
'config.telegramStartOverHint': 'Telegram verification did not complete. Click Start over to generate a new code and try again.',
|
||||||
'config.telegramOpenBot': 'Open bot in Telegram',
|
'config.telegramOpenBot': 'Open bot in Telegram',
|
||||||
'config.wechatHint': 'Open the WeChat QR page in a new tab, then scan and confirm in WeChat.',
|
|
||||||
'config.wechatConnect': 'Open QR Page',
|
|
||||||
'config.wechatWaiting': 'Waiting for scan...',
|
|
||||||
'config.wechatPreparingQr': 'Preparing WeChat QR page...',
|
|
||||||
'config.wechatQrTitle': 'Open WeChat QR Page',
|
|
||||||
'config.wechatQrOpen': 'Open QR Page',
|
|
||||||
'config.wechatQrIntro': 'The QR flow opens in a separate tab.',
|
|
||||||
'config.wechatQrReady': 'QR page is ready. Open it in a new tab, then scan and confirm in WeChat.',
|
|
||||||
'config.wechatQrWaiting': 'Preparing the WeChat QR page...',
|
|
||||||
'config.wechatQrScanned': 'QR scanned. Confirm the login in WeChat.',
|
|
||||||
'config.wechatQrRefreshed': 'QR page refreshed.',
|
|
||||||
'config.wechatQrRefreshedHint': 'The previous QR page expired. Open the new page and scan again.',
|
|
||||||
'config.wechatConnected': 'WeChat connected.',
|
|
||||||
'config.wechatQrFailed': 'WeChat connection failed.',
|
|
||||||
'config.interactiveLoginStartFailed': 'Failed to start interactive login',
|
|
||||||
'config.interactiveLoginFailed': 'Interactive login failed',
|
|
||||||
'config.connectedSuccess': '{name} connected successfully',
|
|
||||||
'config.optional': ' (optional)',
|
'config.optional': ' (optional)',
|
||||||
'config.alreadySet': '(already set — leave empty to keep)',
|
'config.alreadySet': '(already set — leave empty to keep)',
|
||||||
'config.alreadyConfigured': 'Already configured',
|
'config.alreadyConfigured': 'Already configured',
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ I18n.register('zh-CN', {
|
|||||||
'status.restart': '重启',
|
'status.restart': '重启',
|
||||||
'status.active': '已激活',
|
'status.active': '已激活',
|
||||||
'status.installed': '已安装',
|
'status.installed': '已安装',
|
||||||
'status.configured': '已配置',
|
|
||||||
'status.awaitingPairing': '等待配对',
|
'status.awaitingPairing': '等待配对',
|
||||||
'status.pairingShort': '配对中',
|
|
||||||
|
|
||||||
// 仪表盘
|
// 仪表盘
|
||||||
'dashboard.connections': '连接数',
|
'dashboard.connections': '连接数',
|
||||||
@@ -360,24 +358,6 @@ I18n.register('zh-CN', {
|
|||||||
'config.telegramCommandLabel': '请在 Telegram 中发送:',
|
'config.telegramCommandLabel': '请在 Telegram 中发送:',
|
||||||
'config.telegramStartOver': '重新开始',
|
'config.telegramStartOver': '重新开始',
|
||||||
'config.telegramStartOverHint': 'Telegram 验证未完成。点击“重新开始”以生成新的验证码并重试。',
|
'config.telegramStartOverHint': 'Telegram 验证未完成。点击“重新开始”以生成新的验证码并重试。',
|
||||||
'config.telegramOpenBot': '在 Telegram 中打开机器人',
|
|
||||||
'config.wechatHint': '在新标签页打开微信扫码页,然后在微信里扫码并确认。',
|
|
||||||
'config.wechatConnect': '打开扫码页',
|
|
||||||
'config.wechatWaiting': '等待扫码中...',
|
|
||||||
'config.wechatPreparingQr': '正在准备微信扫码页...',
|
|
||||||
'config.wechatQrTitle': '打开微信扫码页',
|
|
||||||
'config.wechatQrOpen': '打开扫码页',
|
|
||||||
'config.wechatQrIntro': '扫码流程会在新标签页中打开。',
|
|
||||||
'config.wechatQrReady': '扫码页已就绪。请在新标签页打开后,用微信扫码并确认。',
|
|
||||||
'config.wechatQrWaiting': '正在准备微信扫码页...',
|
|
||||||
'config.wechatQrScanned': '已扫码,请在微信中确认登录。',
|
|
||||||
'config.wechatQrRefreshed': '扫码页已刷新。',
|
|
||||||
'config.wechatQrRefreshedHint': '之前的扫码页已过期,请打开新页面重新扫码。',
|
|
||||||
'config.wechatConnected': '微信已连接。',
|
|
||||||
'config.wechatQrFailed': '微信连接失败。',
|
|
||||||
'config.interactiveLoginStartFailed': '启动交互式登录失败',
|
|
||||||
'config.interactiveLoginFailed': '交互式登录失败',
|
|
||||||
'config.connectedSuccess': '{name} 连接成功',
|
|
||||||
'config.optional': '(可选)',
|
'config.optional': '(可选)',
|
||||||
'config.alreadySet': '(已设置 — 留空以保持不变)',
|
'config.alreadySet': '(已设置 — 留空以保持不变)',
|
||||||
'config.alreadyConfigured': '已配置',
|
'config.alreadyConfigured': '已配置',
|
||||||
|
|||||||
@@ -2961,18 +2961,15 @@ body {
|
|||||||
/* WASM channel setup stepper */
|
/* WASM channel setup stepper */
|
||||||
.ext-stepper {
|
.ext-stepper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin: 8px 0 4px;
|
margin: 8px 0 4px;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepper-step {
|
.stepper-step {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
min-width: 0;
|
|
||||||
flex: 1 1 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepper-circle {
|
.stepper-circle {
|
||||||
@@ -2989,10 +2986,7 @@ body {
|
|||||||
|
|
||||||
.stepper-label {
|
.stepper-label {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
white-space: normal;
|
white-space: nowrap;
|
||||||
overflow-wrap: anywhere;
|
|
||||||
line-height: 1.25;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepper-step.completed .stepper-circle {
|
.stepper-step.completed .stepper-circle {
|
||||||
@@ -3049,8 +3043,7 @@ body {
|
|||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
flex: 0 0 20px;
|
flex-shrink: 0;
|
||||||
align-self: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepper-connector.completed {
|
.stepper-connector.completed {
|
||||||
@@ -3245,17 +3238,6 @@ body {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.configure-qr-login {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 16px 0 0 0;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.configure-verification-title {
|
.configure-verification-title {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -3280,29 +3262,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.configure-verification-link {
|
.configure-verification-link {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
background: var(--accent-subtle);
|
|
||||||
color: var(--accent, var(--text-link, #4ea3ff));
|
color: var(--accent, var(--text-link, #4ea3ff));
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background var(--transition-fast), transform 150ms var(--ease-spring);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.configure-verification-link:hover {
|
.configure-verification-link:hover {
|
||||||
background: var(--badge-sandbox-bg);
|
text-decoration: underline;
|
||||||
transform: translateY(-1px);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.configure-verification-link:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.configure-inline-error {
|
.configure-inline-error {
|
||||||
|
|||||||
@@ -306,7 +306,6 @@ pub fn classify_wasm_channel_activation(
|
|||||||
ext: &crate::extensions::InstalledExtension,
|
ext: &crate::extensions::InstalledExtension,
|
||||||
has_paired: bool,
|
has_paired: bool,
|
||||||
has_owner_binding: bool,
|
has_owner_binding: bool,
|
||||||
requires_binding: bool,
|
|
||||||
) -> Option<ExtensionActivationStatus> {
|
) -> Option<ExtensionActivationStatus> {
|
||||||
if ext.kind != crate::extensions::ExtensionKind::WasmChannel {
|
if ext.kind != crate::extensions::ExtensionKind::WasmChannel {
|
||||||
return None;
|
return None;
|
||||||
@@ -317,7 +316,7 @@ pub fn classify_wasm_channel_activation(
|
|||||||
} else if !ext.authenticated {
|
} else if !ext.authenticated {
|
||||||
ExtensionActivationStatus::Installed
|
ExtensionActivationStatus::Installed
|
||||||
} else if ext.active {
|
} else if ext.active {
|
||||||
if !requires_binding || has_paired || has_owner_binding {
|
if has_paired || has_owner_binding {
|
||||||
ExtensionActivationStatus::Active
|
ExtensionActivationStatus::Active
|
||||||
} else {
|
} else {
|
||||||
ExtensionActivationStatus::Pairing
|
ExtensionActivationStatus::Pairing
|
||||||
@@ -387,8 +386,6 @@ pub struct ExtensionSetupResponse {
|
|||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub secrets: Vec<SecretFieldInfo>,
|
pub secrets: Vec<SecretFieldInfo>,
|
||||||
pub fields: Vec<SetupFieldInfo>,
|
pub fields: Vec<SetupFieldInfo>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub interactive_login: Option<crate::extensions::InteractiveLoginInfo>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -421,32 +418,6 @@ pub struct ExtensionSetupRequest {
|
|||||||
pub fields: std::collections::HashMap<String, String>,
|
pub fields: std::collections::HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ExtensionInteractiveLoginStartRequest {
|
|
||||||
#[serde(default)]
|
|
||||||
pub force: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct ExtensionInteractiveLoginPollRequest {
|
|
||||||
pub session_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct ExtensionInteractiveLoginResponse {
|
|
||||||
pub success: bool,
|
|
||||||
pub status: String,
|
|
||||||
pub message: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub session_id: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub qr_code_url: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub instructions: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub activated: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ActionResponse {
|
pub struct ActionResponse {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
|
|||||||
+5
-184
@@ -473,8 +473,7 @@ pub struct PendingOAuthFlow {
|
|||||||
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
|
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
|
||||||
/// SSE broadcast manager for notifying the web UI.
|
/// SSE broadcast manager for notifying the web UI.
|
||||||
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
|
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
|
||||||
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
|
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
||||||
/// Kept as `gateway_token` for public API compatibility.
|
|
||||||
pub gateway_token: Option<String>,
|
pub gateway_token: Option<String>,
|
||||||
/// Additional form params for the token exchange request.
|
/// Additional form params for the token exchange request.
|
||||||
/// Used for provider-specific requirements such as RFC 8707 `resource`.
|
/// Used for provider-specific requirements such as RFC 8707 `resource`.
|
||||||
@@ -497,12 +496,6 @@ impl std::fmt::Debug for PendingOAuthFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PendingOAuthFlow {
|
|
||||||
pub fn oauth_proxy_auth_token(&self) -> Option<&str> {
|
|
||||||
self.gateway_token.as_deref()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
|
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
|
||||||
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
|
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
|
||||||
|
|
||||||
@@ -536,22 +529,6 @@ pub fn exchange_proxy_url() -> Option<String> {
|
|||||||
.filter(|url| !url.is_empty())
|
.filter(|url| !url.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the configured OAuth proxy auth token, if any.
|
|
||||||
///
|
|
||||||
/// New hosted infra can inject a dedicated shared proxy secret via
|
|
||||||
/// `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`. Existing hosted instances continue to
|
|
||||||
/// work by falling back to `GATEWAY_AUTH_TOKEN`.
|
|
||||||
pub fn oauth_proxy_auth_token() -> Option<String> {
|
|
||||||
fn normalized_env_value(key: &str) -> Option<String> {
|
|
||||||
crate::config::helpers::env_or_override(key)
|
|
||||||
.map(|value| value.trim().to_string())
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized_env_value("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN")
|
|
||||||
.or_else(|| normalized_env_value("GATEWAY_AUTH_TOKEN"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
||||||
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
@@ -697,8 +674,6 @@ pub fn strip_instance_prefix(state: &str) -> &str {
|
|||||||
|
|
||||||
pub struct ProxyTokenExchangeRequest<'a> {
|
pub struct ProxyTokenExchangeRequest<'a> {
|
||||||
pub proxy_url: &'a str,
|
pub proxy_url: &'a str,
|
||||||
/// OAuth proxy auth token.
|
|
||||||
/// Kept as `gateway_token` for public API compatibility.
|
|
||||||
pub gateway_token: &'a str,
|
pub gateway_token: &'a str,
|
||||||
pub token_url: &'a str,
|
pub token_url: &'a str,
|
||||||
pub client_id: &'a str,
|
pub client_id: &'a str,
|
||||||
@@ -712,8 +687,6 @@ pub struct ProxyTokenExchangeRequest<'a> {
|
|||||||
|
|
||||||
pub struct ProxyRefreshTokenRequest<'a> {
|
pub struct ProxyRefreshTokenRequest<'a> {
|
||||||
pub proxy_url: &'a str,
|
pub proxy_url: &'a str,
|
||||||
/// OAuth proxy auth token.
|
|
||||||
/// Kept as `gateway_token` for public API compatibility.
|
|
||||||
pub gateway_token: &'a str,
|
pub gateway_token: &'a str,
|
||||||
pub token_url: &'a str,
|
pub token_url: &'a str,
|
||||||
pub client_id: &'a str,
|
pub client_id: &'a str,
|
||||||
@@ -756,7 +729,7 @@ fn oauth_token_response_from_json(
|
|||||||
|
|
||||||
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
||||||
///
|
///
|
||||||
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
|
/// Authenticated via the gateway auth token (Bearer header). The caller may
|
||||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
||||||
/// the provider requires it.
|
/// the provider requires it.
|
||||||
///
|
///
|
||||||
@@ -768,7 +741,7 @@ pub async fn exchange_via_proxy(
|
|||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||||
if request.gateway_token.is_empty() {
|
if request.gateway_token.is_empty() {
|
||||||
return Err(OAuthCallbackError::Io(
|
return Err(OAuthCallbackError::Io(
|
||||||
"OAuth proxy auth token is required for proxy token exchange".to_string(),
|
"Gateway auth token is required for proxy token exchange".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
|
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
|
||||||
@@ -823,7 +796,7 @@ pub async fn exchange_via_proxy(
|
|||||||
|
|
||||||
/// Refresh an OAuth access token via the platform's token refresh proxy.
|
/// Refresh an OAuth access token via the platform's token refresh proxy.
|
||||||
///
|
///
|
||||||
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
|
/// Authenticated via the gateway auth token (Bearer header). The caller may
|
||||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
||||||
/// the provider requires it.
|
/// the provider requires it.
|
||||||
pub async fn refresh_token_via_proxy(
|
pub async fn refresh_token_via_proxy(
|
||||||
@@ -831,7 +804,7 @@ pub async fn refresh_token_via_proxy(
|
|||||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||||
if request.gateway_token.is_empty() {
|
if request.gateway_token.is_empty() {
|
||||||
return Err(OAuthCallbackError::Io(
|
return Err(OAuthCallbackError::Io(
|
||||||
"OAuth proxy auth token is required for proxy token refresh".to_string(),
|
"Gateway auth token is required for proxy token refresh".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1037,37 +1010,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EnvVarGuard {
|
|
||||||
key: &'static str,
|
|
||||||
original: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for EnvVarGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
if let Some(ref value) = self.original {
|
|
||||||
std::env::set_var(self.key, value);
|
|
||||||
} else {
|
|
||||||
std::env::remove_var(self.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
|
|
||||||
let original = std::env::var(key).ok();
|
|
||||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
|
||||||
unsafe {
|
|
||||||
if let Some(value) = value {
|
|
||||||
std::env::set_var(key, value);
|
|
||||||
} else {
|
|
||||||
std::env::remove_var(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EnvVarGuard { key, original }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hosted_proxy_client_secret_suppresses_builtin_secret() {
|
fn test_hosted_proxy_client_secret_suppresses_builtin_secret() {
|
||||||
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
|
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
|
||||||
@@ -1088,79 +1030,6 @@ mod tests {
|
|||||||
assert_eq!(result, client_secret);
|
assert_eq!(result, client_secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_exchange_via_proxy_sends_auth_and_form() {
|
|
||||||
let server = MockProxyServer::start().await;
|
|
||||||
let mut extra_token_params = HashMap::new();
|
|
||||||
extra_token_params.insert("resource".to_string(), "https://mcp.notion.com".to_string());
|
|
||||||
|
|
||||||
let response = super::exchange_via_proxy(super::ProxyTokenExchangeRequest {
|
|
||||||
proxy_url: &server.base_url(),
|
|
||||||
gateway_token: "shared-oauth-proxy-secret",
|
|
||||||
code: "auth-code-123",
|
|
||||||
redirect_uri: "https://oauth.example.com/oauth/callback",
|
|
||||||
token_url: "https://oauth2.googleapis.com/token",
|
|
||||||
client_id: TEST_OAUTH_CLIENT_ID,
|
|
||||||
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
|
|
||||||
access_token_field: "access_token",
|
|
||||||
code_verifier: Some("code-verifier-123"),
|
|
||||||
extra_token_params: &extra_token_params,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("proxy exchange succeeds");
|
|
||||||
|
|
||||||
assert_eq!(response.access_token, "proxy-access-token");
|
|
||||||
assert_eq!(
|
|
||||||
response.refresh_token.as_deref(),
|
|
||||||
Some("proxy-refresh-token")
|
|
||||||
);
|
|
||||||
assert_eq!(response.expires_in, Some(7200));
|
|
||||||
|
|
||||||
let requests = server.requests().await;
|
|
||||||
assert_eq!(requests.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0].authorization.as_deref(),
|
|
||||||
Some("Bearer shared-oauth-proxy-secret")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0].form.get("code").map(String::as_str),
|
|
||||||
Some("auth-code-123")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0].form.get("redirect_uri").map(String::as_str),
|
|
||||||
Some("https://oauth.example.com/oauth/callback")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0].form.get("token_url").map(String::as_str),
|
|
||||||
Some("https://oauth2.googleapis.com/token")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0].form.get("client_id").map(String::as_str),
|
|
||||||
Some(TEST_OAUTH_CLIENT_ID)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0].form.get("client_secret").map(String::as_str),
|
|
||||||
Some(TEST_OAUTH_CLIENT_SECRET)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0]
|
|
||||||
.form
|
|
||||||
.get("access_token_field")
|
|
||||||
.map(String::as_str),
|
|
||||||
Some("access_token")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0].form.get("code_verifier").map(String::as_str),
|
|
||||||
Some("code-verifier-123")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0].form.get("resource").map(String::as_str),
|
|
||||||
Some("https://mcp.notion.com")
|
|
||||||
);
|
|
||||||
|
|
||||||
server.shutdown().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_refresh_token_via_proxy_sends_auth_and_form() {
|
async fn test_refresh_token_via_proxy_sends_auth_and_form() {
|
||||||
let server = MockProxyServer::start().await;
|
let server = MockProxyServer::start().await;
|
||||||
@@ -1666,54 +1535,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_oauth_proxy_auth_token_prefers_dedicated_env() {
|
|
||||||
let _guard = lock_env();
|
|
||||||
let _proxy_guard = set_env_var(
|
|
||||||
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
|
|
||||||
Some("shared-proxy-secret"),
|
|
||||||
);
|
|
||||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
|
||||||
Some("shared-proxy-secret")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_oauth_proxy_auth_token_falls_back_to_gateway_token() {
|
|
||||||
let _guard = lock_env();
|
|
||||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
|
||||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
|
||||||
Some("gateway-token")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_oauth_proxy_auth_token_whitespace_dedicated_env_falls_back_to_gateway_token() {
|
|
||||||
let _guard = lock_env();
|
|
||||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", Some(" "));
|
|
||||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
|
|
||||||
Some("gateway-token")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_oauth_proxy_auth_token_returns_none_when_unset() {
|
|
||||||
let _guard = lock_env();
|
|
||||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
|
||||||
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
|
||||||
|
|
||||||
assert_eq!(crate::cli::oauth_defaults::oauth_proxy_auth_token(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_strip_instance_prefix_with_colon() {
|
fn test_strip_instance_prefix_with_colon() {
|
||||||
use crate::cli::oauth_defaults::strip_instance_prefix;
|
use crate::cli::oauth_defaults::strip_instance_prefix;
|
||||||
|
|||||||
+1
-20
@@ -1,6 +1,6 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
|
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
@@ -23,8 +23,6 @@ pub struct AgentConfig {
|
|||||||
pub max_cost_per_day_cents: Option<u64>,
|
pub max_cost_per_day_cents: Option<u64>,
|
||||||
/// Maximum LLM/tool actions per hour. None = unlimited.
|
/// Maximum LLM/tool actions per hour. None = unlimited.
|
||||||
pub max_actions_per_hour: Option<u64>,
|
pub max_actions_per_hour: Option<u64>,
|
||||||
/// Maximum daily LLM spend per user in cents. None = unlimited.
|
|
||||||
pub max_cost_per_user_per_day_cents: Option<u64>,
|
|
||||||
/// Maximum tool-call iterations per agentic loop invocation. Default 50.
|
/// Maximum tool-call iterations per agentic loop invocation. Default 50.
|
||||||
pub max_tool_iterations: usize,
|
pub max_tool_iterations: usize,
|
||||||
/// When true, skip tool approval checks entirely. For benchmarks/CI.
|
/// When true, skip tool approval checks entirely. For benchmarks/CI.
|
||||||
@@ -33,13 +31,6 @@ pub struct AgentConfig {
|
|||||||
pub default_timezone: String,
|
pub default_timezone: String,
|
||||||
/// Maximum tokens per job (0 = unlimited).
|
/// Maximum tokens per job (0 = unlimited).
|
||||||
pub max_tokens_per_job: u64,
|
pub max_tokens_per_job: u64,
|
||||||
/// Whether the deployment is multi-tenant (multiple users sharing one
|
|
||||||
/// instance). Auto-detected from GATEWAY_USER_TOKENS presence.
|
|
||||||
pub multi_tenant: bool,
|
|
||||||
/// Maximum concurrent LLM calls per user. None = use default (4).
|
|
||||||
pub max_llm_concurrent_per_user: Option<usize>,
|
|
||||||
/// Maximum concurrent jobs per user. None = use default (3).
|
|
||||||
pub max_jobs_concurrent_per_user: Option<usize>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentConfig {
|
impl AgentConfig {
|
||||||
@@ -58,14 +49,10 @@ impl AgentConfig {
|
|||||||
allow_local_tools: true,
|
allow_local_tools: true,
|
||||||
max_cost_per_day_cents: None,
|
max_cost_per_day_cents: None,
|
||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_cost_per_user_per_day_cents: None,
|
|
||||||
max_tool_iterations: 10,
|
max_tool_iterations: 10,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_tokens_per_job: 0,
|
max_tokens_per_job: 0,
|
||||||
multi_tenant: false,
|
|
||||||
max_llm_concurrent_per_user: None,
|
|
||||||
max_jobs_concurrent_per_user: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +87,6 @@ impl AgentConfig {
|
|||||||
allow_local_tools: parse_bool_env("ALLOW_LOCAL_TOOLS", false)?,
|
allow_local_tools: parse_bool_env("ALLOW_LOCAL_TOOLS", false)?,
|
||||||
max_cost_per_day_cents: parse_option_env("MAX_COST_PER_DAY_CENTS")?,
|
max_cost_per_day_cents: parse_option_env("MAX_COST_PER_DAY_CENTS")?,
|
||||||
max_actions_per_hour: parse_option_env("MAX_ACTIONS_PER_HOUR")?,
|
max_actions_per_hour: parse_option_env("MAX_ACTIONS_PER_HOUR")?,
|
||||||
max_cost_per_user_per_day_cents: parse_option_env("MAX_COST_PER_USER_PER_DAY_CENTS")?,
|
|
||||||
max_tool_iterations: parse_optional_env(
|
max_tool_iterations: parse_optional_env(
|
||||||
"AGENT_MAX_TOOL_ITERATIONS",
|
"AGENT_MAX_TOOL_ITERATIONS",
|
||||||
settings.agent.max_tool_iterations,
|
settings.agent.max_tool_iterations,
|
||||||
@@ -126,11 +112,6 @@ impl AgentConfig {
|
|||||||
"AGENT_MAX_TOKENS_PER_JOB",
|
"AGENT_MAX_TOKENS_PER_JOB",
|
||||||
settings.agent.max_tokens_per_job,
|
settings.agent.max_tokens_per_job,
|
||||||
)?,
|
)?,
|
||||||
// Auto-detected from GATEWAY_USER_TOKENS presence. Not a separate
|
|
||||||
// knob — multi-tenant mode is always implied by configuring user tokens.
|
|
||||||
multi_tenant: optional_env("GATEWAY_USER_TOKENS")?.is_some(),
|
|
||||||
max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?,
|
|
||||||
max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ pub struct HeartbeatConfig {
|
|||||||
pub quiet_hours_end: Option<u32>,
|
pub quiet_hours_end: Option<u32>,
|
||||||
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
||||||
pub timezone: Option<String>,
|
pub timezone: Option<String>,
|
||||||
/// When true, cycle through all users with routines. Auto-detected from
|
|
||||||
/// GATEWAY_USER_TOKENS or set explicitly via HEARTBEAT_MULTI_TENANT.
|
|
||||||
pub multi_tenant: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HeartbeatConfig {
|
impl Default for HeartbeatConfig {
|
||||||
@@ -37,7 +34,6 @@ impl Default for HeartbeatConfig {
|
|||||||
quiet_hours_start: None,
|
quiet_hours_start: None,
|
||||||
quiet_hours_end: None,
|
quiet_hours_end: None,
|
||||||
timezone: None,
|
timezone: None,
|
||||||
multi_tenant: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,12 +101,6 @@ impl HeartbeatConfig {
|
|||||||
}
|
}
|
||||||
tz
|
tz
|
||||||
},
|
},
|
||||||
// Auto-detect multi-tenant mode from GATEWAY_USER_TOKENS presence,
|
|
||||||
// or allow explicit override via HEARTBEAT_MULTI_TENANT.
|
|
||||||
multi_tenant: parse_bool_env(
|
|
||||||
"HEARTBEAT_MULTI_TENANT",
|
|
||||||
optional_env("GATEWAY_USER_TOKENS")?.is_some(),
|
|
||||||
)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,9 +192,6 @@ pub struct JobContext {
|
|||||||
/// but subsequent tools (e.g., `json`) may need the full output. This
|
/// but subsequent tools (e.g., `json`) may need the full output. This
|
||||||
/// stash stores the complete, unsanitized output so tools can reference
|
/// stash stores the complete, unsanitized output so tools can reference
|
||||||
/// previous results by ID via `$tool_call_id` parameter syntax.
|
/// previous results by ID via `$tool_call_id` parameter syntax.
|
||||||
///
|
|
||||||
/// Also used for cross-tool implicit state (keys prefixed with `__`) such
|
|
||||||
/// as `__routine_last_name` for fallback recovery in routine tool chains.
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
|
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
|
||||||
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
|
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
|
||||||
|
|||||||
@@ -530,24 +530,10 @@ impl RoutineStore for LibSqlBackend {
|
|||||||
async fn get_webhook_routine_by_path(
|
async fn get_webhook_routine_by_path(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
user_id: Option<&str>,
|
|
||||||
) -> Result<Option<Routine>, DatabaseError> {
|
) -> Result<Option<Routine>, DatabaseError> {
|
||||||
let conn = self.connect().await?;
|
let conn = self.connect().await?;
|
||||||
let mut rows = if let Some(uid) = user_id {
|
let mut rows = conn
|
||||||
conn.query(
|
.query(
|
||||||
&format!(
|
|
||||||
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'webhook' \
|
|
||||||
AND user_id = ?2 \
|
|
||||||
AND (json_extract(trigger_config, '$.path') = ?1 \
|
|
||||||
OR (json_extract(trigger_config, '$.path') IS NULL AND CAST(id AS TEXT) = ?1))",
|
|
||||||
ROUTINE_COLUMNS
|
|
||||||
),
|
|
||||||
params![path, uid],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
} else {
|
|
||||||
conn.query(
|
|
||||||
&format!(
|
&format!(
|
||||||
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'webhook' \
|
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'webhook' \
|
||||||
AND (json_extract(trigger_config, '$.path') = ?1 \
|
AND (json_extract(trigger_config, '$.path') = ?1 \
|
||||||
@@ -557,8 +543,7 @@ impl RoutineStore for LibSqlBackend {
|
|||||||
params![path],
|
params![path],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||||
};
|
|
||||||
|
|
||||||
match rows
|
match rows
|
||||||
.next()
|
.next()
|
||||||
|
|||||||
@@ -545,7 +545,6 @@ pub trait RoutineStore: Send + Sync {
|
|||||||
async fn get_webhook_routine_by_path(
|
async fn get_webhook_routine_by_path(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
user_id: Option<&str>,
|
|
||||||
) -> Result<Option<Routine>, DatabaseError>;
|
) -> Result<Option<Routine>, DatabaseError>;
|
||||||
|
|
||||||
/// List routine runs that were dispatched as full_job but have not yet
|
/// List routine runs that were dispatched as full_job but have not yet
|
||||||
|
|||||||
+1
-2
@@ -529,9 +529,8 @@ impl RoutineStore for PgBackend {
|
|||||||
async fn get_webhook_routine_by_path(
|
async fn get_webhook_routine_by_path(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
user_id: Option<&str>,
|
|
||||||
) -> Result<Option<Routine>, DatabaseError> {
|
) -> Result<Option<Routine>, DatabaseError> {
|
||||||
self.store.get_webhook_routine_by_path(path, user_id).await
|
self.store.get_webhook_routine_by_path(path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
|
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
|
||||||
|
|||||||
+53
-922
File diff suppressed because it is too large
Load Diff
+9
-54
@@ -19,7 +19,6 @@
|
|||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub(crate) mod wechat_login;
|
|
||||||
|
|
||||||
pub use discovery::OnlineDiscovery;
|
pub use discovery::OnlineDiscovery;
|
||||||
pub use manager::ExtensionManager;
|
pub use manager::ExtensionManager;
|
||||||
@@ -70,12 +69,12 @@ pub struct RegistryEntry {
|
|||||||
/// Where to get this extension.
|
/// Where to get this extension.
|
||||||
pub source: ExtensionSource,
|
pub source: ExtensionSource,
|
||||||
/// Fallback source when the primary source fails (e.g., download 404 → build from source).
|
/// Fallback source when the primary source fails (e.g., download 404 → build from source).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub fallback_source: Option<Box<ExtensionSource>>,
|
pub fallback_source: Option<Box<ExtensionSource>>,
|
||||||
/// How authentication works.
|
/// How authentication works.
|
||||||
pub auth_hint: AuthHint,
|
pub auth_hint: AuthHint,
|
||||||
/// Extension version (semver), if known.
|
/// Extension version (semver), if known.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,14 +87,17 @@ pub enum ExtensionSource {
|
|||||||
/// Downloadable WASM binary.
|
/// Downloadable WASM binary.
|
||||||
WasmDownload {
|
WasmDownload {
|
||||||
wasm_url: String,
|
wasm_url: String,
|
||||||
|
#[serde(default)]
|
||||||
capabilities_url: Option<String>,
|
capabilities_url: Option<String>,
|
||||||
},
|
},
|
||||||
/// Build from local source directory.
|
/// Build from local source directory.
|
||||||
WasmBuildable {
|
WasmBuildable {
|
||||||
#[serde(alias = "repo_url")]
|
#[serde(alias = "repo_url")]
|
||||||
source_dir: String,
|
source_dir: String,
|
||||||
|
#[serde(default)]
|
||||||
build_dir: Option<String>,
|
build_dir: Option<String>,
|
||||||
/// Crate name used to locate the build artifact binary.
|
/// Crate name used to locate the build artifact binary.
|
||||||
|
#[serde(default)]
|
||||||
crate_name: Option<String>,
|
crate_name: Option<String>,
|
||||||
},
|
},
|
||||||
/// Discovered online (not yet validated for a specific source type).
|
/// Discovered online (not yet validated for a specific source type).
|
||||||
@@ -387,9 +389,13 @@ impl<'de> Deserialize<'de> for AuthResult {
|
|||||||
struct Raw {
|
struct Raw {
|
||||||
name: String,
|
name: String,
|
||||||
kind: ExtensionKind,
|
kind: ExtensionKind,
|
||||||
|
#[serde(default)]
|
||||||
auth_url: Option<String>,
|
auth_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
callback_type: Option<String>,
|
callback_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
instructions: Option<String>,
|
instructions: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
setup_url: Option<String>,
|
setup_url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
awaiting_token: bool,
|
awaiting_token: bool,
|
||||||
@@ -433,52 +439,6 @@ impl<'de> Deserialize<'de> for AuthResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interactive login metadata surfaced to setup UIs.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct InteractiveLoginInfo {
|
|
||||||
/// Login method identifier (for example `qr_code`).
|
|
||||||
pub method: String,
|
|
||||||
/// User-facing button label.
|
|
||||||
pub button_label: String,
|
|
||||||
/// Optional short instructions shown above the login control.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub instructions: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of starting an interactive extension login flow.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct InteractiveLoginStartResult {
|
|
||||||
/// Opaque session identifier used by follow-up poll requests.
|
|
||||||
pub session_id: String,
|
|
||||||
/// Flow status (`pending`, `error`).
|
|
||||||
pub status: String,
|
|
||||||
/// Human-readable message for the UI.
|
|
||||||
pub message: String,
|
|
||||||
/// Optional QR/image URL for browser display.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub qr_code_url: Option<String>,
|
|
||||||
/// Optional short instructions shown alongside the QR code.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub instructions: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of polling an interactive extension login flow.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct InteractiveLoginPollResult {
|
|
||||||
/// Session identifier associated with this poll result.
|
|
||||||
pub session_id: String,
|
|
||||||
/// Flow status (`pending`, `scanned`, `refreshed`, `succeeded`, `failed`).
|
|
||||||
pub status: String,
|
|
||||||
/// Human-readable message for the UI.
|
|
||||||
pub message: String,
|
|
||||||
/// Optional refreshed QR/image URL.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub qr_code_url: Option<String>,
|
|
||||||
/// Whether the extension was successfully activated as part of login completion.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub activated: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of activating an extension.
|
/// Result of activating an extension.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ActivateResult {
|
pub struct ActivateResult {
|
||||||
@@ -546,10 +506,6 @@ pub struct InstalledExtension {
|
|||||||
/// Whether this extension has an auth configuration (OAuth or manual token).
|
/// Whether this extension has an auth configuration (OAuth or manual token).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_auth: bool,
|
pub has_auth: bool,
|
||||||
/// Whether this extension still needs owner binding / pairing before it should
|
|
||||||
/// be treated as fully active in the UI.
|
|
||||||
#[serde(default)]
|
|
||||||
pub requires_binding: bool,
|
|
||||||
/// Whether this extension is installed locally (false = available in registry but not installed).
|
/// Whether this extension is installed locally (false = available in registry but not installed).
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub installed: bool,
|
pub installed: bool,
|
||||||
@@ -1000,7 +956,6 @@ mod tests {
|
|||||||
tools: vec!["send_email".to_string(), "read_inbox".to_string()],
|
tools: vec!["send_email".to_string(), "read_inbox".to_string()],
|
||||||
needs_setup: true,
|
needs_setup: true,
|
||||||
has_auth: true,
|
has_auth: true,
|
||||||
requires_binding: false,
|
|
||||||
installed: false,
|
installed: false,
|
||||||
activation_error: Some("token expired".to_string()),
|
activation_error: Some("token expired".to_string()),
|
||||||
version: None,
|
version: None,
|
||||||
|
|||||||
@@ -1,443 +0,0 @@
|
|||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::extensions::{
|
|
||||||
ExtensionError, InteractiveLoginInfo, InteractiveLoginPollResult, InteractiveLoginStartResult,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(crate) const WECHAT_CHANNEL_NAME: &str = "wechat";
|
|
||||||
pub(crate) const WECHAT_BASE_URL_SETTING_PATH: &str = "extensions.wechat.base_url";
|
|
||||||
pub(crate) const WECHAT_DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
|
|
||||||
pub(crate) const WECHAT_DEFAULT_BOT_TYPE: &str = "3";
|
|
||||||
|
|
||||||
const LOGIN_SESSION_TTL: Duration = Duration::from_secs(5 * 60);
|
|
||||||
const QR_LONG_POLL_TIMEOUT: Duration = Duration::from_secs(35);
|
|
||||||
const QR_FETCH_TIMEOUT: Duration = Duration::from_secs(15);
|
|
||||||
const MAX_QR_REFRESH_COUNT: u8 = 3;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub(crate) struct PendingWechatLogin {
|
|
||||||
pub user_id: String,
|
|
||||||
pub session_id: String,
|
|
||||||
pub qrcode: String,
|
|
||||||
pub qr_code_url: String,
|
|
||||||
pub started_at: Instant,
|
|
||||||
pub base_url: String,
|
|
||||||
pub bot_type: String,
|
|
||||||
pub refresh_count: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PendingWechatLogin {
|
|
||||||
pub fn is_fresh(&self) -> bool {
|
|
||||||
self.started_at.elapsed() < LOGIN_SESSION_TTL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub(crate) struct ConfirmedWechatLogin {
|
|
||||||
pub bot_token: String,
|
|
||||||
pub base_url: Option<String>,
|
|
||||||
pub ilink_bot_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) enum WechatLoginPollOutcome {
|
|
||||||
Pending(InteractiveLoginPollResult),
|
|
||||||
Confirmed(ConfirmedWechatLogin),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
struct QrCodeResponse {
|
|
||||||
qrcode: String,
|
|
||||||
qrcode_img_content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
struct QrStatusResponse {
|
|
||||||
status: String,
|
|
||||||
bot_token: Option<String>,
|
|
||||||
ilink_bot_id: Option<String>,
|
|
||||||
baseurl: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn interactive_login_info() -> InteractiveLoginInfo {
|
|
||||||
InteractiveLoginInfo {
|
|
||||||
method: "qr_code".to_string(),
|
|
||||||
button_label: "Connect WeChat".to_string(),
|
|
||||||
instructions: Some("Scan the QR code with WeChat to connect this channel.".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn purge_expired_logins(
|
|
||||||
sessions: &mut std::collections::HashMap<String, PendingWechatLogin>,
|
|
||||||
) {
|
|
||||||
sessions.retain(|_, session| session.is_fresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn start_login(
|
|
||||||
user_id: &str,
|
|
||||||
base_url: &str,
|
|
||||||
bot_type: &str,
|
|
||||||
) -> Result<(PendingWechatLogin, InteractiveLoginStartResult), ExtensionError> {
|
|
||||||
let qr = fetch_qr_code(base_url, bot_type).await?;
|
|
||||||
Ok(build_pending_login(user_id, base_url, bot_type, qr))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn poll_login(
|
|
||||||
session: &mut PendingWechatLogin,
|
|
||||||
) -> Result<WechatLoginPollOutcome, ExtensionError> {
|
|
||||||
if !session.is_fresh() {
|
|
||||||
return Ok(WechatLoginPollOutcome::Pending(
|
|
||||||
InteractiveLoginPollResult {
|
|
||||||
session_id: session.session_id.clone(),
|
|
||||||
status: "failed".to_string(),
|
|
||||||
message: "The QR code expired. Start a new WeChat connection.".to_string(),
|
|
||||||
qr_code_url: None,
|
|
||||||
activated: Some(false),
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = poll_qr_status(&session.base_url, &session.qrcode).await?;
|
|
||||||
let refreshed_qr = if status.status == "expired" && session.refresh_count < MAX_QR_REFRESH_COUNT
|
|
||||||
{
|
|
||||||
Some(fetch_qr_code(&session.base_url, &session.bot_type).await?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
handle_poll_status(session, status, refreshed_qr)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_pending_login(
|
|
||||||
user_id: &str,
|
|
||||||
base_url: &str,
|
|
||||||
bot_type: &str,
|
|
||||||
qr: QrCodeResponse,
|
|
||||||
) -> (PendingWechatLogin, InteractiveLoginStartResult) {
|
|
||||||
let session_id = Uuid::new_v4().to_string();
|
|
||||||
let session = PendingWechatLogin {
|
|
||||||
user_id: user_id.to_string(),
|
|
||||||
session_id: session_id.clone(),
|
|
||||||
qrcode: qr.qrcode,
|
|
||||||
qr_code_url: qr.qrcode_img_content.clone(),
|
|
||||||
started_at: Instant::now(),
|
|
||||||
base_url: base_url.to_string(),
|
|
||||||
bot_type: bot_type.to_string(),
|
|
||||||
refresh_count: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = InteractiveLoginStartResult {
|
|
||||||
session_id,
|
|
||||||
status: "pending".to_string(),
|
|
||||||
message: "Open the WeChat QR page to continue.".to_string(),
|
|
||||||
qr_code_url: Some(qr.qrcode_img_content),
|
|
||||||
instructions: Some(
|
|
||||||
"Keep this window open while you scan and confirm on your phone.".to_string(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
(session, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_poll_status(
|
|
||||||
session: &mut PendingWechatLogin,
|
|
||||||
status: QrStatusResponse,
|
|
||||||
refreshed_qr: Option<QrCodeResponse>,
|
|
||||||
) -> Result<WechatLoginPollOutcome, ExtensionError> {
|
|
||||||
match status.status.as_str() {
|
|
||||||
"wait" => Ok(WechatLoginPollOutcome::Pending(
|
|
||||||
InteractiveLoginPollResult {
|
|
||||||
session_id: session.session_id.clone(),
|
|
||||||
status: "pending".to_string(),
|
|
||||||
message: "Waiting for the QR code to be scanned.".to_string(),
|
|
||||||
qr_code_url: None,
|
|
||||||
activated: None,
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
"scaned" => Ok(WechatLoginPollOutcome::Pending(
|
|
||||||
InteractiveLoginPollResult {
|
|
||||||
session_id: session.session_id.clone(),
|
|
||||||
status: "scanned".to_string(),
|
|
||||||
message: "QR code scanned. Confirm the login in WeChat.".to_string(),
|
|
||||||
qr_code_url: None,
|
|
||||||
activated: None,
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
"expired" => {
|
|
||||||
session.refresh_count = session.refresh_count.saturating_add(1);
|
|
||||||
if session.refresh_count > MAX_QR_REFRESH_COUNT {
|
|
||||||
return Ok(WechatLoginPollOutcome::Pending(
|
|
||||||
InteractiveLoginPollResult {
|
|
||||||
session_id: session.session_id.clone(),
|
|
||||||
status: "failed".to_string(),
|
|
||||||
message: "The QR code expired too many times. Start again.".to_string(),
|
|
||||||
qr_code_url: None,
|
|
||||||
activated: Some(false),
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let refreshed = refreshed_qr.ok_or_else(|| {
|
|
||||||
ExtensionError::Other(
|
|
||||||
"WeChat QR status expired without a refreshed QR code".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
session.qrcode = refreshed.qrcode;
|
|
||||||
session.qr_code_url = refreshed.qrcode_img_content.clone();
|
|
||||||
session.started_at = Instant::now();
|
|
||||||
|
|
||||||
Ok(WechatLoginPollOutcome::Pending(
|
|
||||||
InteractiveLoginPollResult {
|
|
||||||
session_id: session.session_id.clone(),
|
|
||||||
status: "refreshed".to_string(),
|
|
||||||
message: "The QR code expired, so a fresh one was generated.".to_string(),
|
|
||||||
qr_code_url: Some(refreshed.qrcode_img_content),
|
|
||||||
activated: None,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
"confirmed" => {
|
|
||||||
let bot_token = status.bot_token.filter(|token| !token.trim().is_empty());
|
|
||||||
let ilink_bot_id = status
|
|
||||||
.ilink_bot_id
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ExtensionError::Other(
|
|
||||||
"WeChat login succeeded but no bot account id was returned".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let bot_token = bot_token.ok_or_else(|| {
|
|
||||||
ExtensionError::Other(
|
|
||||||
"WeChat login succeeded but no bot token was returned".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(WechatLoginPollOutcome::Confirmed(ConfirmedWechatLogin {
|
|
||||||
bot_token,
|
|
||||||
base_url: status.baseurl.filter(|value| !value.trim().is_empty()),
|
|
||||||
ilink_bot_id,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
other => {
|
|
||||||
tracing::warn!(status = other, "Unexpected WeChat QR status");
|
|
||||||
Ok(WechatLoginPollOutcome::Pending(
|
|
||||||
InteractiveLoginPollResult {
|
|
||||||
session_id: session.session_id.clone(),
|
|
||||||
status: "failed".to_string(),
|
|
||||||
message: format!("Unexpected WeChat login status: {other}"),
|
|
||||||
qr_code_url: None,
|
|
||||||
activated: Some(false),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_trailing_slash(base_url: &str) -> String {
|
|
||||||
if base_url.ends_with('/') {
|
|
||||||
base_url.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{base_url}/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_qr_code(base_url: &str, bot_type: &str) -> Result<QrCodeResponse, ExtensionError> {
|
|
||||||
let base = ensure_trailing_slash(base_url);
|
|
||||||
let url = format!(
|
|
||||||
"{base}ilink/bot/get_bot_qrcode?bot_type={}",
|
|
||||||
urlencoding::encode(bot_type)
|
|
||||||
);
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(QR_FETCH_TIMEOUT)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| ExtensionError::Other(format!("Failed to create WeChat login client: {e}")))?;
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ExtensionError::Other(format!("Failed to fetch WeChat QR code: {e}")))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
tracing::warn!(status = %status, "WeChat QR code request failed");
|
|
||||||
return Err(ExtensionError::Other(format!(
|
|
||||||
"WeChat QR code request failed with {status}: {body}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
response
|
|
||||||
.json::<QrCodeResponse>()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat QR code response: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn poll_qr_status(base_url: &str, qrcode: &str) -> Result<QrStatusResponse, ExtensionError> {
|
|
||||||
let base = ensure_trailing_slash(base_url);
|
|
||||||
let url = format!(
|
|
||||||
"{base}ilink/bot/get_qrcode_status?qrcode={}",
|
|
||||||
urlencoding::encode(qrcode)
|
|
||||||
);
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(QR_LONG_POLL_TIMEOUT)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| ExtensionError::Other(format!("Failed to create WeChat poll client: {e}")))?;
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(&url)
|
|
||||||
.header("iLink-App-ClientVersion", "1")
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let response = match response {
|
|
||||||
Ok(response) => response,
|
|
||||||
Err(error) if error.is_timeout() => {
|
|
||||||
return Ok(QrStatusResponse {
|
|
||||||
status: "wait".to_string(),
|
|
||||||
bot_token: None,
|
|
||||||
ilink_bot_id: None,
|
|
||||||
baseurl: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
return Err(ExtensionError::Other(format!(
|
|
||||||
"Failed to poll WeChat QR status: {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
|
||||||
tracing::warn!(status = %status, "WeChat QR status poll failed");
|
|
||||||
return Err(ExtensionError::Other(format!(
|
|
||||||
"WeChat QR status poll failed with {status}: {body}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
response
|
|
||||||
.json::<QrStatusResponse>()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat QR status: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
QrCodeResponse, QrStatusResponse, WechatLoginPollOutcome, build_pending_login,
|
|
||||||
handle_poll_status,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_build_pending_login_returns_qr_state_and_result() {
|
|
||||||
let (session, start_result) = build_pending_login(
|
|
||||||
"owner",
|
|
||||||
"https://ilink.example",
|
|
||||||
"3",
|
|
||||||
QrCodeResponse {
|
|
||||||
qrcode: "qr-123".to_string(),
|
|
||||||
qrcode_img_content: "https://qr.example/one".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(session.user_id, "owner");
|
|
||||||
assert_eq!(session.base_url, "https://ilink.example");
|
|
||||||
assert_eq!(session.bot_type, "3");
|
|
||||||
assert_eq!(session.qrcode, "qr-123");
|
|
||||||
assert_eq!(session.qr_code_url, "https://qr.example/one");
|
|
||||||
assert_eq!(start_result.status, "pending");
|
|
||||||
assert_eq!(
|
|
||||||
start_result.qr_code_url.as_deref(),
|
|
||||||
Some("https://qr.example/one")
|
|
||||||
);
|
|
||||||
assert_eq!(start_result.session_id, session.session_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_handle_poll_status_confirms_login() -> Result<(), String> {
|
|
||||||
let (mut session, _) = build_pending_login(
|
|
||||||
"owner",
|
|
||||||
"https://ilink.example",
|
|
||||||
"3",
|
|
||||||
QrCodeResponse {
|
|
||||||
qrcode: "qr-123".to_string(),
|
|
||||||
qrcode_img_content: "https://qr.example/one".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let outcome = handle_poll_status(
|
|
||||||
&mut session,
|
|
||||||
QrStatusResponse {
|
|
||||||
status: "confirmed".to_string(),
|
|
||||||
bot_token: Some("bot-token-123".to_string()),
|
|
||||||
ilink_bot_id: Some("wx-bot-1".to_string()),
|
|
||||||
baseurl: Some("https://override.example".to_string()),
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match outcome {
|
|
||||||
WechatLoginPollOutcome::Confirmed(confirmed) => {
|
|
||||||
assert_eq!(confirmed.bot_token, "bot-token-123");
|
|
||||||
assert_eq!(confirmed.ilink_bot_id, "wx-bot-1");
|
|
||||||
assert_eq!(
|
|
||||||
confirmed.base_url.as_deref(),
|
|
||||||
Some("https://override.example")
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
WechatLoginPollOutcome::Pending(result) => Err(format!(
|
|
||||||
"expected confirmed login, got pending status {}",
|
|
||||||
result.status
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_handle_poll_status_refreshes_expired_qr() -> Result<(), String> {
|
|
||||||
let (mut session, _) = build_pending_login(
|
|
||||||
"owner",
|
|
||||||
"https://ilink.example",
|
|
||||||
"3",
|
|
||||||
QrCodeResponse {
|
|
||||||
qrcode: "qr-initial".to_string(),
|
|
||||||
qrcode_img_content: "https://qr.example/initial".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let outcome = handle_poll_status(
|
|
||||||
&mut session,
|
|
||||||
QrStatusResponse {
|
|
||||||
status: "expired".to_string(),
|
|
||||||
bot_token: None,
|
|
||||||
ilink_bot_id: None,
|
|
||||||
baseurl: None,
|
|
||||||
},
|
|
||||||
Some(QrCodeResponse {
|
|
||||||
qrcode: "qr-refreshed".to_string(),
|
|
||||||
qrcode_img_content: "https://qr.example/refreshed".to_string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match outcome {
|
|
||||||
WechatLoginPollOutcome::Pending(result) => {
|
|
||||||
assert_eq!(result.status, "refreshed");
|
|
||||||
assert_eq!(
|
|
||||||
result.qr_code_url.as_deref(),
|
|
||||||
Some("https://qr.example/refreshed")
|
|
||||||
);
|
|
||||||
assert_eq!(session.qrcode, "qr-refreshed");
|
|
||||||
assert_eq!(session.refresh_count, 1);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
WechatLoginPollOutcome::Confirmed(_) => {
|
|
||||||
Err("expected QR refresh before confirmation".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-13
@@ -1162,25 +1162,15 @@ impl Store {
|
|||||||
pub async fn get_webhook_routine_by_path(
|
pub async fn get_webhook_routine_by_path(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
user_id: Option<&str>,
|
|
||||||
) -> Result<Option<Routine>, DatabaseError> {
|
) -> Result<Option<Routine>, DatabaseError> {
|
||||||
let conn = self.conn().await?;
|
let conn = self.conn().await?;
|
||||||
let row = if let Some(uid) = user_id {
|
let row = conn
|
||||||
conn.query_opt(
|
.query_opt(
|
||||||
"SELECT * FROM routines WHERE enabled AND trigger_type = 'webhook' \
|
|
||||||
AND user_id = $2 \
|
|
||||||
AND (trigger_config->>'path' = $1 OR (trigger_config->>'path' IS NULL AND id::text = $1))",
|
|
||||||
&[&path, &uid],
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
conn.query_opt(
|
|
||||||
"SELECT * FROM routines WHERE enabled AND trigger_type = 'webhook' \
|
"SELECT * FROM routines WHERE enabled AND trigger_type = 'webhook' \
|
||||||
AND (trigger_config->>'path' = $1 OR (trigger_config->>'path' IS NULL AND id::text = $1))",
|
AND (trigger_config->>'path' = $1 OR (trigger_config->>'path' IS NULL AND id::text = $1))",
|
||||||
&[&path],
|
&[&path],
|
||||||
)
|
)
|
||||||
.await?
|
.await?;
|
||||||
};
|
|
||||||
row.as_ref().map(row_to_routine).transpose()
|
row.as_ref().map(row_to_routine).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ pub mod service;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod tenant;
|
|
||||||
pub mod timezone;
|
pub mod timezone;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod tracing_fmt;
|
pub mod tracing_fmt;
|
||||||
|
|||||||
+1
-2
@@ -63,8 +63,7 @@ pub use provider::{
|
|||||||
};
|
};
|
||||||
pub use reasoning::{
|
pub use reasoning::{
|
||||||
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
|
ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN,
|
||||||
TOOL_INTENT_NUDGE, TRUNCATED_TOOL_CALL_NOTICE, TokenUsage, ToolSelection, is_silent_reply,
|
TOOL_INTENT_NUDGE, TokenUsage, ToolSelection, is_silent_reply, llm_signals_tool_intent,
|
||||||
llm_signals_tool_intent,
|
|
||||||
};
|
};
|
||||||
pub use recording::RecordingLlm;
|
pub use recording::RecordingLlm;
|
||||||
pub use registry::{ProviderDefinition, ProviderProtocol, ProviderRegistry};
|
pub use registry::{ProviderDefinition, ProviderProtocol, ProviderRegistry};
|
||||||
|
|||||||
+8
-211
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::llm::error::LlmError;
|
use crate::llm::error::LlmError;
|
||||||
|
|
||||||
use crate::llm::{
|
use crate::llm::{
|
||||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, Role, ToolCall,
|
ChatMessage, CompletionRequest, LlmProvider, Role, ToolCall, ToolCompletionRequest,
|
||||||
ToolCompletionRequest, ToolDefinition,
|
ToolDefinition,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Token the agent returns when it has nothing to say (e.g. in group chats).
|
/// Token the agent returns when it has nothing to say (e.g. in group chats).
|
||||||
@@ -23,13 +23,6 @@ You said you would perform an action, but you did not include any tool calls.\n\
|
|||||||
Do NOT describe what you intend to do — actually call the tool now.\n\
|
Do NOT describe what you intend to do — actually call the tool now.\n\
|
||||||
Use the tool_calls mechanism to invoke the appropriate tool.";
|
Use the tool_calls mechanism to invoke the appropriate tool.";
|
||||||
|
|
||||||
/// Notice injected when the LLM's response was truncated mid-tool-call,
|
|
||||||
/// causing incomplete parameters. Tells the LLM to try a different approach.
|
|
||||||
pub const TRUNCATED_TOOL_CALL_NOTICE: &str = "\
|
|
||||||
Your previous response was truncated while generating tool call parameters. \
|
|
||||||
The tool calls were discarded. Please try a different approach — \
|
|
||||||
summarize or transform the data instead of echoing it verbatim in a tool call.";
|
|
||||||
|
|
||||||
/// Seed value used as the second argument to `generate_tool_call_id` when
|
/// Seed value used as the second argument to `generate_tool_call_id` when
|
||||||
/// recovering tool calls from malformed LLM text responses. This must differ
|
/// recovering tool calls from malformed LLM text responses. This must differ
|
||||||
/// from the `0` seed used in `rig_adapter::normalized_tool_call_id` to avoid
|
/// from the `0` seed used in `rig_adapter::normalized_tool_call_id` to avoid
|
||||||
@@ -201,17 +194,11 @@ pub struct ReasoningContext {
|
|||||||
pub metadata: std::collections::HashMap<String, String>,
|
pub metadata: std::collections::HashMap<String, String>,
|
||||||
/// When true, force a text-only response (ignore available tools).
|
/// When true, force a text-only response (ignore available tools).
|
||||||
/// Used by the agentic loop to guarantee termination near the iteration limit.
|
/// Used by the agentic loop to guarantee termination near the iteration limit.
|
||||||
/// Sticky: once set, never cleared within a loop invocation. Callers must
|
|
||||||
/// create a fresh `ReasoningContext` per `run_agentic_loop()` call.
|
|
||||||
pub force_text: bool,
|
pub force_text: bool,
|
||||||
/// Pre-built system prompt. When set, `respond_with_tools` uses this directly
|
/// Pre-built system prompt. When set, `respond_with_tools` uses this directly
|
||||||
/// instead of calling `build_system_prompt_with_tools`. Allows callers to build
|
/// instead of calling `build_system_prompt_with_tools`. Allows callers to build
|
||||||
/// the prompt once and reuse it across iterations.
|
/// the prompt once and reuse it across iterations.
|
||||||
pub system_prompt: Option<String>,
|
pub system_prompt: Option<String>,
|
||||||
/// Per-user model override. When set, completion requests use this model
|
|
||||||
/// instead of the provider's default. Only effective with providers that
|
|
||||||
/// support per-request model overrides (e.g. NearAI).
|
|
||||||
pub model_override: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReasoningContext {
|
impl ReasoningContext {
|
||||||
@@ -225,7 +212,6 @@ impl ReasoningContext {
|
|||||||
metadata: std::collections::HashMap::new(),
|
metadata: std::collections::HashMap::new(),
|
||||||
force_text: false,
|
force_text: false,
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
model_override: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +344,6 @@ pub enum RespondResult {
|
|||||||
pub struct RespondOutput {
|
pub struct RespondOutput {
|
||||||
pub result: RespondResult,
|
pub result: RespondResult,
|
||||||
pub usage: TokenUsage,
|
pub usage: TokenUsage,
|
||||||
pub finish_reason: FinishReason,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reasoning engine for the agent.
|
/// Reasoning engine for the agent.
|
||||||
@@ -540,17 +525,6 @@ impl Reasoning {
|
|||||||
|
|
||||||
let response = self.llm.complete_with_tools(request).await?;
|
let response = self.llm.complete_with_tools(request).await?;
|
||||||
|
|
||||||
// If the response was truncated, tool call parameters are likely incomplete.
|
|
||||||
// Return empty so the caller can fall through to respond_with_tools() which
|
|
||||||
// has a larger output token budget.
|
|
||||||
if response.finish_reason == FinishReason::Length {
|
|
||||||
tracing::warn!(
|
|
||||||
"select_tools response truncated (finish_reason=Length), \
|
|
||||||
discarding potentially incomplete tool selections"
|
|
||||||
);
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let shared_reasoning = response
|
let shared_reasoning = response
|
||||||
.content
|
.content
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
@@ -697,9 +671,6 @@ Respond in JSON format:
|
|||||||
.with_temperature(0.7)
|
.with_temperature(0.7)
|
||||||
.with_tool_choice("auto");
|
.with_tool_choice("auto");
|
||||||
request.metadata = context.metadata.clone();
|
request.metadata = context.metadata.clone();
|
||||||
if let Some(ref model) = context.model_override {
|
|
||||||
request.model = Some(model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = self.llm.complete_with_tools(request).await?;
|
let response = self.llm.complete_with_tools(request).await?;
|
||||||
let usage = TokenUsage {
|
let usage = TokenUsage {
|
||||||
@@ -743,7 +714,6 @@ Respond in JSON format:
|
|||||||
content: narrative,
|
content: narrative,
|
||||||
},
|
},
|
||||||
usage,
|
usage,
|
||||||
finish_reason: response.finish_reason,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,7 +741,6 @@ Respond in JSON format:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
usage,
|
usage,
|
||||||
finish_reason: response.finish_reason,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +766,6 @@ Respond in JSON format:
|
|||||||
Ok(RespondOutput {
|
Ok(RespondOutput {
|
||||||
result: RespondResult::Text(final_text),
|
result: RespondResult::Text(final_text),
|
||||||
usage,
|
usage,
|
||||||
finish_reason: response.finish_reason,
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// No tools, use simple completion
|
// No tools, use simple completion
|
||||||
@@ -805,9 +773,6 @@ Respond in JSON format:
|
|||||||
.with_max_tokens(4096)
|
.with_max_tokens(4096)
|
||||||
.with_temperature(0.7);
|
.with_temperature(0.7);
|
||||||
request.metadata = context.metadata.clone();
|
request.metadata = context.metadata.clone();
|
||||||
if let Some(ref model) = context.model_override {
|
|
||||||
request.model = Some(model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = self.llm.complete(request).await?;
|
let response = self.llm.complete(request).await?;
|
||||||
let pre_truncated = truncate_at_tool_tags(&response.content);
|
let pre_truncated = truncate_at_tool_tags(&response.content);
|
||||||
@@ -829,7 +794,6 @@ Respond in JSON format:
|
|||||||
cache_read_input_tokens: response.cache_read_input_tokens,
|
cache_read_input_tokens: response.cache_read_input_tokens,
|
||||||
cache_creation_input_tokens: response.cache_creation_input_tokens,
|
cache_creation_input_tokens: response.cache_creation_input_tokens,
|
||||||
},
|
},
|
||||||
finish_reason: response.finish_reason,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1370,49 +1334,6 @@ fn is_inside_code(pos: usize, regions: &[CodeRegion]) -> bool {
|
|||||||
regions.iter().any(|r| pos >= r.start && pos < r.end)
|
regions.iter().any(|r| pos >= r.start && pos < r.end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether a byte range overlaps any code region.
|
|
||||||
fn overlaps_code_region(start: usize, end: usize, regions: &[CodeRegion]) -> bool {
|
|
||||||
regions.iter().any(|r| start < r.end && end > r.start)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the byte bounds of the line containing `pos`, excluding the trailing newline.
|
|
||||||
fn line_bounds(text: &str, pos: usize) -> (usize, usize) {
|
|
||||||
let start = text[..pos].rfind('\n').map_or(0, |idx| idx + 1);
|
|
||||||
let end = text[pos..].find('\n').map_or(text.len(), |idx| pos + idx);
|
|
||||||
(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Only recover XML-style tool calls when they are isolated content outside
|
|
||||||
/// markdown code and quote contexts. This avoids converting code examples or
|
|
||||||
/// quoted snippets into executable tool calls.
|
|
||||||
fn is_recoverable_tool_call_segment(
|
|
||||||
text: &str,
|
|
||||||
start: usize,
|
|
||||||
end: usize,
|
|
||||||
code_regions: &[CodeRegion],
|
|
||||||
) -> bool {
|
|
||||||
if overlaps_code_region(start, end, code_regions) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (first_line_start, first_line_end) = line_bounds(text, start);
|
|
||||||
let first_line = &text[first_line_start..first_line_end];
|
|
||||||
|
|
||||||
if first_line.trim_start().starts_with('>') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (_, last_line_end) = line_bounds(text, end.saturating_sub(1));
|
|
||||||
let first_line_prefix = &text[first_line_start..start];
|
|
||||||
let last_line_suffix = &text[end..last_line_end];
|
|
||||||
|
|
||||||
if !first_line_prefix.trim().is_empty() || !last_line_suffix.trim().is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clean up LLM response by stripping model-internal tags and reasoning patterns.
|
/// Clean up LLM response by stripping model-internal tags and reasoning patterns.
|
||||||
///
|
///
|
||||||
/// Some models (GLM-4.7, etc.) emit XML-tagged internal state like
|
/// Some models (GLM-4.7, etc.) emit XML-tagged internal state like
|
||||||
@@ -1432,7 +1353,6 @@ fn recover_tool_calls_from_content(
|
|||||||
) -> Vec<ToolCall> {
|
) -> Vec<ToolCall> {
|
||||||
let tool_names: std::collections::HashSet<&str> =
|
let tool_names: std::collections::HashSet<&str> =
|
||||||
available_tools.iter().map(|t| t.name.as_str()).collect();
|
available_tools.iter().map(|t| t.name.as_str()).collect();
|
||||||
let code_regions = find_code_regions(content);
|
|
||||||
let mut calls = Vec::new();
|
let mut calls = Vec::new();
|
||||||
|
|
||||||
for (open, close) in &[
|
for (open, close) in &[
|
||||||
@@ -1441,23 +1361,15 @@ fn recover_tool_calls_from_content(
|
|||||||
("<function_call>", "</function_call>"),
|
("<function_call>", "</function_call>"),
|
||||||
("<|function_call|>", "<|/function_call|>"),
|
("<|function_call|>", "<|/function_call|>"),
|
||||||
] {
|
] {
|
||||||
let mut search_from = 0;
|
let mut remaining = content;
|
||||||
while let Some(offset) = content[search_from..].find(open) {
|
while let Some(start) = remaining.find(open) {
|
||||||
let start = search_from + offset;
|
|
||||||
let inner_start = start + open.len();
|
let inner_start = start + open.len();
|
||||||
let after = &content[inner_start..];
|
let after = &remaining[inner_start..];
|
||||||
let Some(end_offset) = after.find(close) else {
|
let Some(end) = after.find(close) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let end = inner_start + end_offset;
|
let inner = after[..end].trim();
|
||||||
let segment_end = end + close.len();
|
remaining = &after[end + close.len()..];
|
||||||
search_from = segment_end;
|
|
||||||
|
|
||||||
if !is_recoverable_tool_call_segment(content, start, segment_end, &code_regions) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let inner = content[inner_start..end].trim();
|
|
||||||
|
|
||||||
if inner.is_empty() {
|
if inner.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -2390,40 +2302,6 @@ That's my plan."#;
|
|||||||
assert_eq!(calls[0].name, "tool_list");
|
assert_eq!(calls[0].name, "tool_list");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_recover_tool_call_in_fenced_code_block_ignored() {
|
|
||||||
let tools = make_tools(&["tool_list"]);
|
|
||||||
let content = "Here is the XML format:\n\n```xml\n<tool_call>tool_list</tool_call>\n```";
|
|
||||||
let calls = recover_tool_calls_from_content(content, &tools);
|
|
||||||
assert!(calls.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_recover_tool_call_in_inline_code_ignored() {
|
|
||||||
let tools = make_tools(&["tool_list"]);
|
|
||||||
let content = "Use `<tool_call>tool_list</tool_call>` to illustrate the syntax.";
|
|
||||||
let calls = recover_tool_calls_from_content(content, &tools);
|
|
||||||
assert!(calls.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_recover_tool_call_in_blockquote_ignored() {
|
|
||||||
let tools = make_tools(&["tool_list"]);
|
|
||||||
let content = "The page replied:\n> <tool_call>tool_list</tool_call>";
|
|
||||||
let calls = recover_tool_calls_from_content(content, &tools);
|
|
||||||
assert!(calls.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_recover_multiline_json_tool_call_on_own_line() {
|
|
||||||
let tools = make_tools(&["memory_search"]);
|
|
||||||
let content = "Let me check.\n\n<tool_call>\n{\"name\": \"memory_search\", \"arguments\": {\"query\": \"test\"}}\n</tool_call>\n\nDone.";
|
|
||||||
let calls = recover_tool_calls_from_content(content, &tools);
|
|
||||||
assert_eq!(calls.len(), 1);
|
|
||||||
assert_eq!(calls[0].name, "memory_search");
|
|
||||||
assert_eq!(calls[0].arguments, serde_json::json!({"query": "test"}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- System prompt building tests (issue #565) ----
|
// ---- System prompt building tests (issue #565) ----
|
||||||
|
|
||||||
fn make_test_reasoning() -> Reasoning {
|
fn make_test_reasoning() -> Reasoning {
|
||||||
@@ -3340,85 +3218,4 @@ That's my plan."#;
|
|||||||
let cleaned = clean_response(&pre_truncated);
|
let cleaned = clean_response(&pre_truncated);
|
||||||
assert!(cleaned.trim().is_empty());
|
assert!(cleaned.trim().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- select_tools truncation guard ----
|
|
||||||
|
|
||||||
/// Mock provider that returns tool calls with a configurable finish_reason.
|
|
||||||
struct TruncatingLlm {
|
|
||||||
finish_reason: crate::llm::FinishReason,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl crate::llm::LlmProvider for TruncatingLlm {
|
|
||||||
fn model_name(&self) -> &str {
|
|
||||||
"truncating-stub"
|
|
||||||
}
|
|
||||||
fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) {
|
|
||||||
(rust_decimal::Decimal::ZERO, rust_decimal::Decimal::ZERO)
|
|
||||||
}
|
|
||||||
async fn complete(
|
|
||||||
&self,
|
|
||||||
_request: crate::llm::CompletionRequest,
|
|
||||||
) -> Result<crate::llm::CompletionResponse, crate::llm::error::LlmError> {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
async fn complete_with_tools(
|
|
||||||
&self,
|
|
||||||
_request: crate::llm::ToolCompletionRequest,
|
|
||||||
) -> Result<crate::llm::ToolCompletionResponse, crate::llm::error::LlmError> {
|
|
||||||
Ok(crate::llm::ToolCompletionResponse {
|
|
||||||
content: Some("I'll write the report.".to_string()),
|
|
||||||
tool_calls: vec![ToolCall {
|
|
||||||
id: "call_1".to_string(),
|
|
||||||
name: "memory_write".to_string(),
|
|
||||||
arguments: serde_json::json!({}),
|
|
||||||
reasoning: None,
|
|
||||||
}],
|
|
||||||
input_tokens: 5000,
|
|
||||||
output_tokens: 1024,
|
|
||||||
finish_reason: self.finish_reason,
|
|
||||||
cache_read_input_tokens: 0,
|
|
||||||
cache_creation_input_tokens: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_select_tools_returns_empty_on_truncation() {
|
|
||||||
let llm = Arc::new(TruncatingLlm {
|
|
||||||
finish_reason: FinishReason::Length,
|
|
||||||
});
|
|
||||||
let reasoning = Reasoning::new(llm);
|
|
||||||
let mut ctx = ReasoningContext::new().with_message(ChatMessage::user("Write a report"));
|
|
||||||
ctx.available_tools.push(ToolDefinition {
|
|
||||||
name: "memory_write".to_string(),
|
|
||||||
description: "Write to memory".to_string(),
|
|
||||||
parameters: serde_json::json!({"type": "object"}),
|
|
||||||
});
|
|
||||||
|
|
||||||
let selections = reasoning.select_tools(&ctx).await.unwrap();
|
|
||||||
assert!(
|
|
||||||
selections.is_empty(),
|
|
||||||
"Truncated tool selections should be discarded (got {} selections)",
|
|
||||||
selections.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_select_tools_returns_selections_when_not_truncated() {
|
|
||||||
let llm = Arc::new(TruncatingLlm {
|
|
||||||
finish_reason: FinishReason::ToolUse,
|
|
||||||
});
|
|
||||||
let reasoning = Reasoning::new(llm);
|
|
||||||
let mut ctx = ReasoningContext::new().with_message(ChatMessage::user("Write a report"));
|
|
||||||
ctx.available_tools.push(ToolDefinition {
|
|
||||||
name: "memory_write".to_string(),
|
|
||||||
description: "Write to memory".to_string(),
|
|
||||||
parameters: serde_json::json!({"type": "object"}),
|
|
||||||
});
|
|
||||||
|
|
||||||
let selections = reasoning.select_tools(&ctx).await.unwrap();
|
|
||||||
assert_eq!(selections.len(), 1);
|
|
||||||
assert_eq!(selections[0].tool_name, "memory_write");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-32
@@ -598,30 +598,6 @@ fn build_rig_request(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject a per-request model override into the rig request's `additional_params`.
|
|
||||||
///
|
|
||||||
/// Rig-core bakes the model name at construction time inside each provider's
|
|
||||||
/// `CompletionModel` implementation. The actual HTTP request body includes a
|
|
||||||
/// `model` field set by the provider. Rig-core's `#[serde(flatten)]` on
|
|
||||||
/// `additional_params` emits these fields AFTER the provider's own fields.
|
|
||||||
/// Most API servers (Python, Go) use last-key-wins when deserializing
|
|
||||||
/// duplicate JSON keys, so the injected `model` value takes effect.
|
|
||||||
fn inject_model_override(rig_req: &mut RigRequest, model_override: Option<&str>) {
|
|
||||||
let Some(model) = model_override else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
match rig_req.additional_params {
|
|
||||||
Some(ref mut params) => {
|
|
||||||
if let Some(obj) = params.as_object_mut() {
|
|
||||||
obj.insert("model".to_string(), serde_json::json!(model));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
rig_req.additional_params = Some(serde_json::json!({ "model": model }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<M> LlmProvider for RigAdapter<M>
|
impl<M> LlmProvider for RigAdapter<M>
|
||||||
where
|
where
|
||||||
@@ -656,7 +632,15 @@ where
|
|||||||
&self,
|
&self,
|
||||||
mut request: CompletionRequest,
|
mut request: CompletionRequest,
|
||||||
) -> Result<CompletionResponse, LlmError> {
|
) -> Result<CompletionResponse, LlmError> {
|
||||||
let model_override = request.model.take();
|
if let Some(requested_model) = request.model.as_deref()
|
||||||
|
&& requested_model != self.model_name.as_str()
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
requested_model = requested_model,
|
||||||
|
active_model = %self.model_name,
|
||||||
|
"Per-request model override is not supported for this provider; using configured model"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
self.strip_unsupported_completion_params(&mut request);
|
self.strip_unsupported_completion_params(&mut request);
|
||||||
|
|
||||||
@@ -664,7 +648,7 @@ where
|
|||||||
crate::llm::provider::sanitize_tool_messages(&mut messages);
|
crate::llm::provider::sanitize_tool_messages(&mut messages);
|
||||||
let (preamble, history) = convert_messages(&messages);
|
let (preamble, history) = convert_messages(&messages);
|
||||||
|
|
||||||
let mut rig_req = build_rig_request(
|
let rig_req = build_rig_request(
|
||||||
preamble,
|
preamble,
|
||||||
history,
|
history,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -674,8 +658,6 @@ where
|
|||||||
self.cache_retention,
|
self.cache_retention,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
inject_model_override(&mut rig_req, model_override.as_deref());
|
|
||||||
|
|
||||||
let response =
|
let response =
|
||||||
self.model
|
self.model
|
||||||
.completion(rig_req)
|
.completion(rig_req)
|
||||||
@@ -713,7 +695,15 @@ where
|
|||||||
&self,
|
&self,
|
||||||
mut request: ToolCompletionRequest,
|
mut request: ToolCompletionRequest,
|
||||||
) -> Result<ToolCompletionResponse, LlmError> {
|
) -> Result<ToolCompletionResponse, LlmError> {
|
||||||
let model_override = request.model.take();
|
if let Some(requested_model) = request.model.as_deref()
|
||||||
|
&& requested_model != self.model_name.as_str()
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
requested_model = requested_model,
|
||||||
|
active_model = %self.model_name,
|
||||||
|
"Per-request model override is not supported for this provider; using configured model"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
self.strip_unsupported_tool_params(&mut request);
|
self.strip_unsupported_tool_params(&mut request);
|
||||||
|
|
||||||
@@ -726,7 +716,7 @@ where
|
|||||||
let tools = convert_tools(&request.tools);
|
let tools = convert_tools(&request.tools);
|
||||||
let tool_choice = convert_tool_choice(request.tool_choice.as_deref());
|
let tool_choice = convert_tool_choice(request.tool_choice.as_deref());
|
||||||
|
|
||||||
let mut rig_req = build_rig_request(
|
let rig_req = build_rig_request(
|
||||||
preamble,
|
preamble,
|
||||||
history,
|
history,
|
||||||
tools,
|
tools,
|
||||||
@@ -736,8 +726,6 @@ where
|
|||||||
self.cache_retention,
|
self.cache_retention,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
inject_model_override(&mut rig_req, model_override.as_deref());
|
|
||||||
|
|
||||||
let response =
|
let response =
|
||||||
self.model
|
self.model
|
||||||
.completion(rig_req)
|
.completion(rig_req)
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ impl TranscriptionMiddleware {
|
|||||||
Some(f) => f,
|
Some(f) => f,
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
attachment_id = %attachment.id,
|
|
||||||
mime = %attachment.mime_type,
|
mime = %attachment.mime_type,
|
||||||
"Skipping audio attachment with unsupported format"
|
"Skipping audio attachment with unsupported format"
|
||||||
);
|
);
|
||||||
@@ -129,6 +128,11 @@ impl TranscriptionMiddleware {
|
|||||||
|
|
||||||
match self.provider.transcribe(&attachment.data, format).await {
|
match self.provider.transcribe(&attachment.data, format).await {
|
||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
|
tracing::info!(
|
||||||
|
attachment_id = %attachment.id,
|
||||||
|
text_len = text.len(),
|
||||||
|
"Transcribed audio attachment"
|
||||||
|
);
|
||||||
transcriptions.push((i, text));
|
transcriptions.push((i, text));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -914,10 +914,6 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
},
|
},
|
||||||
builder: components.builder,
|
builder: components.builder,
|
||||||
llm_backend: config.llm.backend.clone(),
|
llm_backend: config.llm.backend.clone(),
|
||||||
tenant_rates: Arc::new(ironclaw::tenant::TenantRateRegistry::new(
|
|
||||||
config.agent.max_llm_concurrent_per_user.unwrap_or(4),
|
|
||||||
config.agent.max_jobs_concurrent_per_user.unwrap_or(3),
|
|
||||||
)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let channels_for_warnings = Arc::clone(&channels);
|
let channels_for_warnings = Arc::clone(&channels);
|
||||||
|
|||||||
-906
@@ -1,906 +0,0 @@
|
|||||||
//! Compile-time tenant isolation.
|
|
||||||
//!
|
|
||||||
//! Provides two database access tiers:
|
|
||||||
//!
|
|
||||||
//! - **[`TenantScope`]** (default): All operations are bound to a single user.
|
|
||||||
//! ID-based lookups return `None` if the resource doesn't belong to this user.
|
|
||||||
//! This is the only way handler code should access the database.
|
|
||||||
//!
|
|
||||||
//! - **[`AdminScope`]**: Cross-tenant access for system-level operations
|
|
||||||
//! (heartbeat, routine engine, self-repair). Must be obtained explicitly via
|
|
||||||
//! [`AgentDeps::admin_store()`](crate::agent::AgentDeps::admin_store).
|
|
||||||
//!
|
|
||||||
//! [`TenantCtx`] bundles a `TenantScope` with workspace, cost guard, and
|
|
||||||
//! per-tenant rate limiting. Constructed once per request at the entry point
|
|
||||||
//! where a `user_id` becomes known.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use tokio::sync::{Semaphore, SemaphorePermit};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::agent::BrokenTool;
|
|
||||||
use crate::agent::cost_guard::{CostGuard, CostLimitExceeded};
|
|
||||||
use crate::agent::routine::{Routine, RoutineRun, RunStatus};
|
|
||||||
use crate::context::{ActionRecord, JobContext, JobState};
|
|
||||||
use crate::db::Database;
|
|
||||||
use crate::error::DatabaseError;
|
|
||||||
use crate::history::{
|
|
||||||
AgentJobRecord, AgentJobSummary, ConversationMessage, ConversationSummary, LlmCallRecord,
|
|
||||||
SandboxJobRecord, SandboxJobSummary, SettingRow,
|
|
||||||
};
|
|
||||||
use crate::workspace::Workspace;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TenantScope — scoped database access (default tier)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Scoped database view. All operations are bound to a single user.
|
|
||||||
///
|
|
||||||
/// This is the **only** way handler code should access the database.
|
|
||||||
/// ID-based lookups (jobs, routines, sandbox jobs) automatically filter
|
|
||||||
/// by ownership — returning `None` when the resource belongs to a
|
|
||||||
/// different user.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TenantScope {
|
|
||||||
user_id: String,
|
|
||||||
inner: Arc<dyn Database>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TenantScope {
|
|
||||||
pub fn new(user_id: impl Into<String>, db: Arc<dyn Database>) -> Self {
|
|
||||||
Self {
|
|
||||||
user_id: user_id.into(),
|
|
||||||
inner: db,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_id(&self) -> &str {
|
|
||||||
&self.user_id
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Jobs ===
|
|
||||||
|
|
||||||
pub async fn list_agent_jobs(&self) -> Result<Vec<AgentJobRecord>, DatabaseError> {
|
|
||||||
self.inner.list_agent_jobs_for_user(&self.user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn agent_job_summary(&self) -> Result<AgentJobSummary, DatabaseError> {
|
|
||||||
self.inner.agent_job_summary_for_user(&self.user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch a job by ID, returning `None` if it doesn't belong to this user.
|
|
||||||
pub async fn get_job(&self, id: Uuid) -> Result<Option<JobContext>, DatabaseError> {
|
|
||||||
match self.inner.get_job(id).await? {
|
|
||||||
Some(ctx) if ctx.user_id == self.user_id => Ok(Some(ctx)),
|
|
||||||
_ => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_agent_job_failure_reason(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
) -> Result<Option<String>, DatabaseError> {
|
|
||||||
// Verify ownership first
|
|
||||||
if self.get_job(id).await?.is_none() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
self.inner.get_agent_job_failure_reason(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_job_status(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: JobState,
|
|
||||||
failure_reason: Option<&str>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
// Verify ownership before mutating
|
|
||||||
if self.get_job(id).await?.is_none() {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "job".to_string(),
|
|
||||||
id: id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner
|
|
||||||
.update_job_status(id, status, failure_reason)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Sandbox jobs ===
|
|
||||||
|
|
||||||
pub async fn list_sandbox_jobs(&self) -> Result<Vec<SandboxJobRecord>, DatabaseError> {
|
|
||||||
self.inner.list_sandbox_jobs_for_user(&self.user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn sandbox_job_summary(&self) -> Result<SandboxJobSummary, DatabaseError> {
|
|
||||||
self.inner.sandbox_job_summary_for_user(&self.user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch a sandbox job by ID, returning `None` if it doesn't belong to this user.
|
|
||||||
pub async fn get_sandbox_job(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
) -> Result<Option<SandboxJobRecord>, DatabaseError> {
|
|
||||||
match self.inner.get_sandbox_job(id).await? {
|
|
||||||
Some(job) if job.user_id == self.user_id => Ok(Some(job)),
|
|
||||||
_ => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn sandbox_job_belongs_to_user(&self, job_id: Uuid) -> Result<bool, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.sandbox_job_belongs_to_user(job_id, &self.user_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Routines ===
|
|
||||||
|
|
||||||
pub async fn list_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
|
||||||
self.inner.list_routines(&self.user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_routine_by_name(&self, name: &str) -> Result<Option<Routine>, DatabaseError> {
|
|
||||||
self.inner.get_routine_by_name(&self.user_id, name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch a routine by ID, returning `None` if it doesn't belong to this user.
|
|
||||||
pub async fn get_routine(&self, id: Uuid) -> Result<Option<Routine>, DatabaseError> {
|
|
||||||
match self.inner.get_routine(id).await? {
|
|
||||||
Some(r) if r.user_id == self.user_id => Ok(Some(r)),
|
|
||||||
_ => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
|
|
||||||
debug_assert_eq!(
|
|
||||||
routine.user_id, self.user_id,
|
|
||||||
"routine.user_id must match TenantScope user"
|
|
||||||
);
|
|
||||||
self.inner.create_routine(routine).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
|
|
||||||
// Verify ownership
|
|
||||||
if self.get_routine(routine.id).await?.is_none() {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "routine".to_string(),
|
|
||||||
id: routine.id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner.update_routine(routine).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_routine(&self, id: Uuid) -> Result<bool, DatabaseError> {
|
|
||||||
// Verify ownership
|
|
||||||
if self.get_routine(id).await?.is_none() {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "routine".to_string(),
|
|
||||||
id: id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner.delete_routine(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List routine runs, verifying the routine belongs to this user.
|
|
||||||
pub async fn list_routine_runs(
|
|
||||||
&self,
|
|
||||||
routine_id: Uuid,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<RoutineRun>, DatabaseError> {
|
|
||||||
// Verify routine ownership first
|
|
||||||
if self.get_routine(routine_id).await?.is_none() {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "routine".to_string(),
|
|
||||||
id: routine_id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner.list_routine_runs(routine_id, limit).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_webhook_routine_by_path(
|
|
||||||
&self,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<Option<Routine>, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.get_webhook_routine_by_path(path, Some(&self.user_id))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Settings ===
|
|
||||||
|
|
||||||
pub async fn get_setting(&self, key: &str) -> Result<Option<serde_json::Value>, DatabaseError> {
|
|
||||||
self.inner.get_setting(&self.user_id, key).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_setting_full(&self, key: &str) -> Result<Option<SettingRow>, DatabaseError> {
|
|
||||||
self.inner.get_setting_full(&self.user_id, key).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_setting(
|
|
||||||
&self,
|
|
||||||
key: &str,
|
|
||||||
value: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.set_setting(&self.user_id, key, value).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_setting(&self, key: &str) -> Result<bool, DatabaseError> {
|
|
||||||
self.inner.delete_setting(&self.user_id, key).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_settings(&self) -> Result<Vec<SettingRow>, DatabaseError> {
|
|
||||||
self.inner.list_settings(&self.user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_all_settings(
|
|
||||||
&self,
|
|
||||||
) -> Result<HashMap<String, serde_json::Value>, DatabaseError> {
|
|
||||||
self.inner.get_all_settings(&self.user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn set_all_settings(
|
|
||||||
&self,
|
|
||||||
settings: &HashMap<String, serde_json::Value>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.set_all_settings(&self.user_id, settings).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn has_settings(&self) -> Result<bool, DatabaseError> {
|
|
||||||
self.inner.has_settings(&self.user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Conversations ===
|
|
||||||
|
|
||||||
pub async fn create_conversation(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
thread_id: Option<&str>,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.create_conversation(channel, &self.user_id, thread_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_conversation(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
channel: &str,
|
|
||||||
thread_id: Option<&str>,
|
|
||||||
) -> Result<bool, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.ensure_conversation(id, channel, &self.user_id, thread_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_conversations_with_preview(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<ConversationSummary>, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.list_conversations_with_preview(&self.user_id, channel, limit)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_conversations_all_channels(
|
|
||||||
&self,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<Vec<ConversationSummary>, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.list_conversations_all_channels(&self.user_id, limit)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_or_create_routine_conversation(
|
|
||||||
&self,
|
|
||||||
routine_id: Uuid,
|
|
||||||
routine_name: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.get_or_create_routine_conversation(routine_id, routine_name, &self.user_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_or_create_heartbeat_conversation(&self) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.get_or_create_heartbeat_conversation(&self.user_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_or_create_assistant_conversation(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.get_or_create_assistant_conversation(&self.user_id, channel)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn conversation_belongs_to_user(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
) -> Result<bool, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.conversation_belongs_to_user(conversation_id, &self.user_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a message to a conversation owned by this tenant.
|
|
||||||
///
|
|
||||||
/// Verifies the conversation belongs to this user before adding.
|
|
||||||
pub async fn add_conversation_message(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
role: &str,
|
|
||||||
content: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.add_conversation_message(conversation_id, role, content)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.touch_conversation(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_conversation_messages(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
) -> Result<Vec<ConversationMessage>, DatabaseError> {
|
|
||||||
self.inner.list_conversation_messages(conversation_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_conversation_messages_paginated(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
before: Option<DateTime<Utc>>,
|
|
||||||
limit: i64,
|
|
||||||
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.list_conversation_messages_paginated(conversation_id, before, limit)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_conversation_with_metadata(
|
|
||||||
&self,
|
|
||||||
channel: &str,
|
|
||||||
metadata: &serde_json::Value,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.create_conversation_with_metadata(channel, &self.user_id, metadata)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_conversation_metadata_field(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
key: &str,
|
|
||||||
value: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.update_conversation_metadata_field(id, key, value)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_conversation_metadata(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
) -> Result<Option<serde_json::Value>, DatabaseError> {
|
|
||||||
self.inner.get_conversation_metadata(id).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AdminScope — explicit cross-tenant access
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Cross-tenant database access for system-level operations.
|
|
||||||
///
|
|
||||||
/// **Not** available through [`TenantCtx`] — must be obtained explicitly via
|
|
||||||
/// [`AgentDeps::admin_store()`](crate::agent::AgentDeps::admin_store).
|
|
||||||
///
|
|
||||||
/// Used by: heartbeat enumeration, routine engine scheduling, self-repair,
|
|
||||||
/// scheduler job persistence, worker status updates.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AdminScope {
|
|
||||||
inner: Arc<dyn Database>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AdminScope {
|
|
||||||
pub fn new(db: Arc<dyn Database>) -> Self {
|
|
||||||
Self { inner: db }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Access the raw Database trait object.
|
|
||||||
///
|
|
||||||
/// Prefer using the typed methods on AdminScope instead. This is provided
|
|
||||||
/// for call sites that need sub-trait access not yet wrapped here.
|
|
||||||
pub fn db(&self) -> &Arc<dyn Database> {
|
|
||||||
&self.inner
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Routine engine ===
|
|
||||||
|
|
||||||
pub async fn list_all_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
|
||||||
self.inner.list_all_routines().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_event_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
|
||||||
self.inner.list_event_routines().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_due_cron_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
|
||||||
self.inner.list_due_cron_routines().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
|
|
||||||
self.inner.list_dispatched_routine_runs().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn count_running_routine_runs_batch(
|
|
||||||
&self,
|
|
||||||
routine_ids: &[Uuid],
|
|
||||||
) -> Result<HashMap<Uuid, i64>, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.count_running_routine_runs_batch(routine_ids)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn batch_get_last_run_status(
|
|
||||||
&self,
|
|
||||||
routine_ids: &[Uuid],
|
|
||||||
) -> Result<HashMap<Uuid, RunStatus>, DatabaseError> {
|
|
||||||
self.inner.batch_get_last_run_status(routine_ids).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result<i64, DatabaseError> {
|
|
||||||
self.inner.count_running_routine_runs(routine_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_routine_runtime(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
last_run_at: DateTime<Utc>,
|
|
||||||
next_fire_at: Option<DateTime<Utc>>,
|
|
||||||
run_count: u64,
|
|
||||||
consecutive_failures: u32,
|
|
||||||
state: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.update_routine_runtime(
|
|
||||||
id,
|
|
||||||
last_run_at,
|
|
||||||
next_fire_at,
|
|
||||||
run_count,
|
|
||||||
consecutive_failures,
|
|
||||||
state,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.create_routine_run(run).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn complete_routine_run(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: RunStatus,
|
|
||||||
result_summary: Option<&str>,
|
|
||||||
tokens_used: Option<i32>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.complete_routine_run(id, status, result_summary, tokens_used)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn link_routine_run_to_job(
|
|
||||||
&self,
|
|
||||||
run_id: Uuid,
|
|
||||||
job_id: Uuid,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.link_routine_run_to_job(run_id, job_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_routine(&self, id: Uuid) -> Result<Option<Routine>, DatabaseError> {
|
|
||||||
self.inner.get_routine(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.update_routine(routine).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Self-repair ===
|
|
||||||
|
|
||||||
pub async fn get_stuck_jobs(&self) -> Result<Vec<Uuid>, DatabaseError> {
|
|
||||||
self.inner.get_stuck_jobs().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_broken_tools(&self, threshold: i32) -> Result<Vec<BrokenTool>, DatabaseError> {
|
|
||||||
self.inner.get_broken_tools(threshold).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn record_tool_failure(
|
|
||||||
&self,
|
|
||||||
tool_name: &str,
|
|
||||||
error_message: &str,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.record_tool_failure(tool_name, error_message)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn mark_tool_repaired(&self, tool_name: &str) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.mark_tool_repaired(tool_name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn increment_repair_attempts(&self, tool_name: &str) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.increment_repair_attempts(tool_name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Sandbox housekeeping ===
|
|
||||||
|
|
||||||
pub async fn cleanup_stale_sandbox_jobs(&self) -> Result<u64, DatabaseError> {
|
|
||||||
self.inner.cleanup_stale_sandbox_jobs().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_sandbox_job(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
) -> Result<Option<SandboxJobRecord>, DatabaseError> {
|
|
||||||
self.inner.get_sandbox_job(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.save_sandbox_job(job).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_sandbox_job_status(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: &str,
|
|
||||||
success: Option<bool>,
|
|
||||||
message: Option<&str>,
|
|
||||||
started_at: Option<DateTime<Utc>>,
|
|
||||||
completed_at: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.update_sandbox_job_status(id, status, success, message, started_at, completed_at)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.update_sandbox_job_mode(id, mode).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_sandbox_job_mode(&self, id: Uuid) -> Result<Option<String>, DatabaseError> {
|
|
||||||
self.inner.get_sandbox_job_mode(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_job_event(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
event_type: &str,
|
|
||||||
data: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.save_job_event(job_id, event_type, data).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_job_events(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
limit: Option<i64>,
|
|
||||||
) -> Result<Vec<crate::history::JobEventRecord>, DatabaseError> {
|
|
||||||
self.inner.list_job_events(job_id, limit).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Job persistence (scheduler, worker) ===
|
|
||||||
|
|
||||||
pub async fn get_job(&self, id: Uuid) -> Result<Option<JobContext>, DatabaseError> {
|
|
||||||
self.inner.get_job(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.save_job(ctx).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_job_status(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
status: JobState,
|
|
||||||
failure_reason: Option<&str>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.update_job_status(id, status, failure_reason)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn mark_job_stuck(&self, id: Uuid) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.mark_job_stuck(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_agent_jobs(&self) -> Result<Vec<AgentJobRecord>, DatabaseError> {
|
|
||||||
self.inner.list_agent_jobs().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_agent_job_failure_reason(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
) -> Result<Option<String>, DatabaseError> {
|
|
||||||
self.inner.get_agent_job_failure_reason(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === LLM call recording ===
|
|
||||||
|
|
||||||
pub async fn record_llm_call(&self, record: &LlmCallRecord<'_>) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner.record_llm_call(record).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_action(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
action: &ActionRecord,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner.save_action(job_id, action).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_job_actions(&self, job_id: Uuid) -> Result<Vec<ActionRecord>, DatabaseError> {
|
|
||||||
self.inner.get_job_actions(job_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Estimation ===
|
|
||||||
|
|
||||||
pub async fn save_estimation_snapshot(
|
|
||||||
&self,
|
|
||||||
job_id: Uuid,
|
|
||||||
category: &str,
|
|
||||||
tool_names: &[String],
|
|
||||||
estimated_cost: Decimal,
|
|
||||||
estimated_time_secs: i32,
|
|
||||||
estimated_value: Decimal,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.save_estimation_snapshot(
|
|
||||||
job_id,
|
|
||||||
category,
|
|
||||||
tool_names,
|
|
||||||
estimated_cost,
|
|
||||||
estimated_time_secs,
|
|
||||||
estimated_value,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_estimation_actuals(
|
|
||||||
&self,
|
|
||||||
id: Uuid,
|
|
||||||
actual_cost: Decimal,
|
|
||||||
actual_time_secs: i32,
|
|
||||||
actual_value: Option<Decimal>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.update_estimation_actuals(id, actual_cost, actual_time_secs, actual_value)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Conversations (admin context) ===
|
|
||||||
|
|
||||||
pub async fn add_conversation_message(
|
|
||||||
&self,
|
|
||||||
conversation_id: Uuid,
|
|
||||||
role: &str,
|
|
||||||
content: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.add_conversation_message(conversation_id, role, content)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_or_create_routine_conversation(
|
|
||||||
&self,
|
|
||||||
routine_id: Uuid,
|
|
||||||
routine_name: &str,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.get_or_create_routine_conversation(routine_id, routine_name, user_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_or_create_heartbeat_conversation(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<Uuid, DatabaseError> {
|
|
||||||
self.inner
|
|
||||||
.get_or_create_heartbeat_conversation(user_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TenantRateState / TenantRateRegistry — per-user concurrency
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Per-tenant concurrency limits.
|
|
||||||
pub struct TenantRateState {
|
|
||||||
/// Limits concurrent LLM calls for this user.
|
|
||||||
pub llm_semaphore: Arc<Semaphore>,
|
|
||||||
/// Limits concurrent jobs for this user.
|
|
||||||
pub job_semaphore: Arc<Semaphore>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TenantRateState {
|
|
||||||
pub fn new(max_llm_concurrent: usize, max_job_concurrent: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
llm_semaphore: Arc::new(Semaphore::new(max_llm_concurrent)),
|
|
||||||
job_semaphore: Arc::new(Semaphore::new(max_job_concurrent)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registry that lazily creates per-tenant rate state.
|
|
||||||
///
|
|
||||||
/// Uses `tokio::sync::RwLock<HashMap>` (consistent with the rest of the
|
|
||||||
/// codebase — no DashMap dependency).
|
|
||||||
pub struct TenantRateRegistry {
|
|
||||||
state: tokio::sync::RwLock<HashMap<String, Arc<TenantRateState>>>,
|
|
||||||
max_llm_concurrent: usize,
|
|
||||||
max_job_concurrent: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TenantRateRegistry {
|
|
||||||
pub fn new(max_llm_concurrent: usize, max_job_concurrent: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
state: tokio::sync::RwLock::new(HashMap::new()),
|
|
||||||
max_llm_concurrent,
|
|
||||||
max_job_concurrent,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get or lazily create rate state for a user.
|
|
||||||
pub async fn get_or_create(&self, user_id: &str) -> Arc<TenantRateState> {
|
|
||||||
// Fast path: read lock
|
|
||||||
{
|
|
||||||
let map = self.state.read().await;
|
|
||||||
if let Some(s) = map.get(user_id) {
|
|
||||||
return Arc::clone(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Slow path: write lock with double-check
|
|
||||||
let mut map = self.state.write().await;
|
|
||||||
if let Some(s) = map.get(user_id) {
|
|
||||||
return Arc::clone(s);
|
|
||||||
}
|
|
||||||
let s = Arc::new(TenantRateState::new(
|
|
||||||
self.max_llm_concurrent,
|
|
||||||
self.max_job_concurrent,
|
|
||||||
));
|
|
||||||
map.insert(user_id.to_string(), Arc::clone(&s));
|
|
||||||
s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// TenantCtx — per-request tenant execution context
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Per-request tenant execution context.
|
|
||||||
///
|
|
||||||
/// Bundles a [`TenantScope`] (scoped DB access), workspace, cost guard,
|
|
||||||
/// and per-tenant rate limiting. Constructed once per request via
|
|
||||||
/// [`AgentDeps::tenant_ctx()`](crate::agent::AgentDeps::tenant_ctx).
|
|
||||||
///
|
|
||||||
/// `Clone + Send + Sync` — safe to store on `ChatDelegate` without lifetime issues.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TenantCtx {
|
|
||||||
user_id: String,
|
|
||||||
store: Option<TenantScope>,
|
|
||||||
workspace: Option<Arc<Workspace>>,
|
|
||||||
cost_guard: Arc<CostGuard>,
|
|
||||||
rate: Arc<TenantRateState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TenantCtx {
|
|
||||||
pub fn new(
|
|
||||||
user_id: impl Into<String>,
|
|
||||||
store: Option<TenantScope>,
|
|
||||||
workspace: Option<Arc<Workspace>>,
|
|
||||||
cost_guard: Arc<CostGuard>,
|
|
||||||
rate: Arc<TenantRateState>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
user_id: user_id.into(),
|
|
||||||
store,
|
|
||||||
workspace,
|
|
||||||
cost_guard,
|
|
||||||
rate,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn user_id(&self) -> &str {
|
|
||||||
&self.user_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn store(&self) -> Option<&TenantScope> {
|
|
||||||
self.store.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn workspace(&self) -> Option<&Arc<Workspace>> {
|
|
||||||
self.workspace.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cost_guard(&self) -> &CostGuard {
|
|
||||||
&self.cost_guard
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check cost limits for this tenant (global + per-user).
|
|
||||||
pub async fn check_cost_allowed(&self) -> Result<(), CostLimitExceeded> {
|
|
||||||
self.cost_guard.check_allowed_for_user(&self.user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record an LLM call for this tenant.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn record_llm_call(
|
|
||||||
&self,
|
|
||||||
model: &str,
|
|
||||||
input_tokens: u32,
|
|
||||||
output_tokens: u32,
|
|
||||||
cache_read_input_tokens: u32,
|
|
||||||
cache_creation_input_tokens: u32,
|
|
||||||
cache_read_discount: Decimal,
|
|
||||||
cache_write_multiplier: Decimal,
|
|
||||||
cost_per_token: Option<(Decimal, Decimal)>,
|
|
||||||
) -> Decimal {
|
|
||||||
self.cost_guard
|
|
||||||
.record_llm_call_for_user(
|
|
||||||
&self.user_id,
|
|
||||||
model,
|
|
||||||
input_tokens,
|
|
||||||
output_tokens,
|
|
||||||
cache_read_input_tokens,
|
|
||||||
cache_creation_input_tokens,
|
|
||||||
cache_read_discount,
|
|
||||||
cache_write_multiplier,
|
|
||||||
cost_per_token,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Acquire an LLM concurrency permit for this tenant.
|
|
||||||
pub async fn acquire_llm_permit(&self) -> Result<SemaphorePermit<'_>, crate::error::Error> {
|
|
||||||
self.rate.llm_semaphore.acquire().await.map_err(|_| {
|
|
||||||
crate::error::Error::Config(crate::error::ConfigError::InvalidValue {
|
|
||||||
key: "llm_semaphore".to_string(),
|
|
||||||
message: "semaphore closed".to_string(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_rate_registry_returns_same_state_for_same_user() {
|
|
||||||
let registry = TenantRateRegistry::new(4, 3);
|
|
||||||
let a1 = registry.get_or_create("alice").await;
|
|
||||||
let a2 = registry.get_or_create("alice").await;
|
|
||||||
assert!(Arc::ptr_eq(&a1, &a2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_rate_registry_different_users_get_different_state() {
|
|
||||||
let registry = TenantRateRegistry::new(4, 3);
|
|
||||||
let alice = registry.get_or_create("alice").await;
|
|
||||||
let bob = registry.get_or_create("bob").await;
|
|
||||||
assert!(!Arc::ptr_eq(&alice, &bob));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -532,7 +532,6 @@ impl TestHarnessBuilder {
|
|||||||
let cost_guard = Arc::new(CostGuard::new(CostGuardConfig {
|
let cost_guard = Arc::new(CostGuard::new(CostGuardConfig {
|
||||||
max_cost_per_day_cents: None,
|
max_cost_per_day_cents: None,
|
||||||
max_actions_per_hour: None,
|
max_actions_per_hour: None,
|
||||||
max_cost_per_user_per_day_cents: None,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let channel = if self.stub_channel {
|
let channel = if self.stub_channel {
|
||||||
@@ -565,7 +564,6 @@ impl TestHarnessBuilder {
|
|||||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||||
builder: None,
|
builder: None,
|
||||||
llm_backend: "nearai".to_string(),
|
llm_backend: "nearai".to_string(),
|
||||||
tenant_rates: std::sync::Arc::new(crate::tenant::TenantRateRegistry::new(4, 3)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
TestHarness {
|
TestHarness {
|
||||||
|
|||||||
@@ -650,23 +650,6 @@ pub(crate) fn routine_update_parameters_schema() -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROUTINE_LAST_NAME_STASH_KEY: &str = "__routine_last_name";
|
|
||||||
|
|
||||||
async fn stash_last_routine_name(ctx: &JobContext, name: &str) {
|
|
||||||
ctx.tool_output_stash
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(ROUTINE_LAST_NAME_STASH_KEY.to_string(), name.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restore_last_routine_name(ctx: &JobContext) -> Option<String> {
|
|
||||||
ctx.tool_output_stash
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get(ROUTINE_LAST_NAME_STASH_KEY)
|
|
||||||
.cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map<String, Value>> {
|
fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map<String, Value>> {
|
||||||
params.get(field).and_then(Value::as_object)
|
params.get(field).and_then(Value::as_object)
|
||||||
}
|
}
|
||||||
@@ -932,7 +915,7 @@ fn parse_routine_create_request(
|
|||||||
fn build_routine_trigger(trigger: &NormalizedTriggerRequest) -> Trigger {
|
fn build_routine_trigger(trigger: &NormalizedTriggerRequest) -> Trigger {
|
||||||
match trigger {
|
match trigger {
|
||||||
NormalizedTriggerRequest::Cron { schedule, timezone } => Trigger::Cron {
|
NormalizedTriggerRequest::Cron { schedule, timezone } => Trigger::Cron {
|
||||||
schedule: normalize_cron_expression(schedule),
|
schedule: schedule.clone(),
|
||||||
timezone: timezone.clone(),
|
timezone: timezone.clone(),
|
||||||
},
|
},
|
||||||
NormalizedTriggerRequest::Manual => Trigger::Manual,
|
NormalizedTriggerRequest::Manual => Trigger::Manual,
|
||||||
@@ -1110,7 +1093,6 @@ impl Tool for RoutineCreateTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let normalized = parse_routine_create_request(¶ms)?;
|
let normalized = parse_routine_create_request(¶ms)?;
|
||||||
stash_last_routine_name(ctx, &normalized.name).await;
|
|
||||||
let trigger = build_routine_trigger(&normalized.trigger);
|
let trigger = build_routine_trigger(&normalized.trigger);
|
||||||
let action =
|
let action =
|
||||||
build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution);
|
build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution);
|
||||||
@@ -1292,7 +1274,6 @@ impl Tool for RoutineUpdateTool {
|
|||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = require_str(¶ms, "name")?;
|
let name = require_str(¶ms, "name")?;
|
||||||
stash_last_routine_name(ctx, name).await;
|
|
||||||
|
|
||||||
let mut routine = self
|
let mut routine = self
|
||||||
.store
|
.store
|
||||||
@@ -1430,24 +1411,11 @@ impl Tool for RoutineDeleteTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = if let Some(name) = params.get("name").and_then(|v| v.as_str()) {
|
let name = require_str(¶ms, "name")?;
|
||||||
if name.trim().is_empty() {
|
|
||||||
return Err(ToolError::InvalidParameters(
|
|
||||||
"'name' parameter cannot be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
name.to_string()
|
|
||||||
} else {
|
|
||||||
restore_last_routine_name(ctx).await.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters(
|
|
||||||
"missing 'name' parameter and no previous routine target to infer".to_string(),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
};
|
|
||||||
|
|
||||||
let routine = self
|
let routine = self
|
||||||
.store
|
.store
|
||||||
.get_routine_by_name(&ctx.user_id, &name)
|
.get_routine_by_name(&ctx.user_id, name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
|
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
|
||||||
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
|
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
|
||||||
@@ -1462,7 +1430,7 @@ impl Tool for RoutineDeleteTool {
|
|||||||
self.engine.refresh_event_cache().await;
|
self.engine.refresh_event_cache().await;
|
||||||
|
|
||||||
let result = serde_json::json!({
|
let result = serde_json::json!({
|
||||||
"name": &name,
|
"name": name,
|
||||||
"deleted": deleted,
|
"deleted": deleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1868,20 +1836,6 @@ mod tests {
|
|||||||
assert_eq!(parsed.cooldown_secs, 30);
|
assert_eq!(parsed.cooldown_secs, 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_routine_trigger_normalizes_cron_schedule() {
|
|
||||||
let trigger = build_routine_trigger(&NormalizedTriggerRequest::Cron {
|
|
||||||
schedule: "0 0 9 * * MON-FRI".to_string(),
|
|
||||||
timezone: Some("UTC".to_string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
trigger,
|
|
||||||
Trigger::Cron { schedule, timezone }
|
|
||||||
if schedule == "0 0 9 * * MON-FRI *" && timezone.as_deref() == Some("UTC")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_grouped_message_event_with_tools() {
|
fn parses_grouped_message_event_with_tools() {
|
||||||
let params = serde_json::json!({
|
let params = serde_json::json!({
|
||||||
|
|||||||
+1
-19
@@ -117,11 +117,6 @@ impl McpClient {
|
|||||||
/// The config must use HTTP transport (the default); for stdio/UDS use `new_with_transport`.
|
/// The config must use HTTP transport (the default); for stdio/UDS use `new_with_transport`.
|
||||||
///
|
///
|
||||||
/// Returns an error if the config uses a non-HTTP transport.
|
/// Returns an error if the config uses a non-HTTP transport.
|
||||||
///
|
|
||||||
/// **Note:** The session manager is NOT wired into the transport. For
|
|
||||||
/// production use, prefer `create_client_from_config()` which constructs
|
|
||||||
/// the transport with session tracking.
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn new_with_config(config: McpServerConfig) -> Result<Self, ToolError> {
|
pub fn new_with_config(config: McpServerConfig) -> Result<Self, ToolError> {
|
||||||
if !matches!(
|
if !matches!(
|
||||||
config.effective_transport(),
|
config.effective_transport(),
|
||||||
@@ -219,14 +214,7 @@ impl McpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach a session manager to the **client** only.
|
/// Attach a session manager for Streamable HTTP session tracking.
|
||||||
///
|
|
||||||
/// **Warning:** This does NOT wire the session manager into the underlying
|
|
||||||
/// `HttpMcpTransport`, so the transport will not capture `Mcp-Session-Id`
|
|
||||||
/// from responses. For production use, construct the transport with
|
|
||||||
/// `HttpMcpTransport::with_session_manager()` and pass it to
|
|
||||||
/// `new_with_transport()` instead. See `create_client_from_config()`.
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn with_session_manager(mut self, session_manager: Arc<McpSessionManager>) -> Self {
|
pub fn with_session_manager(mut self, session_manager: Arc<McpSessionManager>) -> Self {
|
||||||
self.session_manager = Some(session_manager);
|
self.session_manager = Some(session_manager);
|
||||||
self
|
self
|
||||||
@@ -247,12 +235,6 @@ impl McpClient {
|
|||||||
self.session_manager.is_some()
|
self.session_manager.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the underlying transport (test-only).
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn transport(&self) -> &Arc<dyn McpTransport> {
|
|
||||||
&self.transport
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the next request ID.
|
/// Get the next request ID.
|
||||||
fn next_request_id(&self) -> u64 {
|
fn next_request_id(&self) -> u64 {
|
||||||
self.next_id.fetch_add(1, Ordering::SeqCst)
|
self.next_id.fetch_add(1, Ordering::SeqCst)
|
||||||
|
|||||||
+16
-101
@@ -7,7 +7,6 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::secrets::SecretsStore;
|
use crate::secrets::SecretsStore;
|
||||||
use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig};
|
use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig};
|
||||||
use crate::tools::mcp::http_transport::HttpMcpTransport;
|
|
||||||
use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport};
|
use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport};
|
||||||
|
|
||||||
/// Error returned when MCP client creation fails.
|
/// Error returned when MCP client creation fails.
|
||||||
@@ -79,37 +78,33 @@ pub async fn create_client_from_config(
|
|||||||
Err(McpFactoryError::UnixNotSupported { name: server_name })
|
Err(McpFactoryError::UnixNotSupported { name: server_name })
|
||||||
}
|
}
|
||||||
EffectiveTransport::Http => {
|
EffectiveTransport::Http => {
|
||||||
// Authenticated (OAuth) path: tokens exist or server requires auth.
|
|
||||||
if let Some(ref secrets) = secrets {
|
if let Some(ref secrets) = secrets {
|
||||||
let has_tokens =
|
let has_tokens =
|
||||||
crate::tools::mcp::is_authenticated(&server, secrets, user_id).await;
|
crate::tools::mcp::is_authenticated(&server, secrets, user_id).await;
|
||||||
|
|
||||||
if has_tokens || server.requires_auth() {
|
if has_tokens || server.requires_auth() {
|
||||||
return Ok(McpClient::new_authenticated(
|
Ok(McpClient::new_authenticated(
|
||||||
server,
|
server,
|
||||||
Arc::clone(session_manager),
|
Arc::clone(session_manager),
|
||||||
Arc::clone(secrets),
|
Arc::clone(secrets),
|
||||||
user_id,
|
user_id,
|
||||||
));
|
))
|
||||||
|
} else {
|
||||||
|
Ok(McpClient::new_with_config(server)
|
||||||
|
.map_err(|e| McpFactoryError::InvalidConfig {
|
||||||
|
name: server_name.clone(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?
|
||||||
|
.with_session_manager(Arc::clone(session_manager)))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Ok(McpClient::new_with_config(server)
|
||||||
|
.map_err(|e| McpFactoryError::InvalidConfig {
|
||||||
|
name: server_name,
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?
|
||||||
|
.with_session_manager(Arc::clone(session_manager)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-OAuth HTTP: wire the session manager into the *transport* so
|
|
||||||
// it captures `Mcp-Session-Id` from responses. Passing it only to
|
|
||||||
// the client (via `with_session_manager`) is not enough — the
|
|
||||||
// transport must know about it to read/write the header.
|
|
||||||
let transport = Arc::new(
|
|
||||||
HttpMcpTransport::new(server.url.clone(), server.name.clone())
|
|
||||||
.with_session_manager(Arc::clone(session_manager)),
|
|
||||||
);
|
|
||||||
Ok(McpClient::new_with_transport(
|
|
||||||
server.name.clone(),
|
|
||||||
transport,
|
|
||||||
Some(Arc::clone(session_manager)),
|
|
||||||
secrets,
|
|
||||||
user_id,
|
|
||||||
Some(server),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,84 +134,4 @@ mod tests {
|
|||||||
"non-OAuth HTTP clients must carry a session manager"
|
"non-OAuth HTTP clients must carry a session manager"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test: the factory must wire the session manager into the
|
|
||||||
/// *transport*, not just the client. Otherwise the transport never
|
|
||||||
/// captures `Mcp-Session-Id` from responses and subsequent requests
|
|
||||||
/// lack the header, causing the server to reject them.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_factory_non_oauth_http_transport_captures_session_id() {
|
|
||||||
use axum::http::header::HeaderName;
|
|
||||||
use axum::{Router, http::StatusCode, response::IntoResponse, routing::post};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
const SESSION_ID: &str = "test-session-abc123";
|
|
||||||
|
|
||||||
async fn session_echo() -> impl IntoResponse {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
|
||||||
"result": {}
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
(
|
|
||||||
StatusCode::OK,
|
|
||||||
[(
|
|
||||||
HeaderName::from_static("mcp-session-id"),
|
|
||||||
SESSION_ID.to_string(),
|
|
||||||
)],
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let app = Router::new().route("/", post(session_echo));
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let url = format!("http://127.0.0.1:{}", addr.port());
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let server = McpServerConfig::new("session-test", &url);
|
|
||||||
let session_manager = Arc::new(McpSessionManager::new());
|
|
||||||
let process_manager = Arc::new(McpProcessManager::new());
|
|
||||||
|
|
||||||
let client = create_client_from_config(
|
|
||||||
server,
|
|
||||||
&session_manager,
|
|
||||||
&process_manager,
|
|
||||||
None,
|
|
||||||
"test-user",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("factory should succeed for HTTP config");
|
|
||||||
|
|
||||||
// Pre-create a session entry so that update_session_id has something to update.
|
|
||||||
// In production, the MCP initialize handshake calls get_or_create before responses arrive.
|
|
||||||
session_manager.get_or_create("session-test", &url).await;
|
|
||||||
|
|
||||||
// Send a request through the client's transport to trigger session capture.
|
|
||||||
use crate::tools::mcp::protocol::McpRequest;
|
|
||||||
let request = McpRequest {
|
|
||||||
jsonrpc: "2.0".to_string(),
|
|
||||||
id: Some(1),
|
|
||||||
method: "test".to_string(),
|
|
||||||
params: Some(serde_json::json!({})),
|
|
||||||
};
|
|
||||||
let headers = std::collections::HashMap::new();
|
|
||||||
client
|
|
||||||
.transport()
|
|
||||||
.send(&request, &headers)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
// Verify the session manager captured the session ID from the response.
|
|
||||||
let captured = session_manager.get_session_id("session-test").await;
|
|
||||||
assert_eq!(
|
|
||||||
captured.as_deref(),
|
|
||||||
Some(SESSION_ID),
|
|
||||||
"transport must capture Mcp-Session-Id into session manager"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,34 +494,6 @@ mod tests {
|
|||||||
assert_eq!(echoed["authorization"], "Bearer oauth-token");
|
assert_eq!(echoed["authorization"], "Bearer oauth-token");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test for #1436: 202 Accepted responses for notifications
|
|
||||||
/// were parsed as JSON, causing "Failed to parse MCP response" errors
|
|
||||||
/// that broke the MCP session handshake.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_wire_202_accepted_for_notification() {
|
|
||||||
use axum::{Router, http::StatusCode, routing::post};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
async fn accept_notification() -> StatusCode {
|
|
||||||
StatusCode::ACCEPTED
|
|
||||||
}
|
|
||||||
|
|
||||||
let app = Router::new().route("/", post(accept_notification));
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let url = format!("http://127.0.0.1:{}", addr.port());
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let transport = HttpMcpTransport::new(&url, "test-202");
|
|
||||||
let request = McpRequest::initialized_notification();
|
|
||||||
let response = transport.send(&request, &HashMap::new()).await.unwrap();
|
|
||||||
assert!(response.result.is_none());
|
|
||||||
assert!(response.error.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_wire_custom_auth_preserved_when_no_per_request_auth() {
|
async fn test_wire_custom_auth_preserved_when_no_per_request_auth() {
|
||||||
let (url, _handle) = spawn_echo_server().await;
|
let (url, _handle) = spawn_echo_server().await;
|
||||||
|
|||||||
@@ -446,14 +446,16 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefr
|
|||||||
builtin.as_ref(),
|
builtin.as_ref(),
|
||||||
exchange_proxy_url.is_some(),
|
exchange_proxy_url.is_some(),
|
||||||
);
|
);
|
||||||
let oauth_proxy_auth_token = crate::cli::oauth_defaults::oauth_proxy_auth_token();
|
let gateway_token = crate::config::helpers::env_or_override("GATEWAY_AUTH_TOKEN")
|
||||||
|
.map(|token| token.trim().to_string())
|
||||||
|
.filter(|token| !token.is_empty());
|
||||||
|
|
||||||
Some(OAuthRefreshConfig {
|
Some(OAuthRefreshConfig {
|
||||||
token_url: oauth.token_url.clone(),
|
token_url: oauth.token_url.clone(),
|
||||||
client_id,
|
client_id,
|
||||||
client_secret,
|
client_secret,
|
||||||
exchange_proxy_url,
|
exchange_proxy_url,
|
||||||
gateway_token: oauth_proxy_auth_token,
|
gateway_token,
|
||||||
secret_name: auth.secret_name.clone(),
|
secret_name: auth.secret_name.clone(),
|
||||||
provider: auth.provider.clone(),
|
provider: auth.provider.clone(),
|
||||||
})
|
})
|
||||||
@@ -889,11 +891,6 @@ mod tests {
|
|||||||
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
let _guard = lock_env();
|
|
||||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
|
|
||||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
|
||||||
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
|
||||||
|
|
||||||
let caps = CapabilitiesFile {
|
let caps = CapabilitiesFile {
|
||||||
auth: Some(AuthCapabilitySchema {
|
auth: Some(AuthCapabilitySchema {
|
||||||
secret_name: "google_oauth_token".to_string(),
|
secret_name: "google_oauth_token".to_string(),
|
||||||
@@ -985,7 +982,6 @@ mod tests {
|
|||||||
let _guard = lock_env();
|
let _guard = lock_env();
|
||||||
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
|
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
|
||||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
|
||||||
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
|
||||||
|
|
||||||
// google_oauth_token should fall back to built-in credentials
|
// google_oauth_token should fall back to built-in credentials
|
||||||
let caps = CapabilitiesFile {
|
let caps = CapabilitiesFile {
|
||||||
@@ -1025,7 +1021,6 @@ mod tests {
|
|||||||
Some("https://compose-api.example.com"),
|
Some("https://compose-api.example.com"),
|
||||||
);
|
);
|
||||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||||
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
|
||||||
let _client_id_guard =
|
let _client_id_guard =
|
||||||
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
||||||
|
|
||||||
@@ -1066,7 +1061,6 @@ mod tests {
|
|||||||
Some("https://compose-api.example.com"),
|
Some("https://compose-api.example.com"),
|
||||||
);
|
);
|
||||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
||||||
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
|
|
||||||
let _client_id_guard =
|
let _client_id_guard =
|
||||||
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
||||||
let _client_secret_guard =
|
let _client_secret_guard =
|
||||||
@@ -1101,47 +1095,6 @@ mod tests {
|
|||||||
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
|
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_resolve_oauth_refresh_config_hosted_proxy_prefers_dedicated_proxy_auth_token() {
|
|
||||||
use crate::tools::wasm::capabilities_schema::{
|
|
||||||
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
|
||||||
};
|
|
||||||
|
|
||||||
let _guard = lock_env();
|
|
||||||
let _proxy_guard = set_env_var(
|
|
||||||
"IRONCLAW_OAUTH_EXCHANGE_URL",
|
|
||||||
Some("https://compose-api.example.com"),
|
|
||||||
);
|
|
||||||
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
|
|
||||||
let _oauth_proxy_token_guard = set_env_var(
|
|
||||||
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
|
|
||||||
Some("shared-oauth-proxy-secret"),
|
|
||||||
);
|
|
||||||
let _client_id_guard =
|
|
||||||
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
|
|
||||||
|
|
||||||
let caps = CapabilitiesFile {
|
|
||||||
auth: Some(AuthCapabilitySchema {
|
|
||||||
secret_name: "google_oauth_token".to_string(),
|
|
||||||
provider: Some("google".to_string()),
|
|
||||||
oauth: Some(OAuthConfigSchema {
|
|
||||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
|
||||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
|
||||||
client_id_env: Some("GOOGLE_OAUTH_CLIENT_ID".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = super::resolve_oauth_refresh_config(&caps).expect("hosted oauth config");
|
|
||||||
assert_eq!(
|
|
||||||
config.gateway_token.as_deref(),
|
|
||||||
Some("shared-oauth-proxy-secret")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Security regression tests
|
// Security regression tests
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -62,8 +62,7 @@ pub struct OAuthRefreshConfig {
|
|||||||
pub client_secret: Option<String>,
|
pub client_secret: Option<String>,
|
||||||
/// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080").
|
/// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080").
|
||||||
pub exchange_proxy_url: Option<String>,
|
pub exchange_proxy_url: Option<String>,
|
||||||
/// OAuth proxy auth token for authenticating with the hosted OAuth proxy.
|
/// Gateway auth token for authenticating with the hosted OAuth proxy.
|
||||||
/// Kept as `gateway_token` for public API compatibility.
|
|
||||||
pub gateway_token: Option<String>,
|
pub gateway_token: Option<String>,
|
||||||
/// Secret name of the access token (e.g., "google_oauth_token").
|
/// Secret name of the access token (e.g., "google_oauth_token").
|
||||||
/// The refresh token lives at `{secret_name}_refresh_token`.
|
/// The refresh token lives at `{secret_name}_refresh_token`.
|
||||||
@@ -72,12 +71,6 @@ pub struct OAuthRefreshConfig {
|
|||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OAuthRefreshConfig {
|
|
||||||
fn oauth_proxy_auth_token(&self) -> Option<&str> {
|
|
||||||
self.gateway_token.as_deref()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pre-resolved credential for host-based injection.
|
/// Pre-resolved credential for host-based injection.
|
||||||
///
|
///
|
||||||
/// Built before each WASM execution by decrypting secrets from the store.
|
/// Built before each WASM execution by decrypting secrets from the store.
|
||||||
@@ -1225,9 +1218,9 @@ async fn refresh_oauth_token(
|
|||||||
let refresh_name = format!("{}_refresh_token", config.secret_name);
|
let refresh_name = format!("{}_refresh_token", config.secret_name);
|
||||||
|
|
||||||
if let Some(proxy_url) = config.exchange_proxy_url.as_deref() {
|
if let Some(proxy_url) = config.exchange_proxy_url.as_deref() {
|
||||||
let Some(oauth_proxy_auth_token) = config.oauth_proxy_auth_token() else {
|
let Some(gateway_token) = config.gateway_token.as_deref() else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"OAuth refresh proxy is configured, but no OAuth proxy auth token is available"
|
"OAuth refresh proxy is configured, but no gateway auth token is available"
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -1242,7 +1235,7 @@ async fn refresh_oauth_token(
|
|||||||
let token_response = match oauth_defaults::refresh_token_via_proxy(
|
let token_response = match oauth_defaults::refresh_token_via_proxy(
|
||||||
oauth_defaults::ProxyRefreshTokenRequest {
|
oauth_defaults::ProxyRefreshTokenRequest {
|
||||||
proxy_url,
|
proxy_url,
|
||||||
gateway_token: oauth_proxy_auth_token,
|
gateway_token,
|
||||||
token_url: &config.token_url,
|
token_url: &config.token_url,
|
||||||
client_id: &config.client_id,
|
client_id: &config.client_id,
|
||||||
client_secret: config.client_secret.as_deref(),
|
client_secret: config.client_secret.as_deref(),
|
||||||
@@ -2711,8 +2704,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_oauth_proxy_auth_token()
|
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_gateway_token() {
|
||||||
{
|
|
||||||
use crate::secrets::{
|
use crate::secrets::{
|
||||||
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-5
@@ -20,6 +20,7 @@ use crate::agent::scheduler::WorkerMessage;
|
|||||||
use crate::agent::task::TaskOutput;
|
use crate::agent::task::TaskOutput;
|
||||||
use crate::channels::web::types::ToolDecisionDto;
|
use crate::channels::web::types::ToolDecisionDto;
|
||||||
use crate::context::{ContextManager, JobState};
|
use crate::context::{ContextManager, JobState};
|
||||||
|
use crate::db::Database;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::hooks::HookRegistry;
|
use crate::hooks::HookRegistry;
|
||||||
use crate::llm::{
|
use crate::llm::{
|
||||||
@@ -27,7 +28,6 @@ use crate::llm::{
|
|||||||
ToolSelection,
|
ToolSelection,
|
||||||
};
|
};
|
||||||
use crate::safety::SafetyLayer;
|
use crate::safety::SafetyLayer;
|
||||||
use crate::tenant::AdminScope;
|
|
||||||
use crate::tools::execute::process_tool_result;
|
use crate::tools::execute::process_tool_result;
|
||||||
use crate::tools::rate_limiter::RateLimitResult;
|
use crate::tools::rate_limiter::RateLimitResult;
|
||||||
use crate::tools::{
|
use crate::tools::{
|
||||||
@@ -45,7 +45,7 @@ pub struct WorkerDeps {
|
|||||||
pub llm: Arc<dyn LlmProvider>,
|
pub llm: Arc<dyn LlmProvider>,
|
||||||
pub safety: Arc<SafetyLayer>,
|
pub safety: Arc<SafetyLayer>,
|
||||||
pub tools: Arc<ToolRegistry>,
|
pub tools: Arc<ToolRegistry>,
|
||||||
pub store: Option<AdminScope>,
|
pub store: Option<Arc<dyn Database>>,
|
||||||
pub hooks: Arc<HookRegistry>,
|
pub hooks: Arc<HookRegistry>,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
pub use_planning: bool,
|
pub use_planning: bool,
|
||||||
@@ -94,7 +94,7 @@ impl Worker {
|
|||||||
&self.deps.tools
|
&self.deps.tools
|
||||||
}
|
}
|
||||||
|
|
||||||
fn store(&self) -> Option<&AdminScope> {
|
fn store(&self) -> Option<&Arc<dyn Database>> {
|
||||||
self.deps.store.as_ref()
|
self.deps.store.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,7 +1158,6 @@ impl<'a> JobDelegate<'a> {
|
|||||||
Ok(crate::llm::RespondOutput {
|
Ok(crate::llm::RespondOutput {
|
||||||
result: RespondResult::Text(String::new()),
|
result: RespondResult::Text(String::new()),
|
||||||
usage: crate::llm::TokenUsage::default(),
|
usage: crate::llm::TokenUsage::default(),
|
||||||
finish_reason: crate::llm::FinishReason::Stop,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1284,7 +1283,6 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
|
|||||||
content: reasoning_text,
|
content: reasoning_text,
|
||||||
},
|
},
|
||||||
usage: crate::llm::TokenUsage::default(),
|
usage: crate::llm::TokenUsage::default(),
|
||||||
finish_reason: crate::llm::FinishReason::ToolUse,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(_) => {} // empty selections, fall through
|
Ok(_) => {} // empty selections, fall through
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ fn reject_if_injected(path: &str, content: &str) -> Result<(), WorkspaceError> {
|
|||||||
///
|
///
|
||||||
/// Allows Workspace to work with either a PostgreSQL `Repository` (the original
|
/// Allows Workspace to work with either a PostgreSQL `Repository` (the original
|
||||||
/// path) or any `Database` trait implementation (e.g. libSQL backend).
|
/// path) or any `Database` trait implementation (e.g. libSQL backend).
|
||||||
#[derive(Clone)]
|
|
||||||
enum WorkspaceStorage {
|
enum WorkspaceStorage {
|
||||||
/// PostgreSQL-backed repository (uses connection pool directly).
|
/// PostgreSQL-backed repository (uses connection pool directly).
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
@@ -577,60 +576,6 @@ impl Workspace {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clone the workspace configuration for a different primary user scope.
|
|
||||||
///
|
|
||||||
/// This preserves search config, embeddings, shared read scopes, memory
|
|
||||||
/// layers, and privacy classifier while switching the primary read/write
|
|
||||||
/// scope to `user_id`.
|
|
||||||
pub fn scoped_to_user(&self, user_id: impl Into<String>) -> Self {
|
|
||||||
let user_id = user_id.into();
|
|
||||||
|
|
||||||
let mut memory_layers = self.memory_layers.clone();
|
|
||||||
for layer in &mut memory_layers {
|
|
||||||
if layer.sensitivity == crate::workspace::layer::LayerSensitivity::Private
|
|
||||||
&& layer.scope == self.user_id
|
|
||||||
{
|
|
||||||
layer.scope = user_id.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut read_user_ids = vec![user_id.clone()];
|
|
||||||
for scope in &self.read_user_ids {
|
|
||||||
if scope != &self.user_id && !read_user_ids.contains(scope) {
|
|
||||||
read_user_ids.push(scope.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for scope in crate::workspace::layer::MemoryLayer::read_scopes(&memory_layers) {
|
|
||||||
if !read_user_ids.contains(&scope) {
|
|
||||||
read_user_ids.push(scope);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let preserve_flags = user_id == self.user_id;
|
|
||||||
Self {
|
|
||||||
user_id,
|
|
||||||
read_user_ids,
|
|
||||||
agent_id: self.agent_id,
|
|
||||||
storage: self.storage.clone(),
|
|
||||||
embeddings: self.embeddings.clone(),
|
|
||||||
bootstrap_pending: std::sync::atomic::AtomicBool::new(if preserve_flags {
|
|
||||||
self.bootstrap_pending
|
|
||||||
.load(std::sync::atomic::Ordering::Acquire)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}),
|
|
||||||
bootstrap_completed: std::sync::atomic::AtomicBool::new(if preserve_flags {
|
|
||||||
self.bootstrap_completed
|
|
||||||
.load(std::sync::atomic::Ordering::Acquire)
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}),
|
|
||||||
search_defaults: self.search_defaults.clone(),
|
|
||||||
memory_layers,
|
|
||||||
privacy_classifier: self.privacy_classifier.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the user ID (primary scope for writes).
|
/// Get the user ID (primary scope for writes).
|
||||||
pub fn user_id(&self) -> &str {
|
pub fn user_id(&self) -> &str {
|
||||||
&self.user_id
|
&self.user_id
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use crate::workspace::document::{MemoryChunk, MemoryDocument, WorkspaceEntry};
|
|||||||
use crate::workspace::search::{RankedResult, SearchConfig, SearchResult, fuse_results};
|
use crate::workspace::search::{RankedResult, SearchConfig, SearchResult, fuse_results};
|
||||||
|
|
||||||
/// Database repository for workspace operations.
|
/// Database repository for workspace operations.
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Repository {
|
pub struct Repository {
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,44 +205,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Test 5: routine_update_fail_delete_fallback
|
// Test 5: routine_manual_create_defaults_to_tools_enabled
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn routine_update_fail_delete_fallback() {
|
|
||||||
let trace = LlmTrace::from_file(concat!(
|
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
|
||||||
"/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json"
|
|
||||||
))
|
|
||||||
.expect("failed to load routine_update_fail_delete_fallback.json");
|
|
||||||
|
|
||||||
let rig = TestRigBuilder::new()
|
|
||||||
.with_trace(trace.clone())
|
|
||||||
.with_auto_approve_tools(true)
|
|
||||||
.build()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
rig.send_message("Try converting a routine trigger, then recover by deleting it")
|
|
||||||
.await;
|
|
||||||
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
|
|
||||||
|
|
||||||
rig.verify_trace_expects(&trace, &responses);
|
|
||||||
|
|
||||||
let completed = rig.tool_calls_completed();
|
|
||||||
assert!(
|
|
||||||
completed.iter().any(|(n, ok)| n == "routine_update" && !ok),
|
|
||||||
"routine_update should fail in this regression path: {completed:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
completed.iter().any(|(n, ok)| n == "routine_delete" && *ok),
|
|
||||||
"routine_delete should recover successfully via preserved routine identity: {completed:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
rig.shutdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Test 6: routine_manual_create_defaults_to_tools_enabled
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -283,7 +246,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Test 7: routine_manual_create_explicit_no_tools
|
// Test 6: routine_manual_create_explicit_no_tools
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -324,7 +287,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Test 8: routine_history
|
// Test 7: routine_history
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -476,7 +439,7 @@ mod tests {
|
|||||||
|
|
||||||
match &routine.trigger {
|
match &routine.trigger {
|
||||||
Trigger::Cron { schedule, timezone } => {
|
Trigger::Cron { schedule, timezone } => {
|
||||||
assert_eq!(schedule, "0 0 9 * * MON-FRI *");
|
assert_eq!(schedule, "0 0 9 * * MON-FRI");
|
||||||
assert_eq!(timezone.as_deref(), Some("UTC"));
|
assert_eq!(timezone.as_deref(), Some("UTC"));
|
||||||
}
|
}
|
||||||
other => panic!("expected cron trigger, got {other:?}"),
|
other => panic!("expected cron trigger, got {other:?}"),
|
||||||
|
|||||||
@@ -337,14 +337,14 @@ mod tests {
|
|||||||
SchedulerDeps {
|
SchedulerDeps {
|
||||||
tools: registry.clone(),
|
tools: registry.clone(),
|
||||||
extension_manager: extension_manager.clone(),
|
extension_manager: extension_manager.clone(),
|
||||||
store: Some(ironclaw::tenant::AdminScope::new(db.clone())),
|
store: Some(db.clone()),
|
||||||
hooks: Arc::new(HookRegistry::new()),
|
hooks: Arc::new(HookRegistry::new()),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
Arc::new(RoutineEngine::new(
|
Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
ironclaw::tenant::AdminScope::new(db),
|
db,
|
||||||
llm,
|
llm,
|
||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -448,7 +448,7 @@ mod tests {
|
|||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
ironclaw::tenant::AdminScope::new(db.clone()),
|
db.clone(),
|
||||||
llm,
|
llm,
|
||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -527,7 +527,7 @@ mod tests {
|
|||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
ironclaw::tenant::AdminScope::new(db.clone()),
|
db.clone(),
|
||||||
llm,
|
llm,
|
||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -614,7 +614,7 @@ mod tests {
|
|||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
ironclaw::tenant::AdminScope::new(db.clone()),
|
db.clone(),
|
||||||
llm,
|
llm,
|
||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -723,7 +723,7 @@ mod tests {
|
|||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
ironclaw::tenant::AdminScope::new(db.clone()),
|
db.clone(),
|
||||||
llm,
|
llm,
|
||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -866,7 +866,7 @@ mod tests {
|
|||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
ironclaw::tenant::AdminScope::new(db.clone()),
|
db.clone(),
|
||||||
llm,
|
llm,
|
||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -1049,7 +1049,7 @@ mod tests {
|
|||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
ironclaw::tenant::AdminScope::new(Arc::clone(&db)),
|
Arc::clone(&db),
|
||||||
llm,
|
llm,
|
||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -1171,7 +1171,7 @@ mod tests {
|
|||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
ironclaw::tenant::AdminScope::new(db.clone()),
|
db.clone(),
|
||||||
llm,
|
llm,
|
||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -1279,7 +1279,7 @@ mod tests {
|
|||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
config,
|
config,
|
||||||
ironclaw::tenant::AdminScope::new(db.clone()),
|
db.clone(),
|
||||||
llm,
|
llm,
|
||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
|
|||||||
@@ -201,7 +201,6 @@ mod tests {
|
|||||||
sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig,
|
sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig,
|
||||||
builder: None,
|
builder: None,
|
||||||
llm_backend: "nearai".to_string(),
|
llm_backend: "nearai".to_string(),
|
||||||
tenant_rates: std::sync::Arc::new(ironclaw::tenant::TenantRateRegistry::new(4, 3)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let gateway = Arc::new(TestChannel::new());
|
let gateway = Arc::new(TestChannel::new());
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ mod tests {
|
|||||||
|
|
||||||
use crate::support::test_rig::TestRigBuilder;
|
use crate::support::test_rig::TestRigBuilder;
|
||||||
use crate::support::trace_llm::LlmTrace;
|
use crate::support::trace_llm::LlmTrace;
|
||||||
use ironclaw::workspace::Workspace;
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Test 1: write_chunk_search
|
// Test 1: write_chunk_search
|
||||||
@@ -269,7 +268,6 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn identity_in_system_prompt() {
|
async fn identity_in_system_prompt() {
|
||||||
const TEST_USER_ID: &str = "test-user";
|
|
||||||
let trace = LlmTrace::from_file(concat!(
|
let trace = LlmTrace::from_file(concat!(
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
"/tests/fixtures/llm_traces/workspace/identity_prompt.json"
|
"/tests/fixtures/llm_traces/workspace/identity_prompt.json"
|
||||||
@@ -282,7 +280,7 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Seed an IDENTITY.md so the system prompt has real content to inject.
|
// Seed an IDENTITY.md so the system prompt has real content to inject.
|
||||||
let ws = Workspace::new_with_db(TEST_USER_ID, rig.database().clone());
|
let ws = rig.workspace().expect("workspace must be available");
|
||||||
ws.write(
|
ws.write(
|
||||||
"IDENTITY.md",
|
"IDENTITY.md",
|
||||||
"I am TestBot, a helpful testing assistant created for E2E verification.",
|
"I am TestBot, a helpful testing assistant created for E2E verification.",
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
{
|
|
||||||
"model_name": "test-routine-update-fail-delete-fallback",
|
|
||||||
"expects": {
|
|
||||||
"tools_used": ["routine_create", "routine_update", "routine_delete"],
|
|
||||||
"tool_results_contain": {
|
|
||||||
"routine_update": "Cannot update schedule or timezone on a non-cron routine.",
|
|
||||||
"routine_delete": "temp-routine"
|
|
||||||
},
|
|
||||||
"min_responses": 1
|
|
||||||
},
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"response": {
|
|
||||||
"type": "tool_calls",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_rc_fallback",
|
|
||||||
"name": "routine_create",
|
|
||||||
"arguments": {
|
|
||||||
"name": "temp-routine",
|
|
||||||
"trigger_type": "manual",
|
|
||||||
"prompt": "Temporary routine for fallback test."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"input_tokens": 120,
|
|
||||||
"output_tokens": 40
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"response": {
|
|
||||||
"type": "tool_calls",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_ru_fallback",
|
|
||||||
"name": "routine_update",
|
|
||||||
"arguments": {
|
|
||||||
"name": "temp-routine",
|
|
||||||
"schedule": "0 */10 * * * *"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"input_tokens": 200,
|
|
||||||
"output_tokens": 30
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"response": {
|
|
||||||
"type": "tool_calls",
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_rd_fallback",
|
|
||||||
"name": "routine_delete",
|
|
||||||
"arguments": {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"input_tokens": 300,
|
|
||||||
"output_tokens": 20
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"response": {
|
|
||||||
"type": "text",
|
|
||||||
"content": "I recovered from the failed update and cleaned up the original routine.",
|
|
||||||
"input_tokens": 380,
|
|
||||||
"output_tokens": 25
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
//! Regression tests for multi-tenant system prompts.
|
//! Tests proving that multi-tenant system prompts are broken.
|
||||||
//!
|
//!
|
||||||
//! The agent must build the conversational system prompt from a workspace
|
//! Bug: In multi-tenant mode, the agent loop uses `self.workspace()` which
|
||||||
//! scoped to the incoming message's user, not from the shared owner-scope
|
//! returns a single shared workspace (user_id="default"). Identity files
|
||||||
//! workspace created at startup. Otherwise per-user identity files
|
//! (IDENTITY.md, SOUL.md, USER.md) seeded under per-user IDs ("alice",
|
||||||
//! (IDENTITY.md, SOUL.md, USER.md) become invisible and different users can
|
//! "bob") are invisible to this workspace, so the system prompt is
|
||||||
//! see the same owner-scoped prompt.
|
//! empty/wrong.
|
||||||
//!
|
//!
|
||||||
//! These tests:
|
//! These tests:
|
||||||
//! 1. Seed identity files for two users (alice, bob) in the database
|
//! 1. Seed identity files for two users (alice, bob) in the database
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
//! correct user's identity
|
//! correct user's identity
|
||||||
//! 4. Verify user A's identity doesn't leak into user B's prompt
|
//! 4. Verify user A's identity doesn't leak into user B's prompt
|
||||||
//!
|
//!
|
||||||
//! These tests ensure each user's identity is isolated correctly.
|
//! All tests are expected to FAIL until the bug is fixed.
|
||||||
|
|
||||||
#[cfg(feature = "libsql")]
|
#[cfg(feature = "libsql")]
|
||||||
mod support;
|
mod support;
|
||||||
|
|||||||
@@ -266,7 +266,6 @@ impl GatewayWorkflowHarness {
|
|||||||
sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig,
|
sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig,
|
||||||
builder: None,
|
builder: None,
|
||||||
llm_backend: "nearai".to_string(),
|
llm_backend: "nearai".to_string(),
|
||||||
tenant_rates: std::sync::Arc::new(ironclaw::tenant::TenantRateRegistry::new(4, 3)),
|
|
||||||
},
|
},
|
||||||
channels,
|
channels,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -642,7 +642,7 @@ impl TestRigBuilder {
|
|||||||
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
|
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
routine_config,
|
routine_config,
|
||||||
ironclaw::tenant::AdminScope::new(Arc::clone(db_arc)),
|
Arc::clone(db_arc),
|
||||||
components.llm.clone(),
|
components.llm.clone(),
|
||||||
Arc::clone(ws),
|
Arc::clone(ws),
|
||||||
notify_tx,
|
notify_tx,
|
||||||
@@ -762,7 +762,6 @@ impl TestRigBuilder {
|
|||||||
sandbox_readiness: ironclaw::agent::SandboxReadiness::Available, // tests don't use real Docker
|
sandbox_readiness: ironclaw::agent::SandboxReadiness::Available, // tests don't use real Docker
|
||||||
builder: None,
|
builder: None,
|
||||||
llm_backend: "nearai".to_string(),
|
llm_backend: "nearai".to_string(),
|
||||||
tenant_rates: std::sync::Arc::new(ironclaw::tenant::TenantRateRegistry::new(4, 3)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 7. Create TestChannel and ChannelManager.
|
// 7. Create TestChannel and ChannelManager.
|
||||||
|
|||||||
Reference in New Issue
Block a user