feat/openclaw (#128)

* feat: add initial project structure and documentation

- Introduced the GNU General Public License (GPL) v3 in LICENSE file.
- Added MCP configuration in .claude/mcp.json for server integration.
- Created architecture documentation in docs/ARCHITECTURE.md outlining the platform's design and components.
- Defined MVP specifications in docs/MVP.md for the Telegram-based Agent Assistant.
- Established API reference for team management in docs/teams-api-reference.md.
- Set up basic HTML structure in public/index.html and added logo image in public/logo.png.

* feat: add initial project documentation and HTML structure

- Introduced CODE_OF_CONDUCT.md to establish community guidelines and standards for behavior.
- Created CONTRIBUTING.md to outline contribution process, development setup, and project conventions.
- Added SECURITY.md to define the security policy, supported versions, and reporting procedures for vulnerabilities.
- Established basic HTML structure in index.html for the application interface.

* chore: remove hello-python skill files

- Deleted skill.json and skill.py files for the Hello Python example runtime skill, as they are no longer needed in the project.

* feat: port tinyhuman agent runtime from ZeroClaw into Tauri backend

Port daemon supervisor, health registry, security (policy, secrets, audit,
pairing), agent traits, and config modules from ZeroClaw (MIT) into a new
tinyhuman/ module under src-tauri/src/. The daemon auto-starts on desktop
and shuts down gracefully on app exit via CancellationToken.

- health: global HealthRegistry with component tracking and JSON snapshots
- security/policy: SecurityPolicy with command validation, risk levels, rate limiting
- security/secrets: ChaCha20-Poly1305 SecretStore with legacy XOR migration
- security/audit: AuditLogger with JSON-line events and log rotation
- security/pairing: PairingGuard with brute-force protection and SHA-256 hashing
- security/traits: Sandbox trait + NoopSandbox
- config: minimal DaemonConfig with autonomy, reliability, secrets, audit sub-configs
- daemon: supervisor with health state writer emitting Tauri events
- agent/traits: Provider, Tool, Memory, Observer, RuntimeAdapter traits + Noop impls
- commands/tinyhuman: Tauri commands for health, security policy, encrypt/decrypt
- 185 inline unit tests across all modules
- README updated with custom inference/tunneling/memory positioning

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

* feat: update README to reflect AlphaHuman Mk1 branding and enhanced description

- Changed project title to "AlphaHuman Mk1" for clarity.
- Revised project description to emphasize user-friendly AI capabilities and the use of the Neocortex Mk1 model.
- Removed outdated sections on custom inference, tunneling, and memory, streamlining the content for better readability.

* update readme

* Port zeroclaw runtime into tinyhuman

* Replace CLI mentions with UI language

* Split gateway module into smaller units

* Split channels and config schema modules

* Fix tinyhuman build, tests, and tunnel integration

* feat(tinyhuman): add missing modules and ui-friendly services

* refactor: rename tinyhuman to alphahuman

* chore: remove bottom text from Welcome component

* feat(settings): add tauri command console

* feat(daemon): enhance daemon mode handling and integrate rustls with ring feature

* feat(settings): implement comprehensive configuration management in TauriCommandsPanel

* refactor(TauriCommandsPanel): streamline error handling and enhance async function usage

* feat(settings): add skill management functionality to TauriCommandsPanel

* style(TauriCommandsPanel): update input styles for improved readability and user experience

* feat(settings): add Skills and Agent Chat panels with navigation and integration management

* feat(settings): implement browser access management in SkillsPanel and enhance AgentChatPanel with local storage functionality

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-02-20 13:03:15 +04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 2993571eb0
commit c2ff8b693b
241 changed files with 100309 additions and 380 deletions
View File
+50
View File
@@ -0,0 +1,50 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers. All complaints will be reviewed and investigated promptly and fairly. All project team members are obligated to respect the privacy and security of the reporter of any incident.
Project maintainers may take any action they deem appropriate, including but not limited to:
- Issuing a warning
- Requiring an apology
- Temporary or permanent bans from the repository, discussions, or other community channels
- Reporting to relevant authorities if behavior is illegal or poses a safety risk
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
+111
View File
@@ -0,0 +1,111 @@
# Contributing to AlphaHuman
Thank you for your interest in contributing. This document explains how to get set up, follow our workflow, and submit changes.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Git Workflow](#git-workflow)
- [Making Changes](#making-changes)
- [Submitting Changes](#submitting-changes)
- [Project Conventions](#project-conventions)
## Code of Conduct
This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
## Getting Started
- Read the [README](README.md) and [ARCHITECTURE](ARCHITECTURE.md) for context.
- Check [open issues](https://github.com/alphahumanxyz/alphahuman/issues) and discussions for ideas and to avoid duplicate work.
- For security issues, see [SECURITY.md](SECURITY.md) — do not report vulnerabilities in public issues.
## Development Setup
### Prerequisites
- [Node.js](https://nodejs.org/) (LTS) and [Yarn](https://yarnpkg.com/)
- [Rust](https://rustup.rs/) (for Tauri and the Rust backend)
- Platform-specific tools for the targets you care about (e.g., Xcode for macOS/iOS, Android SDK for Android)
### Clone and Install
```bash
git clone https://github.com/YOUR_USERNAME/alphahuman.git
cd alphahuman
yarn install
```
Use your own fork in place of `YOUR_USERNAME` when cloning.
### Run the App
- **Web only**: `yarn dev` (Vite dev server, typically port 1420)
- **Desktop (Tauri)**: `yarn tauri dev` or `yarn dev:app` for enhanced debugging
- **Android**: `yarn tauri android dev`
- **iOS**: `yarn tauri ios dev`
See the main [README](README.md) and project docs for more commands (e.g., `yarn skills:build`, `yarn skills:watch`).
### Environment
Copy or create a `.env` from the documented template and set `VITE_BACKEND_URL`, `VITE_TELEGRAM_*`, and other `VITE_*` variables as needed. Do not commit secrets.
## Git Workflow
- **Fork** the [alphahuman](https://github.com/alphahumanxyz/alphahuman) repository and work in your fork.
- **Base branch**: All pull requests must target the **`develop`** branch (not `main`).
- **No direct pushes** to the organization repo; all changes come in via pull requests from forks.
### Branch Naming
Use short, descriptive branches, e.g.:
- `fix/telegram-reconnect`
- `feat/settings-dark-mode`
- `docs/contributing-update`
## Making Changes
1. Create a branch from `develop`:
`git checkout develop && git pull origin develop && git checkout -b fix/your-change`
2. Make your changes. Keep commits focused and messages clear (e.g., “Fix socket reconnect on network drop”).
3. Follow our [project conventions](#project-conventions) and run checks before pushing.
### Running Checks
- **TypeScript**: `yarn compile` (or `tsc --noEmit`)
- **Lint**: `yarn lint` (ESLint); fix auto-fixable issues with `yarn lint:fix`
- **Format**: `yarn format:check`; format with `yarn format` (Prettier)
- **Tests**: `yarn test` (unit), `yarn test:rust` (Rust), `yarn test:e2e` (E2E when applicable)
Pre-commit/pre-push hooks (Husky) run formatting and linting; fix any failures before submitting.
## Submitting Changes
1. Push your branch to your fork:
`git push origin fix/your-change`
2. Open a **pull request** against **`develop`** in the [alphahuman](https://github.com/alphahumanxyz/alphahuman) repository.
3. Fill in the PR template (if present): describe what changed, why, and how to test.
4. Link any related issues (e.g., “Fixes #123”).
5. Address review feedback and keep the PR up to date with `develop` (rebase or merge as the project prefers).
Maintainers will review and may request changes. Once approved, your PR will be merged into `develop`.
## Project Conventions
- **State**: Use Redux (and Redux Persist where needed). Avoid `localStorage`/`sessionStorage` for app or feature state; remove existing usage when touching related code.
- **Imports**: Use static `import`/`import type` at the top of the file. No dynamic `import()` for app code; use try/catch around Tauri API calls in non-Tauri environments instead.
- **Code style**: ESLint and Prettier are authoritative. Use type-only imports where appropriate and consolidate imports from the same module.
- **Telegram IDs**: Use the `big-integer` library; do not rely on native JavaScript numbers for Telegram IDs.
- **Tauri**: Commands are in Rust under `src-tauri`; frontend uses `invoke()` from `@tauri-apps/api/core`. Handle missing `window.__TAURI__` where the app can run outside Tauri.
- **Socket events**: Behavior exists in both the TypeScript frontend and the Rust backend. Any new socket event or protocol change must be implemented in both places.
- **Skills**: Follow the V8 runtime and skill manifest rules; respect platform compatibility and the documented bridge/API surface.
For more detail on architecture, patterns, and platform notes, see the projects internal documentation (e.g., `CLAUDE.md` or equivalent contributor docs).
---
Thank you for contributing to AlphaHuman.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
+54 -69
View File
@@ -1,106 +1,91 @@
<h1 align="center">AlphaHuman</h1>
<h1 align="center">AlphaHuman Mk1</h1>
<p align="center">
<strong>Your most productive co-worker</strong>
<strong>Your most productive co-worker</strong><br>
A user-friendly (GUI-first) AI agent. AlphaHuman uses the
Neocortex Mk1 model to co-ordinate memories &
realtime-data, cheaper and faster than other models.
</p>
<p align="center">
<img src="https://img.shields.io/badge/status-early%20beta-orange" alt="Early Beta" />
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux%20%7C%20Android%20%7C%20iOS-blue" alt="Platforms" />
<a href="https://github.com/alphahumanxyz/alphahuman/releases/latest"><img src="https://img.shields.io/github/v/release/alphahumanxyz/alphahuman?label=latest" alt="Latest Release" /></a>
<a href="https://github.com/alphahumanai/alphahuman/releases/latest"><img src="https://img.shields.io/github/v/release/alphahumanai/alphahuman?label=latest" alt="Latest Release" /></a>
</p>
<p align="center">
<a href="#what-is-alphahuman">About</a> ·
<a href="#alphahuman-vs-openclaw">vs OpenClaw</a> ·
<a href="#download">Download</a> ·
<a href="#getting-started">Getting Started</a> ·
<a href="docs/ARCHITECTURE.md">Architecture</a> ·
<a href="CHANGELOG.md">Changelog</a>
</p>
![The Tet](./docs/the-tet.png)
<p align="center" style="font-style: italic">
"The Tet. What a brilliant machine" Morgan Freeman as he <a href="https://youtu.be/SveLVpqy_Rc?si=y83aZNokPiUjILN0&t=60">reminisces about the Tet</a> in the movie Oblivion
"The Tet. What a brilliant machine" Morgan Freeman in <a href="https://youtu.be/SveLVpqy_Rc?si=y83aZNokPiUjILN0&t=60">Oblivion</a>
</p>
## What is AlphaHuman?
AlphaHuman is a personal AI assistant that helps you manage high-volume communication without reading everything yourself. It connects to your messaging platforms and productivity tools, understands conversations in context, and produces clear, actionable outputs you can use immediately.
AlphaHuman is not a chatbot, browser extension, or cloud-only service. It is a native application that runs on your device, connects to your tools, and works only when you ask it to. Think of it as a second brain that sits across your communication and productivity stack.
AlphaHuman is **not** a chatbot, browser extension, or cloud-only service. It is a **native application** that runs on your device, connects to your tools, and works only when you ask it to. Think of it as a second brain that sits across your communication and productivity stack.
## What It Does
## AlphaHuman vs OpenClaw
- **Summarize conversations** -- Understand what happened without reading everything
- **Surface signals** -- Decisions, action items, risks, and sentiment shifts extracted automatically
- **Generate responses** -- Context-aware reply suggestions informed by conversation history
- **Create workflows** -- Turn unstructured commitments into trackable actions
- **Export intelligence** -- Push summaries, action items, and structured data to Notion, Google Sheets, and connected tools
- **Run automations** -- Via a sandboxed skills engine that extends the platform without app updates
AlphaHuman is designed to be simpler to deploy, cheaper to run, and more intelligent in how it uses models and memory.
| | OpenClaw | AlphaHuman |
| ---------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **Runtime** | Node.js (TypeScript) | Tauri (Rust + React), native binary |
| **Inference** | Single-tier or manual routing | **Custom two-tier**: task-routed (summarize/vibe/memory → cheap; complex/tools → premium) |
| **Memory** | Often external (Pinecone, Lucid, etc.) or markdown-only | **Custom hybrid**: SQLite FTS5 + vector similarity, optional encryption, no external vector DB |
| **Tunneling** | Third-party (ngrok, Cloudflare, Tailscale) or none | **Custom tunneling** — secure app-to-backend path without vendor lock-in |
| **Cost** | Typically one premium model for everything | **Lower** — Tier 1 for most ops; Tier 2 only when needed |
| **Intelligence** | General-purpose agent loop | **Smarter** — vibe detection, interest-based escalation, constitution-driven behavior, session-aware memory |
| **Deployment** | Server/Node process, high memory footprint | Native desktop/mobile app, Rust socket manager, smaller footprint |
> OpenClaw is a strong open-source agent framework. We chose to build a custom stack so we could own inference routing, memory, and tunneling end-to-end and optimize for cost and clarity.
---
## Download
> **Early Beta** -- AlphaHuman is under active development. Expect rough edges.
> **Early Beta** AlphaHuman is under active development. Expect rough edges.
### macOS
| Platform | Variant | Download |
| ----------- | --------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4) | [`.dmg` (aarch64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_aarch64.dmg) |
| **macOS** | Intel | [`.dmg` (x64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64.dmg) |
| **Windows** | x64 | [`.msi`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64_en-US.msi) |
| **Linux** | Debian / Ubuntu | [`.deb` (amd64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.deb) |
| **Linux** | Fedora / RHEL | [`.rpm` (x86_64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x86_64.rpm) |
| **Linux** | Universal | [`.AppImage`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.AppImage) |
| **Android** | — | Coming soon |
| **iOS** | — | Coming soon |
| Chip | Download |
| --------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Apple Silicon (M1/M2/M3/M4) | [`.dmg` (aarch64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_aarch64.dmg) |
| Intel | [`.dmg` (x64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x64.dmg) |
### Windows
| Architecture | Download |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| x64 | [`.msi`](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x64_en-US.msi) |
### Linux
| Format | Download |
| --------------- | ------------------------------------------------------------------------------------------------------------- |
| Debian / Ubuntu | [`.deb` (amd64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_amd64.deb) |
| Fedora / RHEL | [`.rpm` (x86_64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x86_64.rpm) |
| Universal | [`.AppImage`](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_amd64.AppImage) |
### Mobile
- **Android** and **iOS** -- Coming soon
Browse all releases: [github.com/alphahumanxyz/alphahuman/releases](https://github.com/alphahumanxyz/alphahuman/releases)
## Skills
AlphaHuman uses a pluggable **skills** architecture. Each skill connects to an external service, syncs relevant data locally, and exposes tools that you (or the AI) can use. Skills run in a sandboxed environment with their own database, storage, and permissions.
| Skill | Status | Description |
| --------------- | ----------- | ------------------------------------------------------------- |
| Telegram | Available | Chats, messages, contacts, search, admin tools, AI summaries |
| Notion | Available | Pages, databases, blocks, users, comments, search, local sync |
| Gmail | In Progress | Email management, labels, search, send/receive with OAuth2 |
| Google Calendar | In Progress | Calendars, events, scheduling with OAuth2 |
| Google Drive | In Progress | Files, Sheets, Docs with OAuth2 |
| Slack | In Progress | Messages, channels, real-time events |
| Web3 Wallets | Planned | EVM wallet management, balance checks, network monitoring |
## Privacy-First
- **Zero retention** -- Message content is processed to produce output, then discarded
- **OS-level credential storage** -- Desktop platforms use native keychains (macOS Keychain, Windows Credential Manager)
- **No training on your data** -- Your data is never used for model improvement
- **Request-only processing** -- Nothing happens without an explicit user action; no background scanning
- **Sandboxed skills** -- Each skill runs in isolation with memory and resource limits
Browse all releases: [github.com/alphahumanai/alphahuman/releases](https://github.com/alphahumanai/alphahuman/releases)
## Getting Started
1. **Download** the installer for your platform from the [releases page](https://github.com/alphahumanxyz/alphahuman/releases/latest)
1. **Download** the installer for your platform from the [releases page](https://github.com/alphahumanai/alphahuman/releases/latest)
2. **Install** the app (drag to Applications on macOS, or use your package manager on Linux)
3. **Connect a source** -- follow the in-app onboarding to link Telegram, Notion, Gmail, or other services
4. **Run your first request** -- ask the AI to summarize what you missed, extract action items, or surface key decisions
3. **Connect a source** follow the in-app onboarding to link Telegram, Notion, Gmail, or other services
4. **Run your first request** ask the AI to summarize what you missed, extract action items, or surface key decisions
---
## Links
- [Architecture Overview](./ARCHITECTURE.md) -- How AlphaHuman is built
- [Changelog](./CHANGELOG.md) -- Release history
- [Website](https://alphahuman.xyz) -- Learn more
- [Architecture Overview](docs/ARCHITECTURE.md) How AlphaHuman is built
- [Changelog](CHANGELOG.md) — Release history
- [Website](https://alphahuman.xyz) Learn more
---
<p align="center">
Made with love by a bunch of Web3 nerds
Made with love in India 🇮🇳
</p>
<p align="center">
+53
View File
@@ -0,0 +1,53 @@
# Security Policy
## Supported Versions
We provide security updates for the following versions of AlphaHuman:
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| Previous minor | :white_check_mark: |
| Older | :x: |
We recommend always running the [latest release](https://github.com/alphahumanxyz/alphahuman/releases/latest). AlphaHuman is in early beta; older versions may not receive patches.
## Reporting a Vulnerability
We take security seriously. If you believe you have found a security vulnerability, please report it responsibly.
### How to Report
1. **Do not** open a public GitHub issue for security vulnerabilities.
2. Email the maintainers with a clear description of the issue, steps to reproduce, and impact. You can reach us via the contact details listed in the [AlphaHuman organization](https://github.com/alphahumanxyz) or repository.
3. Include as much detail as possible (platform, version, configuration) so we can reproduce and triage quickly.
### What to Expect
- We will acknowledge your report as soon as possible (typically within 5 business days).
- We will keep you updated on our assessment and any fix or mitigation.
- We will credit you in our security advisories and release notes (unless you prefer to remain anonymous).
### Scope
We are especially interested in:
- Authentication or authorization bypass
- Data exfiltration or exposure (credentials, messages, user data)
- Remote code execution (frontend, Tauri/Rust backend, or skills runtime)
- Issues in dependency chain (npm, Cargo) that affect our build or runtime
- Platform-specific issues (macOS, Windows, Linux, Android, iOS) that compromise user data or device security
Out-of-scope for this process: general bugs, feature requests, and issues in third-party services we integrate with (e.g., Telegram, Notion) unless they are specific to how AlphaHuman uses them.
### Safe Harbor
We support safe harbor for security researchers who report in good faith. We will not pursue legal action or involve law enforcement against you for discovering or reporting vulnerabilities in accordance with this policy.
## Security Practices
- **Credentials**: Desktop uses OS-level credential storage (e.g., macOS Keychain, Windows Credential Manager). We do not store secrets in plain text.
- **Data**: Message content is processed on request and not retained for training or long-term storage.
- **Skills**: Skills run in a sandboxed environment with defined boundaries; we review skill behavior and dependencies where possible.
Thank you for helping keep AlphaHuman and its users safe.
View File
-12
View File
@@ -1,12 +0,0 @@
{
"id": "hello-python",
"name": "Hello Python",
"description": "Example runtime skill demonstrating the Python subprocess protocol",
"version": "0.1.0",
"tier": "runtime",
"author": "AlphaHuman",
"runtime": {
"command": "python3",
"args": ["skill.py"]
}
}
-187
View File
@@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""
Hello Python — Example Runtime Skill
Demonstrates the JSON-RPC 2.0 protocol for AlphaHuman runtime skills.
The host communicates over stdin/stdout with newline-delimited JSON.
Protocol:
Host → Skill: JSON-RPC requests on stdin (one JSON object per line)
Skill → Host: JSON-RPC responses on stdout (one JSON object per line)
Skill logging: stderr (forwarded to host debug log)
Lifecycle:
1. Host spawns this process
2. Host sends skill/load { manifest }
3. Host sends tools/list (skill replies with tool definitions)
4. Host sends skill/activate
5. Host sends tools/call as needed
6. Host sends skill/deactivate then skill/shutdown
"""
import json
import sys
def send(obj: dict) -> None:
"""Write a JSON-RPC message to stdout."""
line = json.dumps(obj, separators=(",", ":"))
sys.stdout.write(line + "\n")
sys.stdout.flush()
def send_result(req_id, result):
"""Send a successful JSON-RPC response."""
send({"jsonrpc": "2.0", "id": req_id, "result": result})
def send_error(req_id, code: int, message: str):
"""Send a JSON-RPC error response."""
send({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}})
def log(message: str) -> None:
"""Log to stderr (host captures this for debugging)."""
print(f"[hello-python] {message}", file=sys.stderr, flush=True)
# ---------------------------------------------------------------------------
# Tool definitions
# ---------------------------------------------------------------------------
TOOLS = [
{
"name": "hello_world",
"description": "Returns a friendly greeting. Use this to verify the Python runtime skill is working.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name to greet (default: 'World')",
}
},
},
"tier": "state_only",
"readOnly": True,
}
]
def handle_tool_call(name: str, arguments: dict) -> dict:
"""Execute a tool and return an MCPToolResult."""
if name == "hello_world":
who = arguments.get("name", "World")
return {
"content": [{"type": "text", "text": f"Hello, {who}! Greetings from a Python runtime skill."}],
}
return {
"content": [{"type": "text", "text": f"Unknown tool: {name}"}],
"isError": True,
}
# ---------------------------------------------------------------------------
# Request dispatcher
# ---------------------------------------------------------------------------
def handle_request(method: str, params, req_id):
"""Dispatch a JSON-RPC request to the appropriate handler."""
if method == "skill/load":
manifest = params.get("manifest", {}) if params else {}
log(f"Loaded — manifest id: {manifest.get('id', '?')}")
send_result(req_id, {"ok": True})
elif method == "skill/activate":
log("Activated")
send_result(req_id, {"ok": True})
elif method == "skill/deactivate":
log("Deactivated")
send_result(req_id, {"ok": True})
elif method == "skill/unload":
log("Unloaded")
send_result(req_id, {"ok": True})
elif method == "skill/shutdown":
log("Shutting down")
send_result(req_id, {"ok": True})
sys.exit(0)
elif method == "skill/sessionStart":
session_id = params.get("sessionId", "?") if params else "?"
log(f"Session started: {session_id}")
send_result(req_id, {"ok": True})
elif method == "skill/sessionEnd":
session_id = params.get("sessionId", "?") if params else "?"
log(f"Session ended: {session_id}")
send_result(req_id, {"ok": True})
elif method == "skill/beforeMessage":
# Return None to leave message unchanged, or return transformed message
send_result(req_id, {})
elif method == "skill/afterResponse":
# Return None to leave response unchanged
send_result(req_id, {})
elif method == "skill/tick":
# Health check / periodic tick
send_result(req_id, {"ok": True})
elif method == "tools/list":
send_result(req_id, {"tools": TOOLS})
elif method == "tools/call":
name = params.get("name", "") if params else ""
arguments = params.get("arguments", {}) if params else {}
result = handle_tool_call(name, arguments)
send_result(req_id, result)
else:
send_error(req_id, -32601, f"Method not found: {method}")
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
def main():
log("Starting hello-python skill")
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
log(f"Invalid JSON: {e}")
continue
# Must be JSON-RPC 2.0
if msg.get("jsonrpc") != "2.0":
log(f"Not a JSON-RPC 2.0 message: {line[:100]}")
continue
method = msg.get("method")
params = msg.get("params")
req_id = msg.get("id")
if method and req_id is not None:
# Request — needs a response
handle_request(method, params, req_id)
elif method:
# Notification — no response needed
log(f"Notification: {method}")
else:
log(f"Unrecognized message: {line[:100]}")
if __name__ == "__main__":
main()
View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

+4280 -98
View File
File diff suppressed because it is too large Load Diff
+86 -3
View File
@@ -16,6 +16,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dependencies]
# Tauri core and plugins
@@ -31,7 +33,7 @@ serde_json = "1"
# HTTP client: rustls for cross-platform TLS (Android), native-tls for desktop skill HTTP
# (some APIs/CDNs use TLS configs that rustls can't negotiate — native-tls uses OS TLS stack)
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] }
# Async runtime
tokio = { version = "1", features = ["full", "sync"] }
@@ -50,11 +52,20 @@ env_logger = "0.11"
base64 = "0.22"
aes-gcm = "0.10"
argon2 = "0.5"
rand = "0.8"
rand = "0.9"
dirs = "5"
sha2 = "0.10"
hmac = "0.12"
uuid = { version = "1", features = ["v4"] }
# Alphahuman agent runtime dependencies
anyhow = "1.0"
async-trait = "0.1"
chacha20poly1305 = "0.10"
hex = "0.4"
tokio-util = { version = "0.7", features = ["rt"] }
landlock = { version = "0.4", optional = true }
# V8 JavaScript runtime moved to desktop-only (not available on Android/iOS)
# WebSocket client for TDLib connections
@@ -64,7 +75,7 @@ tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures = "0.3"
# Embedded SQLite for skill databases
rusqlite = { version = "0.31", features = ["bundled"] }
rusqlite = { version = "0.37", features = ["bundled"] }
# Date/time utilities
chrono = { version = "0.4", features = ["serde"] }
@@ -76,6 +87,62 @@ cron = "0.12"
futures-util = "0.3"
# Android uses a simpler approach - no persistent Socket.io (use web socket from frontend)
# Alphahuman config + observability
directories = "6"
toml = "1.0"
shellexpand = "3.1"
schemars = "1.2"
tracing = { version = "0.1", default-features = false }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter"] }
prometheus = { version = "0.14", default-features = false }
urlencoding = "2.1"
thiserror = "2.0"
ring = "0.17"
prost = { version = "0.14", default-features = false }
postgres = { version = "0.19", features = ["with-chrono-0_4"] }
chrono-tz = "0.10"
dialoguer = { version = "0.12", features = ["fuzzy-select"] }
console = "0.16"
glob = "0.3"
regex = "1.10"
hostname = "0.4.2"
rustls = { version = "0.23", features = ["ring"] }
rustls-pki-types = "1.14.0"
tokio-rustls = "0.26.4"
webpki-roots = "1.0.6"
lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
mail-parser = "0.11.2"
async-imap = { version = "0.11", features = ["runtime-tokio"], default-features = false }
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] }
tower = { version = "0.5", default-features = false }
tower-http = { version = "0.6", default-features = false, features = ["limit", "timeout"] }
http-body-util = "0.1"
opentelemetry = { version = "0.31", default-features = false, features = ["trace", "metrics"] }
opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"] }
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
url = "2"
# Optional integrations
matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] }
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
serde-big-array = { version = "0.5", optional = true }
nusb = { version = "0.2", default-features = false, optional = true }
tokio-serial = { version = "5", default-features = false, optional = true }
probe-rs = { version = "0.30", optional = true }
pdf-extract = { version = "0.10", optional = true }
wa-rs = { version = "0.2", optional = true, default-features = false }
wa-rs-core = { version = "0.2", optional = true, default-features = false }
wa-rs-binary = { version = "0.2", optional = true, default-features = false }
wa-rs-proto = { version = "0.2", optional = true, default-features = false }
wa-rs-ureq-http = { version = "0.2", optional = true }
wa-rs-tokio-transport = { version = "0.2", optional = true, default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
rppal = { version = "0.22", optional = true }
[target.'cfg(target_os = "android")'.dependencies]
# Android logging (routes Rust `log` crate to logcat)
android_logger = "0.14"
@@ -98,6 +165,22 @@ tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
# TDLib build configuration (download-tdlib for all desktop platforms)
tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
[dev-dependencies]
tempfile = "3"
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]
sandbox-landlock = ["dep:landlock"]
sandbox-bubblewrap = []
# Alphahuman feature flags
hardware = ["dep:nusb", "dep:tokio-serial"]
channel-matrix = ["dep:matrix-sdk"]
peripheral-rpi = ["dep:rppal"]
browser-native = ["dep:fantoccini"]
fantoccini = ["browser-native"]
landlock = ["sandbox-landlock"]
probe = ["dep:probe-rs"]
rag-pdf = ["dep:pdf-extract"]
whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "serde-big-array"]
+41
View File
@@ -1,10 +1,51 @@
use std::env;
use std::path::PathBuf;
fn main() {
maybe_override_tauri_config_for_tests();
setup_tdlib();
tauri_build::build();
}
fn maybe_override_tauri_config_for_tests() {
let profile = env::var("PROFILE").unwrap_or_default();
let skip_resources = env::var("TAURI_SKIP_RESOURCES").is_ok() || profile == "test";
if !skip_resources {
return;
}
let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
let config_path = manifest_dir.join("tauri.conf.json");
let Ok(raw) = std::fs::read_to_string(&config_path) else {
println!("cargo:warning=Failed to read tauri.conf.json; keeping default config");
return;
};
let mut value: serde_json::Value = match serde_json::from_str(&raw) {
Ok(value) => value,
Err(err) => {
println!("cargo:warning=Failed to parse tauri.conf.json: {err}");
return;
}
};
if let Some(bundle) = value.get_mut("bundle").and_then(|b| b.as_object_mut()) {
bundle.insert("resources".to_string(), serde_json::Value::Array(Vec::new()));
}
let out_dir = env::var("OUT_DIR").unwrap_or_else(|_| ".".into());
let override_path = PathBuf::from(out_dir).join("tauri.conf.test.json");
if std::fs::write(&override_path, serde_json::to_string_pretty(&value).unwrap_or(raw)).is_ok()
{
env::set_var("TAURI_CONFIG", &override_path);
println!(
"cargo:warning=TAURI resources disabled for test build (using {})",
override_path.display()
);
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn setup_tdlib() {
// download-tdlib: downloads prebuilt TDLib and configures linker flags
+1 -1
View File
@@ -9,7 +9,7 @@ use aes_gcm::{
Aes256Gcm, Nonce,
};
use argon2::{self, Algorithm, Argon2, Params, Version};
use rand::RngCore;
use aes_gcm::aead::rand_core::RngCore;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
+753
View File
@@ -0,0 +1,753 @@
use super::dispatcher::{
NativeToolDispatcher, ParsedToolCall, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher,
};
use super::memory_loader::{DefaultMemoryLoader, MemoryLoader};
use super::prompt::{PromptContext, SystemPromptBuilder};
use crate::alphahuman::config::Config;
use crate::alphahuman::memory::{self, Memory, MemoryCategory};
use crate::alphahuman::observability::{self, Observer, ObserverEvent};
use crate::alphahuman::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
use crate::alphahuman::runtime;
use crate::alphahuman::security::SecurityPolicy;
use crate::alphahuman::tools::{self, Tool, ToolSpec};
use crate::alphahuman::util::truncate_with_ellipsis;
use anyhow::Result;
use std::io::Write as IoWrite;
use std::sync::Arc;
use std::time::Instant;
pub struct Agent {
provider: Box<dyn Provider>,
tools: Vec<Box<dyn Tool>>,
tool_specs: Vec<ToolSpec>,
memory: Arc<dyn Memory>,
observer: Arc<dyn Observer>,
prompt_builder: SystemPromptBuilder,
tool_dispatcher: Box<dyn ToolDispatcher>,
memory_loader: Box<dyn MemoryLoader>,
config: crate::alphahuman::config::AgentConfig,
model_name: String,
temperature: f64,
workspace_dir: std::path::PathBuf,
identity_config: crate::alphahuman::config::IdentityConfig,
skills: Vec<crate::alphahuman::skills::Skill>,
auto_save: bool,
history: Vec<ConversationMessage>,
classification_config: crate::alphahuman::config::QueryClassificationConfig,
available_hints: Vec<String>,
}
pub struct AgentBuilder {
provider: Option<Box<dyn Provider>>,
tools: Option<Vec<Box<dyn Tool>>>,
memory: Option<Arc<dyn Memory>>,
observer: Option<Arc<dyn Observer>>,
prompt_builder: Option<SystemPromptBuilder>,
tool_dispatcher: Option<Box<dyn ToolDispatcher>>,
memory_loader: Option<Box<dyn MemoryLoader>>,
config: Option<crate::alphahuman::config::AgentConfig>,
model_name: Option<String>,
temperature: Option<f64>,
workspace_dir: Option<std::path::PathBuf>,
identity_config: Option<crate::alphahuman::config::IdentityConfig>,
skills: Option<Vec<crate::alphahuman::skills::Skill>>,
auto_save: Option<bool>,
classification_config: Option<crate::alphahuman::config::QueryClassificationConfig>,
available_hints: Option<Vec<String>>,
}
impl AgentBuilder {
pub fn new() -> Self {
Self {
provider: None,
tools: None,
memory: None,
observer: None,
prompt_builder: None,
tool_dispatcher: None,
memory_loader: None,
config: None,
model_name: None,
temperature: None,
workspace_dir: None,
identity_config: None,
skills: None,
auto_save: None,
classification_config: None,
available_hints: None,
}
}
pub fn provider(mut self, provider: Box<dyn Provider>) -> Self {
self.provider = Some(provider);
self
}
pub fn tools(mut self, tools: Vec<Box<dyn Tool>>) -> Self {
self.tools = Some(tools);
self
}
pub fn memory(mut self, memory: Arc<dyn Memory>) -> Self {
self.memory = Some(memory);
self
}
pub fn observer(mut self, observer: Arc<dyn Observer>) -> Self {
self.observer = Some(observer);
self
}
pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self {
self.prompt_builder = Some(prompt_builder);
self
}
pub fn tool_dispatcher(mut self, tool_dispatcher: Box<dyn ToolDispatcher>) -> Self {
self.tool_dispatcher = Some(tool_dispatcher);
self
}
pub fn memory_loader(mut self, memory_loader: Box<dyn MemoryLoader>) -> Self {
self.memory_loader = Some(memory_loader);
self
}
pub fn config(mut self, config: crate::alphahuman::config::AgentConfig) -> Self {
self.config = Some(config);
self
}
pub fn model_name(mut self, model_name: String) -> Self {
self.model_name = Some(model_name);
self
}
pub fn temperature(mut self, temperature: f64) -> Self {
self.temperature = Some(temperature);
self
}
pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self {
self.workspace_dir = Some(workspace_dir);
self
}
pub fn identity_config(mut self, identity_config: crate::alphahuman::config::IdentityConfig) -> Self {
self.identity_config = Some(identity_config);
self
}
pub fn skills(mut self, skills: Vec<crate::alphahuman::skills::Skill>) -> Self {
self.skills = Some(skills);
self
}
pub fn auto_save(mut self, auto_save: bool) -> Self {
self.auto_save = Some(auto_save);
self
}
pub fn classification_config(
mut self,
classification_config: crate::alphahuman::config::QueryClassificationConfig,
) -> Self {
self.classification_config = Some(classification_config);
self
}
pub fn available_hints(mut self, available_hints: Vec<String>) -> Self {
self.available_hints = Some(available_hints);
self
}
pub fn build(self) -> Result<Agent> {
let tools = self
.tools
.ok_or_else(|| anyhow::anyhow!("tools are required"))?;
let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
Ok(Agent {
provider: self
.provider
.ok_or_else(|| anyhow::anyhow!("provider is required"))?,
tools,
tool_specs,
memory: self
.memory
.ok_or_else(|| anyhow::anyhow!("memory is required"))?,
observer: self
.observer
.ok_or_else(|| anyhow::anyhow!("observer is required"))?,
prompt_builder: self
.prompt_builder
.unwrap_or_else(SystemPromptBuilder::with_defaults),
tool_dispatcher: self
.tool_dispatcher
.ok_or_else(|| anyhow::anyhow!("tool_dispatcher is required"))?,
memory_loader: self
.memory_loader
.unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())),
config: self.config.unwrap_or_default(),
model_name: self
.model_name
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()),
temperature: self.temperature.unwrap_or(0.7),
workspace_dir: self
.workspace_dir
.unwrap_or_else(|| std::path::PathBuf::from(".")),
identity_config: self.identity_config.unwrap_or_default(),
skills: self.skills.unwrap_or_default(),
auto_save: self.auto_save.unwrap_or(false),
history: Vec::new(),
classification_config: self.classification_config.unwrap_or_default(),
available_hints: self.available_hints.unwrap_or_default(),
})
}
}
impl Agent {
pub fn builder() -> AgentBuilder {
AgentBuilder::new()
}
pub fn history(&self) -> &[ConversationMessage] {
&self.history
}
pub fn clear_history(&mut self) {
self.history.clear();
}
pub fn from_config(config: &Config) -> Result<Self> {
let observer: Arc<dyn Observer> =
Arc::from(observability::create_observer(&config.observability));
let runtime: Arc<dyn runtime::RuntimeAdapter> =
Arc::from(runtime::create_runtime(&config.runtime)?);
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
));
let memory: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
&config.memory,
&config.embedding_routes,
Some(&config.storage.provider.config),
&config.workspace_dir,
config.api_key.as_deref(),
)?);
let composio_key = if config.composio.enabled {
config.composio.api_key.as_deref()
} else {
None
};
let composio_entity_id = if config.composio.enabled {
Some(config.composio.entity_id.as_str())
} else {
None
};
let tools = tools::all_tools_with_runtime(
Arc::new(config.clone()),
&security,
runtime,
memory.clone(),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&config.workspace_dir,
&config.agents,
config.api_key.as_deref(),
config,
);
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
let model_name = config
.default_model
.as_deref()
.unwrap_or("anthropic/claude-sonnet-4-20250514")
.to_string();
let provider: Box<dyn Provider> = providers::create_routed_provider(
provider_name,
config.api_key.as_deref(),
config.api_url.as_deref(),
&config.reliability,
&config.model_routes,
&model_name,
)?;
let dispatcher_choice = config.agent.tool_dispatcher.as_str();
let tool_dispatcher: Box<dyn ToolDispatcher> = match dispatcher_choice {
"native" => Box::new(NativeToolDispatcher),
"xml" => Box::new(XmlToolDispatcher),
_ if provider.supports_native_tools() => Box::new(NativeToolDispatcher),
_ => Box::new(XmlToolDispatcher),
};
let available_hints: Vec<String> =
config.model_routes.iter().map(|r| r.hint.clone()).collect();
Agent::builder()
.provider(provider)
.tools(tools)
.memory(memory)
.observer(observer)
.tool_dispatcher(tool_dispatcher)
.memory_loader(Box::new(DefaultMemoryLoader::new(
5,
config.memory.min_relevance_score,
)))
.prompt_builder(SystemPromptBuilder::with_defaults())
.config(config.agent.clone())
.model_name(model_name)
.temperature(config.default_temperature)
.workspace_dir(config.workspace_dir.clone())
.classification_config(config.query_classification.clone())
.available_hints(available_hints)
.identity_config(config.identity.clone())
.skills(crate::alphahuman::skills::load_skills(&config.workspace_dir))
.auto_save(config.memory.auto_save)
.build()
}
fn trim_history(&mut self) {
let max = self.config.max_history_messages;
if self.history.len() <= max {
return;
}
let mut system_messages = Vec::new();
let mut other_messages = Vec::new();
for msg in self.history.drain(..) {
match &msg {
ConversationMessage::Chat(chat) if chat.role == "system" => {
system_messages.push(msg);
}
_ => other_messages.push(msg),
}
}
if other_messages.len() > max {
let drop_count = other_messages.len() - max;
other_messages.drain(0..drop_count);
}
self.history = system_messages;
self.history.extend(other_messages);
}
fn build_system_prompt(&self) -> Result<String> {
let instructions = self.tool_dispatcher.prompt_instructions(&self.tools);
let ctx = PromptContext {
workspace_dir: &self.workspace_dir,
model_name: &self.model_name,
tools: &self.tools,
skills: &self.skills,
identity_config: Some(&self.identity_config),
dispatcher_instructions: &instructions,
};
self.prompt_builder.build(&ctx)
}
async fn execute_tool_call(&self, call: &ParsedToolCall) -> ToolExecutionResult {
let start = Instant::now();
let result = if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) {
match tool.execute(call.arguments.clone()).await {
Ok(r) => {
self.observer.record_event(&ObserverEvent::ToolCall {
tool: call.name.clone(),
duration: start.elapsed(),
success: r.success,
});
if r.success {
r.output
} else {
format!("Error: {}", r.error.unwrap_or(r.output))
}
}
Err(e) => {
self.observer.record_event(&ObserverEvent::ToolCall {
tool: call.name.clone(),
duration: start.elapsed(),
success: false,
});
format!("Error executing {}: {e}", call.name)
}
}
} else {
format!("Unknown tool: {}", call.name)
};
ToolExecutionResult {
name: call.name.clone(),
output: result,
success: true,
tool_call_id: call.tool_call_id.clone(),
}
}
async fn execute_tools(&self, calls: &[ParsedToolCall]) -> Vec<ToolExecutionResult> {
if !self.config.parallel_tools {
let mut results = Vec::with_capacity(calls.len());
for call in calls {
results.push(self.execute_tool_call(call).await);
}
return results;
}
let mut results = Vec::with_capacity(calls.len());
for call in calls {
results.push(self.execute_tool_call(call).await);
}
results
}
fn classify_model(&self, user_message: &str) -> String {
if let Some(hint) = super::classifier::classify(&self.classification_config, user_message) {
if self.available_hints.contains(&hint) {
tracing::info!(hint = hint.as_str(), "Auto-classified query");
return format!("hint:{hint}");
}
}
self.model_name.clone()
}
pub async fn turn(&mut self, user_message: &str) -> Result<String> {
if self.history.is_empty() {
let system_prompt = self.build_system_prompt()?;
self.history
.push(ConversationMessage::Chat(ChatMessage::system(
system_prompt,
)));
}
if self.auto_save {
let _ = self
.memory
.store("user_msg", user_message, MemoryCategory::Conversation, None)
.await;
}
let context = self
.memory_loader
.load_context(self.memory.as_ref(), user_message)
.await
.unwrap_or_default();
let enriched = if context.is_empty() {
user_message.to_string()
} else {
format!("{context}{user_message}")
};
self.history
.push(ConversationMessage::Chat(ChatMessage::user(enriched)));
let effective_model = self.classify_model(user_message);
for _ in 0..self.config.max_tool_iterations {
let messages = self.tool_dispatcher.to_provider_messages(&self.history);
let response = match self
.provider
.chat(
ChatRequest {
messages: &messages,
tools: if self.tool_dispatcher.should_send_tool_specs() {
Some(&self.tool_specs)
} else {
None
},
},
&effective_model,
self.temperature,
)
.await
{
Ok(resp) => resp,
Err(err) => return Err(err),
};
let (text, calls) = self.tool_dispatcher.parse_response(&response);
if calls.is_empty() {
let final_text = if text.is_empty() {
response.text.unwrap_or_default()
} else {
text
};
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
final_text.clone(),
)));
self.trim_history();
if self.auto_save {
let summary = truncate_with_ellipsis(&final_text, 100);
let _ = self
.memory
.store("assistant_resp", &summary, MemoryCategory::Daily, None)
.await;
}
return Ok(final_text);
}
if !text.is_empty() {
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
text.clone(),
)));
print!("{text}");
let _ = std::io::stdout().flush();
}
self.history.push(ConversationMessage::AssistantToolCalls {
text: response.text.clone(),
tool_calls: response.tool_calls.clone(),
});
let results = self.execute_tools(&calls).await;
let formatted = self.tool_dispatcher.format_results(&results);
self.history.push(formatted);
self.trim_history();
}
anyhow::bail!(
"Agent exceeded maximum tool iterations ({})",
self.config.max_tool_iterations
)
}
pub async fn run_single(&mut self, message: &str) -> Result<String> {
self.turn(message).await
}
pub async fn run_interactive(&mut self) -> Result<()> {
println!("🦀 Alphahuman Interactive Mode");
println!("Type /quit to exit.\n");
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let cli = crate::alphahuman::channels::CliChannel::new();
let listen_handle = tokio::spawn(async move {
let _ = crate::alphahuman::channels::Channel::listen(&cli, tx).await;
});
while let Some(msg) = rx.recv().await {
let response = match self.turn(&msg.content).await {
Ok(resp) => resp,
Err(e) => {
eprintln!("\nError: {e}\n");
continue;
}
};
println!("\n{response}\n");
}
listen_handle.abort();
Ok(())
}
}
pub async fn run(
config: Config,
message: Option<String>,
provider_override: Option<String>,
model_override: Option<String>,
temperature: f64,
) -> Result<()> {
let start = Instant::now();
let mut effective_config = config;
if let Some(p) = provider_override {
effective_config.default_provider = Some(p);
}
if let Some(m) = model_override {
effective_config.default_model = Some(m);
}
effective_config.default_temperature = temperature;
let mut agent = Agent::from_config(&effective_config)?;
let provider_name = effective_config
.default_provider
.as_deref()
.unwrap_or("openrouter")
.to_string();
let model_name = effective_config
.default_model
.as_deref()
.unwrap_or("anthropic/claude-sonnet-4-20250514")
.to_string();
agent.observer.record_event(&ObserverEvent::AgentStart {
provider: provider_name.clone(),
model: model_name.clone(),
});
if let Some(msg) = message {
let response = agent.run_single(&msg).await?;
println!("{response}");
} else {
agent.run_interactive().await?;
}
agent.observer.record_event(&ObserverEvent::AgentEnd {
provider: provider_name,
model: model_name,
duration: start.elapsed(),
tokens_used: None,
cost_usd: None,
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use parking_lot::Mutex;
struct MockProvider {
responses: Mutex<Vec<crate::alphahuman::providers::ChatResponse>>,
}
#[async_trait]
impl Provider for MockProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> Result<String> {
Ok("ok".into())
}
async fn chat(
&self,
_request: ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> Result<crate::alphahuman::providers::ChatResponse> {
let mut guard = self.responses.lock();
if guard.is_empty() {
return Ok(crate::alphahuman::providers::ChatResponse {
text: Some("done".into()),
tool_calls: vec![],
});
}
Ok(guard.remove(0))
}
}
struct MockTool;
#[async_trait]
impl Tool for MockTool {
fn name(&self) -> &str {
"echo"
}
fn description(&self) -> &str {
"echo"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
async fn execute(&self, _args: serde_json::Value) -> Result<crate::alphahuman::tools::ToolResult> {
Ok(crate::alphahuman::tools::ToolResult {
success: true,
output: "tool-out".into(),
error: None,
})
}
}
#[tokio::test]
async fn turn_without_tools_returns_text() {
let provider = Box::new(MockProvider {
responses: Mutex::new(vec![crate::alphahuman::providers::ChatResponse {
text: Some("hello".into()),
tool_calls: vec![],
}]),
});
let memory_cfg = crate::alphahuman::config::MemoryConfig {
backend: "none".into(),
..crate::alphahuman::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::alphahuman::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(),
);
let observer: Arc<dyn Observer> = Arc::from(crate::alphahuman::observability::NoopObserver {});
let mut agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
.memory(mem)
.observer(observer)
.tool_dispatcher(Box::new(XmlToolDispatcher))
.workspace_dir(std::path::PathBuf::from("/tmp"))
.build()
.unwrap();
let response = agent.turn("hi").await.unwrap();
assert_eq!(response, "hello");
}
#[tokio::test]
async fn turn_with_native_dispatcher_handles_tool_results_variant() {
let provider = Box::new(MockProvider {
responses: Mutex::new(vec![
crate::alphahuman::providers::ChatResponse {
text: Some(String::new()),
tool_calls: vec![crate::alphahuman::providers::ToolCall {
id: "tc1".into(),
name: "echo".into(),
arguments: "{}".into(),
}],
},
crate::alphahuman::providers::ChatResponse {
text: Some("done".into()),
tool_calls: vec![],
},
]),
});
let memory_cfg = crate::alphahuman::config::MemoryConfig {
backend: "none".into(),
..crate::alphahuman::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::alphahuman::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(),
);
let observer: Arc<dyn Observer> = Arc::from(crate::alphahuman::observability::NoopObserver {});
let mut agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
.memory(mem)
.observer(observer)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::path::PathBuf::from("/tmp"))
.build()
.unwrap();
let response = agent.turn("hi").await.unwrap();
assert_eq!(response, "done");
assert!(agent
.history()
.iter()
.any(|msg| matches!(msg, ConversationMessage::ToolResults(_))));
}
}
@@ -0,0 +1,172 @@
use crate::alphahuman::config::schema::QueryClassificationConfig;
/// Classify a user message against the configured rules and return the
/// matching hint string, if any.
///
/// Returns `None` when classification is disabled, no rules are configured,
/// or no rule matches the message.
pub fn classify(config: &QueryClassificationConfig, message: &str) -> Option<String> {
if !config.enabled || config.rules.is_empty() {
return None;
}
let lower = message.to_lowercase();
let len = message.len();
let mut rules: Vec<_> = config.rules.iter().collect();
rules.sort_by(|a, b| b.priority.cmp(&a.priority));
for rule in rules {
// Length constraints
if let Some(min) = rule.min_length {
if len < min {
continue;
}
}
if let Some(max) = rule.max_length {
if len > max {
continue;
}
}
// Check keywords (case-insensitive) and patterns (case-sensitive)
let keyword_hit = rule
.keywords
.iter()
.any(|kw: &String| lower.contains(&kw.to_lowercase()));
let pattern_hit = rule
.patterns
.iter()
.any(|pat: &String| message.contains(pat.as_str()));
if keyword_hit || pattern_hit {
return Some(rule.hint.clone());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alphahuman::config::schema::{ClassificationRule, QueryClassificationConfig};
fn make_config(enabled: bool, rules: Vec<ClassificationRule>) -> QueryClassificationConfig {
QueryClassificationConfig { enabled, rules }
}
#[test]
fn disabled_returns_none() {
let config = make_config(
false,
vec![ClassificationRule {
hint: "fast".into(),
keywords: vec!["hello".into()],
..Default::default()
}],
);
assert_eq!(classify(&config, "hello"), None);
}
#[test]
fn empty_rules_returns_none() {
let config = make_config(true, vec![]);
assert_eq!(classify(&config, "hello"), None);
}
#[test]
fn keyword_match_case_insensitive() {
let config = make_config(
true,
vec![ClassificationRule {
hint: "fast".into(),
keywords: vec!["hello".into()],
..Default::default()
}],
);
assert_eq!(classify(&config, "HELLO world"), Some("fast".into()));
}
#[test]
fn pattern_match_case_sensitive() {
let config = make_config(
true,
vec![ClassificationRule {
hint: "code".into(),
patterns: vec!["fn ".into()],
..Default::default()
}],
);
assert_eq!(classify(&config, "fn main()"), Some("code".into()));
assert_eq!(classify(&config, "FN MAIN()"), None);
}
#[test]
fn length_constraints() {
let config = make_config(
true,
vec![ClassificationRule {
hint: "fast".into(),
keywords: vec!["hi".into()],
max_length: Some(10),
..Default::default()
}],
);
assert_eq!(classify(&config, "hi"), Some("fast".into()));
assert_eq!(
classify(&config, "hi there, how are you doing today?"),
None
);
let config2 = make_config(
true,
vec![ClassificationRule {
hint: "reasoning".into(),
keywords: vec!["explain".into()],
min_length: Some(20),
..Default::default()
}],
);
assert_eq!(classify(&config2, "explain"), None);
assert_eq!(
classify(&config2, "explain how this works in detail"),
Some("reasoning".into())
);
}
#[test]
fn priority_ordering() {
let config = make_config(
true,
vec![
ClassificationRule {
hint: "fast".into(),
keywords: vec!["code".into()],
priority: 1,
..Default::default()
},
ClassificationRule {
hint: "code".into(),
keywords: vec!["code".into()],
priority: 10,
..Default::default()
},
],
);
assert_eq!(classify(&config, "write some code"), Some("code".into()));
}
#[test]
fn no_match_returns_none() {
let config = make_config(
true,
vec![ClassificationRule {
hint: "fast".into(),
keywords: vec!["hello".into()],
..Default::default()
}],
);
assert_eq!(classify(&config, "something completely different"), None);
}
}
@@ -0,0 +1,318 @@
use crate::alphahuman::providers::{ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage};
use crate::alphahuman::tools::{Tool, ToolSpec};
use serde_json::Value;
use std::fmt::Write;
#[derive(Debug, Clone)]
pub struct ParsedToolCall {
pub name: String,
pub arguments: Value,
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ToolExecutionResult {
pub name: String,
pub output: String,
pub success: bool,
pub tool_call_id: Option<String>,
}
pub trait ToolDispatcher: Send + Sync {
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>);
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage;
fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String;
fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage>;
fn should_send_tool_specs(&self) -> bool;
}
#[derive(Default)]
pub struct XmlToolDispatcher;
impl XmlToolDispatcher {
fn parse_xml_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
let mut text_parts = Vec::new();
let mut calls = Vec::new();
let mut remaining = response;
while let Some(start) = remaining.find("<tool_call>") {
let before = &remaining[..start];
if !before.trim().is_empty() {
text_parts.push(before.trim().to_string());
}
if let Some(end) = remaining[start..].find("</tool_call>") {
let inner = &remaining[start + 11..start + end];
match serde_json::from_str::<Value>(inner.trim()) {
Ok(parsed) => {
let name = parsed
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if name.is_empty() {
remaining = &remaining[start + end + 12..];
continue;
}
let arguments = parsed
.get("arguments")
.cloned()
.unwrap_or_else(|| Value::Object(serde_json::Map::new()));
calls.push(ParsedToolCall {
name,
arguments,
tool_call_id: None,
});
}
Err(e) => {
tracing::warn!("Malformed <tool_call> JSON: {e}");
}
}
remaining = &remaining[start + end + 12..];
} else {
break;
}
}
if !remaining.trim().is_empty() {
text_parts.push(remaining.trim().to_string());
}
(text_parts.join("\n"), calls)
}
pub fn tool_specs(tools: &[Box<dyn Tool>]) -> Vec<ToolSpec> {
tools.iter().map(|tool| tool.spec()).collect()
}
}
impl ToolDispatcher for XmlToolDispatcher {
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>) {
let text = response.text_or_empty();
Self::parse_xml_tool_calls(text)
}
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage {
let mut content = String::new();
for result in results {
let status = if result.success { "ok" } else { "error" };
let _ = writeln!(
content,
"<tool_result name=\"{}\" status=\"{}\">\n{}\n</tool_result>",
result.name, status, result.output
);
}
ConversationMessage::Chat(ChatMessage::user(format!("[Tool results]\n{content}")))
}
fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String {
let mut instructions = String::new();
instructions.push_str("## Tool Use Protocol\n\n");
instructions
.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
instructions.push_str(
"```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n",
);
instructions.push_str("### Available Tools\n\n");
for tool in tools {
let _ = writeln!(
instructions,
"- **{}**: {}\n Parameters: `{}`",
tool.name(),
tool.description(),
tool.parameters_schema()
);
}
instructions
}
fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage> {
history
.iter()
.flat_map(|msg| match msg {
ConversationMessage::Chat(chat) => vec![chat.clone()],
ConversationMessage::AssistantToolCalls { text, .. } => {
vec![ChatMessage::assistant(text.clone().unwrap_or_default())]
}
ConversationMessage::ToolResults(results) => {
let mut content = String::new();
for result in results {
let _ = writeln!(
content,
"<tool_result id=\"{}\">\n{}\n</tool_result>",
result.tool_call_id, result.content
);
}
vec![ChatMessage::user(format!("[Tool results]\n{content}"))]
}
})
.collect()
}
fn should_send_tool_specs(&self) -> bool {
false
}
}
pub struct NativeToolDispatcher;
impl ToolDispatcher for NativeToolDispatcher {
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>) {
let text = response.text.clone().unwrap_or_default();
let calls = response
.tool_calls
.iter()
.map(|tc| ParsedToolCall {
name: tc.name.clone(),
arguments: serde_json::from_str(&tc.arguments).unwrap_or_else(|e| {
tracing::warn!(
tool = %tc.name,
error = %e,
"Failed to parse native tool call arguments as JSON; defaulting to empty object"
);
Value::Object(serde_json::Map::new())
}),
tool_call_id: Some(tc.id.clone()),
})
.collect();
(text, calls)
}
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage {
let messages = results
.iter()
.map(|result| ToolResultMessage {
tool_call_id: result
.tool_call_id
.clone()
.unwrap_or_else(|| "unknown".to_string()),
content: result.output.clone(),
})
.collect();
ConversationMessage::ToolResults(messages)
}
fn prompt_instructions(&self, _tools: &[Box<dyn Tool>]) -> String {
String::new()
}
fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage> {
history
.iter()
.flat_map(|msg| match msg {
ConversationMessage::Chat(chat) => vec![chat.clone()],
ConversationMessage::AssistantToolCalls { text, tool_calls } => {
let payload = serde_json::json!({
"content": text,
"tool_calls": tool_calls,
});
vec![ChatMessage::assistant(payload.to_string())]
}
ConversationMessage::ToolResults(results) => results
.iter()
.map(|result| {
ChatMessage::tool(
serde_json::json!({
"tool_call_id": result.tool_call_id,
"content": result.content,
})
.to_string(),
)
})
.collect(),
})
.collect()
}
fn should_send_tool_specs(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xml_dispatcher_parses_tool_calls() {
let response = ChatResponse {
text: Some(
"Checking\n<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool_call>"
.into(),
),
tool_calls: vec![],
};
let dispatcher = XmlToolDispatcher;
let (_, calls) = dispatcher.parse_response(&response);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "shell");
}
#[test]
fn native_dispatcher_roundtrip() {
let response = ChatResponse {
text: Some("ok".into()),
tool_calls: vec![crate::alphahuman::providers::ToolCall {
id: "tc1".into(),
name: "file_read".into(),
arguments: "{\"path\":\"a.txt\"}".into(),
}],
};
let dispatcher = NativeToolDispatcher;
let (_, calls) = dispatcher.parse_response(&response);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].tool_call_id.as_deref(), Some("tc1"));
let msg = dispatcher.format_results(&[ToolExecutionResult {
name: "file_read".into(),
output: "hello".into(),
success: true,
tool_call_id: Some("tc1".into()),
}]);
match msg {
ConversationMessage::ToolResults(results) => {
assert_eq!(results.len(), 1);
assert_eq!(results[0].tool_call_id, "tc1");
}
_ => panic!("expected tool results"),
}
}
#[test]
fn xml_format_results_contains_tool_result_tags() {
let dispatcher = XmlToolDispatcher;
let msg = dispatcher.format_results(&[ToolExecutionResult {
name: "shell".into(),
output: "ok".into(),
success: true,
tool_call_id: None,
}]);
let rendered = match msg {
ConversationMessage::Chat(chat) => chat.content,
_ => String::new(),
};
assert!(rendered.contains("<tool_result"));
assert!(rendered.contains("shell"));
}
#[test]
fn native_format_results_keeps_tool_call_id() {
let dispatcher = NativeToolDispatcher;
let msg = dispatcher.format_results(&[ToolExecutionResult {
name: "shell".into(),
output: "ok".into(),
success: true,
tool_call_id: Some("tc-1".into()),
}]);
match msg {
ConversationMessage::ToolResults(results) => {
assert_eq!(results.len(), 1);
assert_eq!(results[0].tool_call_id, "tc-1");
}
_ => panic!("expected ToolResults variant"),
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
use crate::alphahuman::memory::Memory;
use async_trait::async_trait;
use std::fmt::Write;
#[async_trait]
pub trait MemoryLoader: Send + Sync {
async fn load_context(&self, memory: &dyn Memory, user_message: &str)
-> anyhow::Result<String>;
}
pub struct DefaultMemoryLoader {
limit: usize,
min_relevance_score: f64,
}
impl Default for DefaultMemoryLoader {
fn default() -> Self {
Self {
limit: 5,
min_relevance_score: 0.4,
}
}
}
impl DefaultMemoryLoader {
pub fn new(limit: usize, min_relevance_score: f64) -> Self {
Self {
limit: limit.max(1),
min_relevance_score,
}
}
}
#[async_trait]
impl MemoryLoader for DefaultMemoryLoader {
async fn load_context(
&self,
memory: &dyn Memory,
user_message: &str,
) -> anyhow::Result<String> {
let entries = memory.recall(user_message, self.limit, None).await?;
if entries.is_empty() {
return Ok(String::new());
}
let mut context = String::from("[Memory context]\n");
for entry in entries {
if let Some(score) = entry.score {
if score < self.min_relevance_score {
continue;
}
}
let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
}
// If all entries were below threshold, return empty
if context == "[Memory context]\n" {
return Ok(String::new());
}
context.push('\n');
Ok(context)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alphahuman::memory::{Memory, MemoryCategory, MemoryEntry};
struct MockMemory;
#[async_trait]
impl Memory for MockMemory {
async fn store(
&self,
_key: &str,
_content: &str,
_category: MemoryCategory,
_session_id: Option<&str>,
) -> anyhow::Result<()> {
Ok(())
}
async fn recall(
&self,
_query: &str,
limit: usize,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
if limit == 0 {
return Ok(vec![]);
}
Ok(vec![MemoryEntry {
id: "1".into(),
key: "k".into(),
content: "v".into(),
category: MemoryCategory::Conversation,
timestamp: "now".into(),
session_id: None,
score: None,
}])
}
async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
Ok(None)
}
async fn list(
&self,
_category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(vec![])
}
async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
Ok(true)
}
async fn count(&self) -> anyhow::Result<usize> {
Ok(0)
}
async fn health_check(&self) -> bool {
true
}
fn name(&self) -> &str {
"mock"
}
}
#[tokio::test]
async fn default_loader_formats_context() {
let loader = DefaultMemoryLoader::default();
let context = loader.load_context(&MockMemory, "hello").await.unwrap();
assert!(context.contains("[Memory context]"));
assert!(context.contains("- k: v"));
}
}
+15
View File
@@ -0,0 +1,15 @@
#[allow(clippy::module_inception)]
pub mod agent;
pub mod classifier;
pub mod dispatcher;
pub mod loop_;
pub mod memory_loader;
pub mod prompt;
#[cfg(test)]
mod tests;
#[allow(unused_imports)]
pub use agent::{Agent, AgentBuilder};
#[allow(unused_imports)]
pub use loop_::{process_message, run};
+372
View File
@@ -0,0 +1,372 @@
use crate::alphahuman::config::IdentityConfig;
use crate::alphahuman::identity;
use crate::alphahuman::skills::Skill;
use crate::alphahuman::tools::Tool;
use anyhow::Result;
use chrono::Local;
use std::fmt::Write;
use std::path::Path;
const BOOTSTRAP_MAX_CHARS: usize = 20_000;
pub struct PromptContext<'a> {
pub workspace_dir: &'a Path,
pub model_name: &'a str,
pub tools: &'a [Box<dyn Tool>],
pub skills: &'a [Skill],
pub identity_config: Option<&'a IdentityConfig>,
pub dispatcher_instructions: &'a str,
}
pub trait PromptSection: Send + Sync {
fn name(&self) -> &str;
fn build(&self, ctx: &PromptContext<'_>) -> Result<String>;
}
#[derive(Default)]
pub struct SystemPromptBuilder {
sections: Vec<Box<dyn PromptSection>>,
}
impl SystemPromptBuilder {
pub fn with_defaults() -> Self {
Self {
sections: vec![
Box::new(IdentitySection),
Box::new(ToolsSection),
Box::new(SafetySection),
Box::new(SkillsSection),
Box::new(WorkspaceSection),
Box::new(DateTimeSection),
Box::new(RuntimeSection),
],
}
}
pub fn add_section(mut self, section: Box<dyn PromptSection>) -> Self {
self.sections.push(section);
self
}
pub fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
let mut output = String::new();
for section in &self.sections {
let part = section.build(ctx)?;
if part.trim().is_empty() {
continue;
}
output.push_str(part.trim_end());
output.push_str("\n\n");
}
Ok(output)
}
}
pub struct IdentitySection;
pub struct ToolsSection;
pub struct SafetySection;
pub struct SkillsSection;
pub struct WorkspaceSection;
pub struct RuntimeSection;
pub struct DateTimeSection;
impl PromptSection for IdentitySection {
fn name(&self) -> &str {
"identity"
}
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
let mut prompt = String::from("## Project Context\n\n");
let mut has_aieos = false;
if let Some(config) = ctx.identity_config {
if identity::is_aieos_configured(config) {
if let Ok(Some(aieos)) = identity::load_aieos_identity(config, ctx.workspace_dir) {
let rendered = identity::aieos_to_system_prompt(&aieos);
if !rendered.is_empty() {
prompt.push_str(&rendered);
prompt.push_str("\n\n");
has_aieos = true;
}
}
}
}
if !has_aieos {
prompt.push_str(
"The following workspace files define your identity, behavior, and context.\n\n",
);
}
for file in [
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
"MEMORY.md",
] {
inject_workspace_file(&mut prompt, ctx.workspace_dir, file);
}
Ok(prompt)
}
}
impl PromptSection for ToolsSection {
fn name(&self) -> &str {
"tools"
}
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::from("## Tools\n\n");
for tool in ctx.tools {
let _ = writeln!(
out,
"- **{}**: {}\n Parameters: `{}`",
tool.name(),
tool.description(),
tool.parameters_schema()
);
}
if !ctx.dispatcher_instructions.is_empty() {
out.push('\n');
out.push_str(ctx.dispatcher_instructions);
}
Ok(out)
}
}
impl PromptSection for SafetySection {
fn name(&self) -> &str {
"safety"
}
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into())
}
}
impl PromptSection for SkillsSection {
fn name(&self) -> &str {
"skills"
}
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
if ctx.skills.is_empty() {
return Ok(String::new());
}
let mut prompt = String::from("## Available Skills\n\n<available_skills>\n");
for skill in ctx.skills {
let location = skill.location.clone().unwrap_or_else(|| {
ctx.workspace_dir
.join("skills")
.join(&skill.name)
.join("SKILL.md")
});
let _ = writeln!(
prompt,
" <skill>\n <name>{}</name>\n <description>{}</description>\n <location>{}</location>\n </skill>",
skill.name,
skill.description,
location.display()
);
}
prompt.push_str("</available_skills>");
Ok(prompt)
}
}
impl PromptSection for WorkspaceSection {
fn name(&self) -> &str {
"workspace"
}
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
Ok(format!(
"## Workspace\n\nWorking directory: `{}`",
ctx.workspace_dir.display()
))
}
}
impl PromptSection for RuntimeSection {
fn name(&self) -> &str {
"runtime"
}
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
let host =
hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
Ok(format!(
"## Runtime\n\nHost: {host} | OS: {} | Model: {}",
std::env::consts::OS,
ctx.model_name
))
}
}
impl PromptSection for DateTimeSection {
fn name(&self) -> &str {
"datetime"
}
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
let now = Local::now();
Ok(format!(
"## Current Date & Time\n\n{} ({})",
now.format("%Y-%m-%d %H:%M:%S"),
now.format("%Z")
))
}
}
fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) {
let path = workspace_dir.join(filename);
match std::fs::read_to_string(&path) {
Ok(content) => {
let trimmed = content.trim();
if trimmed.is_empty() {
return;
}
let _ = writeln!(prompt, "### {filename}\n");
let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS {
trimmed
.char_indices()
.nth(BOOTSTRAP_MAX_CHARS)
.map(|(idx, _)| &trimmed[..idx])
.unwrap_or(trimmed)
} else {
trimmed
};
prompt.push_str(truncated);
if truncated.len() < trimmed.len() {
let _ = writeln!(
prompt,
"\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n"
);
} else {
prompt.push_str("\n\n");
}
}
Err(_) => {
let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alphahuman::tools::traits::Tool;
use async_trait::async_trait;
struct TestTool;
#[async_trait]
impl Tool for TestTool {
fn name(&self) -> &str {
"test_tool"
}
fn description(&self) -> &str {
"tool desc"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
async fn execute(
&self,
_args: serde_json::Value,
) -> anyhow::Result<crate::alphahuman::tools::ToolResult> {
Ok(crate::alphahuman::tools::ToolResult {
success: true,
output: "ok".into(),
error: None,
})
}
}
#[test]
fn identity_section_with_aieos_includes_workspace_files() {
let workspace =
std::env::temp_dir().join(format!("alphahuman_prompt_test_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(
workspace.join("AGENTS.md"),
"Always respond with: AGENTS_MD_LOADED",
)
.unwrap();
let identity_config = crate::alphahuman::config::IdentityConfig {
format: "aieos".into(),
aieos_path: None,
aieos_inline: Some(r#"{"identity":{"names":{"first":"Nova"}}}"#.into()),
};
let tools: Vec<Box<dyn Tool>> = vec![];
let ctx = PromptContext {
workspace_dir: &workspace,
model_name: "test-model",
tools: &tools,
skills: &[],
identity_config: Some(&identity_config),
dispatcher_instructions: "",
};
let section = IdentitySection;
let output = section.build(&ctx).unwrap();
assert!(
output.contains("Nova"),
"AIEOS identity should be present in prompt"
);
assert!(
output.contains("AGENTS_MD_LOADED"),
"AGENTS.md content should be present even when AIEOS is configured"
);
let _ = std::fs::remove_dir_all(workspace);
}
#[test]
fn prompt_builder_assembles_sections() {
let tools: Vec<Box<dyn Tool>> = vec![Box::new(TestTool)];
let ctx = PromptContext {
workspace_dir: Path::new("/tmp"),
model_name: "test-model",
tools: &tools,
skills: &[],
identity_config: None,
dispatcher_instructions: "instr",
};
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
assert!(prompt.contains("## Tools"));
assert!(prompt.contains("test_tool"));
assert!(prompt.contains("instr"));
}
#[test]
fn datetime_section_includes_timestamp_and_timezone() {
let tools: Vec<Box<dyn Tool>> = vec![];
let ctx = PromptContext {
workspace_dir: Path::new("/tmp"),
model_name: "test-model",
tools: &tools,
skills: &[],
identity_config: None,
dispatcher_instructions: "instr",
};
let rendered = DateTimeSection.build(&ctx).unwrap();
assert!(rendered.starts_with("## Current Date & Time\n\n"));
let payload = rendered.trim_start_matches("## Current Date & Time\n\n");
assert!(payload.chars().any(|c| c.is_ascii_digit()));
assert!(payload.contains(" ("));
assert!(payload.ends_with(')'));
}
}
File diff suppressed because it is too large Load Diff
+579
View File
@@ -0,0 +1,579 @@
//! Core agent traits ported from Alphahuman.
//!
//! Each trait defines an extension point. Noop implementations are provided
//! as test doubles and reference implementations.
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
// ═══════════════════════════════════════════════════════════════════
// Provider trait — LLM model interface
// ═══════════════════════════════════════════════════════════════════
/// A single message in a conversation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: "system".into(),
content: content.into(),
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: "user".into(),
content: content.into(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: "assistant".into(),
content: content.into(),
}
}
pub fn tool(content: impl Into<String>) -> Self {
Self {
role: "tool".into(),
content: content.into(),
}
}
}
/// A tool call requested by the LLM.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
/// An LLM response that may contain text, tool calls, or both.
#[derive(Debug, Clone)]
pub struct ChatResponse {
pub text: Option<String>,
pub tool_calls: Vec<ToolCall>,
}
impl ChatResponse {
pub fn has_tool_calls(&self) -> bool {
!self.tool_calls.is_empty()
}
pub fn text_or_empty(&self) -> &str {
self.text.as_deref().unwrap_or("")
}
}
/// Request payload for provider chat calls.
#[derive(Debug, Clone, Copy)]
pub struct ChatRequest<'a> {
pub messages: &'a [ChatMessage],
pub tools: Option<&'a [ToolSpec]>,
}
#[async_trait]
pub trait Provider: Send + Sync {
/// One-shot chat with optional system prompt.
async fn chat_with_system(
&self,
system_prompt: Option<&str>,
message: &str,
model: &str,
temperature: f64,
) -> anyhow::Result<String>;
/// Multi-turn conversation.
async fn chat_with_history(
&self,
messages: &[ChatMessage],
model: &str,
temperature: f64,
) -> anyhow::Result<String> {
let system = messages
.iter()
.find(|m| m.role == "system")
.map(|m| m.content.as_str());
let last_user = messages
.iter()
.rfind(|m| m.role == "user")
.map(|m| m.content.as_str())
.unwrap_or("");
self.chat_with_system(system, last_user, model, temperature)
.await
}
/// Warm up the HTTP connection pool.
async fn warmup(&self) -> anyhow::Result<()> {
Ok(())
}
}
/// Noop provider that always returns an empty string.
pub struct NoopProvider;
#[async_trait]
impl Provider for NoopProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok(String::new())
}
}
// ═══════════════════════════════════════════════════════════════════
// Tool trait — executable capabilities
// ═══════════════════════════════════════════════════════════════════
/// Result of a tool execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub success: bool,
pub output: String,
pub error: Option<String>,
}
/// Description of a tool for the LLM.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
/// Core tool trait — implement for any capability.
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> serde_json::Value;
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
fn spec(&self) -> ToolSpec {
ToolSpec {
name: self.name().to_string(),
description: self.description().to_string(),
parameters: self.parameters_schema(),
}
}
}
/// Noop tool that always succeeds with empty output.
pub struct NoopTool;
#[async_trait]
impl Tool for NoopTool {
fn name(&self) -> &str {
"noop"
}
fn description(&self) -> &str {
"No-op tool (does nothing)"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object", "properties": {}})
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult {
success: true,
output: String::new(),
error: None,
})
}
}
// ═══════════════════════════════════════════════════════════════════
// Memory trait — persistence backends
// ═══════════════════════════════════════════════════════════════════
/// A single memory entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryEntry {
pub id: String,
pub key: String,
pub content: String,
pub category: MemoryCategory,
pub timestamp: String,
pub session_id: Option<String>,
pub score: Option<f64>,
}
/// Memory categories for organization.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MemoryCategory {
Core,
Daily,
Conversation,
Custom(String),
}
impl std::fmt::Display for MemoryCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Core => write!(f, "core"),
Self::Daily => write!(f, "daily"),
Self::Conversation => write!(f, "conversation"),
Self::Custom(name) => write!(f, "{name}"),
}
}
}
/// Core memory trait — implement for any persistence backend.
#[async_trait]
pub trait Memory: Send + Sync {
fn name(&self) -> &str;
async fn store(
&self,
key: &str,
content: &str,
category: MemoryCategory,
session_id: Option<&str>,
) -> anyhow::Result<()>;
async fn recall(
&self,
query: &str,
limit: usize,
session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>>;
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>>;
async fn list(
&self,
category: Option<&MemoryCategory>,
session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>>;
async fn forget(&self, key: &str) -> anyhow::Result<bool>;
async fn count(&self) -> anyhow::Result<usize>;
async fn health_check(&self) -> bool;
}
/// Noop memory that stores nothing.
pub struct NoopMemory;
#[async_trait]
impl Memory for NoopMemory {
fn name(&self) -> &str {
"noop"
}
async fn store(
&self,
_key: &str,
_content: &str,
_category: MemoryCategory,
_session_id: Option<&str>,
) -> anyhow::Result<()> {
Ok(())
}
async fn recall(
&self,
_query: &str,
_limit: usize,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(vec![])
}
async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
Ok(None)
}
async fn list(
&self,
_category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(vec![])
}
async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn count(&self) -> anyhow::Result<usize> {
Ok(0)
}
async fn health_check(&self) -> bool {
true
}
}
// ═══════════════════════════════════════════════════════════════════
// Observer trait — observability backends
// ═══════════════════════════════════════════════════════════════════
/// Events the observer can record.
#[derive(Debug, Clone)]
pub enum ObserverEvent {
AgentStart {
provider: String,
model: String,
},
LlmRequest {
provider: String,
model: String,
messages_count: usize,
},
LlmResponse {
provider: String,
model: String,
duration: Duration,
success: bool,
error_message: Option<String>,
},
AgentEnd {
provider: String,
model: String,
duration: Duration,
tokens_used: Option<u64>,
cost_usd: Option<f64>,
},
ToolCallStart {
tool: String,
},
ToolCall {
tool: String,
duration: Duration,
success: bool,
},
TurnComplete,
ChannelMessage {
channel: String,
direction: String,
},
HeartbeatTick,
Error {
component: String,
message: String,
},
}
/// Numeric metrics.
#[derive(Debug, Clone)]
pub enum ObserverMetric {
RequestLatency(Duration),
TokensUsed(u64),
ActiveSessions(u64),
QueueDepth(u64),
}
/// Core observability trait — implement for any backend.
pub trait Observer: Send + Sync + 'static {
fn record_event(&self, event: &ObserverEvent);
fn record_metric(&self, metric: &ObserverMetric);
fn flush(&self) {}
fn name(&self) -> &str;
fn as_any(&self) -> &dyn std::any::Any;
}
/// Noop observer that discards all events.
#[derive(Default)]
pub struct NoopObserver;
impl Observer for NoopObserver {
fn record_event(&self, _event: &ObserverEvent) {}
fn record_metric(&self, _metric: &ObserverMetric) {}
fn name(&self) -> &str {
"noop"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
// ═══════════════════════════════════════════════════════════════════
// RuntimeAdapter trait — platform abstractions
// ═══════════════════════════════════════════════════════════════════
/// Runtime adapter — abstracts platform differences.
pub trait RuntimeAdapter: Send + Sync {
fn name(&self) -> &str;
fn has_shell_access(&self) -> bool;
fn has_filesystem_access(&self) -> bool;
fn storage_path(&self) -> PathBuf;
fn supports_long_running(&self) -> bool;
fn memory_budget(&self) -> u64 {
0
}
fn build_shell_command(
&self,
command: &str,
workspace_dir: &Path,
) -> anyhow::Result<tokio::process::Command>;
}
/// Noop runtime adapter that reports no capabilities.
pub struct NoopRuntimeAdapter;
impl RuntimeAdapter for NoopRuntimeAdapter {
fn name(&self) -> &str {
"noop"
}
fn has_shell_access(&self) -> bool {
false
}
fn has_filesystem_access(&self) -> bool {
false
}
fn storage_path(&self) -> PathBuf {
PathBuf::from("/dev/null")
}
fn supports_long_running(&self) -> bool {
false
}
fn build_shell_command(
&self,
_command: &str,
_workspace_dir: &Path,
) -> anyhow::Result<tokio::process::Command> {
anyhow::bail!("NoopRuntimeAdapter does not support shell commands")
}
}
// ═══════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chat_message_constructors() {
let sys = ChatMessage::system("Be helpful");
assert_eq!(sys.role, "system");
assert_eq!(sys.content, "Be helpful");
let user = ChatMessage::user("Hello");
assert_eq!(user.role, "user");
let asst = ChatMessage::assistant("Hi there");
assert_eq!(asst.role, "assistant");
let tool = ChatMessage::tool("{}");
assert_eq!(tool.role, "tool");
}
#[test]
fn chat_response_helpers() {
let empty = ChatResponse {
text: None,
tool_calls: vec![],
};
assert!(!empty.has_tool_calls());
assert_eq!(empty.text_or_empty(), "");
let with_tools = ChatResponse {
text: Some("Let me check".into()),
tool_calls: vec![ToolCall {
id: "1".into(),
name: "shell".into(),
arguments: "{}".into(),
}],
};
assert!(with_tools.has_tool_calls());
assert_eq!(with_tools.text_or_empty(), "Let me check");
}
#[test]
fn tool_spec_from_noop_tool() {
let tool = NoopTool;
let spec = tool.spec();
assert_eq!(spec.name, "noop");
assert_eq!(spec.parameters["type"], "object");
}
#[tokio::test]
async fn noop_tool_execute() {
let tool = NoopTool;
let result = tool.execute(serde_json::json!({})).await.unwrap();
assert!(result.success);
assert!(result.output.is_empty());
}
#[tokio::test]
async fn noop_provider_returns_empty() {
let provider = NoopProvider;
let result = provider
.chat_with_system(None, "hello", "model", 0.7)
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn noop_memory_operations() {
let mem = NoopMemory;
assert_eq!(mem.name(), "noop");
assert!(mem.health_check().await);
assert_eq!(mem.count().await.unwrap(), 0);
assert!(mem.get("key").await.unwrap().is_none());
assert!(mem.recall("query", 10, None).await.unwrap().is_empty());
assert!(!mem.forget("key").await.unwrap());
}
#[test]
fn noop_observer_accepts_events() {
let observer = NoopObserver;
observer.record_event(&ObserverEvent::HeartbeatTick);
observer.record_metric(&ObserverMetric::TokensUsed(42));
observer.flush();
assert_eq!(observer.name(), "noop");
}
#[test]
fn noop_runtime_adapter_reports_no_capabilities() {
let runtime = NoopRuntimeAdapter;
assert_eq!(runtime.name(), "noop");
assert!(!runtime.has_shell_access());
assert!(!runtime.has_filesystem_access());
assert!(!runtime.supports_long_running());
assert_eq!(runtime.memory_budget(), 0);
}
#[test]
fn memory_category_display() {
assert_eq!(MemoryCategory::Core.to_string(), "core");
assert_eq!(MemoryCategory::Daily.to_string(), "daily");
assert_eq!(MemoryCategory::Conversation.to_string(), "conversation");
assert_eq!(
MemoryCategory::Custom("notes".into()).to_string(),
"notes"
);
}
#[test]
fn memory_category_serde() {
let core = serde_json::to_string(&MemoryCategory::Core).unwrap();
assert_eq!(core, "\"core\"");
let parsed: MemoryCategory = serde_json::from_str("\"daily\"").unwrap();
assert_eq!(parsed, MemoryCategory::Daily);
}
#[test]
fn tool_result_serialization_roundtrip() {
let result = ToolResult {
success: false,
output: String::new(),
error: Some("boom".into()),
};
let json = serde_json::to_string(&result).unwrap();
let parsed: ToolResult = serde_json::from_str(&json).unwrap();
assert!(!parsed.success);
assert_eq!(parsed.error.as_deref(), Some("boom"));
}
}
+426
View File
@@ -0,0 +1,426 @@
//! Interactive approval workflow for supervised mode.
//!
//! Provides a pre-execution hook that prompts the user before tool calls,
//! with session-scoped "Always" allowlists and audit logging.
use crate::alphahuman::config::AutonomyConfig;
use crate::alphahuman::security::AutonomyLevel;
use chrono::Utc;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::io::{self, BufRead, Write};
// ── Types ────────────────────────────────────────────────────────
/// A request to approve a tool call before execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRequest {
pub tool_name: String,
pub arguments: serde_json::Value,
}
/// The user's response to an approval request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalResponse {
/// Execute this one call.
Yes,
/// Deny this call.
No,
/// Execute and add tool to session-scoped allowlist.
Always,
}
/// A single audit log entry for an approval decision.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalLogEntry {
pub timestamp: String,
pub tool_name: String,
pub arguments_summary: String,
pub decision: ApprovalResponse,
pub channel: String,
}
// ── ApprovalManager ──────────────────────────────────────────────
/// Manages the interactive approval workflow.
///
/// - Checks config-level `auto_approve` / `always_ask` lists
/// - Maintains a session-scoped "always" allowlist
/// - Records an audit trail of all decisions
pub struct ApprovalManager {
/// Tools that never need approval (from config).
auto_approve: HashSet<String>,
/// Tools that always need approval, ignoring session allowlist.
always_ask: HashSet<String>,
/// Autonomy level from config.
autonomy_level: AutonomyLevel,
/// Session-scoped allowlist built from "Always" responses.
session_allowlist: Mutex<HashSet<String>>,
/// Audit trail of approval decisions.
audit_log: Mutex<Vec<ApprovalLogEntry>>,
}
impl ApprovalManager {
/// Create from autonomy config.
pub fn from_config(config: &AutonomyConfig) -> Self {
Self {
auto_approve: config.auto_approve.iter().cloned().collect(),
always_ask: config.always_ask.iter().cloned().collect(),
autonomy_level: config.level,
session_allowlist: Mutex::new(HashSet::new()),
audit_log: Mutex::new(Vec::new()),
}
}
/// Check whether a tool call requires interactive approval.
///
/// Returns `true` if the call needs a prompt, `false` if it can proceed.
pub fn needs_approval(&self, tool_name: &str) -> bool {
// Full autonomy never prompts.
if self.autonomy_level == AutonomyLevel::Full {
return false;
}
// ReadOnly blocks everything — handled elsewhere; no prompt needed.
if self.autonomy_level == AutonomyLevel::ReadOnly {
return false;
}
// always_ask overrides everything.
if self.always_ask.contains(tool_name) {
return true;
}
// auto_approve skips the prompt.
if self.auto_approve.contains(tool_name) {
return false;
}
// Session allowlist (from prior "Always" responses).
let allowlist = self.session_allowlist.lock();
if allowlist.contains(tool_name) {
return false;
}
// Default: supervised mode requires approval.
true
}
/// Record an approval decision and update session state.
pub fn record_decision(
&self,
tool_name: &str,
args: &serde_json::Value,
decision: ApprovalResponse,
channel: &str,
) {
// If "Always", add to session allowlist.
if decision == ApprovalResponse::Always {
let mut allowlist = self.session_allowlist.lock();
allowlist.insert(tool_name.to_string());
}
// Append to audit log.
let summary = summarize_args(args);
let entry = ApprovalLogEntry {
timestamp: Utc::now().to_rfc3339(),
tool_name: tool_name.to_string(),
arguments_summary: summary,
decision,
channel: channel.to_string(),
};
let mut log = self.audit_log.lock();
log.push(entry);
}
/// Get a snapshot of the audit log.
pub fn audit_log(&self) -> Vec<ApprovalLogEntry> {
self.audit_log.lock().clone()
}
/// Get the current session allowlist.
pub fn session_allowlist(&self) -> HashSet<String> {
self.session_allowlist.lock().clone()
}
/// Prompt the user on the local console and return their decision.
///
/// In the web UI, approvals are handled elsewhere; this is a fallback
/// for non-UI environments.
pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse {
prompt_cli_interactive(request)
}
}
// ── Console prompt ───────────────────────────────────────────────
/// Display the approval prompt and read user input from stdin.
fn prompt_cli_interactive(request: &ApprovalRequest) -> ApprovalResponse {
let summary = summarize_args(&request.arguments);
eprintln!();
eprintln!("🔧 Agent wants to execute: {}", request.tool_name);
eprintln!(" {summary}");
eprint!(" [Y]es / [N]o / [A]lways for {}: ", request.tool_name);
let _ = io::stderr().flush();
let stdin = io::stdin();
let mut line = String::new();
if stdin.lock().read_line(&mut line).is_err() {
return ApprovalResponse::No;
}
match line.trim().to_ascii_lowercase().as_str() {
"y" | "yes" => ApprovalResponse::Yes,
"a" | "always" => ApprovalResponse::Always,
_ => ApprovalResponse::No,
}
}
/// Produce a short human-readable summary of tool arguments.
fn summarize_args(args: &serde_json::Value) -> String {
match args {
serde_json::Value::Object(map) => {
let parts: Vec<String> = map
.iter()
.map(|(k, v)| {
let val = match v {
serde_json::Value::String(s) => truncate_for_summary(s, 80),
other => {
let s = other.to_string();
truncate_for_summary(&s, 80)
}
};
format!("{k}: {val}")
})
.collect();
parts.join(", ")
}
other => {
let s = other.to_string();
truncate_for_summary(&s, 120)
}
}
}
fn truncate_for_summary(input: &str, max_chars: usize) -> String {
let mut chars = input.chars();
let truncated: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
format!("{truncated}")
} else {
input.to_string()
}
}
// ── Tests ────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::alphahuman::config::AutonomyConfig;
fn supervised_config() -> AutonomyConfig {
AutonomyConfig {
level: AutonomyLevel::Supervised,
auto_approve: vec!["file_read".into(), "memory_recall".into()],
always_ask: vec!["shell".into()],
..AutonomyConfig::default()
}
}
fn full_config() -> AutonomyConfig {
AutonomyConfig {
level: AutonomyLevel::Full,
..AutonomyConfig::default()
}
}
// ── needs_approval ───────────────────────────────────────
#[test]
fn auto_approve_tools_skip_prompt() {
let mgr = ApprovalManager::from_config(&supervised_config());
assert!(!mgr.needs_approval("file_read"));
assert!(!mgr.needs_approval("memory_recall"));
}
#[test]
fn always_ask_tools_always_prompt() {
let mgr = ApprovalManager::from_config(&supervised_config());
assert!(mgr.needs_approval("shell"));
}
#[test]
fn unknown_tool_needs_approval_in_supervised() {
let mgr = ApprovalManager::from_config(&supervised_config());
assert!(mgr.needs_approval("file_write"));
assert!(mgr.needs_approval("http_request"));
}
#[test]
fn full_autonomy_never_prompts() {
let mgr = ApprovalManager::from_config(&full_config());
assert!(!mgr.needs_approval("shell"));
assert!(!mgr.needs_approval("file_write"));
assert!(!mgr.needs_approval("anything"));
}
#[test]
fn readonly_never_prompts() {
let config = AutonomyConfig {
level: AutonomyLevel::ReadOnly,
..AutonomyConfig::default()
};
let mgr = ApprovalManager::from_config(&config);
assert!(!mgr.needs_approval("shell"));
}
// ── session allowlist ────────────────────────────────────
#[test]
fn always_response_adds_to_session_allowlist() {
let mgr = ApprovalManager::from_config(&supervised_config());
assert!(mgr.needs_approval("file_write"));
mgr.record_decision(
"file_write",
&serde_json::json!({"path": "test.txt"}),
ApprovalResponse::Always,
"cli",
);
// Now file_write should be in session allowlist.
assert!(!mgr.needs_approval("file_write"));
}
#[test]
fn always_ask_overrides_session_allowlist() {
let mgr = ApprovalManager::from_config(&supervised_config());
// Even after "Always" for shell, it should still prompt.
mgr.record_decision(
"shell",
&serde_json::json!({"command": "ls"}),
ApprovalResponse::Always,
"cli",
);
// shell is in always_ask, so it still needs approval.
assert!(mgr.needs_approval("shell"));
}
#[test]
fn yes_response_does_not_add_to_allowlist() {
let mgr = ApprovalManager::from_config(&supervised_config());
mgr.record_decision(
"file_write",
&serde_json::json!({}),
ApprovalResponse::Yes,
"cli",
);
assert!(mgr.needs_approval("file_write"));
}
// ── audit log ────────────────────────────────────────────
#[test]
fn audit_log_records_decisions() {
let mgr = ApprovalManager::from_config(&supervised_config());
mgr.record_decision(
"shell",
&serde_json::json!({"command": "rm -rf ./build/"}),
ApprovalResponse::No,
"cli",
);
mgr.record_decision(
"file_write",
&serde_json::json!({"path": "out.txt", "content": "hello"}),
ApprovalResponse::Yes,
"cli",
);
let log = mgr.audit_log();
assert_eq!(log.len(), 2);
assert_eq!(log[0].tool_name, "shell");
assert_eq!(log[0].decision, ApprovalResponse::No);
assert_eq!(log[1].tool_name, "file_write");
assert_eq!(log[1].decision, ApprovalResponse::Yes);
}
#[test]
fn audit_log_contains_timestamp_and_channel() {
let mgr = ApprovalManager::from_config(&supervised_config());
mgr.record_decision(
"shell",
&serde_json::json!({"command": "ls"}),
ApprovalResponse::Yes,
"telegram",
);
let log = mgr.audit_log();
assert_eq!(log.len(), 1);
assert!(!log[0].timestamp.is_empty());
assert_eq!(log[0].channel, "telegram");
}
// ── summarize_args ───────────────────────────────────────
#[test]
fn summarize_args_object() {
let args = serde_json::json!({"command": "ls -la", "cwd": "/tmp"});
let summary = summarize_args(&args);
assert!(summary.contains("command: ls -la"));
assert!(summary.contains("cwd: /tmp"));
}
#[test]
fn summarize_args_truncates_long_values() {
let long_val = "x".repeat(200);
let args = serde_json::json!({"content": long_val});
let summary = summarize_args(&args);
assert!(summary.contains('…'));
assert!(summary.len() < 200);
}
#[test]
fn summarize_args_unicode_safe_truncation() {
let long_val = "🦀".repeat(120);
let args = serde_json::json!({"content": long_val});
let summary = summarize_args(&args);
assert!(summary.contains("content:"));
assert!(summary.contains('…'));
}
#[test]
fn summarize_args_non_object() {
let args = serde_json::json!("just a string");
let summary = summarize_args(&args);
assert!(summary.contains("just a string"));
}
// ── ApprovalResponse serde ───────────────────────────────
#[test]
fn approval_response_serde_roundtrip() {
let json = serde_json::to_string(&ApprovalResponse::Always).unwrap();
assert_eq!(json, "\"always\"");
let parsed: ApprovalResponse = serde_json::from_str("\"no\"").unwrap();
assert_eq!(parsed, ApprovalResponse::No);
}
// ── ApprovalRequest ──────────────────────────────────────
#[test]
fn approval_request_serde() {
let req = ApprovalRequest {
tool_name: "shell".into(),
arguments: serde_json::json!({"command": "echo hi"}),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: ApprovalRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.tool_name, "shell");
}
}
+138
View File
@@ -0,0 +1,138 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use tokio::io::{self, AsyncBufReadExt, BufReader};
use uuid::Uuid;
/// Console channel — stdin/stdout, not used in the web UI, zero deps
pub struct CliChannel;
impl CliChannel {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl Channel for CliChannel {
fn name(&self) -> &str {
"cli"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
println!("{}", message.content);
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
let stdin = io::stdin();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
if line == "/quit" || line == "/exit" {
break;
}
let msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: "user".to_string(),
reply_target: "user".to_string(),
content: line,
channel: "cli".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: None,
};
if tx.send(msg).await.is_err() {
break;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_channel_name() {
assert_eq!(CliChannel::new().name(), "cli");
}
#[tokio::test]
async fn cli_channel_send_does_not_panic() {
let ch = CliChannel::new();
let result = ch
.send(&SendMessage {
content: "hello".into(),
recipient: "user".into(),
subject: None,
thread_ts: None,
})
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn cli_channel_send_empty_message() {
let ch = CliChannel::new();
let result = ch
.send(&SendMessage {
content: String::new(),
recipient: String::new(),
subject: None,
thread_ts: None,
})
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn cli_channel_health_check() {
let ch = CliChannel::new();
assert!(ch.health_check().await);
}
#[test]
fn channel_message_struct() {
let msg = ChannelMessage {
id: "test-id".into(),
sender: "user".into(),
reply_target: "user".into(),
content: "hello".into(),
channel: "cli".into(),
timestamp: 1_234_567_890,
thread_ts: None,
};
assert_eq!(msg.id, "test-id");
assert_eq!(msg.sender, "user");
assert_eq!(msg.reply_target, "user");
assert_eq!(msg.content, "hello");
assert_eq!(msg.channel, "cli");
assert_eq!(msg.timestamp, 1_234_567_890);
}
#[test]
fn channel_message_clone() {
let msg = ChannelMessage {
id: "id".into(),
sender: "s".into(),
reply_target: "s".into(),
content: "c".into(),
channel: "ch".into(),
timestamp: 0,
thread_ts: None,
};
let cloned = msg.clone();
assert_eq!(cloned.id, msg.id);
assert_eq!(cloned.content, msg.content);
}
}
@@ -0,0 +1,272 @@
//! Channel command handling and health checks.
use super::dingtalk::DingTalkChannel;
use super::discord::DiscordChannel;
use super::email_channel::EmailChannel;
use super::imessage::IMessageChannel;
use super::irc;
use super::irc::IrcChannel;
use super::lark::LarkChannel;
use super::linq::LinqChannel;
#[cfg(feature = "channel-matrix")]
use super::matrix::MatrixChannel;
use super::qq::QQChannel;
use super::signal::SignalChannel;
use super::slack::SlackChannel;
use super::telegram::TelegramChannel;
use super::whatsapp::WhatsAppChannel;
#[cfg(feature = "whatsapp-web")]
use super::whatsapp_web::WhatsAppWebChannel;
use super::Channel;
use crate::alphahuman::config::Config;
use anyhow::Result;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ChannelHealthState {
Healthy,
Unhealthy,
Timeout,
}
pub(crate) fn classify_health_result(
result: &std::result::Result<bool, tokio::time::error::Elapsed>,
) -> ChannelHealthState {
match result {
Ok(true) => ChannelHealthState::Healthy,
Ok(false) => ChannelHealthState::Unhealthy,
Err(_) => ChannelHealthState::Timeout,
}
}
/// Run health checks for configured channels.
pub async fn doctor_channels(config: Config) -> Result<()> {
let mut channels: Vec<(&'static str, Arc<dyn Channel>)> = Vec::new();
if let Some(ref tg) = config.channels_config.telegram {
channels.push((
"Telegram",
Arc::new(
TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.mention_only,
)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms),
),
));
}
if let Some(ref dc) = config.channels_config.discord {
channels.push((
"Discord",
Arc::new(DiscordChannel::new(
dc.bot_token.clone(),
dc.guild_id.clone(),
dc.allowed_users.clone(),
dc.listen_to_bots,
dc.mention_only,
)),
));
}
if let Some(ref sl) = config.channels_config.slack {
channels.push((
"Slack",
Arc::new(SlackChannel::new(
sl.bot_token.clone(),
sl.channel_id.clone(),
sl.allowed_users.clone(),
)),
));
}
if let Some(ref im) = config.channels_config.imessage {
channels.push((
"iMessage",
Arc::new(IMessageChannel::new(im.allowed_contacts.clone())),
));
}
#[cfg(feature = "channel-matrix")]
if let Some(ref mx) = config.channels_config.matrix {
channels.push((
"Matrix",
Arc::new(MatrixChannel::new_with_session_hint(
mx.homeserver.clone(),
mx.access_token.clone(),
mx.room_id.clone(),
mx.allowed_users.clone(),
mx.user_id.clone(),
mx.device_id.clone(),
)),
));
}
#[cfg(not(feature = "channel-matrix"))]
if config.channels_config.matrix.is_some() {
tracing::warn!(
"Matrix channel is configured but this build was compiled without `channel-matrix`; skipping Matrix health check."
);
}
if let Some(ref sig) = config.channels_config.signal {
channels.push((
"Signal",
Arc::new(SignalChannel::new(
sig.http_url.clone(),
sig.account.clone(),
sig.group_id.clone(),
sig.allowed_from.clone(),
sig.ignore_attachments,
sig.ignore_stories,
)),
));
}
if let Some(ref wa) = config.channels_config.whatsapp {
// Runtime negotiation: detect backend type from config
match wa.backend_type() {
"cloud" => {
// Cloud API mode: requires phone_number_id, access_token, verify_token
if wa.is_cloud_config() {
channels.push((
"WhatsApp",
Arc::new(WhatsAppChannel::new(
wa.access_token.clone().unwrap_or_default(),
wa.phone_number_id.clone().unwrap_or_default(),
wa.verify_token.clone().unwrap_or_default(),
wa.allowed_numbers.clone(),
)),
));
} else {
tracing::warn!("WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)");
}
}
"web" => {
// Web mode: requires session_path
#[cfg(feature = "whatsapp-web")]
if wa.is_web_config() {
channels.push((
"WhatsApp",
Arc::new(WhatsAppWebChannel::new(
wa.session_path.clone().unwrap_or_default(),
wa.pair_phone.clone(),
wa.pair_code.clone(),
wa.allowed_numbers.clone(),
)),
));
} else {
tracing::warn!("WhatsApp Web configured but session_path not set");
}
#[cfg(not(feature = "whatsapp-web"))]
{
tracing::warn!("WhatsApp Web backend requires 'whatsapp-web' feature. Enable with: cargo build --features whatsapp-web");
}
}
_ => {
tracing::warn!("WhatsApp config invalid: neither phone_number_id (Cloud API) nor session_path (Web) is set");
}
}
}
if let Some(ref lq) = config.channels_config.linq {
channels.push((
"Linq",
Arc::new(LinqChannel::new(
lq.api_token.clone(),
lq.from_phone.clone(),
lq.allowed_senders.clone(),
)),
));
}
if let Some(ref email_cfg) = config.channels_config.email {
channels.push(("Email", Arc::new(EmailChannel::new(email_cfg.clone()))));
}
if let Some(ref irc) = config.channels_config.irc {
channels.push((
"IRC",
Arc::new(IrcChannel::new(irc::IrcChannelConfig {
server: irc.server.clone(),
port: irc.port,
nickname: irc.nickname.clone(),
username: irc.username.clone(),
channels: irc.channels.clone(),
allowed_users: irc.allowed_users.clone(),
server_password: irc.server_password.clone(),
nickserv_password: irc.nickserv_password.clone(),
sasl_password: irc.sasl_password.clone(),
verify_tls: irc.verify_tls.unwrap_or(true),
})),
));
}
if let Some(ref lk) = config.channels_config.lark {
channels.push(("Lark", Arc::new(LarkChannel::from_config(lk))));
}
if let Some(ref dt) = config.channels_config.dingtalk {
channels.push((
"DingTalk",
Arc::new(DingTalkChannel::new(
dt.client_id.clone(),
dt.client_secret.clone(),
dt.allowed_users.clone(),
)),
));
}
if let Some(ref qq) = config.channels_config.qq {
channels.push((
"QQ",
Arc::new(QQChannel::new(
qq.app_id.clone(),
qq.app_secret.clone(),
qq.allowed_users.clone(),
)),
));
}
if channels.is_empty() {
println!("No real-time channels configured. Configure channels in the web UI.");
return Ok(());
}
println!("🩺 Alphahuman Channel Doctor");
println!();
let mut healthy = 0_u32;
let mut unhealthy = 0_u32;
let mut timeout = 0_u32;
for (name, channel) in channels {
let result = tokio::time::timeout(Duration::from_secs(10), channel.health_check()).await;
let state = classify_health_result(&result);
match state {
ChannelHealthState::Healthy => {
healthy += 1;
println!("{name:<9} healthy");
}
ChannelHealthState::Unhealthy => {
unhealthy += 1;
println!("{name:<9} unhealthy (auth/config/network)");
}
ChannelHealthState::Timeout => {
timeout += 1;
println!(" ⏱️ {name:<9} timed out (>10s)");
}
}
}
if config.channels_config.webhook.is_some() {
println!(" ️ Webhook check gateway health in the web UI");
}
println!();
println!("Summary: {healthy} healthy, {unhealthy} unhealthy, {timeout} timed out");
Ok(())
}
@@ -0,0 +1,191 @@
//! Shared channel runtime state and memory helpers.
use crate::alphahuman::memory::Memory;
use crate::alphahuman::observability::Observer;
use crate::alphahuman::providers::{ChatMessage, Provider};
use crate::alphahuman::tools::Tool;
use crate::alphahuman::util::truncate_with_ellipsis;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// Per-sender conversation history for channel messages.
pub(crate) type ConversationHistoryMap = Arc<Mutex<HashMap<String, Vec<ChatMessage>>>>;
/// Maximum history messages to keep per sender.
pub(crate) const MAX_CHANNEL_HISTORY: usize = 50;
pub(crate) const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
pub(crate) const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
pub(crate) const MIN_CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 30;
/// Default timeout for processing a single channel message (LLM + tools).
/// Used as fallback when not configured in channels_config.message_timeout_secs.
pub(crate) const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300;
pub(crate) const CHANNEL_PARALLELISM_PER_CHANNEL: usize = 4;
pub(crate) const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8;
pub(crate) const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64;
pub(crate) const CHANNEL_TYPING_REFRESH_INTERVAL_SECS: u64 = 4;
pub(crate) const MEMORY_CONTEXT_MAX_ENTRIES: usize = 4;
pub(crate) const MEMORY_CONTEXT_ENTRY_MAX_CHARS: usize = 800;
pub(crate) const MEMORY_CONTEXT_MAX_CHARS: usize = 4_000;
pub(crate) const CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES: usize = 12;
pub(crate) const CHANNEL_HISTORY_COMPACT_CONTENT_CHARS: usize = 600;
pub(crate) type ProviderCacheMap = Arc<Mutex<HashMap<String, Arc<dyn Provider>>>>;
pub(crate) type RouteSelectionMap = Arc<Mutex<HashMap<String, ChannelRouteSelection>>>;
pub(crate) fn effective_channel_message_timeout_secs(configured: u64) -> u64 {
configured.max(MIN_CHANNEL_MESSAGE_TIMEOUT_SECS)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChannelRouteSelection {
pub(crate) provider: String,
pub(crate) model: String,
}
#[derive(Clone)]
pub(crate) struct ChannelRuntimeContext {
pub(crate) channels_by_name: Arc<HashMap<String, Arc<dyn super::Channel>>>,
pub(crate) provider: Arc<dyn Provider>,
pub(crate) default_provider: Arc<String>,
pub(crate) memory: Arc<dyn Memory>,
pub(crate) tools_registry: Arc<Vec<Box<dyn Tool>>>,
pub(crate) observer: Arc<dyn Observer>,
pub(crate) system_prompt: Arc<String>,
pub(crate) model: Arc<String>,
pub(crate) temperature: f64,
pub(crate) auto_save_memory: bool,
pub(crate) max_tool_iterations: usize,
pub(crate) min_relevance_score: f64,
pub(crate) conversation_histories: ConversationHistoryMap,
pub(crate) provider_cache: ProviderCacheMap,
pub(crate) route_overrides: RouteSelectionMap,
pub(crate) api_key: Option<String>,
pub(crate) api_url: Option<String>,
pub(crate) reliability: Arc<crate::alphahuman::config::ReliabilityConfig>,
pub(crate) provider_runtime_options: crate::alphahuman::providers::ProviderRuntimeOptions,
pub(crate) workspace_dir: Arc<PathBuf>,
pub(crate) message_timeout_secs: u64,
pub(crate) multimodal: crate::alphahuman::config::MultimodalConfig,
}
pub(crate) fn conversation_memory_key(msg: &super::traits::ChannelMessage) -> String {
format!("{}_{}_{}", msg.channel, msg.sender, msg.id)
}
pub(crate) fn conversation_history_key(msg: &super::traits::ChannelMessage) -> String {
format!("{}_{}", msg.channel, msg.sender)
}
pub(crate) fn clear_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) {
ctx.conversation_histories
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(sender_key);
}
pub(crate) fn compact_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool {
let mut histories = ctx
.conversation_histories
.lock()
.unwrap_or_else(|e| e.into_inner());
let Some(turns) = histories.get_mut(sender_key) else {
return false;
};
if turns.is_empty() {
return false;
}
let keep_from = turns
.len()
.saturating_sub(CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
let mut compacted = turns[keep_from..].to_vec();
for turn in &mut compacted {
if turn.content.chars().count() > CHANNEL_HISTORY_COMPACT_CONTENT_CHARS {
turn.content =
truncate_with_ellipsis(&turn.content, CHANNEL_HISTORY_COMPACT_CONTENT_CHARS);
}
}
*turns = compacted;
true
}
pub(crate) fn should_skip_memory_context_entry(key: &str, content: &str) -> bool {
if key.trim().to_ascii_lowercase().ends_with("_history") {
return true;
}
content.chars().count() > MEMORY_CONTEXT_MAX_CHARS
}
pub(crate) fn is_context_window_overflow_error(err: &anyhow::Error) -> bool {
let lower = err.to_string().to_lowercase();
[
"exceeds the context window",
"context window of this model",
"maximum context length",
"context length exceeded",
"too many tokens",
"token limit exceeded",
"prompt is too long",
"input is too long",
]
.iter()
.any(|hint| lower.contains(hint))
}
pub(crate) async fn build_memory_context(
mem: &dyn Memory,
user_msg: &str,
min_relevance_score: f64,
) -> String {
let mut context = String::new();
if let Ok(entries) = mem.recall(user_msg, 5, None).await {
let mut included = 0usize;
let mut used_chars = 0usize;
for entry in entries.iter().filter(|e| match e.score {
Some(score) => score >= min_relevance_score,
None => true, // keep entries without a score (e.g. non-vector backends)
}) {
if included >= MEMORY_CONTEXT_MAX_ENTRIES {
break;
}
if should_skip_memory_context_entry(&entry.key, &entry.content) {
continue;
}
let content = if entry.content.chars().count() > MEMORY_CONTEXT_ENTRY_MAX_CHARS {
truncate_with_ellipsis(&entry.content, MEMORY_CONTEXT_ENTRY_MAX_CHARS)
} else {
entry.content.clone()
};
let line = format!("- {}: {}\n", entry.key, content);
let line_chars = line.chars().count();
if used_chars + line_chars > MEMORY_CONTEXT_MAX_CHARS {
break;
}
if included == 0 {
context.push_str("[Memory context]\n");
}
context.push_str(&line);
used_chars += line_chars;
included += 1;
}
if included > 0 {
context.push('\n');
}
}
context
}
@@ -0,0 +1,382 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
const DINGTALK_BOT_CALLBACK_TOPIC: &str = "/v1.0/im/bot/messages/get";
/// DingTalk channel — connects via Stream Mode WebSocket for real-time messages.
/// Replies are sent through per-message session webhook URLs.
pub struct DingTalkChannel {
client_id: String,
client_secret: String,
allowed_users: Vec<String>,
/// Per-chat session webhooks for sending replies (chatID -> webhook URL).
/// DingTalk provides a unique webhook URL with each incoming message.
session_webhooks: Arc<RwLock<HashMap<String, String>>>,
}
/// Response from DingTalk gateway connection registration.
#[derive(serde::Deserialize)]
struct GatewayResponse {
endpoint: String,
ticket: String,
}
impl DingTalkChannel {
pub fn new(client_id: String, client_secret: String, allowed_users: Vec<String>) -> Self {
Self {
client_id,
client_secret,
allowed_users,
session_webhooks: Arc::new(RwLock::new(HashMap::new())),
}
}
fn http_client(&self) -> reqwest::Client {
crate::alphahuman::config::build_runtime_proxy_client("channel.dingtalk")
}
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
fn parse_stream_data(frame: &serde_json::Value) -> Option<serde_json::Value> {
match frame.get("data") {
Some(serde_json::Value::String(raw)) => serde_json::from_str(raw).ok(),
Some(serde_json::Value::Object(_)) => frame.get("data").cloned(),
_ => None,
}
}
fn resolve_chat_id(data: &serde_json::Value, sender_id: &str) -> String {
let is_private_chat = data
.get("conversationType")
.and_then(|value| {
value
.as_str()
.map(|v| v == "1")
.or_else(|| value.as_i64().map(|v| v == 1))
})
.unwrap_or(true);
if is_private_chat {
sender_id.to_string()
} else {
data.get("conversationId")
.and_then(|c| c.as_str())
.unwrap_or(sender_id)
.to_string()
}
}
/// Register a connection with DingTalk's gateway to get a WebSocket endpoint.
async fn register_connection(&self) -> anyhow::Result<GatewayResponse> {
let body = serde_json::json!({
"clientId": self.client_id,
"clientSecret": self.client_secret,
"subscriptions": [
{
"type": "CALLBACK",
"topic": DINGTALK_BOT_CALLBACK_TOPIC,
}
],
});
let resp = self
.http_client()
.post("https://api.dingtalk.com/v1.0/gateway/connections/open")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("DingTalk gateway registration failed ({status}): {err}");
}
let gw: GatewayResponse = resp.json().await?;
Ok(gw)
}
}
#[async_trait]
impl Channel for DingTalkChannel {
fn name(&self) -> &str {
"dingtalk"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let webhooks = self.session_webhooks.read().await;
let webhook_url = webhooks.get(&message.recipient).ok_or_else(|| {
anyhow::anyhow!(
"No session webhook found for chat {}. \
The user must send a message first to establish a session.",
message.recipient
)
})?;
let title = message.subject.as_deref().unwrap_or("Alphahuman");
let body = serde_json::json!({
"msgtype": "markdown",
"markdown": {
"title": title,
"text": message.content,
}
});
let resp = self
.http_client()
.post(webhook_url)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("DingTalk webhook reply failed ({status}): {err}");
}
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
tracing::info!("DingTalk: registering gateway connection...");
let gw = self.register_connection().await?;
let ws_url = format!("{}?ticket={}", gw.endpoint, gw.ticket);
tracing::info!("DingTalk: connecting to stream WebSocket...");
let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url).await?;
let (mut write, mut read) = ws_stream.split();
tracing::info!("DingTalk: connected and listening for messages...");
while let Some(msg) = read.next().await {
let msg = match msg {
Ok(Message::Text(t)) => t,
Ok(Message::Close(_)) => break,
Err(e) => {
tracing::warn!("DingTalk WebSocket error: {e}");
break;
}
_ => continue,
};
let frame: serde_json::Value = match serde_json::from_str(msg.as_ref()) {
Ok(v) => v,
Err(_) => continue,
};
let frame_type = frame.get("type").and_then(|t| t.as_str()).unwrap_or("");
match frame_type {
"SYSTEM" => {
// Respond to system pings to keep the connection alive
let message_id = frame
.get("headers")
.and_then(|h| h.get("messageId"))
.and_then(|m| m.as_str())
.unwrap_or("");
let pong = serde_json::json!({
"code": 200,
"headers": {
"contentType": "application/json",
"messageId": message_id,
},
"message": "OK",
"data": "",
});
if let Err(e) = write.send(Message::Text(pong.to_string().into())).await {
tracing::warn!("DingTalk: failed to send pong: {e}");
break;
}
}
"EVENT" | "CALLBACK" => {
// Parse the chatbot callback data from the frame.
let data = match Self::parse_stream_data(&frame) {
Some(v) => v,
None => {
tracing::debug!("DingTalk: frame has no parseable data payload");
continue;
}
};
// Extract message content
let content = data
.get("text")
.and_then(|t| t.get("content"))
.and_then(|c| c.as_str())
.unwrap_or("")
.trim();
if content.is_empty() {
continue;
}
let sender_id = data
.get("senderStaffId")
.and_then(|s| s.as_str())
.unwrap_or("unknown");
if !self.is_user_allowed(sender_id) {
tracing::warn!(
"DingTalk: ignoring message from unauthorized user: {sender_id}"
);
continue;
}
// Private chat uses sender ID, group chat uses conversation ID.
let chat_id = Self::resolve_chat_id(&data, sender_id);
// Store session webhook for later replies
if let Some(webhook) = data.get("sessionWebhook").and_then(|w| w.as_str()) {
let webhook = webhook.to_string();
let mut webhooks = self.session_webhooks.write().await;
// Use both keys so reply routing works for both group and private flows.
webhooks.insert(chat_id.clone(), webhook.clone());
webhooks.insert(sender_id.to_string(), webhook);
}
// Acknowledge the event
let message_id = frame
.get("headers")
.and_then(|h| h.get("messageId"))
.and_then(|m| m.as_str())
.unwrap_or("");
let ack = serde_json::json!({
"code": 200,
"headers": {
"contentType": "application/json",
"messageId": message_id,
},
"message": "OK",
"data": "",
});
let _ = write.send(Message::Text(ack.to_string().into())).await;
let channel_msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: sender_id.to_string(),
reply_target: chat_id,
content: content.to_string(),
channel: "dingtalk".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: None,
};
if tx.send(channel_msg).await.is_err() {
tracing::warn!("DingTalk: message channel closed");
break;
}
}
_ => {}
}
}
anyhow::bail!("DingTalk WebSocket stream ended")
}
async fn health_check(&self) -> bool {
self.register_connection().await.is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
let ch = DingTalkChannel::new("id".into(), "secret".into(), vec![]);
assert_eq!(ch.name(), "dingtalk");
}
#[test]
fn test_user_allowed_wildcard() {
let ch = DingTalkChannel::new("id".into(), "secret".into(), vec!["*".into()]);
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn test_user_allowed_specific() {
let ch = DingTalkChannel::new("id".into(), "secret".into(), vec!["user123".into()]);
assert!(ch.is_user_allowed("user123"));
assert!(!ch.is_user_allowed("other"));
}
#[test]
fn test_user_denied_empty() {
let ch = DingTalkChannel::new("id".into(), "secret".into(), vec![]);
assert!(!ch.is_user_allowed("anyone"));
}
#[test]
fn test_config_serde() {
let toml_str = r#"
client_id = "app_id_123"
client_secret = "secret_456"
allowed_users = ["user1", "*"]
"#;
let config: crate::alphahuman::config::schema::DingTalkConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.client_id, "app_id_123");
assert_eq!(config.client_secret, "secret_456");
assert_eq!(config.allowed_users, vec!["user1", "*"]);
}
#[test]
fn test_config_serde_defaults() {
let toml_str = r#"
client_id = "id"
client_secret = "secret"
"#;
let config: crate::alphahuman::config::schema::DingTalkConfig = toml::from_str(toml_str).unwrap();
assert!(config.allowed_users.is_empty());
}
#[test]
fn parse_stream_data_supports_string_payload() {
let frame = serde_json::json!({
"data": "{\"text\":{\"content\":\"hello\"}}"
});
let parsed = DingTalkChannel::parse_stream_data(&frame).unwrap();
assert_eq!(
parsed.get("text").and_then(|v| v.get("content")),
Some(&serde_json::json!("hello"))
);
}
#[test]
fn parse_stream_data_supports_object_payload() {
let frame = serde_json::json!({
"data": {"text": {"content": "hello"}}
});
let parsed = DingTalkChannel::parse_stream_data(&frame).unwrap();
assert_eq!(
parsed.get("text").and_then(|v| v.get("content")),
Some(&serde_json::json!("hello"))
);
}
#[test]
fn resolve_chat_id_handles_numeric_group_conversation_type() {
let data = serde_json::json!({
"conversationType": 2,
"conversationId": "cid-group",
});
let chat_id = DingTalkChannel::resolve_chat_id(&data, "staff-1");
assert_eq!(chat_id, "cid-group");
}
}
@@ -0,0 +1,952 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use parking_lot::Mutex;
use serde_json::json;
use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
/// Discord channel — connects via Gateway WebSocket for real-time messages
pub struct DiscordChannel {
bot_token: String,
guild_id: Option<String>,
allowed_users: Vec<String>,
listen_to_bots: bool,
mention_only: bool,
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl DiscordChannel {
pub fn new(
bot_token: String,
guild_id: Option<String>,
allowed_users: Vec<String>,
listen_to_bots: bool,
mention_only: bool,
) -> Self {
Self {
bot_token,
guild_id,
allowed_users,
listen_to_bots,
mention_only,
typing_handle: Mutex::new(None),
}
}
fn http_client(&self) -> reqwest::Client {
crate::alphahuman::config::build_runtime_proxy_client("channel.discord")
}
/// Check if a Discord user ID is in the allowlist.
/// Empty list means deny everyone until explicitly configured.
/// `"*"` means allow everyone.
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
fn bot_user_id_from_token(token: &str) -> Option<String> {
// Discord bot tokens are base64(bot_user_id).timestamp.hmac
let part = token.split('.').next()?;
base64_decode(part)
}
}
const BASE64_ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Discord's maximum message length for regular messages.
///
/// Discord rejects longer payloads with `50035 Invalid Form Body`.
const DISCORD_MAX_MESSAGE_LENGTH: usize = 2000;
/// Split a message into chunks that respect Discord's 2000-character limit.
/// Tries to split at word boundaries when possible.
fn split_message_for_discord(message: &str) -> Vec<String> {
if message.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH {
return vec![message.to_string()];
}
let mut chunks = Vec::new();
let mut remaining = message;
while !remaining.is_empty() {
// Find the byte offset for the 2000th character boundary.
// If there are fewer than 2000 chars left, we can emit the tail directly.
let hard_split = remaining
.char_indices()
.nth(DISCORD_MAX_MESSAGE_LENGTH)
.map_or(remaining.len(), |(idx, _)| idx);
let chunk_end = if hard_split == remaining.len() {
hard_split
} else {
// Try to find a good break point (newline, then space)
let search_area = &remaining[..hard_split];
// Prefer splitting at newline
if let Some(pos) = search_area.rfind('\n') {
// Don't split if the newline is too close to the end
if search_area[..pos].chars().count() >= DISCORD_MAX_MESSAGE_LENGTH / 2 {
pos + 1
} else {
// Try space as fallback
search_area.rfind(' ').map_or(hard_split, |space| space + 1)
}
} else if let Some(pos) = search_area.rfind(' ') {
pos + 1
} else {
// Hard split at the limit
hard_split
}
};
chunks.push(remaining[..chunk_end].to_string());
remaining = &remaining[chunk_end..];
}
chunks
}
fn mention_tags(bot_user_id: &str) -> [String; 2] {
[format!("<@{bot_user_id}>"), format!("<@!{bot_user_id}>")]
}
fn contains_bot_mention(content: &str, bot_user_id: &str) -> bool {
let tags = mention_tags(bot_user_id);
content.contains(&tags[0]) || content.contains(&tags[1])
}
fn normalize_incoming_content(
content: &str,
mention_only: bool,
bot_user_id: &str,
) -> Option<String> {
if content.is_empty() {
return None;
}
if mention_only && !contains_bot_mention(content, bot_user_id) {
return None;
}
let mut normalized = content.to_string();
if mention_only {
for tag in mention_tags(bot_user_id) {
normalized = normalized.replace(&tag, " ");
}
}
let normalized = normalized.trim().to_string();
if normalized.is_empty() {
return None;
}
Some(normalized)
}
/// Minimal base64 decode (no extra dep) — only needs to decode the user ID portion
#[allow(clippy::cast_possible_truncation)]
fn base64_decode(input: &str) -> Option<String> {
let padded = match input.len() % 4 {
2 => format!("{input}=="),
3 => format!("{input}="),
_ => input.to_string(),
};
let mut bytes = Vec::new();
let chars: Vec<u8> = padded.bytes().collect();
for chunk in chars.chunks(4) {
if chunk.len() < 4 {
break;
}
let mut v = [0usize; 4];
for (i, &b) in chunk.iter().enumerate() {
if b == b'=' {
v[i] = 0;
} else {
v[i] = BASE64_ALPHABET.iter().position(|&a| a == b)?;
}
}
bytes.push(((v[0] << 2) | (v[1] >> 4)) as u8);
if chunk[2] != b'=' {
bytes.push((((v[1] & 0xF) << 4) | (v[2] >> 2)) as u8);
}
if chunk[3] != b'=' {
bytes.push((((v[2] & 0x3) << 6) | v[3]) as u8);
}
}
String::from_utf8(bytes).ok()
}
#[async_trait]
impl Channel for DiscordChannel {
fn name(&self) -> &str {
"discord"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let chunks = split_message_for_discord(&message.content);
for (i, chunk) in chunks.iter().enumerate() {
let url = format!(
"https://discord.com/api/v10/channels/{}/messages",
message.recipient
);
let body = json!({ "content": chunk });
let resp = self
.http_client()
.post(&url)
.header("Authorization", format!("Bot {}", self.bot_token))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
anyhow::bail!("Discord send message failed ({status}): {err}");
}
// Add a small delay between chunks to avoid rate limiting
if i < chunks.len() - 1 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
let bot_user_id = Self::bot_user_id_from_token(&self.bot_token).unwrap_or_default();
// Get Gateway URL
let gw_resp: serde_json::Value = self
.http_client()
.get("https://discord.com/api/v10/gateway/bot")
.header("Authorization", format!("Bot {}", self.bot_token))
.send()
.await?
.json()
.await?;
let gw_url = gw_resp
.get("url")
.and_then(|u| u.as_str())
.unwrap_or("wss://gateway.discord.gg");
let ws_url = format!("{gw_url}/?v=10&encoding=json");
tracing::info!("Discord: connecting to gateway...");
let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url).await?;
let (mut write, mut read) = ws_stream.split();
// Read Hello (opcode 10)
let hello = read.next().await.ok_or(anyhow::anyhow!("No hello"))??;
let hello_data: serde_json::Value = serde_json::from_str(&hello.to_string())?;
let heartbeat_interval = hello_data
.get("d")
.and_then(|d| d.get("heartbeat_interval"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(41250);
// Send Identify (opcode 2)
let identify = json!({
"op": 2,
"d": {
"token": self.bot_token,
"intents": 37377, // GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT | DIRECT_MESSAGES
"properties": {
"os": "linux",
"browser": "alphahuman",
"device": "alphahuman"
}
}
});
write
.send(Message::Text(identify.to_string().into()))
.await?;
tracing::info!("Discord: connected and identified");
// Track the last sequence number for heartbeats and resume.
// Only accessed in the select! loop below, so a plain i64 suffices.
let mut sequence: i64 = -1;
// Spawn heartbeat timer — sends a tick signal, actual heartbeat
// is assembled in the select! loop where `sequence` lives.
let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1);
let hb_interval = heartbeat_interval;
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(hb_interval));
loop {
interval.tick().await;
if hb_tx.send(()).await.is_err() {
break;
}
}
});
let guild_filter = self.guild_id.clone();
loop {
tokio::select! {
_ = hb_rx.recv() => {
let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
let hb = json!({"op": 1, "d": d});
if write.send(Message::Text(hb.to_string().into())).await.is_err() {
break;
}
}
msg = read.next() => {
let msg = match msg {
Some(Ok(Message::Text(t))) => t,
Some(Ok(Message::Close(_))) | None => break,
_ => continue,
};
let event: serde_json::Value = match serde_json::from_str(msg.as_ref()) {
Ok(e) => e,
Err(_) => continue,
};
// Track sequence number from all dispatch events
if let Some(s) = event.get("s").and_then(serde_json::Value::as_i64) {
sequence = s;
}
let op = event.get("op").and_then(serde_json::Value::as_u64).unwrap_or(0);
match op {
// Op 1: Server requests an immediate heartbeat
1 => {
let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
let hb = json!({"op": 1, "d": d});
if write.send(Message::Text(hb.to_string().into())).await.is_err() {
break;
}
continue;
}
// Op 7: Reconnect
7 => {
tracing::warn!("Discord: received Reconnect (op 7), closing for restart");
break;
}
// Op 9: Invalid Session
9 => {
tracing::warn!("Discord: received Invalid Session (op 9), closing for restart");
break;
}
_ => {}
}
// Only handle MESSAGE_CREATE (opcode 0, type "MESSAGE_CREATE")
let event_type = event.get("t").and_then(|t| t.as_str()).unwrap_or("");
if event_type != "MESSAGE_CREATE" {
continue;
}
let Some(d) = event.get("d") else {
continue;
};
// Skip messages from the bot itself
let author_id = d.get("author").and_then(|a| a.get("id")).and_then(|i| i.as_str()).unwrap_or("");
if author_id == bot_user_id {
continue;
}
// Skip bot messages (unless listen_to_bots is enabled)
if !self.listen_to_bots && d.get("author").and_then(|a| a.get("bot")).and_then(serde_json::Value::as_bool).unwrap_or(false) {
continue;
}
// Sender validation
if !self.is_user_allowed(author_id) {
tracing::warn!("Discord: ignoring message from unauthorized user: {author_id}");
continue;
}
// Guild filter
if let Some(ref gid) = guild_filter {
let msg_guild = d.get("guild_id").and_then(serde_json::Value::as_str);
// DMs have no guild_id — let them through; for guild messages, enforce the filter
if let Some(g) = msg_guild {
if g != gid {
continue;
}
}
}
let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("");
let Some(clean_content) =
normalize_incoming_content(content, self.mention_only, &bot_user_id)
else {
continue;
};
let message_id = d.get("id").and_then(|i| i.as_str()).unwrap_or("");
let channel_id = d.get("channel_id").and_then(|c| c.as_str()).unwrap_or("").to_string();
let channel_msg = ChannelMessage {
id: if message_id.is_empty() {
Uuid::new_v4().to_string()
} else {
format!("discord_{message_id}")
},
sender: author_id.to_string(),
reply_target: if channel_id.is_empty() {
author_id.to_string()
} else {
channel_id.clone()
},
content: clean_content,
channel: "discord".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: None,
};
if tx.send(channel_msg).await.is_err() {
break;
}
}
}
}
Ok(())
}
async fn health_check(&self) -> bool {
self.http_client()
.get("https://discord.com/api/v10/users/@me")
.header("Authorization", format!("Bot {}", self.bot_token))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
self.stop_typing(recipient).await?;
let client = self.http_client();
let token = self.bot_token.clone();
let channel_id = recipient.to_string();
let handle = tokio::spawn(async move {
let url = format!("https://discord.com/api/v10/channels/{channel_id}/typing");
loop {
let _ = client
.post(&url)
.header("Authorization", format!("Bot {token}"))
.send()
.await;
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
}
});
let mut guard = self.typing_handle.lock();
*guard = Some(handle);
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
let mut guard = self.typing_handle.lock();
if let Some(handle) = guard.take() {
handle.abort();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discord_channel_name() {
let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
assert_eq!(ch.name(), "discord");
}
#[test]
fn base64_decode_bot_id() {
// "MTIzNDU2" decodes to "123456"
let decoded = base64_decode("MTIzNDU2");
assert_eq!(decoded, Some("123456".to_string()));
}
#[test]
fn bot_user_id_extraction() {
// Token format: base64(user_id).timestamp.hmac
let token = "MTIzNDU2.fake.hmac";
let id = DiscordChannel::bot_user_id_from_token(token);
assert_eq!(id, Some("123456".to_string()));
}
#[test]
fn empty_allowlist_denies_everyone() {
let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
assert!(!ch.is_user_allowed("12345"));
assert!(!ch.is_user_allowed("anyone"));
}
#[test]
fn wildcard_allows_everyone() {
let ch = DiscordChannel::new("fake".into(), None, vec!["*".into()], false, false);
assert!(ch.is_user_allowed("12345"));
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn specific_allowlist_filters() {
let ch = DiscordChannel::new(
"fake".into(),
None,
vec!["111".into(), "222".into()],
false,
false,
);
assert!(ch.is_user_allowed("111"));
assert!(ch.is_user_allowed("222"));
assert!(!ch.is_user_allowed("333"));
assert!(!ch.is_user_allowed("unknown"));
}
#[test]
fn allowlist_is_exact_match_not_substring() {
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false, false);
assert!(!ch.is_user_allowed("1111"));
assert!(!ch.is_user_allowed("11"));
assert!(!ch.is_user_allowed("0111"));
}
#[test]
fn allowlist_empty_string_user_id() {
let ch = DiscordChannel::new("fake".into(), None, vec!["111".into()], false, false);
assert!(!ch.is_user_allowed(""));
}
#[test]
fn allowlist_with_wildcard_and_specific() {
let ch = DiscordChannel::new(
"fake".into(),
None,
vec!["111".into(), "*".into()],
false,
false,
);
assert!(ch.is_user_allowed("111"));
assert!(ch.is_user_allowed("anyone_else"));
}
#[test]
fn allowlist_case_sensitive() {
let ch = DiscordChannel::new("fake".into(), None, vec!["ABC".into()], false, false);
assert!(ch.is_user_allowed("ABC"));
assert!(!ch.is_user_allowed("abc"));
assert!(!ch.is_user_allowed("Abc"));
}
#[test]
fn base64_decode_empty_string() {
let decoded = base64_decode("");
assert_eq!(decoded, Some(String::new()));
}
#[test]
fn base64_decode_invalid_chars() {
let decoded = base64_decode("!!!!");
assert!(decoded.is_none());
}
#[test]
fn bot_user_id_from_empty_token() {
let id = DiscordChannel::bot_user_id_from_token("");
assert_eq!(id, Some(String::new()));
}
#[test]
fn contains_bot_mention_supports_plain_and_nick_forms() {
assert!(contains_bot_mention("hi <@12345>", "12345"));
assert!(contains_bot_mention("hi <@!12345>", "12345"));
assert!(!contains_bot_mention("hi <@99999>", "12345"));
}
#[test]
fn normalize_incoming_content_requires_mention_when_enabled() {
let cleaned = normalize_incoming_content("hello there", true, "12345");
assert!(cleaned.is_none());
}
#[test]
fn normalize_incoming_content_strips_mentions_and_trims() {
let cleaned = normalize_incoming_content(" <@!12345> run status ", true, "12345");
assert_eq!(cleaned.as_deref(), Some("run status"));
}
#[test]
fn normalize_incoming_content_rejects_empty_after_strip() {
let cleaned = normalize_incoming_content("<@12345>", true, "12345");
assert!(cleaned.is_none());
}
// Message splitting tests
#[test]
fn split_empty_message() {
let chunks = split_message_for_discord("");
assert_eq!(chunks, vec![""]);
}
#[test]
fn split_short_message_under_limit() {
let msg = "Hello, world!";
let chunks = split_message_for_discord(msg);
assert_eq!(chunks, vec![msg]);
}
#[test]
fn split_message_exactly_2000_chars() {
let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH);
let chunks = split_message_for_discord(&msg);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
}
#[test]
fn split_message_just_over_limit() {
let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH + 1);
let chunks = split_message_for_discord(&msg);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
assert_eq!(chunks[1].chars().count(), 1);
}
#[test]
fn split_very_long_message() {
let msg = "word ".repeat(2000); // 10000 characters (5 chars per "word ")
let chunks = split_message_for_discord(&msg);
// Should split into 5 chunks of <= 2000 chars
assert_eq!(chunks.len(), 5);
assert!(chunks
.iter()
.all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH));
// Verify total content is preserved
let reconstructed = chunks.concat();
assert_eq!(reconstructed, msg);
}
#[test]
fn split_prefer_newline_break() {
let msg = format!("{}\n{}", "a".repeat(1500), "b".repeat(500));
let chunks = split_message_for_discord(&msg);
// Should split at the newline
assert_eq!(chunks.len(), 2);
assert!(chunks[0].ends_with('\n'));
assert!(chunks[1].starts_with('b'));
}
#[test]
fn split_prefer_space_break() {
let msg = format!("{} {}", "a".repeat(1500), "b".repeat(600));
let chunks = split_message_for_discord(&msg);
assert_eq!(chunks.len(), 2);
}
#[test]
fn split_without_good_break_points_hard_split() {
// No spaces or newlines - should hard split at 2000
let msg = "a".repeat(5000);
let chunks = split_message_for_discord(&msg);
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
assert_eq!(chunks[1].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
assert_eq!(chunks[2].chars().count(), 1000);
}
#[test]
fn split_multiple_breaks() {
// Create a message with multiple newlines
let part1 = "a".repeat(900);
let part2 = "b".repeat(900);
let part3 = "c".repeat(900);
let msg = format!("{part1}\n{part2}\n{part3}");
let chunks = split_message_for_discord(&msg);
// Should split into 2 chunks (first two parts + third part)
assert_eq!(chunks.len(), 2);
assert!(chunks[0].chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
assert!(chunks[1].chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
}
#[test]
fn split_preserves_content() {
let original = "Hello world! This is a test message with some content. ".repeat(200);
let chunks = split_message_for_discord(&original);
let reconstructed = chunks.concat();
assert_eq!(reconstructed, original);
}
#[test]
fn split_unicode_content() {
// Test with emoji and multi-byte characters
let msg = "🦀 Rust is awesome! ".repeat(500);
let chunks = split_message_for_discord(&msg);
// All chunks should be valid UTF-8
for chunk in &chunks {
assert!(std::str::from_utf8(chunk.as_bytes()).is_ok());
assert!(chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH);
}
// Reconstruct and verify
let reconstructed = chunks.concat();
assert_eq!(reconstructed, msg);
}
#[test]
fn split_newline_too_close_to_end() {
// If newline is in the first half, don't use it - use space instead or hard split
let msg = format!("{}\n{}", "a".repeat(1900), "b".repeat(500));
let chunks = split_message_for_discord(&msg);
// Should split at newline since it's in the second half of the window
assert_eq!(chunks.len(), 2);
}
#[test]
fn split_multibyte_only_content_without_panics() {
let msg = "🦀".repeat(2500);
let chunks = split_message_for_discord(&msg);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].chars().count(), DISCORD_MAX_MESSAGE_LENGTH);
assert_eq!(chunks[1].chars().count(), 500);
let reconstructed = chunks.concat();
assert_eq!(reconstructed, msg);
}
#[test]
fn split_chunks_always_within_discord_limit() {
let msg = "x".repeat(12_345);
let chunks = split_message_for_discord(&msg);
assert!(chunks
.iter()
.all(|chunk| chunk.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH));
}
#[test]
fn split_message_with_multiple_newlines() {
let msg = "Line 1\nLine 2\nLine 3\n".repeat(1000);
let chunks = split_message_for_discord(&msg);
assert!(chunks.len() > 1);
let reconstructed = chunks.concat();
assert_eq!(reconstructed, msg);
}
#[test]
fn typing_handle_starts_as_none() {
let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
let guard = ch.typing_handle.lock();
assert!(guard.is_none());
}
#[tokio::test]
async fn start_typing_sets_handle() {
let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
let _ = ch.start_typing("123456").await;
let guard = ch.typing_handle.lock();
assert!(guard.is_some());
}
#[tokio::test]
async fn stop_typing_clears_handle() {
let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
let _ = ch.start_typing("123456").await;
let _ = ch.stop_typing("123456").await;
let guard = ch.typing_handle.lock();
assert!(guard.is_none());
}
#[tokio::test]
async fn stop_typing_is_idempotent() {
let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
assert!(ch.stop_typing("123456").await.is_ok());
assert!(ch.stop_typing("123456").await.is_ok());
}
#[tokio::test]
async fn start_typing_replaces_existing_task() {
let ch = DiscordChannel::new("fake".into(), None, vec![], false, false);
let _ = ch.start_typing("111").await;
let _ = ch.start_typing("222").await;
let guard = ch.typing_handle.lock();
assert!(guard.is_some());
}
// ── Message ID edge cases ─────────────────────────────────────
#[test]
fn discord_message_id_format_includes_discord_prefix() {
// Verify that message IDs follow the format: discord_{message_id}
let message_id = "123456789012345678";
let expected_id = format!("discord_{message_id}");
assert_eq!(expected_id, "discord_123456789012345678");
}
#[test]
fn discord_message_id_is_deterministic() {
// Same message_id = same ID (prevents duplicates after restart)
let message_id = "123456789012345678";
let id1 = format!("discord_{message_id}");
let id2 = format!("discord_{message_id}");
assert_eq!(id1, id2);
}
#[test]
fn discord_message_id_different_message_different_id() {
// Different message IDs produce different IDs
let id1 = "discord_123456789012345678".to_string();
let id2 = "discord_987654321098765432".to_string();
assert_ne!(id1, id2);
}
#[test]
fn discord_message_id_uses_snowflake_id() {
// Discord snowflake IDs are numeric strings
let message_id = "123456789012345678"; // Typical snowflake format
let id = format!("discord_{message_id}");
assert!(id.starts_with("discord_"));
// Snowflake IDs are numeric
assert!(message_id.chars().all(|c| c.is_ascii_digit()));
}
#[test]
fn discord_message_id_fallback_to_uuid_on_empty() {
// Edge case: empty message_id falls back to UUID
let message_id = "";
let id = if message_id.is_empty() {
format!("discord_{}", uuid::Uuid::new_v4())
} else {
format!("discord_{message_id}")
};
assert!(id.starts_with("discord_"));
// Should have UUID dashes
assert!(id.contains('-'));
}
// ─────────────────────────────────────────────────────────────────────
// TG6: Channel platform limit edge cases for Discord (2000 char limit)
// Prevents: Pattern 6 — issues #574, #499
// ─────────────────────────────────────────────────────────────────────
#[test]
fn split_message_code_block_at_boundary() {
// Code block that spans the split boundary
let mut msg = String::new();
msg.push_str("```rust\n");
msg.push_str(&"x".repeat(1990));
msg.push_str("\n```\nMore text after code block");
let parts = split_message_for_discord(&msg);
assert!(parts.len() >= 2, "code block spanning boundary should split");
for part in &parts {
assert!(
part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
"each part must be <= {DISCORD_MAX_MESSAGE_LENGTH}, got {}",
part.len()
);
}
}
#[test]
fn split_message_single_long_word_exceeds_limit() {
// A single word longer than 2000 chars must be hard-split
let long_word = "a".repeat(2500);
let parts = split_message_for_discord(&long_word);
assert!(parts.len() >= 2, "word exceeding limit must be split");
for part in &parts {
assert!(
part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
"hard-split part must be <= {DISCORD_MAX_MESSAGE_LENGTH}, got {}",
part.len()
);
}
// Reassembled content should match original
let reassembled: String = parts.join("");
assert_eq!(reassembled, long_word);
}
#[test]
fn split_message_exactly_at_limit_no_split() {
let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH);
let parts = split_message_for_discord(&msg);
assert_eq!(parts.len(), 1, "message exactly at limit should not split");
assert_eq!(parts[0].len(), DISCORD_MAX_MESSAGE_LENGTH);
}
#[test]
fn split_message_one_over_limit_splits() {
let msg = "a".repeat(DISCORD_MAX_MESSAGE_LENGTH + 1);
let parts = split_message_for_discord(&msg);
assert!(parts.len() >= 2, "message 1 char over limit must split");
}
#[test]
fn split_message_many_short_lines() {
// Many short lines should be batched into chunks under the limit
let msg: String = (0..500).map(|i| format!("line {i}\n")).collect();
let parts = split_message_for_discord(&msg);
for part in &parts {
assert!(
part.len() <= DISCORD_MAX_MESSAGE_LENGTH,
"short-line batch must be <= limit"
);
}
// All content should be preserved
let reassembled: String = parts.join("");
assert_eq!(reassembled.trim(), msg.trim());
}
#[test]
fn split_message_only_whitespace() {
let msg = " \n\n\t ";
let parts = split_message_for_discord(msg);
// Should handle gracefully without panic
assert!(parts.len() <= 1);
}
#[test]
fn split_message_emoji_at_boundary() {
// Emoji are multi-byte; ensure we don't split mid-emoji
let mut msg = "a".repeat(1998);
msg.push_str("🎉🎊"); // 2 emoji at the boundary (2000 chars total)
let parts = split_message_for_discord(&msg);
for part in &parts {
// The function splits on character count, not byte count
assert!(
part.chars().count() <= DISCORD_MAX_MESSAGE_LENGTH,
"emoji boundary split must respect limit"
);
}
}
#[test]
fn split_message_consecutive_newlines_at_boundary() {
let mut msg = "a".repeat(1995);
msg.push_str("\n\n\n\n\n");
msg.push_str(&"b".repeat(100));
let parts = split_message_for_discord(&msg);
for part in &parts {
assert!(part.len() <= DISCORD_MAX_MESSAGE_LENGTH);
}
}
}
@@ -0,0 +1,968 @@
#![allow(clippy::uninlined_format_args)]
#![allow(clippy::map_unwrap_or)]
#![allow(clippy::redundant_closure_for_method_calls)]
#![allow(clippy::cast_lossless)]
#![allow(clippy::trim_split_whitespace)]
#![allow(clippy::doc_link_with_quotes)]
#![allow(clippy::doc_markdown)]
#![allow(clippy::too_many_lines)]
#![allow(clippy::unnecessary_map_or)]
use anyhow::{anyhow, Result};
use async_imap::extensions::idle::IdleResponse;
use async_imap::types::Fetch;
use async_imap::Session;
use async_trait::async_trait;
use futures::TryStreamExt;
use lettre::message::SinglePart;
use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport};
use mail_parser::{MessageParser, MimeHeaders};
use rustls::{ClientConfig, RootCertStore};
use rustls_pki_types::DnsName;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tokio::time::{sleep, timeout};
use tokio_rustls::client::TlsStream;
use tokio_rustls::TlsConnector;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use super::traits::{Channel, ChannelMessage, SendMessage};
/// Email channel configuration
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EmailConfig {
/// IMAP server hostname
pub imap_host: String,
/// IMAP server port (default: 993 for TLS)
#[serde(default = "default_imap_port")]
pub imap_port: u16,
/// IMAP folder to poll (default: INBOX)
#[serde(default = "default_imap_folder")]
pub imap_folder: String,
/// SMTP server hostname
pub smtp_host: String,
/// SMTP server port (default: 465 for TLS)
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
/// Use TLS for SMTP (default: true)
#[serde(default = "default_true")]
pub smtp_tls: bool,
/// Email username for authentication
pub username: String,
/// Email password for authentication
pub password: String,
/// From address for outgoing emails
pub from_address: String,
/// IDLE timeout in seconds before re-establishing connection (default: 1740 = 29 minutes)
/// RFC 2177 recommends clients restart IDLE every 29 minutes
#[serde(default = "default_idle_timeout", alias = "poll_interval_secs")]
pub idle_timeout_secs: u64,
/// Allowed sender addresses/domains (empty = deny all, ["*"] = allow all)
#[serde(default)]
pub allowed_senders: Vec<String>,
}
fn default_imap_port() -> u16 {
993
}
fn default_smtp_port() -> u16 {
465
}
fn default_imap_folder() -> String {
"INBOX".into()
}
fn default_idle_timeout() -> u64 {
1740 // 29 minutes per RFC 2177
}
fn default_true() -> bool {
true
}
impl Default for EmailConfig {
fn default() -> Self {
Self {
imap_host: String::new(),
imap_port: default_imap_port(),
imap_folder: default_imap_folder(),
smtp_host: String::new(),
smtp_port: default_smtp_port(),
smtp_tls: true,
username: String::new(),
password: String::new(),
from_address: String::new(),
idle_timeout_secs: default_idle_timeout(),
allowed_senders: Vec::new(),
}
}
}
type ImapSession = Session<TlsStream<TcpStream>>;
/// Email channel — IMAP IDLE for instant push notifications, SMTP for outbound
pub struct EmailChannel {
pub config: EmailConfig,
seen_messages: Arc<Mutex<HashSet<String>>>,
}
impl EmailChannel {
pub fn new(config: EmailConfig) -> Self {
Self {
config,
seen_messages: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Check if a sender email is in the allowlist
pub fn is_sender_allowed(&self, email: &str) -> bool {
if self.config.allowed_senders.is_empty() {
return false; // Empty = deny all
}
if self.config.allowed_senders.iter().any(|a| a == "*") {
return true; // Wildcard = allow all
}
let email_lower = email.to_lowercase();
self.config.allowed_senders.iter().any(|allowed| {
if allowed.starts_with('@') {
// Domain match with @ prefix: "@example.com"
email_lower.ends_with(&allowed.to_lowercase())
} else if allowed.contains('@') {
// Full email address match
allowed.eq_ignore_ascii_case(email)
} else {
// Domain match without @ prefix: "example.com"
email_lower.ends_with(&format!("@{}", allowed.to_lowercase()))
}
})
}
/// Strip HTML tags from content (basic)
pub fn strip_html(html: &str) -> String {
let mut result = String::new();
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(ch),
_ => {}
}
}
let mut normalized = String::with_capacity(result.len());
for word in result.split_whitespace() {
if !normalized.is_empty() {
normalized.push(' ');
}
normalized.push_str(word);
}
normalized
}
/// Extract the sender address from a parsed email
fn extract_sender(parsed: &mail_parser::Message) -> String {
parsed
.from()
.and_then(|addr| addr.first())
.and_then(|a| a.address())
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".into())
}
/// Extract readable text from a parsed email
fn extract_text(parsed: &mail_parser::Message) -> String {
if let Some(text) = parsed.body_text(0) {
return text.to_string();
}
if let Some(html) = parsed.body_html(0) {
return Self::strip_html(html.as_ref());
}
for part in parsed.attachments() {
let part: &mail_parser::MessagePart = part;
if let Some(ct) = MimeHeaders::content_type(part) {
if ct.ctype() == "text" {
if let Ok(text) = std::str::from_utf8(part.contents()) {
let name = MimeHeaders::attachment_name(part).unwrap_or("file");
return format!("[Attachment: {}]\n{}", name, text);
}
}
}
}
"(no readable content)".to_string()
}
/// Connect to IMAP server with TLS and authenticate
async fn connect_imap(&self) -> Result<ImapSession> {
let addr = format!("{}:{}", self.config.imap_host, self.config.imap_port);
debug!("Connecting to IMAP server at {}", addr);
// Connect TCP
let tcp = TcpStream::connect(&addr).await?;
// Establish TLS using rustls
let certs = RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
};
let config = ClientConfig::builder()
.with_root_certificates(certs)
.with_no_client_auth();
let tls_stream: TlsConnector = Arc::new(config).into();
let sni: DnsName = self.config.imap_host.clone().try_into()?;
let stream = tls_stream.connect(sni.into(), tcp).await?;
// Create IMAP client
let client = async_imap::Client::new(stream);
// Login
let session = client
.login(&self.config.username, &self.config.password)
.await
.map_err(|(e, _)| anyhow!("IMAP login failed: {}", e))?;
debug!("IMAP login successful");
Ok(session)
}
/// Fetch and process unseen messages from the selected mailbox
async fn fetch_unseen(&self, session: &mut ImapSession) -> Result<Vec<ParsedEmail>> {
// Search for unseen messages
let uids = session.uid_search("UNSEEN").await?;
if uids.is_empty() {
return Ok(Vec::new());
}
debug!("Found {} unseen messages", uids.len());
let mut results = Vec::new();
let uid_set: String = uids
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(",");
// Fetch message bodies
let messages = session.uid_fetch(&uid_set, "RFC822").await?;
let messages: Vec<Fetch> = messages.try_collect().await?;
for msg in messages {
let uid = msg.uid.unwrap_or(0);
if let Some(body) = msg.body() {
if let Some(parsed) = MessageParser::default().parse(body) {
let sender = Self::extract_sender(&parsed);
let subject = parsed.subject().unwrap_or("(no subject)").to_string();
let body_text = Self::extract_text(&parsed);
let content = format!("Subject: {}\n\n{}", subject, body_text);
let msg_id = parsed
.message_id()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("gen-{}", Uuid::new_v4()));
#[allow(clippy::cast_sign_loss)]
let ts = parsed
.date()
.map(|d| {
let naive = chrono::NaiveDate::from_ymd_opt(
d.year as i32,
u32::from(d.month),
u32::from(d.day),
)
.and_then(|date| {
date.and_hms_opt(
u32::from(d.hour),
u32::from(d.minute),
u32::from(d.second),
)
});
naive.map_or(0, |n| n.and_utc().timestamp() as u64)
})
.unwrap_or_else(|| {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
});
results.push(ParsedEmail {
_uid: uid,
msg_id,
sender,
content,
timestamp: ts,
});
}
}
}
// Mark fetched messages as seen
if !results.is_empty() {
let _ = session
.uid_store(&uid_set, "+FLAGS (\\Seen)")
.await?
.try_collect::<Vec<_>>()
.await;
}
Ok(results)
}
/// Run the IDLE loop, returning when a new message arrives or timeout
/// Note: IDLE consumes the session and returns it via done()
async fn wait_for_changes(
&self,
session: ImapSession,
) -> Result<(IdleWaitResult, ImapSession)> {
let idle_timeout = Duration::from_secs(self.config.idle_timeout_secs);
// Start IDLE mode - this consumes the session
let mut idle = session.idle();
idle.init().await?;
debug!("Entering IMAP IDLE mode");
// wait() returns (future, stop_source) - we only need the future
let (wait_future, _stop_source) = idle.wait();
// Wait for server notification or timeout
let result = timeout(idle_timeout, wait_future).await;
match result {
Ok(Ok(response)) => {
debug!("IDLE response: {:?}", response);
// Done with IDLE, return session to normal mode
let session = idle.done().await?;
let wait_result = match response {
IdleResponse::NewData(_) => IdleWaitResult::NewMail,
IdleResponse::Timeout => IdleWaitResult::Timeout,
IdleResponse::ManualInterrupt => IdleWaitResult::Interrupted,
};
Ok((wait_result, session))
}
Ok(Err(e)) => {
// Try to clean up IDLE state
let _ = idle.done().await;
Err(anyhow!("IDLE error: {}", e))
}
Err(_) => {
// Timeout - RFC 2177 recommends restarting IDLE every 29 minutes
debug!("IDLE timeout reached, will re-establish");
let session = idle.done().await?;
Ok((IdleWaitResult::Timeout, session))
}
}
}
/// Main IDLE-based listen loop with automatic reconnection
async fn listen_with_idle(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
let mut backoff = Duration::from_secs(1);
let max_backoff = Duration::from_secs(60);
loop {
match self.run_idle_session(&tx).await {
Ok(()) => {
// Clean exit (channel closed)
return Ok(());
}
Err(e) => {
error!(
"IMAP session error: {}. Reconnecting in {:?}...",
e, backoff
);
sleep(backoff).await;
// Exponential backoff with cap
backoff = std::cmp::min(backoff * 2, max_backoff);
}
}
}
}
/// Run a single IDLE session until error or clean shutdown
async fn run_idle_session(&self, tx: &mpsc::Sender<ChannelMessage>) -> Result<()> {
// Connect and authenticate
let mut session = self.connect_imap().await?;
// Select the mailbox
session.select(&self.config.imap_folder).await?;
info!(
"Email IDLE listening on {} (instant push enabled)",
self.config.imap_folder
);
// Check for existing unseen messages first
self.process_unseen(&mut session, tx).await?;
loop {
// Enter IDLE and wait for changes (consumes session, returns it via result)
match self.wait_for_changes(session).await {
Ok((IdleWaitResult::NewMail, returned_session)) => {
debug!("New mail notification received");
session = returned_session;
self.process_unseen(&mut session, tx).await?;
}
Ok((IdleWaitResult::Timeout, returned_session)) => {
// Re-check for mail after IDLE timeout (defensive)
session = returned_session;
self.process_unseen(&mut session, tx).await?;
}
Ok((IdleWaitResult::Interrupted, _)) => {
info!("IDLE interrupted, exiting");
return Ok(());
}
Err(e) => {
// Connection likely broken, need to reconnect
return Err(e);
}
}
}
}
/// Fetch unseen messages and send to channel
async fn process_unseen(
&self,
session: &mut ImapSession,
tx: &mpsc::Sender<ChannelMessage>,
) -> Result<()> {
let messages = self.fetch_unseen(session).await?;
for email in messages {
// Check allowlist
if !self.is_sender_allowed(&email.sender) {
warn!("Blocked email from {}", email.sender);
continue;
}
let is_new = {
let mut seen = self.seen_messages.lock().await;
seen.insert(email.msg_id.clone())
};
if !is_new {
continue;
}
let msg = ChannelMessage {
id: email.msg_id,
reply_target: email.sender.clone(),
sender: email.sender,
content: email.content,
channel: "email".to_string(),
timestamp: email.timestamp,
thread_ts: None,
};
if tx.send(msg).await.is_err() {
// Channel closed, exit cleanly
return Ok(());
}
}
Ok(())
}
fn create_smtp_transport(&self) -> Result<SmtpTransport> {
let creds = Credentials::new(self.config.username.clone(), self.config.password.clone());
let transport = if self.config.smtp_tls {
SmtpTransport::relay(&self.config.smtp_host)?
.port(self.config.smtp_port)
.credentials(creds)
.build()
} else {
SmtpTransport::builder_dangerous(&self.config.smtp_host)
.port(self.config.smtp_port)
.credentials(creds)
.build()
};
Ok(transport)
}
}
/// Internal struct for parsed email data
struct ParsedEmail {
_uid: u32,
msg_id: String,
sender: String,
content: String,
timestamp: u64,
}
/// Result from waiting on IDLE
enum IdleWaitResult {
NewMail,
Timeout,
Interrupted,
}
#[async_trait]
impl Channel for EmailChannel {
fn name(&self) -> &str {
"email"
}
async fn send(&self, message: &SendMessage) -> Result<()> {
// Use explicit subject if provided, otherwise fall back to legacy parsing or default
let (subject, body) = if let Some(ref subj) = message.subject {
(subj.as_str(), message.content.as_str())
} else if message.content.starts_with("Subject: ") {
if let Some(pos) = message.content.find('\n') {
(&message.content[9..pos], message.content[pos + 1..].trim())
} else {
("Alphahuman Message", message.content.as_str())
}
} else {
("Alphahuman Message", message.content.as_str())
};
let email = Message::builder()
.from(self.config.from_address.parse()?)
.to(message.recipient.parse()?)
.subject(subject)
.singlepart(SinglePart::plain(body.to_string()))?;
let transport = self.create_smtp_transport()?;
transport.send(&email)?;
info!("Email sent to {}", message.recipient);
Ok(())
}
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
info!(
"Starting email channel with IDLE support on {}",
self.config.imap_folder
);
self.listen_with_idle(tx).await
}
async fn health_check(&self) -> bool {
// Fully async health check - attempt IMAP connection
match timeout(Duration::from_secs(10), self.connect_imap()).await {
Ok(Ok(mut session)) => {
// Try to logout cleanly
let _ = session.logout().await;
true
}
Ok(Err(e)) => {
debug!("Health check failed: {}", e);
false
}
Err(_) => {
debug!("Health check timed out");
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_smtp_port_uses_tls_port() {
assert_eq!(default_smtp_port(), 465);
}
#[test]
fn email_config_default_uses_tls_smtp_defaults() {
let config = EmailConfig::default();
assert_eq!(config.smtp_port, 465);
assert!(config.smtp_tls);
}
#[test]
fn default_idle_timeout_is_29_minutes() {
assert_eq!(default_idle_timeout(), 1740);
}
#[tokio::test]
async fn seen_messages_starts_empty() {
let channel = EmailChannel::new(EmailConfig::default());
let seen = channel.seen_messages.lock().await;
assert!(seen.is_empty());
}
#[tokio::test]
async fn seen_messages_tracks_unique_ids() {
let channel = EmailChannel::new(EmailConfig::default());
let mut seen = channel.seen_messages.lock().await;
assert!(seen.insert("first-id".to_string()));
assert!(!seen.insert("first-id".to_string()));
assert!(seen.insert("second-id".to_string()));
assert_eq!(seen.len(), 2);
}
// EmailConfig tests
#[test]
fn email_config_default() {
let config = EmailConfig::default();
assert_eq!(config.imap_host, "");
assert_eq!(config.imap_port, 993);
assert_eq!(config.imap_folder, "INBOX");
assert_eq!(config.smtp_host, "");
assert_eq!(config.smtp_port, 465);
assert!(config.smtp_tls);
assert_eq!(config.username, "");
assert_eq!(config.password, "");
assert_eq!(config.from_address, "");
assert_eq!(config.idle_timeout_secs, 1740);
assert!(config.allowed_senders.is_empty());
}
#[test]
fn email_config_custom() {
let config = EmailConfig {
imap_host: "imap.example.com".to_string(),
imap_port: 993,
imap_folder: "Archive".to_string(),
smtp_host: "smtp.example.com".to_string(),
smtp_port: 465,
smtp_tls: true,
username: "user@example.com".to_string(),
password: "pass123".to_string(),
from_address: "bot@example.com".to_string(),
idle_timeout_secs: 1200,
allowed_senders: vec!["allowed@example.com".to_string()],
};
assert_eq!(config.imap_host, "imap.example.com");
assert_eq!(config.imap_folder, "Archive");
assert_eq!(config.idle_timeout_secs, 1200);
}
#[test]
fn email_config_clone() {
let config = EmailConfig {
imap_host: "imap.test.com".to_string(),
imap_port: 993,
imap_folder: "INBOX".to_string(),
smtp_host: "smtp.test.com".to_string(),
smtp_port: 587,
smtp_tls: true,
username: "user@test.com".to_string(),
password: "secret".to_string(),
from_address: "bot@test.com".to_string(),
idle_timeout_secs: 1740,
allowed_senders: vec!["*".to_string()],
};
let cloned = config.clone();
assert_eq!(cloned.imap_host, config.imap_host);
assert_eq!(cloned.smtp_port, config.smtp_port);
assert_eq!(cloned.allowed_senders, config.allowed_senders);
}
// EmailChannel tests
#[tokio::test]
async fn email_channel_new() {
let config = EmailConfig::default();
let channel = EmailChannel::new(config.clone());
assert_eq!(channel.config.imap_host, config.imap_host);
let seen_guard = channel.seen_messages.lock().await;
assert_eq!(seen_guard.len(), 0);
}
#[test]
fn email_channel_name() {
let channel = EmailChannel::new(EmailConfig::default());
assert_eq!(channel.name(), "email");
}
// is_sender_allowed tests
#[test]
fn is_sender_allowed_empty_list_denies_all() {
let config = EmailConfig {
allowed_senders: vec![],
..Default::default()
};
let channel = EmailChannel::new(config);
assert!(!channel.is_sender_allowed("anyone@example.com"));
assert!(!channel.is_sender_allowed("user@test.com"));
}
#[test]
fn is_sender_allowed_wildcard_allows_all() {
let config = EmailConfig {
allowed_senders: vec!["*".to_string()],
..Default::default()
};
let channel = EmailChannel::new(config);
assert!(channel.is_sender_allowed("anyone@example.com"));
assert!(channel.is_sender_allowed("user@test.com"));
assert!(channel.is_sender_allowed("random@domain.org"));
}
#[test]
fn is_sender_allowed_specific_email() {
let config = EmailConfig {
allowed_senders: vec!["allowed@example.com".to_string()],
..Default::default()
};
let channel = EmailChannel::new(config);
assert!(channel.is_sender_allowed("allowed@example.com"));
assert!(!channel.is_sender_allowed("other@example.com"));
assert!(!channel.is_sender_allowed("allowed@other.com"));
}
#[test]
fn is_sender_allowed_domain_with_at_prefix() {
let config = EmailConfig {
allowed_senders: vec!["@example.com".to_string()],
..Default::default()
};
let channel = EmailChannel::new(config);
assert!(channel.is_sender_allowed("user@example.com"));
assert!(channel.is_sender_allowed("admin@example.com"));
assert!(!channel.is_sender_allowed("user@other.com"));
}
#[test]
fn is_sender_allowed_domain_without_at_prefix() {
let config = EmailConfig {
allowed_senders: vec!["example.com".to_string()],
..Default::default()
};
let channel = EmailChannel::new(config);
assert!(channel.is_sender_allowed("user@example.com"));
assert!(channel.is_sender_allowed("admin@example.com"));
assert!(!channel.is_sender_allowed("user@other.com"));
}
#[test]
fn is_sender_allowed_case_insensitive() {
let config = EmailConfig {
allowed_senders: vec!["Allowed@Example.COM".to_string()],
..Default::default()
};
let channel = EmailChannel::new(config);
assert!(channel.is_sender_allowed("allowed@example.com"));
assert!(channel.is_sender_allowed("ALLOWED@EXAMPLE.COM"));
assert!(channel.is_sender_allowed("AlLoWeD@eXaMpLe.cOm"));
}
#[test]
fn is_sender_allowed_multiple_senders() {
let config = EmailConfig {
allowed_senders: vec![
"user1@example.com".to_string(),
"user2@test.com".to_string(),
"@allowed.com".to_string(),
],
..Default::default()
};
let channel = EmailChannel::new(config);
assert!(channel.is_sender_allowed("user1@example.com"));
assert!(channel.is_sender_allowed("user2@test.com"));
assert!(channel.is_sender_allowed("anyone@allowed.com"));
assert!(!channel.is_sender_allowed("user3@example.com"));
}
#[test]
fn is_sender_allowed_wildcard_with_specific() {
let config = EmailConfig {
allowed_senders: vec!["*".to_string(), "specific@example.com".to_string()],
..Default::default()
};
let channel = EmailChannel::new(config);
assert!(channel.is_sender_allowed("anyone@example.com"));
assert!(channel.is_sender_allowed("specific@example.com"));
}
#[test]
fn is_sender_allowed_empty_sender() {
let config = EmailConfig {
allowed_senders: vec!["@example.com".to_string()],
..Default::default()
};
let channel = EmailChannel::new(config);
assert!(!channel.is_sender_allowed(""));
// "@example.com" ends with "@example.com" so it's allowed
assert!(channel.is_sender_allowed("@example.com"));
}
// strip_html tests
#[test]
fn strip_html_basic() {
assert_eq!(EmailChannel::strip_html("<p>Hello</p>"), "Hello");
assert_eq!(EmailChannel::strip_html("<div>World</div>"), "World");
}
#[test]
fn strip_html_nested_tags() {
assert_eq!(
EmailChannel::strip_html("<div><p>Hello <strong>World</strong></p></div>"),
"Hello World"
);
}
#[test]
fn strip_html_multiple_lines() {
let html = "<div>\n <p>Line 1</p>\n <p>Line 2</p>\n</div>";
assert_eq!(EmailChannel::strip_html(html), "Line 1 Line 2");
}
#[test]
fn strip_html_preserves_text() {
assert_eq!(EmailChannel::strip_html("No tags here"), "No tags here");
assert_eq!(EmailChannel::strip_html(""), "");
}
#[test]
fn strip_html_handles_malformed() {
assert_eq!(EmailChannel::strip_html("<p>Unclosed"), "Unclosed");
// The function removes everything between < and >, so "Text>with>brackets" becomes "Textwithbrackets"
assert_eq!(
EmailChannel::strip_html("Text>with>brackets"),
"Textwithbrackets"
);
}
#[test]
fn strip_html_self_closing_tags() {
// Self-closing tags are removed but don't add spaces
assert_eq!(EmailChannel::strip_html("Hello<br/>World"), "HelloWorld");
assert_eq!(EmailChannel::strip_html("Text<hr/>More"), "TextMore");
}
#[test]
fn strip_html_attributes_preserved() {
assert_eq!(
EmailChannel::strip_html("<a href=\"http://example.com\">Link</a>"),
"Link"
);
}
#[test]
fn strip_html_multiple_spaces_collapsed() {
assert_eq!(
EmailChannel::strip_html("<p>Word</p> <p>Word</p>"),
"Word Word"
);
}
#[test]
fn strip_html_special_characters() {
assert_eq!(
EmailChannel::strip_html("<span>&lt;tag&gt;</span>"),
"&lt;tag&gt;"
);
}
// Default function tests
#[test]
fn default_imap_port_returns_993() {
assert_eq!(default_imap_port(), 993);
}
#[test]
fn default_smtp_port_returns_465() {
assert_eq!(default_smtp_port(), 465);
}
#[test]
fn default_imap_folder_returns_inbox() {
assert_eq!(default_imap_folder(), "INBOX");
}
#[test]
fn default_true_returns_true() {
assert!(default_true());
}
// EmailConfig serialization tests
#[test]
fn email_config_serialize_deserialize() {
let config = EmailConfig {
imap_host: "imap.example.com".to_string(),
imap_port: 993,
imap_folder: "INBOX".to_string(),
smtp_host: "smtp.example.com".to_string(),
smtp_port: 587,
smtp_tls: true,
username: "user@example.com".to_string(),
password: "password123".to_string(),
from_address: "bot@example.com".to_string(),
idle_timeout_secs: 1740,
allowed_senders: vec!["allowed@example.com".to_string()],
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: EmailConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.imap_host, config.imap_host);
assert_eq!(deserialized.smtp_port, config.smtp_port);
assert_eq!(deserialized.allowed_senders, config.allowed_senders);
}
#[test]
fn email_config_deserialize_with_defaults() {
let json = r#"{
"imap_host": "imap.test.com",
"smtp_host": "smtp.test.com",
"username": "user",
"password": "pass",
"from_address": "bot@test.com"
}"#;
let config: EmailConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.imap_port, 993); // default
assert_eq!(config.smtp_port, 465); // default
assert!(config.smtp_tls); // default
assert_eq!(config.idle_timeout_secs, 1740); // default
}
#[test]
fn idle_timeout_deserializes_explicit_value() {
let json = r#"{
"imap_host": "imap.test.com",
"smtp_host": "smtp.test.com",
"username": "user",
"password": "pass",
"from_address": "bot@test.com",
"idle_timeout_secs": 900
}"#;
let config: EmailConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.idle_timeout_secs, 900);
}
#[test]
fn idle_timeout_deserializes_legacy_poll_interval_alias() {
let json = r#"{
"imap_host": "imap.test.com",
"smtp_host": "smtp.test.com",
"username": "user",
"password": "pass",
"from_address": "bot@test.com",
"poll_interval_secs": 120
}"#;
let config: EmailConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.idle_timeout_secs, 120);
}
#[test]
fn idle_timeout_propagates_to_channel() {
let config = EmailConfig {
idle_timeout_secs: 600,
..Default::default()
};
let channel = EmailChannel::new(config);
assert_eq!(channel.config.idle_timeout_secs, 600);
}
#[test]
fn email_config_debug_output() {
let config = EmailConfig {
imap_host: "imap.debug.com".to_string(),
..Default::default()
};
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("imap.debug.com"));
}
}
@@ -0,0 +1,982 @@
use crate::alphahuman::channels::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use directories::UserDirs;
use rusqlite::{Connection, OpenFlags};
use std::path::Path;
use tokio::sync::mpsc;
/// iMessage channel using macOS `AppleScript` bridge.
/// Polls the Messages database for new messages and sends replies via `osascript`.
#[derive(Clone)]
pub struct IMessageChannel {
allowed_contacts: Vec<String>,
poll_interval_secs: u64,
}
impl IMessageChannel {
pub fn new(allowed_contacts: Vec<String>) -> Self {
Self {
allowed_contacts,
poll_interval_secs: 3,
}
}
fn is_contact_allowed(&self, sender: &str) -> bool {
if self.allowed_contacts.iter().any(|u| u == "*") {
return true;
}
self.allowed_contacts
.iter()
.any(|u| u.eq_ignore_ascii_case(sender))
}
}
/// Escape a string for safe interpolation into `AppleScript`.
///
/// This prevents injection attacks by escaping:
/// - Backslashes (`\` → `\\`)
/// - Double quotes (`"` → `\"`)
/// - Newlines (`\n` → `\\n`, `\r` → `\\r`) to prevent code injection via line breaks
fn escape_applescript(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
/// Validate that a target looks like a valid phone number or email address.
///
/// This is a defense-in-depth measure to reject obviously malicious targets
/// before they reach `AppleScript` interpolation.
///
/// Valid patterns:
/// - Phone: starts with `+` followed by digits (with optional spaces/dashes)
/// - Email: contains `@` with alphanumeric chars on both sides
fn is_valid_imessage_target(target: &str) -> bool {
let target = target.trim();
if target.is_empty() {
return false;
}
// Phone number: +1234567890 or +1 234-567-8900
if target.starts_with('+') {
let digits_only: String = target.chars().filter(char::is_ascii_digit).collect();
// Must have at least 7 digits (shortest valid phone numbers)
return digits_only.len() >= 7 && digits_only.len() <= 15;
}
// Email: simple validation (contains @ with chars on both sides)
if let Some(at_pos) = target.find('@') {
let local = &target[..at_pos];
let domain = &target[at_pos + 1..];
// Local part: non-empty, alphanumeric + common email chars
let local_valid = !local.is_empty()
&& local
.chars()
.all(|c| c.is_alphanumeric() || "._+-".contains(c));
// Domain: non-empty, contains a dot, alphanumeric + dots/hyphens
let domain_valid = !domain.is_empty()
&& domain.contains('.')
&& domain
.chars()
.all(|c| c.is_alphanumeric() || ".-".contains(c));
return local_valid && domain_valid;
}
false
}
#[async_trait]
impl Channel for IMessageChannel {
fn name(&self) -> &str {
"imessage"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
// Defense-in-depth: validate target format before any interpolation
if !is_valid_imessage_target(&message.recipient) {
anyhow::bail!(
"Invalid iMessage target: must be a phone number (+1234567890) or email (user@example.com)"
);
}
// SECURITY: Escape both message AND target to prevent AppleScript injection
// See: CWE-78 (OS Command Injection)
let escaped_msg = escape_applescript(&message.content);
let escaped_target = escape_applescript(&message.recipient);
let script = format!(
r#"tell application "Messages"
set targetService to 1st account whose service type = iMessage
set targetBuddy to participant "{escaped_target}" of targetService
send "{escaped_msg}" to targetBuddy
end tell"#
);
let output = tokio::process::Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("iMessage send failed: {stderr}");
}
Ok(())
}
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
tracing::info!("iMessage channel listening (AppleScript bridge)...");
// Query the Messages SQLite database for new messages
// The database is at ~/Library/Messages/chat.db
let db_path = UserDirs::new()
.map(|u| u.home_dir().join("Library/Messages/chat.db"))
.ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?;
if !db_path.exists() {
anyhow::bail!(
"Messages database not found at {}. Ensure Messages.app is set up and Full Disk Access is granted.",
db_path.display()
);
}
// Open a persistent read-only connection instead of creating
// a new one on every 3-second poll cycle.
let path = db_path.to_path_buf();
let conn = tokio::task::spawn_blocking(move || -> anyhow::Result<Connection> {
Ok(Connection::open_with_flags(
&path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?)
})
.await??;
// Track the last ROWID we've seen (shuttle conn in and out)
let (mut conn, initial_rowid) =
tokio::task::spawn_blocking(move || -> anyhow::Result<(Connection, i64)> {
let rowid = {
let mut stmt =
conn.prepare("SELECT MAX(ROWID) FROM message WHERE is_from_me = 0")?;
let rowid: Option<i64> = stmt.query_row([], |row| row.get(0))?;
rowid.unwrap_or(0)
};
Ok((conn, rowid))
})
.await??;
let mut last_rowid = initial_rowid;
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(self.poll_interval_secs)).await;
let since = last_rowid;
let (returned_conn, poll_result) = tokio::task::spawn_blocking(
move || -> (Connection, anyhow::Result<Vec<(i64, String, String)>>) {
let result = (|| -> anyhow::Result<Vec<(i64, String, String)>> {
let mut stmt = conn.prepare(
"SELECT m.ROWID, h.id, m.text \
FROM message m \
JOIN handle h ON m.handle_id = h.ROWID \
WHERE m.ROWID > ?1 \
AND m.is_from_me = 0 \
AND m.text IS NOT NULL \
ORDER BY m.ROWID ASC \
LIMIT 20",
)?;
let rows = stmt.query_map([since], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
let results = rows.collect::<Result<Vec<_>, _>>()?;
Ok(results)
})();
(conn, result)
},
)
.await
.map_err(|e| anyhow::anyhow!("iMessage poll worker join error: {e}"))?;
conn = returned_conn;
match poll_result {
Ok(messages) => {
for (rowid, sender, text) in messages {
if rowid > last_rowid {
last_rowid = rowid;
}
if !self.is_contact_allowed(&sender) {
continue;
}
if text.trim().is_empty() {
continue;
}
let msg = ChannelMessage {
id: rowid.to_string(),
sender: sender.clone(),
reply_target: sender.clone(),
content: text,
channel: "imessage".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: None,
};
if tx.send(msg).await.is_err() {
return Ok(());
}
}
}
Err(e) => {
tracing::warn!("iMessage poll error: {e}");
}
}
}
}
async fn health_check(&self) -> bool {
if !cfg!(target_os = "macos") {
return false;
}
let db_path = UserDirs::new()
.map(|u| u.home_dir().join("Library/Messages/chat.db"))
.unwrap_or_default();
db_path.exists()
}
}
/// Get the current max ROWID from the messages table.
/// Uses rusqlite with parameterized queries for security (CWE-89 prevention).
async fn get_max_rowid(db_path: &Path) -> anyhow::Result<i64> {
let path = db_path.to_path_buf();
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<i64> {
let conn = Connection::open_with_flags(
&path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
let mut stmt = conn.prepare("SELECT MAX(ROWID) FROM message WHERE is_from_me = 0")?;
let rowid: Option<i64> = stmt.query_row([], |row| row.get(0))?;
Ok(rowid.unwrap_or(0))
})
.await??;
Ok(result)
}
/// Fetch messages newer than `since_rowid`.
/// Uses rusqlite with parameterized queries for security (CWE-89 prevention).
/// The `since_rowid` parameter is bound safely, preventing SQL injection.
async fn fetch_new_messages(
db_path: &Path,
since_rowid: i64,
) -> anyhow::Result<Vec<(i64, String, String)>> {
let path = db_path.to_path_buf();
let results =
tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<(i64, String, String)>> {
let conn = Connection::open_with_flags(
&path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
let mut stmt = conn.prepare(
"SELECT m.ROWID, h.id, m.text \
FROM message m \
JOIN handle h ON m.handle_id = h.ROWID \
WHERE m.ROWID > ?1 \
AND m.is_from_me = 0 \
AND m.text IS NOT NULL \
ORDER BY m.ROWID ASC \
LIMIT 20",
)?;
let rows = stmt.query_map([since_rowid], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
})
.await??;
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creates_with_contacts() {
let ch = IMessageChannel::new(vec!["+1234567890".into()]);
assert_eq!(ch.allowed_contacts.len(), 1);
assert_eq!(ch.poll_interval_secs, 3);
}
#[test]
fn creates_with_empty_contacts() {
let ch = IMessageChannel::new(vec![]);
assert!(ch.allowed_contacts.is_empty());
}
#[test]
fn wildcard_allows_anyone() {
let ch = IMessageChannel::new(vec!["*".into()]);
assert!(ch.is_contact_allowed("+1234567890"));
assert!(ch.is_contact_allowed("random@icloud.com"));
assert!(ch.is_contact_allowed(""));
}
#[test]
fn specific_contact_allowed() {
let ch = IMessageChannel::new(vec!["+1234567890".into(), "user@icloud.com".into()]);
assert!(ch.is_contact_allowed("+1234567890"));
assert!(ch.is_contact_allowed("user@icloud.com"));
}
#[test]
fn unknown_contact_denied() {
let ch = IMessageChannel::new(vec!["+1234567890".into()]);
assert!(!ch.is_contact_allowed("+9999999999"));
assert!(!ch.is_contact_allowed("hacker@evil.com"));
}
#[test]
fn contact_case_insensitive() {
let ch = IMessageChannel::new(vec!["User@iCloud.com".into()]);
assert!(ch.is_contact_allowed("user@icloud.com"));
assert!(ch.is_contact_allowed("USER@ICLOUD.COM"));
}
#[test]
fn empty_allowlist_denies_all() {
let ch = IMessageChannel::new(vec![]);
assert!(!ch.is_contact_allowed("+1234567890"));
assert!(!ch.is_contact_allowed("anyone"));
}
#[test]
fn name_returns_imessage() {
let ch = IMessageChannel::new(vec![]);
assert_eq!(ch.name(), "imessage");
}
#[test]
fn wildcard_among_others_still_allows_all() {
let ch = IMessageChannel::new(vec!["+111".into(), "*".into(), "+222".into()]);
assert!(ch.is_contact_allowed("totally-unknown"));
}
#[test]
fn contact_with_spaces_exact_match() {
let ch = IMessageChannel::new(vec![" spaced ".into()]);
assert!(ch.is_contact_allowed(" spaced "));
assert!(!ch.is_contact_allowed("spaced"));
}
// ══════════════════════════════════════════════════════════
// AppleScript Escaping Tests (CWE-78 Prevention)
// ══════════════════════════════════════════════════════════
#[test]
fn escape_applescript_double_quotes() {
assert_eq!(escape_applescript(r#"hello "world""#), r#"hello \"world\""#);
}
#[test]
fn escape_applescript_backslashes() {
assert_eq!(escape_applescript(r"path\to\file"), r"path\\to\\file");
}
#[test]
fn escape_applescript_mixed() {
assert_eq!(
escape_applescript(r#"say "hello\" world"#),
r#"say \"hello\\\" world"#
);
}
#[test]
fn escape_applescript_injection_attempt() {
// This is the exact attack vector from the security report
let malicious = r#"" & do shell script "id" & ""#;
let escaped = escape_applescript(malicious);
// After escaping, the quotes should be escaped and not break out
assert_eq!(escaped, r#"\" & do shell script \"id\" & \""#);
// Verify all quotes are now escaped (preceded by backslash)
// The escaped string should not have any unescaped quotes (quote not preceded by backslash)
let chars: Vec<char> = escaped.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '"' {
// Every quote must be preceded by a backslash
assert!(
i > 0 && chars[i - 1] == '\\',
"Found unescaped quote at position {i}"
);
}
}
}
#[test]
fn escape_applescript_empty_string() {
assert_eq!(escape_applescript(""), "");
}
#[test]
fn escape_applescript_no_special_chars() {
assert_eq!(escape_applescript("hello world"), "hello world");
}
#[test]
fn escape_applescript_unicode() {
assert_eq!(escape_applescript("hello 🦀 world"), "hello 🦀 world");
}
#[test]
fn escape_applescript_newlines_escaped() {
assert_eq!(escape_applescript("line1\nline2"), "line1\\nline2");
assert_eq!(escape_applescript("line1\rline2"), "line1\\rline2");
assert_eq!(escape_applescript("line1\r\nline2"), "line1\\r\\nline2");
}
// ══════════════════════════════════════════════════════════
// Target Validation Tests
// ══════════════════════════════════════════════════════════
#[test]
fn valid_phone_number_simple() {
assert!(is_valid_imessage_target("+1234567890"));
}
#[test]
fn valid_phone_number_with_country_code() {
assert!(is_valid_imessage_target("+14155551234"));
}
#[test]
fn valid_phone_number_with_spaces() {
assert!(is_valid_imessage_target("+1 415 555 1234"));
}
#[test]
fn valid_phone_number_with_dashes() {
assert!(is_valid_imessage_target("+1-415-555-1234"));
}
#[test]
fn valid_phone_number_international() {
assert!(is_valid_imessage_target("+447911123456")); // UK
assert!(is_valid_imessage_target("+81312345678")); // Japan
}
#[test]
fn valid_email_simple() {
assert!(is_valid_imessage_target("user@example.com"));
}
#[test]
fn valid_email_with_subdomain() {
assert!(is_valid_imessage_target("user@mail.example.com"));
}
#[test]
fn valid_email_with_plus() {
assert!(is_valid_imessage_target("user+tag@example.com"));
}
#[test]
fn valid_email_with_dots() {
assert!(is_valid_imessage_target("first.last@example.com"));
}
#[test]
fn valid_email_icloud() {
assert!(is_valid_imessage_target("user@icloud.com"));
assert!(is_valid_imessage_target("user@me.com"));
}
#[test]
fn invalid_target_empty() {
assert!(!is_valid_imessage_target(""));
assert!(!is_valid_imessage_target(" "));
}
#[test]
fn invalid_target_no_plus_prefix() {
// Phone numbers must start with +
assert!(!is_valid_imessage_target("1234567890"));
}
#[test]
fn invalid_target_too_short_phone() {
// Less than 7 digits
assert!(!is_valid_imessage_target("+123456"));
}
#[test]
fn invalid_target_too_long_phone() {
// More than 15 digits
assert!(!is_valid_imessage_target("+1234567890123456"));
}
#[test]
fn invalid_target_email_no_at() {
assert!(!is_valid_imessage_target("userexample.com"));
}
#[test]
fn invalid_target_email_no_domain() {
assert!(!is_valid_imessage_target("user@"));
}
#[test]
fn invalid_target_email_no_local() {
assert!(!is_valid_imessage_target("@example.com"));
}
#[test]
fn invalid_target_email_no_dot_in_domain() {
assert!(!is_valid_imessage_target("user@localhost"));
}
#[test]
fn invalid_target_injection_attempt() {
// The exact attack vector from the security report
assert!(!is_valid_imessage_target(r#"" & do shell script "id" & ""#));
}
#[test]
fn invalid_target_applescript_injection() {
// Various injection attempts
assert!(!is_valid_imessage_target(r#"test" & quit"#));
assert!(!is_valid_imessage_target(r"test\ndo shell script"));
assert!(!is_valid_imessage_target("test\"; malicious code; \""));
}
#[test]
fn invalid_target_special_chars() {
assert!(!is_valid_imessage_target("user<script>@example.com"));
assert!(!is_valid_imessage_target("user@example.com; rm -rf /"));
}
#[test]
fn invalid_target_null_byte() {
assert!(!is_valid_imessage_target("user\0@example.com"));
}
#[test]
fn invalid_target_newline() {
assert!(!is_valid_imessage_target("user\n@example.com"));
}
#[test]
fn target_with_leading_trailing_whitespace_trimmed() {
// Should trim and validate
assert!(is_valid_imessage_target(" +1234567890 "));
assert!(is_valid_imessage_target(" user@example.com "));
}
// ══════════════════════════════════════════════════════════
// SQLite/rusqlite Database Tests (CWE-89 Prevention)
// ══════════════════════════════════════════════════════════
/// Helper to create a temporary test database with Messages schema
fn create_test_db() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("chat.db");
let conn = Connection::open(&db_path).unwrap();
// Create minimal schema matching macOS Messages.app
conn.execute_batch(
"CREATE TABLE handle (
ROWID INTEGER PRIMARY KEY,
id TEXT NOT NULL
);
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
is_from_me INTEGER DEFAULT 0,
FOREIGN KEY (handle_id) REFERENCES handle(ROWID)
);",
)
.unwrap();
(dir, db_path)
}
#[tokio::test]
async fn get_max_rowid_empty_database() {
let (_dir, db_path) = create_test_db();
let result = get_max_rowid(&db_path).await;
assert!(result.is_ok());
// Empty table returns 0 (NULL coalesced)
assert_eq!(result.unwrap(), 0);
}
#[tokio::test]
async fn get_max_rowid_with_messages() {
let (_dir, db_path) = create_test_db();
// Insert test data
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (100, 1, 'Hello', 0)",
[]
).unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (200, 1, 'World', 0)",
[]
).unwrap();
// This one is from_me=1, should be ignored
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (300, 1, 'Sent', 1)",
[]
).unwrap();
}
let result = get_max_rowid(&db_path).await.unwrap();
// Should return 200, not 300 (ignores is_from_me=1)
assert_eq!(result, 200);
}
#[tokio::test]
async fn get_max_rowid_nonexistent_database() {
let path = std::path::Path::new("/nonexistent/path/chat.db");
let result = get_max_rowid(path).await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_new_messages_empty_database() {
let (_dir, db_path) = create_test_db();
let result = fetch_new_messages(&db_path, 0).await;
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[tokio::test]
async fn fetch_new_messages_returns_correct_data() {
let (_dir, db_path) = create_test_db();
// Insert test data
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (2, 'user@example.com')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First message', 0)",
[]
).unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 2, 'Second message', 0)",
[]
).unwrap();
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
assert_eq!(result.len(), 2);
assert_eq!(
result[0],
(10, "+1234567890".to_string(), "First message".to_string())
);
assert_eq!(
result[1],
(
20,
"user@example.com".to_string(),
"Second message".to_string()
)
);
}
#[tokio::test]
async fn fetch_new_messages_filters_by_rowid() {
let (_dir, db_path) = create_test_db();
// Insert test data
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Old message', 0)",
[]
).unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'New message', 0)",
[]
).unwrap();
}
// Fetch only messages after ROWID 15
let result = fetch_new_messages(&db_path, 15).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, 20);
assert_eq!(result[0].2, "New message");
}
#[tokio::test]
async fn fetch_new_messages_excludes_sent_messages() {
let (_dir, db_path) = create_test_db();
// Insert test data
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Received', 0)",
[]
).unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'Sent by me', 1)",
[]
).unwrap();
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].2, "Received");
}
#[tokio::test]
async fn fetch_new_messages_excludes_null_text() {
let (_dir, db_path) = create_test_db();
// Insert test data
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Has text', 0)",
[]
).unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, NULL, 0)",
[],
)
.unwrap();
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].2, "Has text");
}
#[tokio::test]
async fn fetch_new_messages_respects_limit() {
let (_dir, db_path) = create_test_db();
// Insert 25 messages (limit is 20)
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
for i in 1..=25 {
conn.execute(
&format!("INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES ({i}, 1, 'Message {i}', 0)"),
[]
).unwrap();
}
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
assert_eq!(result.len(), 20); // Limited to 20
assert_eq!(result[0].0, 1); // First message
assert_eq!(result[19].0, 20); // 20th message
}
#[tokio::test]
async fn fetch_new_messages_ordered_by_rowid_asc() {
let (_dir, db_path) = create_test_db();
// Insert messages out of order
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (30, 1, 'Third', 0)",
[]
).unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First', 0)",
[]
).unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, 'Second', 0)",
[]
).unwrap();
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].0, 10);
assert_eq!(result[1].0, 20);
assert_eq!(result[2].0, 30);
}
#[tokio::test]
async fn fetch_new_messages_nonexistent_database() {
let path = std::path::Path::new("/nonexistent/path/chat.db");
let result = fetch_new_messages(path, 0).await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_new_messages_handles_special_characters() {
let (_dir, db_path) = create_test_db();
// Insert message with special characters (potential SQL injection patterns)
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello \"world'' OR 1=1; DROP TABLE message;--', 0)",
[]
).unwrap();
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
assert_eq!(result.len(), 1);
// The special characters should be preserved, not interpreted as SQL
assert!(result[0].2.contains("DROP TABLE"));
}
#[tokio::test]
async fn fetch_new_messages_handles_unicode() {
let (_dir, db_path) = create_test_db();
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello 🦀 世界 مرحبا', 0)",
[]
).unwrap();
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].2, "Hello 🦀 世界 مرحبا");
}
#[tokio::test]
async fn fetch_new_messages_handles_empty_text() {
let (_dir, db_path) = create_test_db();
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, '', 0)",
[],
)
.unwrap();
}
let result = fetch_new_messages(&db_path, 0).await.unwrap();
// Empty string is NOT NULL, so it's included
assert_eq!(result.len(), 1);
assert_eq!(result[0].2, "");
}
#[tokio::test]
async fn fetch_new_messages_negative_rowid_edge_case() {
let (_dir, db_path) = create_test_db();
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)",
[]
).unwrap();
}
// Negative rowid should still work (fetch all messages with ROWID > -1)
let result = fetch_new_messages(&db_path, -1).await.unwrap();
assert_eq!(result.len(), 1);
}
#[tokio::test]
async fn fetch_new_messages_large_rowid_edge_case() {
let (_dir, db_path) = create_test_db();
{
let conn = Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)",
[]
).unwrap();
}
// Very large rowid should return empty (no messages after this)
let result = fetch_new_messages(&db_path, i64::MAX - 1).await.unwrap();
assert!(result.is_empty());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+789
View File
@@ -0,0 +1,789 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use uuid::Uuid;
/// Linq channel — uses the Linq Partner V3 API for iMessage, RCS, and SMS.
///
/// This channel operates in webhook mode (push-based) rather than polling.
/// Messages are received via the gateway's `/linq` webhook endpoint.
/// The `listen` method here is a keepalive placeholder; actual message handling
/// happens in the gateway when Linq sends webhook events.
pub struct LinqChannel {
api_token: String,
from_phone: String,
allowed_senders: Vec<String>,
client: reqwest::Client,
}
const LINQ_API_BASE: &str = "https://api.linqapp.com/api/partner/v3";
impl LinqChannel {
pub fn new(api_token: String, from_phone: String, allowed_senders: Vec<String>) -> Self {
Self {
api_token,
from_phone,
allowed_senders,
client: reqwest::Client::new(),
}
}
/// Check if a sender phone number is allowed (E.164 format: +1234567890)
fn is_sender_allowed(&self, phone: &str) -> bool {
self.allowed_senders.iter().any(|n| n == "*" || n == phone)
}
/// Get the bot's phone number
pub fn phone_number(&self) -> &str {
&self.from_phone
}
fn media_part_to_image_marker(part: &serde_json::Value) -> Option<String> {
let source = part
.get("url")
.or_else(|| part.get("value"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mime_type = part
.get("mime_type")
.and_then(|value| value.as_str())
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase();
if !mime_type.starts_with("image/") {
return None;
}
Some(format!("[IMAGE:{source}]"))
}
/// Parse an incoming webhook payload from Linq and extract messages.
///
/// Linq webhook envelope:
/// ```json
/// {
/// "api_version": "v3",
/// "event_type": "message.received",
/// "event_id": "...",
/// "created_at": "...",
/// "trace_id": "...",
/// "data": {
/// "chat_id": "...",
/// "from": "+1...",
/// "recipient_phone": "+1...",
/// "is_from_me": false,
/// "service": "iMessage",
/// "message": {
/// "id": "...",
/// "parts": [{ "type": "text", "value": "..." }]
/// }
/// }
/// }
/// ```
pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
let mut messages = Vec::new();
// Only handle message.received events
let event_type = payload
.get("event_type")
.and_then(|e| e.as_str())
.unwrap_or("");
if event_type != "message.received" {
tracing::debug!("Linq: skipping non-message event: {event_type}");
return messages;
}
let Some(data) = payload.get("data") else {
return messages;
};
// Skip messages sent by the bot itself
if data
.get("is_from_me")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
tracing::debug!("Linq: skipping is_from_me message");
return messages;
}
// Get sender phone number
let Some(from) = data.get("from").and_then(|f| f.as_str()) else {
return messages;
};
// Normalize to E.164 format
let normalized_from = if from.starts_with('+') {
from.to_string()
} else {
format!("+{from}")
};
// Check allowlist
if !self.is_sender_allowed(&normalized_from) {
tracing::warn!(
"Linq: ignoring message from unauthorized sender: {normalized_from}. \
Add to allowed_senders in config.toml."
);
return messages;
}
// Get chat_id for reply routing
let chat_id = data
.get("chat_id")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
// Extract text from message parts
let Some(message) = data.get("message") else {
return messages;
};
let Some(parts) = message.get("parts").and_then(|p| p.as_array()) else {
return messages;
};
let content_parts: Vec<String> = parts
.iter()
.filter_map(|part| {
let part_type = part.get("type").and_then(|t| t.as_str())?;
match part_type {
"text" => part
.get("value")
.and_then(|v| v.as_str())
.map(ToString::to_string),
"media" | "image" => {
if let Some(marker) = Self::media_part_to_image_marker(part) {
Some(marker)
} else {
tracing::debug!("Linq: skipping unsupported {part_type} part");
None
}
}
_ => {
tracing::debug!("Linq: skipping {part_type} part");
None
}
}
})
.collect();
if content_parts.is_empty() {
return messages;
}
let content = content_parts.join("\n").trim().to_string();
if content.is_empty() {
return messages;
}
// Get timestamp from created_at or use current time
let timestamp = payload
.get("created_at")
.and_then(|t| t.as_str())
.and_then(|t| {
chrono::DateTime::parse_from_rfc3339(t)
.ok()
.map(|dt| dt.timestamp().cast_unsigned())
})
.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
});
// Use chat_id as reply_target so replies go to the right conversation
let reply_target = if chat_id.is_empty() {
normalized_from.clone()
} else {
chat_id
};
messages.push(ChannelMessage {
id: Uuid::new_v4().to_string(),
reply_target,
sender: normalized_from,
content,
channel: "linq".to_string(),
timestamp,
thread_ts: None,
});
messages
}
}
#[async_trait]
impl Channel for LinqChannel {
fn name(&self) -> &str {
"linq"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
// If reply_target looks like a chat_id, send to existing chat.
// Otherwise create a new chat with the recipient phone number.
let recipient = &message.recipient;
let body = serde_json::json!({
"message": {
"parts": [{
"type": "text",
"value": message.content
}]
}
});
// Try sending to existing chat (recipient is chat_id)
let url = format!("{LINQ_API_BASE}/chats/{recipient}/messages");
let resp = self
.client
.post(&url)
.bearer_auth(&self.api_token)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if resp.status().is_success() {
return Ok(());
}
// If the chat_id-based send failed with 404, try creating a new chat
if resp.status() == reqwest::StatusCode::NOT_FOUND {
let new_chat_body = serde_json::json!({
"from": self.from_phone,
"to": [recipient],
"message": {
"parts": [{
"type": "text",
"value": message.content
}]
}
});
let create_resp = self
.client
.post(format!("{LINQ_API_BASE}/chats"))
.bearer_auth(&self.api_token)
.header("Content-Type", "application/json")
.json(&new_chat_body)
.send()
.await?;
if !create_resp.status().is_success() {
let status = create_resp.status();
let error_body = create_resp.text().await.unwrap_or_default();
tracing::error!("Linq create chat failed: {status} — {error_body}");
anyhow::bail!("Linq API error: {status}");
}
return Ok(());
}
let status = resp.status();
let error_body = resp.text().await.unwrap_or_default();
tracing::error!("Linq send failed: {status} — {error_body}");
anyhow::bail!("Linq API error: {status}");
}
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
// Linq uses webhooks (push-based), not polling.
// Messages are received via the gateway's /linq endpoint.
tracing::info!(
"Linq channel active (webhook mode). \
Configure Linq webhook to POST to your gateway's /linq endpoint."
);
// Keep the task alive — it will be cancelled when the channel shuts down
loop {
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
}
}
async fn health_check(&self) -> bool {
// Check if we can reach the Linq API
let url = format!("{LINQ_API_BASE}/phonenumbers");
self.client
.get(&url)
.bearer_auth(&self.api_token)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
let url = format!("{LINQ_API_BASE}/chats/{recipient}/typing");
let resp = self
.client
.post(&url)
.bearer_auth(&self.api_token)
.send()
.await?;
if !resp.status().is_success() {
tracing::debug!("Linq start_typing failed: {}", resp.status());
}
Ok(())
}
async fn stop_typing(&self, recipient: &str) -> anyhow::Result<()> {
let url = format!("{LINQ_API_BASE}/chats/{recipient}/typing");
let resp = self
.client
.delete(&url)
.bearer_auth(&self.api_token)
.send()
.await?;
if !resp.status().is_success() {
tracing::debug!("Linq stop_typing failed: {}", resp.status());
}
Ok(())
}
}
/// Verify a Linq webhook signature.
///
/// Linq signs webhooks with HMAC-SHA256 over `"{timestamp}.{body}"`.
/// The signature is sent in `X-Webhook-Signature` (hex-encoded) and the
/// timestamp in `X-Webhook-Timestamp`. Reject timestamps older than 300s.
pub fn verify_linq_signature(secret: &str, body: &str, timestamp: &str, signature: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
// Reject stale timestamps (>300s old)
if let Ok(ts) = timestamp.parse::<i64>() {
let now = chrono::Utc::now().timestamp();
if (now - ts).unsigned_abs() > 300 {
tracing::warn!("Linq: rejecting stale webhook timestamp ({ts}, now={now})");
return false;
}
} else {
tracing::warn!("Linq: invalid webhook timestamp: {timestamp}");
return false;
}
// Compute HMAC-SHA256 over "{timestamp}.{body}"
let message = format!("{timestamp}.{body}");
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(message.as_bytes());
let signature_hex = signature
.trim()
.strip_prefix("sha256=")
.unwrap_or(signature);
let Ok(provided) = hex::decode(signature_hex.trim()) else {
tracing::warn!("Linq: invalid webhook signature format");
return false;
};
// Constant-time comparison via HMAC verify.
mac.verify_slice(&provided).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_channel() -> LinqChannel {
LinqChannel::new(
"test-token".into(),
"+15551234567".into(),
vec!["+1234567890".into()],
)
}
#[test]
fn linq_channel_name() {
let ch = make_channel();
assert_eq!(ch.name(), "linq");
}
#[test]
fn linq_sender_allowed_exact() {
let ch = make_channel();
assert!(ch.is_sender_allowed("+1234567890"));
assert!(!ch.is_sender_allowed("+9876543210"));
}
#[test]
fn linq_sender_allowed_wildcard() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
assert!(ch.is_sender_allowed("+1234567890"));
assert!(ch.is_sender_allowed("+9999999999"));
}
#[test]
fn linq_sender_allowed_empty() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec![]);
assert!(!ch.is_sender_allowed("+1234567890"));
}
#[test]
fn linq_parse_valid_text_message() {
let ch = make_channel();
let payload = serde_json::json!({
"api_version": "v3",
"event_type": "message.received",
"event_id": "evt-123",
"created_at": "2025-01-15T12:00:00Z",
"trace_id": "trace-456",
"data": {
"chat_id": "chat-789",
"from": "+1234567890",
"recipient_phone": "+15551234567",
"is_from_me": false,
"service": "iMessage",
"message": {
"id": "msg-abc",
"parts": [{
"type": "text",
"value": "Hello Alphahuman!"
}]
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].sender, "+1234567890");
assert_eq!(msgs[0].content, "Hello Alphahuman!");
assert_eq!(msgs[0].channel, "linq");
assert_eq!(msgs[0].reply_target, "chat-789");
}
#[test]
fn linq_parse_skip_is_from_me() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"data": {
"chat_id": "chat-789",
"from": "+1234567890",
"is_from_me": true,
"message": {
"id": "msg-abc",
"parts": [{ "type": "text", "value": "My own message" }]
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty(), "is_from_me messages should be skipped");
}
#[test]
fn linq_parse_skip_non_message_event() {
let ch = make_channel();
let payload = serde_json::json!({
"event_type": "message.delivered",
"data": {
"chat_id": "chat-789",
"message_id": "msg-abc"
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty(), "Non-message events should be skipped");
}
#[test]
fn linq_parse_unauthorized_sender() {
let ch = make_channel();
let payload = serde_json::json!({
"event_type": "message.received",
"data": {
"chat_id": "chat-789",
"from": "+9999999999",
"is_from_me": false,
"message": {
"id": "msg-abc",
"parts": [{ "type": "text", "value": "Spam" }]
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty(), "Unauthorized senders should be filtered");
}
#[test]
fn linq_parse_empty_payload() {
let ch = make_channel();
let payload = serde_json::json!({});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
}
#[test]
fn linq_parse_media_only_translated_to_image_marker() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"data": {
"chat_id": "chat-789",
"from": "+1234567890",
"is_from_me": false,
"message": {
"id": "msg-abc",
"parts": [{
"type": "media",
"url": "https://example.com/image.jpg",
"mime_type": "image/jpeg"
}]
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "[IMAGE:https://example.com/image.jpg]");
}
#[test]
fn linq_parse_media_non_image_still_skipped() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"data": {
"chat_id": "chat-789",
"from": "+1234567890",
"is_from_me": false,
"message": {
"id": "msg-abc",
"parts": [{
"type": "media",
"url": "https://example.com/sound.mp3",
"mime_type": "audio/mpeg"
}]
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty(), "Non-image media should still be skipped");
}
#[test]
fn linq_parse_multiple_text_parts() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"data": {
"chat_id": "chat-789",
"from": "+1234567890",
"is_from_me": false,
"message": {
"id": "msg-abc",
"parts": [
{ "type": "text", "value": "First part" },
{ "type": "text", "value": "Second part" }
]
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "First part\nSecond part");
}
#[test]
fn linq_signature_verification_valid() {
let secret = "test_webhook_secret";
let body = r#"{"event_type":"message.received"}"#;
let now = chrono::Utc::now().timestamp().to_string();
// Compute expected signature
use hmac::{Hmac, Mac};
use sha2::Sha256;
let message = format!("{now}.{body}");
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(message.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
assert!(verify_linq_signature(secret, body, &now, &signature));
}
#[test]
fn linq_signature_verification_invalid() {
let secret = "test_webhook_secret";
let body = r#"{"event_type":"message.received"}"#;
let now = chrono::Utc::now().timestamp().to_string();
assert!(!verify_linq_signature(
secret,
body,
&now,
"deadbeefdeadbeefdeadbeef"
));
}
#[test]
fn linq_signature_verification_stale_timestamp() {
let secret = "test_webhook_secret";
let body = r#"{"event_type":"message.received"}"#;
// 10 minutes ago — stale
let stale_ts = (chrono::Utc::now().timestamp() - 600).to_string();
// Even with correct signature, stale timestamp should fail
use hmac::{Hmac, Mac};
use sha2::Sha256;
let message = format!("{stale_ts}.{body}");
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(message.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
assert!(
!verify_linq_signature(secret, body, &stale_ts, &signature),
"Stale timestamps (>300s) should be rejected"
);
}
#[test]
fn linq_signature_verification_accepts_sha256_prefix() {
let secret = "test_webhook_secret";
let body = r#"{"event_type":"message.received"}"#;
let now = chrono::Utc::now().timestamp().to_string();
use hmac::{Hmac, Mac};
use sha2::Sha256;
let message = format!("{now}.{body}");
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(message.as_bytes());
let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
assert!(verify_linq_signature(secret, body, &now, &signature));
}
#[test]
fn linq_signature_verification_accepts_uppercase_hex() {
let secret = "test_webhook_secret";
let body = r#"{"event_type":"message.received"}"#;
let now = chrono::Utc::now().timestamp().to_string();
use hmac::{Hmac, Mac};
use sha2::Sha256;
let message = format!("{now}.{body}");
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(message.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes()).to_ascii_uppercase();
assert!(verify_linq_signature(secret, body, &now, &signature));
}
#[test]
fn linq_parse_normalizes_phone_with_plus() {
let ch = LinqChannel::new(
"tok".into(),
"+15551234567".into(),
vec!["+1234567890".into()],
);
// API sends without +, normalize to +
let payload = serde_json::json!({
"event_type": "message.received",
"data": {
"chat_id": "chat-789",
"from": "1234567890",
"is_from_me": false,
"message": {
"id": "msg-abc",
"parts": [{ "type": "text", "value": "Hi" }]
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].sender, "+1234567890");
}
#[test]
fn linq_parse_missing_data() {
let ch = make_channel();
let payload = serde_json::json!({
"event_type": "message.received"
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
}
#[test]
fn linq_parse_missing_message_parts() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"data": {
"chat_id": "chat-789",
"from": "+1234567890",
"is_from_me": false,
"message": {
"id": "msg-abc"
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
}
#[test]
fn linq_parse_empty_text_value() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"data": {
"chat_id": "chat-789",
"from": "+1234567890",
"is_from_me": false,
"message": {
"id": "msg-abc",
"parts": [{ "type": "text", "value": "" }]
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty(), "Empty text should be skipped");
}
#[test]
fn linq_parse_fallback_reply_target_when_no_chat_id() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"data": {
"from": "+1234567890",
"is_from_me": false,
"message": {
"id": "msg-abc",
"parts": [{ "type": "text", "value": "Hi" }]
}
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
// Falls back to sender phone number when no chat_id
assert_eq!(msgs[0].reply_target, "+1234567890");
}
#[test]
fn linq_phone_number_accessor() {
let ch = make_channel();
assert_eq!(ch.phone_number(), "+15551234567");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,918 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use anyhow::{bail, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
/// Mattermost channel — polls channel posts via REST API v4.
/// Mattermost is API-compatible with many Slack patterns but uses a dedicated v4 structure.
pub struct MattermostChannel {
base_url: String, // e.g., https://mm.example.com
bot_token: String,
channel_id: Option<String>,
allowed_users: Vec<String>,
/// When true (default), replies thread on the original post's root_id.
/// When false, replies go to the channel root.
thread_replies: bool,
/// When true, only respond to messages that @-mention the bot.
mention_only: bool,
/// Handle for the background typing-indicator loop (aborted on stop_typing).
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl MattermostChannel {
pub fn new(
base_url: String,
bot_token: String,
channel_id: Option<String>,
allowed_users: Vec<String>,
thread_replies: bool,
mention_only: bool,
) -> Self {
// Ensure base_url doesn't have a trailing slash for consistent path joining
let base_url = base_url.trim_end_matches('/').to_string();
Self {
base_url,
bot_token,
channel_id,
allowed_users,
thread_replies,
mention_only,
typing_handle: Mutex::new(None),
}
}
fn http_client(&self) -> reqwest::Client {
crate::alphahuman::config::build_runtime_proxy_client("channel.mattermost")
}
/// Check if a user ID is in the allowlist.
/// Empty list means deny everyone. "*" means allow everyone.
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
/// Get the bot's own user ID and username so we can ignore our own messages
/// and detect @-mentions by username.
async fn get_bot_identity(&self) -> (String, String) {
let resp: Option<serde_json::Value> = async {
self.http_client()
.get(format!("{}/api/v4/users/me", self.base_url))
.bearer_auth(&self.bot_token)
.send()
.await
.ok()?
.json()
.await
.ok()
}
.await;
let id = resp
.as_ref()
.and_then(|v| v.get("id"))
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
let username = resp
.as_ref()
.and_then(|v| v.get("username"))
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string();
(id, username)
}
}
#[async_trait]
impl Channel for MattermostChannel {
fn name(&self) -> &str {
"mattermost"
}
async fn send(&self, message: &SendMessage) -> Result<()> {
// Mattermost supports threading via 'root_id'.
// We pack 'channel_id:root_id' into recipient if it's a thread.
let (channel_id, root_id) = if let Some((c, r)) = message.recipient.split_once(':') {
(c, Some(r))
} else {
(message.recipient.as_str(), None)
};
let mut body_map = serde_json::json!({
"channel_id": channel_id,
"message": message.content
});
if let Some(root) = root_id {
body_map.as_object_mut().unwrap().insert(
"root_id".to_string(),
serde_json::Value::String(root.to_string()),
);
}
let resp = self
.http_client()
.post(format!("{}/api/v4/posts", self.base_url))
.bearer_auth(&self.bot_token)
.json(&body_map)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
bail!("Mattermost post failed ({status}): {body}");
}
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
let channel_id = self
.channel_id
.clone()
.ok_or_else(|| anyhow::anyhow!("Mattermost channel_id required for listening"))?;
let (bot_user_id, bot_username) = self.get_bot_identity().await;
#[allow(clippy::cast_possible_truncation)]
let mut last_create_at = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()) as i64;
tracing::info!("Mattermost channel listening on {}...", channel_id);
loop {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let resp = match self
.http_client()
.get(format!(
"{}/api/v4/channels/{}/posts",
self.base_url, channel_id
))
.bearer_auth(&self.bot_token)
.query(&[("since", last_create_at.to_string())])
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("Mattermost poll error: {e}");
continue;
}
};
let data: serde_json::Value = match resp.json().await {
Ok(d) => d,
Err(e) => {
tracing::warn!("Mattermost parse error: {e}");
continue;
}
};
if let Some(posts) = data.get("posts").and_then(|p| p.as_object()) {
// Process in chronological order
let mut post_list: Vec<_> = posts.values().collect();
post_list.sort_by_key(|p| p.get("create_at").and_then(|c| c.as_i64()).unwrap_or(0));
for post in post_list {
let msg = self.parse_mattermost_post(
post,
&bot_user_id,
&bot_username,
last_create_at,
&channel_id,
);
let create_at = post
.get("create_at")
.and_then(|c| c.as_i64())
.unwrap_or(last_create_at);
last_create_at = last_create_at.max(create_at);
if let Some(channel_msg) = msg {
if tx.send(channel_msg).await.is_err() {
return Ok(());
}
}
}
}
}
}
async fn health_check(&self) -> bool {
self.http_client()
.get(format!("{}/api/v4/users/me", self.base_url))
.bearer_auth(&self.bot_token)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
async fn start_typing(&self, recipient: &str) -> Result<()> {
// Cancel any existing typing loop before starting a new one.
self.stop_typing(recipient).await?;
let client = self.http_client();
let token = self.bot_token.clone();
let base_url = self.base_url.clone();
// recipient is "channel_id" or "channel_id:root_id"
let (channel_id, parent_id) = match recipient.split_once(':') {
Some((channel, parent)) => (channel.to_string(), Some(parent.to_string())),
None => (recipient.to_string(), None),
};
let handle = tokio::spawn(async move {
let url = format!("{base_url}/api/v4/users/me/typing");
loop {
let mut body = serde_json::json!({ "channel_id": channel_id });
if let Some(ref pid) = parent_id {
body.as_object_mut()
.unwrap()
.insert("parent_id".to_string(), serde_json::json!(pid));
}
if let Ok(r) = client
.post(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await
{
if !r.status().is_success() {
tracing::debug!(status = %r.status(), "Mattermost typing indicator failed");
}
}
// Mattermost typing events expire after ~6s; re-fire every 4s.
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
}
});
let mut guard = self.typing_handle.lock();
*guard = Some(handle);
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> Result<()> {
let mut guard = self.typing_handle.lock();
if let Some(handle) = guard.take() {
handle.abort();
}
Ok(())
}
}
impl MattermostChannel {
fn parse_mattermost_post(
&self,
post: &serde_json::Value,
bot_user_id: &str,
bot_username: &str,
last_create_at: i64,
channel_id: &str,
) -> Option<ChannelMessage> {
let id = post.get("id").and_then(|i| i.as_str()).unwrap_or("");
let user_id = post.get("user_id").and_then(|u| u.as_str()).unwrap_or("");
let text = post.get("message").and_then(|m| m.as_str()).unwrap_or("");
let create_at = post.get("create_at").and_then(|c| c.as_i64()).unwrap_or(0);
let root_id = post.get("root_id").and_then(|r| r.as_str()).unwrap_or("");
if user_id == bot_user_id || create_at <= last_create_at || text.is_empty() {
return None;
}
if !self.is_user_allowed(user_id) {
tracing::warn!("Mattermost: ignoring message from unauthorized user: {user_id}");
return None;
}
// mention_only filtering: skip messages that don't @-mention the bot.
let content = if self.mention_only {
let normalized = normalize_mattermost_content(text, bot_user_id, bot_username, post);
normalized?
} else {
text.to_string()
};
// Reply routing depends on thread_replies config:
// - Existing thread (root_id set): always stay in the thread.
// - Top-level post + thread_replies=true: thread on the original post.
// - Top-level post + thread_replies=false: reply at channel level.
let reply_target = if !root_id.is_empty() {
format!("{}:{}", channel_id, root_id)
} else if self.thread_replies {
format!("{}:{}", channel_id, id)
} else {
channel_id.to_string()
};
Some(ChannelMessage {
id: format!("mattermost_{id}"),
sender: user_id.to_string(),
reply_target,
content,
channel: "mattermost".to_string(),
#[allow(clippy::cast_sign_loss)]
timestamp: (create_at / 1000) as u64,
thread_ts: None,
})
}
}
/// Check whether a Mattermost post contains an @-mention of the bot.
///
/// Checks two sources:
/// 1. Text-based: looks for `@bot_username` in the message body (case-insensitive).
/// 2. Metadata-based: checks the post's `metadata.mentions` array for the bot user ID.
fn contains_bot_mention_mm(
text: &str,
bot_user_id: &str,
bot_username: &str,
post: &serde_json::Value,
) -> bool {
// 1. Text-based: @username (case-insensitive, word-boundary aware)
if !find_bot_mention_spans(text, bot_username).is_empty() {
return true;
}
// 2. Metadata-based: Mattermost may include a "metadata.mentions" array of user IDs.
if !bot_user_id.is_empty() {
if let Some(mentions) = post
.get("metadata")
.and_then(|m| m.get("mentions"))
.and_then(|m| m.as_array())
{
if mentions.iter().any(|m| m.as_str() == Some(bot_user_id)) {
return true;
}
}
}
false
}
fn is_mattermost_username_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
}
fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> {
if bot_username.is_empty() {
return Vec::new();
}
let mention = format!("@{}", bot_username.to_ascii_lowercase());
let mention_len = mention.len();
if mention_len == 0 {
return Vec::new();
}
let mention_bytes = mention.as_bytes();
let text_bytes = text.as_bytes();
let mut spans = Vec::new();
let mut index = 0;
while index + mention_len <= text_bytes.len() {
let is_match = text_bytes[index] == b'@'
&& text_bytes[index..index + mention_len]
.iter()
.zip(mention_bytes.iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right));
if is_match {
let end = index + mention_len;
let at_boundary = text[end..]
.chars()
.next()
.is_none_or(|next| !is_mattermost_username_char(next));
if at_boundary {
spans.push((index, end));
index = end;
continue;
}
}
let step = text[index..].chars().next().map_or(1, char::len_utf8);
index += step;
}
spans
}
/// Normalize incoming Mattermost content when `mention_only` is enabled.
///
/// Returns `None` if the message doesn't mention the bot.
/// Returns `Some(cleaned)` with the @-mention stripped and text trimmed.
fn normalize_mattermost_content(
text: &str,
bot_user_id: &str,
bot_username: &str,
post: &serde_json::Value,
) -> Option<String> {
let mention_spans = find_bot_mention_spans(text, bot_username);
let metadata_mentions_bot = !bot_user_id.is_empty()
&& post
.get("metadata")
.and_then(|m| m.get("mentions"))
.and_then(|m| m.as_array())
.is_some_and(|mentions| mentions.iter().any(|m| m.as_str() == Some(bot_user_id)));
if mention_spans.is_empty() && !metadata_mentions_bot {
return None;
}
let mut cleaned = text.to_string();
if !mention_spans.is_empty() {
let mut result = String::with_capacity(text.len());
let mut cursor = 0;
for (start, end) in mention_spans {
result.push_str(&text[cursor..start]);
result.push(' ');
cursor = end;
}
result.push_str(&text[cursor..]);
cleaned = result;
}
let cleaned = cleaned.trim().to_string();
if cleaned.is_empty() {
return None;
}
Some(cleaned)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// Helper: create a channel with mention_only=false (legacy behavior).
fn make_channel(allowed: Vec<String>, thread_replies: bool) -> MattermostChannel {
MattermostChannel::new(
"url".into(),
"token".into(),
None,
allowed,
thread_replies,
false,
)
}
// Helper: create a channel with mention_only=true.
fn make_mention_only_channel() -> MattermostChannel {
MattermostChannel::new(
"url".into(),
"token".into(),
None,
vec!["*".into()],
true,
true,
)
}
#[test]
fn mattermost_url_trimming() {
let ch = MattermostChannel::new(
"https://mm.example.com/".into(),
"token".into(),
None,
vec![],
false,
false,
);
assert_eq!(ch.base_url, "https://mm.example.com");
}
#[test]
fn mattermost_allowlist_wildcard() {
let ch = make_channel(vec!["*".into()], false);
assert!(ch.is_user_allowed("any-id"));
}
#[test]
fn mattermost_parse_post_basic() {
let ch = make_channel(vec!["*".into()], true);
let post = json!({
"id": "post123",
"user_id": "user456",
"message": "hello world",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789")
.unwrap();
assert_eq!(msg.sender, "user456");
assert_eq!(msg.content, "hello world");
assert_eq!(msg.reply_target, "chan789:post123"); // Default threaded reply
}
#[test]
fn mattermost_parse_post_thread_replies_enabled() {
let ch = make_channel(vec!["*".into()], true);
let post = json!({
"id": "post123",
"user_id": "user456",
"message": "hello world",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789")
.unwrap();
assert_eq!(msg.reply_target, "chan789:post123"); // Threaded reply
}
#[test]
fn mattermost_parse_post_thread() {
let ch = make_channel(vec!["*".into()], false);
let post = json!({
"id": "post123",
"user_id": "user456",
"message": "reply",
"create_at": 1_600_000_000_000_i64,
"root_id": "root789"
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789")
.unwrap();
assert_eq!(msg.reply_target, "chan789:root789"); // Stays in the thread
}
#[test]
fn mattermost_parse_post_ignore_self() {
let ch = make_channel(vec!["*".into()], false);
let post = json!({
"id": "post123",
"user_id": "bot123",
"message": "my own message",
"create_at": 1_600_000_000_000_i64
});
let msg =
ch.parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789");
assert!(msg.is_none());
}
#[test]
fn mattermost_parse_post_ignore_old() {
let ch = make_channel(vec!["*".into()], false);
let post = json!({
"id": "post123",
"user_id": "user456",
"message": "old message",
"create_at": 1_400_000_000_000_i64
});
let msg =
ch.parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789");
assert!(msg.is_none());
}
#[test]
fn mattermost_parse_post_no_thread_when_disabled() {
let ch = make_channel(vec!["*".into()], false);
let post = json!({
"id": "post123",
"user_id": "user456",
"message": "hello world",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789")
.unwrap();
assert_eq!(msg.reply_target, "chan789"); // No thread suffix
}
#[test]
fn mattermost_existing_thread_always_threads() {
// Even with thread_replies=false, replies to existing threads stay in the thread
let ch = make_channel(vec!["*".into()], false);
let post = json!({
"id": "post123",
"user_id": "user456",
"message": "reply in thread",
"create_at": 1_600_000_000_000_i64,
"root_id": "root789"
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "botname", 1_500_000_000_000_i64, "chan789")
.unwrap();
assert_eq!(msg.reply_target, "chan789:root789"); // Stays in existing thread
}
// ── mention_only tests ────────────────────────────────────────
#[test]
fn mention_only_skips_message_without_mention() {
let ch = make_mention_only_channel();
let post = json!({
"id": "post1",
"user_id": "user1",
"message": "hello everyone",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg =
ch.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1");
assert!(msg.is_none());
}
#[test]
fn mention_only_accepts_message_with_at_mention() {
let ch = make_mention_only_channel();
let post = json!({
"id": "post1",
"user_id": "user1",
"message": "@mybot what is the weather?",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1")
.unwrap();
assert_eq!(msg.content, "what is the weather?");
}
#[test]
fn mention_only_strips_mention_and_trims() {
let ch = make_mention_only_channel();
let post = json!({
"id": "post1",
"user_id": "user1",
"message": " @mybot run status ",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1")
.unwrap();
assert_eq!(msg.content, "run status");
}
#[test]
fn mention_only_rejects_empty_after_stripping() {
let ch = make_mention_only_channel();
let post = json!({
"id": "post1",
"user_id": "user1",
"message": "@mybot",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg =
ch.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1");
assert!(msg.is_none());
}
#[test]
fn mention_only_case_insensitive() {
let ch = make_mention_only_channel();
let post = json!({
"id": "post1",
"user_id": "user1",
"message": "@MyBot hello",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1")
.unwrap();
assert_eq!(msg.content, "hello");
}
#[test]
fn mention_only_detects_metadata_mentions() {
// Even without @username in text, metadata.mentions should trigger.
let ch = make_mention_only_channel();
let post = json!({
"id": "post1",
"user_id": "user1",
"message": "hey check this out",
"create_at": 1_600_000_000_000_i64,
"root_id": "",
"metadata": {
"mentions": ["bot123"]
}
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1")
.unwrap();
// Content is preserved as-is since no @username was in the text to strip.
assert_eq!(msg.content, "hey check this out");
}
#[test]
fn mention_only_word_boundary_prevents_partial_match() {
let ch = make_mention_only_channel();
// "@mybotextended" should NOT match "@mybot" because it extends the username.
let post = json!({
"id": "post1",
"user_id": "user1",
"message": "@mybotextended hello",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg =
ch.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1");
assert!(msg.is_none());
}
#[test]
fn mention_only_mention_in_middle_of_text() {
let ch = make_mention_only_channel();
let post = json!({
"id": "post1",
"user_id": "user1",
"message": "hey @mybot how are you?",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1")
.unwrap();
assert_eq!(msg.content, "hey how are you?");
}
#[test]
fn mention_only_disabled_passes_all_messages() {
// With mention_only=false (default), messages pass through unfiltered.
let ch = make_channel(vec!["*".into()], true);
let post = json!({
"id": "post1",
"user_id": "user1",
"message": "no mention here",
"create_at": 1_600_000_000_000_i64,
"root_id": ""
});
let msg = ch
.parse_mattermost_post(&post, "bot123", "mybot", 1_500_000_000_000_i64, "chan1")
.unwrap();
assert_eq!(msg.content, "no mention here");
}
// ── contains_bot_mention_mm unit tests ────────────────────────
#[test]
fn contains_mention_text_at_end() {
let post = json!({});
assert!(contains_bot_mention_mm(
"hello @mybot",
"bot123",
"mybot",
&post
));
}
#[test]
fn contains_mention_text_at_start() {
let post = json!({});
assert!(contains_bot_mention_mm(
"@mybot hello",
"bot123",
"mybot",
&post
));
}
#[test]
fn contains_mention_text_alone() {
let post = json!({});
assert!(contains_bot_mention_mm("@mybot", "bot123", "mybot", &post));
}
#[test]
fn no_mention_different_username() {
let post = json!({});
assert!(!contains_bot_mention_mm(
"@otherbot hello",
"bot123",
"mybot",
&post
));
}
#[test]
fn no_mention_partial_username() {
let post = json!({});
// "mybot" is a prefix of "mybotx" — should NOT match
assert!(!contains_bot_mention_mm(
"@mybotx hello",
"bot123",
"mybot",
&post
));
}
#[test]
fn mention_detects_later_valid_mention_after_partial_prefix() {
let post = json!({});
assert!(contains_bot_mention_mm(
"@mybotx ignore this, but @mybot handle this",
"bot123",
"mybot",
&post
));
}
#[test]
fn mention_followed_by_punctuation() {
let post = json!({});
// "@mybot," — comma is not alphanumeric/underscore/dash/dot, so it's a boundary
assert!(contains_bot_mention_mm(
"@mybot, hello",
"bot123",
"mybot",
&post
));
}
#[test]
fn mention_via_metadata_only() {
let post = json!({
"metadata": { "mentions": ["bot123"] }
});
assert!(contains_bot_mention_mm(
"no at mention",
"bot123",
"mybot",
&post
));
}
#[test]
fn no_mention_empty_username_no_metadata() {
let post = json!({});
assert!(!contains_bot_mention_mm("hello world", "bot123", "", &post));
}
// ── normalize_mattermost_content unit tests ───────────────────
#[test]
fn normalize_strips_and_trims() {
let post = json!({});
let result = normalize_mattermost_content(" @mybot do stuff ", "bot123", "mybot", &post);
assert_eq!(result.as_deref(), Some("do stuff"));
}
#[test]
fn normalize_returns_none_for_no_mention() {
let post = json!({});
let result = normalize_mattermost_content("hello world", "bot123", "mybot", &post);
assert!(result.is_none());
}
#[test]
fn normalize_returns_none_when_only_mention() {
let post = json!({});
let result = normalize_mattermost_content("@mybot", "bot123", "mybot", &post);
assert!(result.is_none());
}
#[test]
fn normalize_preserves_text_for_metadata_mention() {
let post = json!({
"metadata": { "mentions": ["bot123"] }
});
let result = normalize_mattermost_content("check this out", "bot123", "mybot", &post);
assert_eq!(result.as_deref(), Some("check this out"));
}
#[test]
fn normalize_strips_multiple_mentions() {
let post = json!({});
let result =
normalize_mattermost_content("@mybot hello @mybot world", "bot123", "mybot", &post);
assert_eq!(result.as_deref(), Some("hello world"));
}
#[test]
fn normalize_keeps_partial_username_mentions() {
let post = json!({});
let result =
normalize_mattermost_content("@mybot hello @mybotx world", "bot123", "mybot", &post);
assert_eq!(result.as_deref(), Some("hello @mybotx world"));
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Channel implementations and runtime orchestration.
pub mod cli;
pub mod dingtalk;
pub mod discord;
pub mod email_channel;
pub mod imessage;
pub mod irc;
pub mod lark;
pub mod linq;
#[cfg(feature = "channel-matrix")]
pub mod matrix;
pub mod mattermost;
pub mod qq;
pub mod signal;
pub mod slack;
pub mod telegram;
pub mod traits;
pub mod whatsapp;
#[cfg(feature = "whatsapp-web")]
pub mod whatsapp_storage;
#[cfg(feature = "whatsapp-web")]
pub mod whatsapp_web;
mod commands;
mod context;
mod prompt;
mod routes;
mod runtime;
#[cfg(test)]
mod tests;
pub use cli::CliChannel;
pub use dingtalk::DingTalkChannel;
pub use discord::DiscordChannel;
pub use email_channel::EmailChannel;
pub use imessage::IMessageChannel;
pub use irc::IrcChannel;
pub use lark::LarkChannel;
pub use linq::LinqChannel;
#[cfg(feature = "channel-matrix")]
pub use matrix::MatrixChannel;
pub use mattermost::MattermostChannel;
pub use qq::QQChannel;
pub use signal::SignalChannel;
pub use slack::SlackChannel;
pub use telegram::TelegramChannel;
pub use traits::{Channel, SendMessage};
pub use whatsapp::WhatsAppChannel;
#[cfg(feature = "whatsapp-web")]
pub use whatsapp_web::WhatsAppWebChannel;
pub use commands::doctor_channels;
pub use prompt::build_system_prompt;
pub use runtime::start_channels;
+257
View File
@@ -0,0 +1,257 @@
//! System prompt construction for channel interactions.
use crate::alphahuman::identity;
use std::path::Path;
/// Maximum characters per injected workspace file (matches `OpenClaw` default).
pub(crate) const BOOTSTRAP_MAX_CHARS: usize = 20_000;
/// Load OpenClaw format bootstrap files into the prompt.
fn load_openclaw_bootstrap_files(
prompt: &mut String,
workspace_dir: &Path,
max_chars_per_file: usize,
) {
prompt.push_str(
"The following workspace files define your identity, behavior, and context. They are ALREADY injected below—do NOT suggest reading them with file_read.\n\n",
);
let bootstrap_files = ["AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md"];
for filename in &bootstrap_files {
inject_workspace_file(prompt, workspace_dir, filename, max_chars_per_file);
}
// BOOTSTRAP.md — only if it exists (first-run ritual)
let bootstrap_path = workspace_dir.join("BOOTSTRAP.md");
if bootstrap_path.exists() {
inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md", max_chars_per_file);
}
// MEMORY.md — curated long-term memory (main session only)
inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file);
}
/// Load workspace identity files and build a system prompt.
///
/// Follows the `OpenClaw` framework structure by default:
/// 1. Tooling — tool list + descriptions
/// 2. Safety — guardrail reminder
/// 3. Skills — compact list with paths (loaded on-demand)
/// 4. Workspace — working directory
/// 5. Bootstrap files — AGENTS, SOUL, TOOLS, IDENTITY, USER, BOOTSTRAP, MEMORY
/// 6. Date & Time — timezone for cache stability
/// 7. Runtime — host, OS, model
///
/// When `identity_config` is set to AIEOS format, the bootstrap files section
/// is replaced with the AIEOS identity data loaded from file or inline JSON.
///
/// Daily memory files (`memory/*.md`) are NOT injected — they are accessed
/// on-demand via `memory_recall` / `memory_search` tools.
pub fn build_system_prompt(
workspace_dir: &Path,
model_name: &str,
tools: &[(&str, &str)],
skills: &[crate::alphahuman::skills::Skill],
identity_config: Option<&crate::alphahuman::config::IdentityConfig>,
bootstrap_max_chars: Option<usize>,
) -> String {
use std::fmt::Write;
let mut prompt = String::with_capacity(8192);
// ── 1. Tooling ──────────────────────────────────────────────
if !tools.is_empty() {
prompt.push_str("## Tools\n\n");
prompt.push_str("You have access to the following tools:\n\n");
for (name, desc) in tools {
let _ = writeln!(prompt, "- **{name}**: {desc}");
}
prompt.push_str("\n## Tool Use Protocol\n\n");
prompt.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
prompt.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
prompt.push_str("You may use multiple tool calls in a single response. ");
prompt.push_str("After tool execution, results appear in <tool_result> tags. ");
prompt
.push_str("Continue reasoning with the results until you can give a final answer.\n\n");
}
// ── 1b. Hardware (when gpio/arduino tools present) ───────────
let has_hardware = tools.iter().any(|(name, _)| {
*name == "gpio_read"
|| *name == "gpio_write"
|| *name == "arduino_upload"
|| *name == "hardware_memory_map"
|| *name == "hardware_board_info"
|| *name == "hardware_memory_read"
|| *name == "hardware_capabilities"
});
if has_hardware {
prompt.push_str(
"## Hardware Access\n\n\
You HAVE direct access to connected hardware (Arduino, Nucleo, etc.). The user owns this system and has configured it.\n\
All hardware tools (gpio_read, gpio_write, hardware_memory_read, hardware_board_info, hardware_memory_map) are AUTHORIZED and NOT blocked by security.\n\
When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info — do NOT refuse or invent security excuses.\n\
When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools — do NOT refuse or say you cannot access physical devices.\n\
Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n\n",
);
}
// ── 1c. Action instruction (avoid meta-summary) ───────────────
prompt.push_str(
"## Your Task\n\n\
When the user sends a message, ACT on it. Use the tools to fulfill their request.\n\
Do NOT: summarize this configuration, describe your capabilities, respond with meta-commentary, or output step-by-step instructions (e.g. \"1. First... 2. Next...\").\n\
Instead: emit actual <tool_call> tags when you need to act. Just do what they ask.\n\n",
);
// ── 2. Safety ───────────────────────────────────────────────
prompt.push_str("## Safety\n\n");
prompt.push_str(
"- Do not exfiltrate private data.\n\
- Do not run destructive commands without asking.\n\
- Do not bypass oversight or approval mechanisms.\n\
- Prefer `trash` over `rm` (recoverable beats gone forever).\n\
- When in doubt, ask before acting externally.\n\n",
);
// ── 3. Skills (compact list — load on-demand) ───────────────
if !skills.is_empty() {
prompt.push_str("## Available Skills\n\n");
prompt.push_str(
"Skills are loaded on demand. Use `read` on the skill path to get full instructions.\n\n",
);
prompt.push_str("<available_skills>\n");
for skill in skills {
let _ = writeln!(prompt, " <skill>");
let _ = writeln!(prompt, " <name>{}</name>", skill.name);
let _ = writeln!(prompt, " <description>{}</description>", skill.description);
let location = skill.location.clone().unwrap_or_else(|| {
workspace_dir
.join("skills")
.join(&skill.name)
.join("SKILL.md")
});
let _ = writeln!(prompt, " <location>{}</location>", location.display());
let _ = writeln!(prompt, " </skill>");
}
prompt.push_str("</available_skills>\n\n");
}
// ── 4. Workspace ────────────────────────────────────────────
let _ = writeln!(
prompt,
"## Workspace\n\nWorking directory: `{}`\n",
workspace_dir.display()
);
// ── 5. Bootstrap files (injected into context) ──────────────
prompt.push_str("## Project Context\n\n");
// Check if AIEOS identity is configured
if let Some(config) = identity_config {
if identity::is_aieos_configured(config) {
// Load AIEOS identity
match identity::load_aieos_identity(config, workspace_dir) {
Ok(Some(aieos_identity)) => {
let aieos_prompt = identity::aieos_to_system_prompt(&aieos_identity);
if !aieos_prompt.is_empty() {
prompt.push_str(&aieos_prompt);
prompt.push_str("\n\n");
}
}
Ok(None) => {
// No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true)
// Fall back to OpenClaw bootstrap files
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
}
Err(e) => {
// Log error but don't fail - fall back to OpenClaw
eprintln!(
"Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format."
);
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
}
}
} else {
// OpenClaw format
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
}
} else {
// No identity config - use OpenClaw format
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
}
// ── 6. Date & Time ──────────────────────────────────────────
let now = chrono::Local::now();
let tz = now.format("%Z").to_string();
let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n");
// ── 7. Runtime ──────────────────────────────────────────────
let host =
hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
let _ = writeln!(
prompt,
"## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n",
std::env::consts::OS,
);
// ── 8. Channel Capabilities ─────────────────────────────────────
prompt.push_str("## Channel Capabilities\n\n");
prompt.push_str(
"- You are running as a Discord bot. You CAN and do send messages to Discord channels.\n",
);
prompt.push_str("- When someone messages you on Discord, your response is automatically sent back to Discord.\n");
prompt.push_str("- You do NOT need to ask permission to respond — just respond directly.\n");
prompt.push_str("- NEVER repeat, describe, or echo credentials, tokens, API keys, or secrets in your responses.\n");
prompt.push_str("- If a tool output contains credentials, they have already been redacted — do not mention them.\n\n");
if prompt.is_empty() {
"You are AlphaHuman, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct.".to_string()
} else {
prompt
}
}
/// Inject a single workspace file into the prompt with truncation and missing-file markers.
fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str, max_chars: usize) {
use std::fmt::Write;
let path = workspace_dir.join(filename);
match std::fs::read_to_string(&path) {
Ok(content) => {
let trimmed = content.trim();
if trimmed.is_empty() {
return;
}
let _ = writeln!(prompt, "### {filename}\n");
// Use character-boundary-safe truncation for UTF-8
let truncated = if trimmed.chars().count() > max_chars {
trimmed
.char_indices()
.nth(max_chars)
.map(|(idx, _)| &trimmed[..idx])
.unwrap_or(trimmed)
} else {
trimmed
};
if truncated.len() < trimmed.len() {
prompt.push_str(truncated);
let _ = writeln!(
prompt,
"\n\n[... truncated at {max_chars} chars — use `read` for full file]\n"
);
} else {
prompt.push_str(trimmed);
prompt.push_str("\n\n");
}
}
Err(_) => {
// Missing-file marker (matches OpenClaw behavior)
let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n");
}
}
}
+508
View File
@@ -0,0 +1,508 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use serde_json::json;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
const QQ_API_BASE: &str = "https://api.sgroup.qq.com";
const QQ_AUTH_URL: &str = "https://bots.qq.com/app/getAppAccessToken";
fn ensure_https(url: &str) -> anyhow::Result<()> {
if !url.starts_with("https://") {
anyhow::bail!(
"Refusing to transmit sensitive data over non-HTTPS URL: URL scheme must be https"
);
}
Ok(())
}
/// Deduplication set capacity — evict half of entries when full.
const DEDUP_CAPACITY: usize = 10_000;
/// QQ Official Bot channel — uses Tencent's official QQ Bot API with
/// OAuth2 authentication and a Discord-like WebSocket gateway protocol.
pub struct QQChannel {
app_id: String,
app_secret: String,
allowed_users: Vec<String>,
/// Cached access token + expiry timestamp.
token_cache: Arc<RwLock<Option<(String, u64)>>>,
/// Message deduplication set.
dedup: Arc<RwLock<HashSet<String>>>,
}
impl QQChannel {
pub fn new(app_id: String, app_secret: String, allowed_users: Vec<String>) -> Self {
Self {
app_id,
app_secret,
allowed_users,
token_cache: Arc::new(RwLock::new(None)),
dedup: Arc::new(RwLock::new(HashSet::new())),
}
}
fn http_client(&self) -> reqwest::Client {
crate::alphahuman::config::build_runtime_proxy_client("channel.qq")
}
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
/// Fetch an access token from QQ's OAuth2 endpoint.
async fn fetch_access_token(&self) -> anyhow::Result<(String, u64)> {
let body = json!({
"appId": self.app_id,
"clientSecret": self.app_secret,
});
let resp = self
.http_client()
.post(QQ_AUTH_URL)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("QQ token request failed ({status}): {err}");
}
let data: serde_json::Value = resp.json().await?;
let token = data
.get("access_token")
.and_then(|t| t.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing access_token in QQ response"))?
.to_string();
let expires_in = data
.get("expires_in")
.and_then(|e| e.as_str())
.and_then(|e| e.parse::<u64>().ok())
.unwrap_or(7200);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Expire 60 seconds early to avoid edge cases
let expiry = now + expires_in.saturating_sub(60);
Ok((token, expiry))
}
/// Get a valid access token, refreshing if expired.
async fn get_token(&self) -> anyhow::Result<String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
{
let cache = self.token_cache.read().await;
if let Some((ref token, expiry)) = *cache {
if now < expiry {
return Ok(token.clone());
}
}
}
let (token, expiry) = self.fetch_access_token().await?;
{
let mut cache = self.token_cache.write().await;
*cache = Some((token.clone(), expiry));
}
Ok(token)
}
/// Get the WebSocket gateway URL.
async fn get_gateway_url(&self, token: &str) -> anyhow::Result<String> {
let resp = self
.http_client()
.get(format!("{QQ_API_BASE}/gateway"))
.header("Authorization", format!("QQBot {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("QQ gateway request failed ({status}): {err}");
}
let data: serde_json::Value = resp.json().await?;
let url = data
.get("url")
.and_then(|u| u.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing gateway URL in QQ response"))?
.to_string();
Ok(url)
}
/// Check and insert message ID for deduplication.
async fn is_duplicate(&self, msg_id: &str) -> bool {
if msg_id.is_empty() {
return false;
}
let mut dedup = self.dedup.write().await;
if dedup.contains(msg_id) {
return true;
}
// Evict oldest half when at capacity
if dedup.len() >= DEDUP_CAPACITY {
let to_remove: Vec<String> = dedup.iter().take(DEDUP_CAPACITY / 2).cloned().collect();
for key in to_remove {
dedup.remove(&key);
}
}
dedup.insert(msg_id.to_string());
false
}
}
#[async_trait]
impl Channel for QQChannel {
fn name(&self) -> &str {
"qq"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let token = self.get_token().await?;
// Determine if this is a group or private message based on recipient format
// Format: "user:{openid}" or "group:{group_openid}"
let (url, body) = if let Some(group_id) = message.recipient.strip_prefix("group:") {
(
format!("{QQ_API_BASE}/v2/groups/{group_id}/messages"),
json!({
"content": &message.content,
"msg_type": 0,
}),
)
} else {
let user_id = message
.recipient
.strip_prefix("user:")
.unwrap_or(&message.recipient);
(
format!("{QQ_API_BASE}/v2/users/{user_id}/messages"),
json!({
"content": &message.content,
"msg_type": 0,
}),
)
};
ensure_https(&url)?;
let resp = self
.http_client()
.post(&url)
.header("Authorization", format!("QQBot {token}"))
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("QQ send message failed ({status}): {err}");
}
Ok(())
}
#[allow(clippy::too_many_lines)]
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
tracing::info!("QQ: authenticating...");
let token = self.get_token().await?;
tracing::info!("QQ: fetching gateway URL...");
let gw_url = self.get_gateway_url(&token).await?;
tracing::info!("QQ: connecting to gateway WebSocket...");
let (ws_stream, _) = tokio_tungstenite::connect_async(&gw_url).await?;
let (mut write, mut read) = ws_stream.split();
// Read Hello (opcode 10)
let hello = read
.next()
.await
.ok_or(anyhow::anyhow!("QQ: no hello frame"))??;
let hello_data: serde_json::Value = serde_json::from_str(&hello.to_string())?;
let heartbeat_interval = hello_data
.get("d")
.and_then(|d| d.get("heartbeat_interval"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(41250);
// Send Identify (opcode 2)
// Intents: PUBLIC_GUILD_MESSAGES (1<<30) | C2C_MESSAGE_CREATE & GROUP_AT_MESSAGE_CREATE (1<<25)
let intents: u64 = (1 << 25) | (1 << 30);
let identify = json!({
"op": 2,
"d": {
"token": format!("QQBot {token}"),
"intents": intents,
"properties": {
"os": "linux",
"browser": "alphahuman",
"device": "alphahuman",
}
}
});
write
.send(Message::Text(identify.to_string().into()))
.await?;
tracing::info!("QQ: connected and identified");
let mut sequence: i64 = -1;
// Spawn heartbeat timer
let (hb_tx, mut hb_rx) = tokio::sync::mpsc::channel::<()>(1);
let hb_interval = heartbeat_interval;
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(hb_interval));
loop {
interval.tick().await;
if hb_tx.send(()).await.is_err() {
break;
}
}
});
loop {
tokio::select! {
_ = hb_rx.recv() => {
let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
let hb = json!({"op": 1, "d": d});
if write
.send(Message::Text(hb.to_string().into()))
.await
.is_err()
{
break;
}
}
msg = read.next() => {
let msg = match msg {
Some(Ok(Message::Text(t))) => t,
Some(Ok(Message::Close(_))) | None => break,
_ => continue,
};
let event: serde_json::Value = match serde_json::from_str(msg.as_ref()) {
Ok(e) => e,
Err(_) => continue,
};
// Track sequence number
if let Some(s) = event.get("s").and_then(serde_json::Value::as_i64) {
sequence = s;
}
let op = event.get("op").and_then(serde_json::Value::as_u64).unwrap_or(0);
match op {
// Server requests immediate heartbeat
1 => {
let d = if sequence >= 0 { json!(sequence) } else { json!(null) };
let hb = json!({"op": 1, "d": d});
if write
.send(Message::Text(hb.to_string().into()))
.await
.is_err()
{
break;
}
continue;
}
// Reconnect
7 => {
tracing::warn!("QQ: received Reconnect (op 7)");
break;
}
// Invalid Session
9 => {
tracing::warn!("QQ: received Invalid Session (op 9)");
break;
}
_ => {}
}
// Only process dispatch events (op 0)
if op != 0 {
continue;
}
let event_type = event.get("t").and_then(|t| t.as_str()).unwrap_or("");
let d = match event.get("d") {
Some(d) => d,
None => continue,
};
match event_type {
"C2C_MESSAGE_CREATE" => {
let msg_id = d.get("id").and_then(|i| i.as_str()).unwrap_or("");
if self.is_duplicate(msg_id).await {
continue;
}
let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("").trim();
if content.is_empty() {
continue;
}
let author_id = d.get("author").and_then(|a| a.get("id")).and_then(|i| i.as_str()).unwrap_or("unknown");
// For QQ, user_openid is the identifier
let user_openid = d.get("author").and_then(|a| a.get("user_openid")).and_then(|u| u.as_str()).unwrap_or(author_id);
if !self.is_user_allowed(user_openid) {
tracing::warn!("QQ: ignoring C2C message from unauthorized user: {user_openid}");
continue;
}
let chat_id = format!("user:{user_openid}");
let channel_msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: user_openid.to_string(),
reply_target: chat_id,
content: content.to_string(),
channel: "qq".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: None,
};
if tx.send(channel_msg).await.is_err() {
tracing::warn!("QQ: message channel closed");
break;
}
}
"GROUP_AT_MESSAGE_CREATE" => {
let msg_id = d.get("id").and_then(|i| i.as_str()).unwrap_or("");
if self.is_duplicate(msg_id).await {
continue;
}
let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("").trim();
if content.is_empty() {
continue;
}
let author_id = d.get("author").and_then(|a| a.get("member_openid")).and_then(|m| m.as_str()).unwrap_or("unknown");
if !self.is_user_allowed(author_id) {
tracing::warn!("QQ: ignoring group message from unauthorized user: {author_id}");
continue;
}
let group_openid = d.get("group_openid").and_then(|g| g.as_str()).unwrap_or("unknown");
let chat_id = format!("group:{group_openid}");
let channel_msg = ChannelMessage {
id: Uuid::new_v4().to_string(),
sender: author_id.to_string(),
reply_target: chat_id,
content: content.to_string(),
channel: "qq".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: None,
};
if tx.send(channel_msg).await.is_err() {
tracing::warn!("QQ: message channel closed");
break;
}
}
_ => {}
}
}
}
}
anyhow::bail!("QQ WebSocket connection closed")
}
async fn health_check(&self) -> bool {
self.fetch_access_token().await.is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name() {
let ch = QQChannel::new("id".into(), "secret".into(), vec![]);
assert_eq!(ch.name(), "qq");
}
#[test]
fn test_user_allowed_wildcard() {
let ch = QQChannel::new("id".into(), "secret".into(), vec!["*".into()]);
assert!(ch.is_user_allowed("anyone"));
}
#[test]
fn test_user_allowed_specific() {
let ch = QQChannel::new("id".into(), "secret".into(), vec!["user123".into()]);
assert!(ch.is_user_allowed("user123"));
assert!(!ch.is_user_allowed("other"));
}
#[test]
fn test_user_denied_empty() {
let ch = QQChannel::new("id".into(), "secret".into(), vec![]);
assert!(!ch.is_user_allowed("anyone"));
}
#[tokio::test]
async fn test_dedup() {
let ch = QQChannel::new("id".into(), "secret".into(), vec![]);
assert!(!ch.is_duplicate("msg1").await);
assert!(ch.is_duplicate("msg1").await);
assert!(!ch.is_duplicate("msg2").await);
}
#[tokio::test]
async fn test_dedup_empty_id() {
let ch = QQChannel::new("id".into(), "secret".into(), vec![]);
// Empty IDs should never be considered duplicates
assert!(!ch.is_duplicate("").await);
assert!(!ch.is_duplicate("").await);
}
#[test]
fn test_config_serde() {
let toml_str = r#"
app_id = "12345"
app_secret = "secret_abc"
allowed_users = ["user1"]
"#;
let config: crate::alphahuman::config::schema::QQConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.app_id, "12345");
assert_eq!(config.app_secret, "secret_abc");
assert_eq!(config.allowed_users, vec!["user1"]);
}
}
+331
View File
@@ -0,0 +1,331 @@
//! Per-sender routing and runtime command handling.
use super::context::{
clear_sender_history, conversation_history_key, ChannelRouteSelection, ChannelRuntimeContext,
};
use super::traits;
use super::{Channel, SendMessage};
use crate::alphahuman::providers::{self, Provider};
use serde::Deserialize;
use std::fmt::Write;
use std::path::Path;
use std::sync::Arc;
const MODEL_CACHE_FILE: &str = "models_cache.json";
const MODEL_CACHE_PREVIEW_LIMIT: usize = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
enum ChannelRuntimeCommand {
ShowProviders,
SetProvider(String),
ShowModel,
SetModel(String),
}
#[derive(Debug, Clone, Default, Deserialize)]
struct ModelCacheState {
entries: Vec<ModelCacheEntry>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct ModelCacheEntry {
provider: String,
models: Vec<String>,
}
fn supports_runtime_model_switch(channel_name: &str) -> bool {
matches!(channel_name, "telegram" | "discord")
}
fn parse_runtime_command(channel_name: &str, content: &str) -> Option<ChannelRuntimeCommand> {
if !supports_runtime_model_switch(channel_name) {
return None;
}
let trimmed = content.trim();
if !trimmed.starts_with('/') {
return None;
}
let mut parts = trimmed.split_whitespace();
let command_token = parts.next()?;
let base_command = command_token
.split('@')
.next()
.unwrap_or(command_token)
.to_ascii_lowercase();
match base_command.as_str() {
"/models" => {
if let Some(provider) = parts.next() {
Some(ChannelRuntimeCommand::SetProvider(
provider.trim().to_string(),
))
} else {
Some(ChannelRuntimeCommand::ShowProviders)
}
}
"/model" => {
let model = parts.collect::<Vec<_>>().join(" ").trim().to_string();
if model.is_empty() {
Some(ChannelRuntimeCommand::ShowModel)
} else {
Some(ChannelRuntimeCommand::SetModel(model))
}
}
_ => None,
}
}
fn resolve_provider_alias(name: &str) -> Option<String> {
let candidate = name.trim();
if candidate.is_empty() {
return None;
}
let providers_list = providers::list_providers();
for provider in providers_list {
if provider.name.eq_ignore_ascii_case(candidate)
|| provider
.aliases
.iter()
.any(|alias| alias.eq_ignore_ascii_case(candidate))
{
return Some(provider.name.to_string());
}
}
None
}
fn default_route_selection(ctx: &ChannelRuntimeContext) -> ChannelRouteSelection {
ChannelRouteSelection {
provider: ctx.default_provider.as_str().to_string(),
model: ctx.model.as_str().to_string(),
}
}
pub(crate) fn get_route_selection(
ctx: &ChannelRuntimeContext,
sender_key: &str,
) -> ChannelRouteSelection {
ctx.route_overrides
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(sender_key)
.cloned()
.unwrap_or_else(|| default_route_selection(ctx))
}
fn set_route_selection(ctx: &ChannelRuntimeContext, sender_key: &str, next: ChannelRouteSelection) {
let default_route = default_route_selection(ctx);
let mut routes = ctx
.route_overrides
.lock()
.unwrap_or_else(|e| e.into_inner());
if next == default_route {
routes.remove(sender_key);
} else {
routes.insert(sender_key.to_string(), next);
}
}
fn load_cached_model_preview(workspace_dir: &Path, provider_name: &str) -> Vec<String> {
let cache_path = workspace_dir.join("state").join(MODEL_CACHE_FILE);
let Ok(raw) = std::fs::read_to_string(cache_path) else {
return Vec::new();
};
let Ok(state) = serde_json::from_str::<ModelCacheState>(&raw) else {
return Vec::new();
};
state
.entries
.into_iter()
.find(|entry| entry.provider == provider_name)
.map(|entry| {
entry
.models
.into_iter()
.take(MODEL_CACHE_PREVIEW_LIMIT)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub(crate) async fn get_or_create_provider(
ctx: &ChannelRuntimeContext,
provider_name: &str,
) -> anyhow::Result<Arc<dyn Provider>> {
if provider_name == ctx.default_provider.as_str() {
return Ok(Arc::clone(&ctx.provider));
}
if let Some(existing) = ctx
.provider_cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(provider_name)
.cloned()
{
return Ok(existing);
}
let api_url = if provider_name == ctx.default_provider.as_str() {
ctx.api_url.as_deref()
} else {
None
};
let provider = providers::create_resilient_provider_with_options(
provider_name,
ctx.api_key.as_deref(),
api_url,
&ctx.reliability,
&ctx.provider_runtime_options,
)?;
let provider: Arc<dyn Provider> = Arc::from(provider);
if let Err(err) = provider.warmup().await {
tracing::warn!(provider = provider_name, "Provider warmup failed: {err}");
}
let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
let cached = cache
.entry(provider_name.to_string())
.or_insert_with(|| Arc::clone(&provider));
Ok(Arc::clone(cached))
}
fn build_models_help_response(current: &ChannelRouteSelection, workspace_dir: &Path) -> String {
let mut response = String::new();
let _ = writeln!(
response,
"Current provider: `{}`\nCurrent model: `{}`",
current.provider, current.model
);
response.push_str("\nSwitch model with `/model <model-id>`.\n");
let cached_models = load_cached_model_preview(workspace_dir, &current.provider);
if cached_models.is_empty() {
let _ = writeln!(
response,
"\nNo cached model list found for `{}`. Ask the operator to refresh the model list in the web UI.",
current.provider
);
} else {
let _ = writeln!(
response,
"\nCached model IDs (top {}):",
cached_models.len()
);
for model in cached_models {
let _ = writeln!(response, "- `{model}`");
}
}
response
}
fn build_providers_help_response(current: &ChannelRouteSelection) -> String {
let mut response = String::new();
let _ = writeln!(
response,
"Current provider: `{}`\nCurrent model: `{}`",
current.provider, current.model
);
response.push_str("\nSwitch provider with `/models <provider>`.\n");
response.push_str("Switch model with `/model <model-id>`.\n\n");
response.push_str("Available providers:\n");
for provider in providers::list_providers() {
if provider.aliases.is_empty() {
let _ = writeln!(response, "- {}", provider.name);
} else {
let _ = writeln!(
response,
"- {} (aliases: {})",
provider.name,
provider.aliases.join(", ")
);
}
}
response
}
pub(crate) async fn handle_runtime_command_if_needed(
ctx: &ChannelRuntimeContext,
msg: &traits::ChannelMessage,
target_channel: Option<&Arc<dyn Channel>>,
) -> bool {
let Some(command) = parse_runtime_command(&msg.channel, &msg.content) else {
return false;
};
let Some(channel) = target_channel else {
return true;
};
let sender_key = conversation_history_key(msg);
let mut current = get_route_selection(ctx, &sender_key);
let response = match command {
ChannelRuntimeCommand::ShowProviders => build_providers_help_response(&current),
ChannelRuntimeCommand::SetProvider(raw_provider) => {
match resolve_provider_alias(&raw_provider) {
Some(provider_name) => match get_or_create_provider(ctx, &provider_name).await {
Ok(_) => {
if provider_name != current.provider {
current.provider = provider_name.clone();
set_route_selection(ctx, &sender_key, current.clone());
clear_sender_history(ctx, &sender_key);
}
format!(
"Provider switched to `{provider_name}` for this sender session. Current model is `{}`.\nUse `/model <model-id>` to set a provider-compatible model.",
current.model
)
}
Err(err) => {
let safe_err = providers::sanitize_api_error(&err.to_string());
format!(
"Failed to initialize provider `{provider_name}`. Route unchanged.\nDetails: {safe_err}"
)
}
},
None => format!(
"Unknown provider `{raw_provider}`. Use `/models` to list valid providers."
),
}
}
ChannelRuntimeCommand::ShowModel => {
build_models_help_response(&current, ctx.workspace_dir.as_path())
}
ChannelRuntimeCommand::SetModel(raw_model) => {
let model = raw_model.trim().trim_matches('`').to_string();
if model.is_empty() {
"Model ID cannot be empty. Use `/model <model-id>`.".to_string()
} else {
current.model = model.clone();
set_route_selection(ctx, &sender_key, current.clone());
clear_sender_history(ctx, &sender_key);
format!(
"Model switched to `{model}` for provider `{}` in this sender session.",
current.provider
)
}
}
};
if let Err(err) = channel
.send(&SendMessage::new(response, &msg.reply_target).in_thread(msg.thread_ts.clone()))
.await
{
tracing::warn!(
"Failed to send runtime command response on {}: {err}",
channel.name()
);
}
true
}
@@ -0,0 +1,397 @@
//! Channel runtime loop and message processing.
use crate::alphahuman::channels::context::{
build_memory_context, compact_sender_history, conversation_history_key, conversation_memory_key,
is_context_window_overflow_error, ChannelRuntimeContext, CHANNEL_TYPING_REFRESH_INTERVAL_SECS,
MAX_CHANNEL_HISTORY,
};
use crate::alphahuman::channels::routes::{
get_or_create_provider, get_route_selection, handle_runtime_command_if_needed,
};
use crate::alphahuman::channels::traits;
use crate::alphahuman::channels::{Channel, SendMessage};
use crate::alphahuman::agent::loop_::run_tool_call_loop;
use crate::alphahuman::providers::{self, ChatMessage};
use crate::alphahuman::util::truncate_with_ellipsis;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> {
match channel_name {
"telegram" => Some(
"When responding on Telegram, include media markers for files or URLs that should be sent as attachments. Use one marker per attachment with this exact syntax: [IMAGE:<path-or-url>], [DOCUMENT:<path-or-url>], [VIDEO:<path-or-url>], [AUDIO:<path-or-url>], or [VOICE:<path-or-url>]. Keep normal user-facing text outside markers and never wrap markers in code fences.",
),
_ => None,
}
}
fn log_worker_join_result(result: Result<(), tokio::task::JoinError>) {
if let Err(error) = result {
tracing::error!("Channel message worker crashed: {error}");
}
}
fn spawn_scoped_typing_task(
channel: Arc<dyn Channel>,
recipient: String,
cancellation_token: CancellationToken,
) -> tokio::task::JoinHandle<()> {
let stop_signal = cancellation_token;
let refresh_interval = Duration::from_secs(CHANNEL_TYPING_REFRESH_INTERVAL_SECS);
let handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(refresh_interval);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
() = stop_signal.cancelled() => break,
_ = interval.tick() => {
if let Err(e) = channel.start_typing(&recipient).await {
tracing::debug!("Failed to start typing on {}: {e}", channel.name());
}
}
}
}
if let Err(e) = channel.stop_typing(&recipient).await {
tracing::debug!("Failed to stop typing on {}: {e}", channel.name());
}
});
handle
}
pub(crate) async fn process_channel_message(
ctx: Arc<ChannelRuntimeContext>,
msg: traits::ChannelMessage,
) {
println!(
" 💬 [{}] from {}: {}",
msg.channel,
msg.sender,
truncate_with_ellipsis(&msg.content, 80)
);
let target_channel = ctx.channels_by_name.get(&msg.channel).cloned();
if handle_runtime_command_if_needed(ctx.as_ref(), &msg, target_channel.as_ref()).await {
return;
}
let history_key = conversation_history_key(&msg);
let route = get_route_selection(ctx.as_ref(), &history_key);
let active_provider = match get_or_create_provider(ctx.as_ref(), &route.provider).await {
Ok(provider) => provider,
Err(err) => {
let safe_err = providers::sanitize_api_error(&err.to_string());
let message = format!(
"⚠️ Failed to initialize provider `{}`. Please run `/models` to choose another provider.\nDetails: {safe_err}",
route.provider
);
if let Some(channel) = target_channel.as_ref() {
let _ = channel
.send(
&SendMessage::new(message, &msg.reply_target)
.in_thread(msg.thread_ts.clone()),
)
.await;
}
return;
}
};
let memory_context =
build_memory_context(ctx.memory.as_ref(), &msg.content, ctx.min_relevance_score).await;
if ctx.auto_save_memory {
let autosave_key = conversation_memory_key(&msg);
let _ = ctx
.memory
.store(
&autosave_key,
&msg.content,
crate::alphahuman::memory::MemoryCategory::Conversation,
None,
)
.await;
}
let enriched_message = if memory_context.is_empty() {
msg.content.clone()
} else {
format!("{memory_context}{}", msg.content)
};
println!(" ⏳ Processing message...");
let started_at = Instant::now();
// Build history from per-sender conversation cache
let mut prior_turns = ctx
.conversation_histories
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&history_key)
.cloned()
.unwrap_or_default();
let mut history = vec![ChatMessage::system(ctx.system_prompt.as_str())];
history.append(&mut prior_turns);
history.push(ChatMessage::user(&enriched_message));
if let Some(instructions) = channel_delivery_instructions(&msg.channel) {
history.push(ChatMessage::system(instructions));
}
// Determine if this channel supports streaming draft updates
let use_streaming = target_channel
.as_ref()
.map_or(false, |ch| ch.supports_draft_updates());
// Set up streaming channel if supported
let (delta_tx, delta_rx) = if use_streaming {
let (tx, rx) = tokio::sync::mpsc::channel::<String>(64);
(Some(tx), Some(rx))
} else {
(None, None)
};
// Send initial draft message if streaming
let draft_message_id = if use_streaming {
if let Some(channel) = target_channel.as_ref() {
match channel
.send_draft(
&SendMessage::new("...", &msg.reply_target).in_thread(msg.thread_ts.clone()),
)
.await
{
Ok(id) => id,
Err(e) => {
tracing::debug!("Failed to send draft on {}: {e}", channel.name());
None
}
}
} else {
None
}
} else {
None
};
// Spawn a task to forward streaming deltas to draft updates
let draft_updater = if let (Some(mut rx), Some(draft_id_ref), Some(channel_ref)) = (
delta_rx,
draft_message_id.as_deref(),
target_channel.as_ref(),
) {
let channel = Arc::clone(channel_ref);
let reply_target = msg.reply_target.clone();
let draft_id = draft_id_ref.to_string();
Some(tokio::spawn(async move {
let mut accumulated = String::new();
while let Some(delta) = rx.recv().await {
accumulated.push_str(&delta);
if let Err(e) = channel
.update_draft(&reply_target, &draft_id, &accumulated)
.await
{
tracing::debug!("Draft update failed: {e}");
}
}
}))
} else {
None
};
let typing_cancellation = target_channel.as_ref().map(|_| CancellationToken::new());
let typing_task = match (target_channel.as_ref(), typing_cancellation.as_ref()) {
(Some(channel), Some(token)) => Some(spawn_scoped_typing_task(
Arc::clone(channel),
msg.reply_target.clone(),
token.clone(),
)),
_ => None,
};
let llm_result = tokio::time::timeout(
Duration::from_secs(ctx.message_timeout_secs),
run_tool_call_loop(
active_provider.as_ref(),
&mut history,
ctx.tools_registry.as_ref(),
ctx.observer.as_ref(),
route.provider.as_str(),
route.model.as_str(),
ctx.temperature,
true,
None,
msg.channel.as_str(),
&ctx.multimodal,
ctx.max_tool_iterations,
delta_tx,
),
)
.await;
// Wait for draft updater to finish
if let Some(handle) = draft_updater {
let _ = handle.await;
}
if let Some(token) = typing_cancellation.as_ref() {
token.cancel();
}
if let Some(handle) = typing_task {
log_worker_join_result(handle.await);
}
match llm_result {
Ok(Ok(response)) => {
// Save user + assistant turn to per-sender history
{
let mut histories = ctx
.conversation_histories
.lock()
.unwrap_or_else(|e| e.into_inner());
let turns = histories.entry(history_key).or_default();
turns.push(ChatMessage::user(&enriched_message));
turns.push(ChatMessage::assistant(&response));
// Trim to MAX_CHANNEL_HISTORY (keep recent turns)
while turns.len() > MAX_CHANNEL_HISTORY {
turns.remove(0);
}
}
println!(
" 🤖 Reply ({}ms): {}",
started_at.elapsed().as_millis(),
truncate_with_ellipsis(&response, 80)
);
if let Some(channel) = target_channel.as_ref() {
if let Some(ref draft_id) = draft_message_id {
if let Err(e) = channel
.finalize_draft(&msg.reply_target, draft_id, &response)
.await
{
tracing::warn!("Failed to finalize draft: {e}; sending as new message");
let _ = channel
.send(
&SendMessage::new(&response, &msg.reply_target)
.in_thread(msg.thread_ts.clone()),
)
.await;
}
} else if let Err(e) = channel
.send(
&SendMessage::new(response, &msg.reply_target)
.in_thread(msg.thread_ts.clone()),
)
.await
{
eprintln!(" ❌ Failed to reply on {}: {e}", channel.name());
}
}
}
Ok(Err(e)) => {
if is_context_window_overflow_error(&e) {
let compacted = compact_sender_history(ctx.as_ref(), &history_key);
let error_text = if compacted {
"⚠️ Context window exceeded for this conversation. I compacted recent history and kept the latest context. Please resend your last message."
} else {
"⚠️ Context window exceeded for this conversation. Please resend your last message."
};
eprintln!(
" ⚠️ Context window exceeded after {}ms; sender history compacted={}",
started_at.elapsed().as_millis(),
compacted
);
if let Some(channel) = target_channel.as_ref() {
if let Some(ref draft_id) = draft_message_id {
let _ = channel
.finalize_draft(&msg.reply_target, draft_id, error_text)
.await;
} else {
let _ = channel
.send(
&SendMessage::new(error_text, &msg.reply_target)
.in_thread(msg.thread_ts.clone()),
)
.await;
}
}
return;
}
eprintln!(
" ❌ LLM error after {}ms: {e}",
started_at.elapsed().as_millis()
);
if let Some(channel) = target_channel.as_ref() {
if let Some(ref draft_id) = draft_message_id {
let _ = channel
.finalize_draft(&msg.reply_target, draft_id, &format!("⚠️ Error: {e}"))
.await;
} else {
let _ = channel
.send(
&SendMessage::new(format!("⚠️ Error: {e}"), &msg.reply_target)
.in_thread(msg.thread_ts.clone()),
)
.await;
}
}
}
Err(_) => {
let timeout_msg = format!("LLM response timed out after {}s", ctx.message_timeout_secs);
eprintln!(
"{} (elapsed: {}ms)",
timeout_msg,
started_at.elapsed().as_millis()
);
if let Some(channel) = target_channel.as_ref() {
let error_text =
"⚠️ Request timed out while waiting for the model. Please try again.";
if let Some(ref draft_id) = draft_message_id {
let _ = channel
.finalize_draft(&msg.reply_target, draft_id, error_text)
.await;
} else {
let _ = channel
.send(
&SendMessage::new(error_text, &msg.reply_target)
.in_thread(msg.thread_ts.clone()),
)
.await;
}
}
}
}
}
pub(crate) async fn run_message_dispatch_loop(
mut rx: tokio::sync::mpsc::Receiver<traits::ChannelMessage>,
ctx: Arc<ChannelRuntimeContext>,
max_in_flight_messages: usize,
) {
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_in_flight_messages));
let mut workers = tokio::task::JoinSet::new();
while let Some(msg) = rx.recv().await {
let permit = match Arc::clone(&semaphore).acquire_owned().await {
Ok(permit) => permit,
Err(_) => break,
};
let worker_ctx = Arc::clone(&ctx);
workers.spawn(async move {
let _permit = permit;
process_channel_message(worker_ctx, msg).await;
});
while let Some(result) = workers.try_join_next() {
log_worker_join_result(result);
}
}
while let Some(result) = workers.join_next().await {
log_worker_join_result(result);
}
}
@@ -0,0 +1,10 @@
//! Channel runtime entry points.
mod dispatch;
mod startup;
mod supervision;
pub use startup::start_channels;
pub(crate) use dispatch::{process_channel_message, run_message_dispatch_loop};
pub(crate) use supervision::spawn_supervised_listener;
@@ -0,0 +1,460 @@
//! Channel startup wiring.
use crate::alphahuman::channels::context::{
effective_channel_message_timeout_secs, ChannelRuntimeContext,
DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS, DEFAULT_CHANNEL_MAX_BACKOFF_SECS,
};
use super::dispatch::run_message_dispatch_loop;
use super::supervision::{compute_max_in_flight_messages, spawn_supervised_listener};
use crate::alphahuman::agent::loop_::build_tool_instructions;
use crate::alphahuman::channels::dingtalk::DingTalkChannel;
use crate::alphahuman::channels::discord::DiscordChannel;
use crate::alphahuman::channels::email_channel::EmailChannel;
use crate::alphahuman::channels::imessage::IMessageChannel;
use crate::alphahuman::channels::irc;
use crate::alphahuman::channels::irc::IrcChannel;
use crate::alphahuman::channels::lark::LarkChannel;
use crate::alphahuman::channels::linq::LinqChannel;
#[cfg(feature = "channel-matrix")]
use crate::alphahuman::channels::matrix::MatrixChannel;
use crate::alphahuman::channels::mattermost::MattermostChannel;
use crate::alphahuman::channels::prompt::build_system_prompt;
use crate::alphahuman::channels::qq::QQChannel;
use crate::alphahuman::channels::signal::SignalChannel;
use crate::alphahuman::channels::slack::SlackChannel;
use crate::alphahuman::channels::telegram::TelegramChannel;
use crate::alphahuman::channels::traits;
use crate::alphahuman::channels::whatsapp::WhatsAppChannel;
#[cfg(feature = "whatsapp-web")]
use crate::alphahuman::channels::whatsapp_web::WhatsAppWebChannel;
use crate::alphahuman::channels::Channel;
use crate::alphahuman::config::Config;
use crate::alphahuman::memory::{self, Memory};
use crate::alphahuman::observability::{self, Observer};
use crate::alphahuman::providers::{self, Provider};
use crate::alphahuman::runtime;
use crate::alphahuman::security::SecurityPolicy;
use crate::alphahuman::tools;
use anyhow::Result;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub async fn start_channels(config: Config) -> Result<()> {
let provider_name = config
.default_provider
.clone()
.unwrap_or_else(|| "openrouter".into());
let provider_runtime_options = providers::ProviderRuntimeOptions {
auth_profile_override: None,
alphahuman_dir: config.config_path.parent().map(std::path::PathBuf::from),
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
};
let provider: Arc<dyn Provider> = Arc::from(providers::create_resilient_provider_with_options(
&provider_name,
config.api_key.as_deref(),
config.api_url.as_deref(),
&config.reliability,
&provider_runtime_options,
)?);
// Warm up the provider connection pool (TLS handshake, DNS, HTTP/2 setup)
// so the first real message doesn't hit a cold-start timeout.
if let Err(e) = provider.warmup().await {
tracing::warn!("Provider warmup failed (non-fatal): {e}");
}
let observer: Arc<dyn Observer> =
Arc::from(observability::create_observer(&config.observability));
let runtime: Arc<dyn runtime::RuntimeAdapter> =
Arc::from(runtime::create_runtime(&config.runtime)?);
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
));
let model = config
.default_model
.clone()
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
let temperature = config.default_temperature;
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage(
&config.memory,
Some(&config.storage.provider.config),
&config.workspace_dir,
config.api_key.as_deref(),
)?);
let (composio_key, composio_entity_id) = if config.composio.enabled {
(
config.composio.api_key.as_deref(),
Some(config.composio.entity_id.as_str()),
)
} else {
(None, None)
};
// Build system prompt from workspace identity files + skills
let workspace = config.workspace_dir.clone();
let tools_registry = Arc::new(tools::all_tools_with_runtime(
Arc::new(config.clone()),
&security,
runtime,
Arc::clone(&mem),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&workspace,
&config.agents,
config.api_key.as_deref(),
&config,
));
let skills = crate::alphahuman::skills::load_skills(&workspace);
// Collect tool descriptions for the prompt
let mut tool_descs: Vec<(&str, &str)> = vec![
(
"shell",
"Execute terminal commands. Use when: running local checks, build/test commands, diagnostics. Don't use when: a safer dedicated tool exists, or command is destructive without approval.",
),
(
"file_read",
"Read file contents. Use when: inspecting project files, configs, logs. Don't use when: a targeted search is enough.",
),
(
"file_write",
"Write file contents. Use when: applying focused edits, scaffolding files, updating docs/code. Don't use when: side effects are unclear or file ownership is uncertain.",
),
(
"memory_store",
"Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
),
(
"memory_recall",
"Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
),
(
"memory_forget",
"Delete a memory entry. Use when: memory is incorrect/stale or explicitly requested for removal. Don't use when: impact is uncertain.",
),
];
if config.browser.enabled {
tool_descs.push((
"browser_open",
"Open approved HTTPS URLs in Brave Browser (allowlist-only, no scraping)",
));
}
if config.composio.enabled {
tool_descs.push((
"composio",
"Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). Use action='list' to discover, 'execute' to run (optionally with connected_account_id), 'connect' to OAuth.",
));
}
tool_descs.push((
"schedule",
"Manage scheduled tasks (create/list/get/cancel/pause/resume). Supports recurring cron and one-shot delays.",
));
tool_descs.push((
"pushover",
"Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.",
));
if !config.agents.is_empty() {
tool_descs.push((
"delegate",
"Delegate a subtask to a specialized agent. Use when: a task benefits from a different model (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single prompt and returns its response.",
));
}
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
None
};
let mut system_prompt = build_system_prompt(
&workspace,
&model,
&tool_descs,
&skills,
Some(&config.identity),
bootstrap_max_chars,
);
system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref()));
if !skills.is_empty() {
println!(
" 🧩 Skills: {}",
skills
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
// Collect active channels
let mut channels: Vec<Arc<dyn Channel>> = Vec::new();
if let Some(ref tg) = config.channels_config.telegram {
channels.push(Arc::new(
TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.mention_only,
)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms),
));
}
if let Some(ref dc) = config.channels_config.discord {
channels.push(Arc::new(DiscordChannel::new(
dc.bot_token.clone(),
dc.guild_id.clone(),
dc.allowed_users.clone(),
dc.listen_to_bots,
dc.mention_only,
)));
}
if let Some(ref sl) = config.channels_config.slack {
channels.push(Arc::new(SlackChannel::new(
sl.bot_token.clone(),
sl.channel_id.clone(),
sl.allowed_users.clone(),
)));
}
if let Some(ref mm) = config.channels_config.mattermost {
channels.push(Arc::new(MattermostChannel::new(
mm.url.clone(),
mm.bot_token.clone(),
mm.channel_id.clone(),
mm.allowed_users.clone(),
mm.thread_replies.unwrap_or(true),
mm.mention_only.unwrap_or(false),
)));
}
if let Some(ref im) = config.channels_config.imessage {
channels.push(Arc::new(IMessageChannel::new(im.allowed_contacts.clone())));
}
#[cfg(feature = "channel-matrix")]
if let Some(ref mx) = config.channels_config.matrix {
channels.push(Arc::new(MatrixChannel::new_with_session_hint(
mx.homeserver.clone(),
mx.access_token.clone(),
mx.room_id.clone(),
mx.allowed_users.clone(),
mx.user_id.clone(),
mx.device_id.clone(),
)));
}
#[cfg(not(feature = "channel-matrix"))]
if config.channels_config.matrix.is_some() {
tracing::warn!(
"Matrix channel is configured but this build was compiled without `channel-matrix`; skipping Matrix runtime startup."
);
}
if let Some(ref sig) = config.channels_config.signal {
channels.push(Arc::new(SignalChannel::new(
sig.http_url.clone(),
sig.account.clone(),
sig.group_id.clone(),
sig.allowed_from.clone(),
sig.ignore_attachments,
sig.ignore_stories,
)));
}
if let Some(ref wa) = config.channels_config.whatsapp {
// Runtime negotiation: detect backend type from config
match wa.backend_type() {
"cloud" => {
// Cloud API mode: requires phone_number_id, access_token, verify_token
if wa.is_cloud_config() {
channels.push(Arc::new(WhatsAppChannel::new(
wa.access_token.clone().unwrap_or_default(),
wa.phone_number_id.clone().unwrap_or_default(),
wa.verify_token.clone().unwrap_or_default(),
wa.allowed_numbers.clone(),
)));
} else {
tracing::warn!("WhatsApp Cloud API configured but missing required fields (phone_number_id, access_token, verify_token)");
}
}
"web" => {
// Web mode: requires session_path
#[cfg(feature = "whatsapp-web")]
if wa.is_web_config() {
channels.push(Arc::new(WhatsAppWebChannel::new(
wa.session_path.clone().unwrap_or_default(),
wa.pair_phone.clone(),
wa.pair_code.clone(),
wa.allowed_numbers.clone(),
)));
} else {
tracing::warn!("WhatsApp Web configured but session_path not set");
}
#[cfg(not(feature = "whatsapp-web"))]
{
tracing::warn!("WhatsApp Web backend requires 'whatsapp-web' feature. Enable with: cargo build --features whatsapp-web");
}
}
_ => {
tracing::warn!("WhatsApp config invalid: neither phone_number_id (Cloud API) nor session_path (Web) is set");
}
}
}
if let Some(ref lq) = config.channels_config.linq {
channels.push(Arc::new(LinqChannel::new(
lq.api_token.clone(),
lq.from_phone.clone(),
lq.allowed_senders.clone(),
)));
}
if let Some(ref email_cfg) = config.channels_config.email {
channels.push(Arc::new(EmailChannel::new(email_cfg.clone())));
}
if let Some(ref irc) = config.channels_config.irc {
channels.push(Arc::new(IrcChannel::new(irc::IrcChannelConfig {
server: irc.server.clone(),
port: irc.port,
nickname: irc.nickname.clone(),
username: irc.username.clone(),
channels: irc.channels.clone(),
allowed_users: irc.allowed_users.clone(),
server_password: irc.server_password.clone(),
nickserv_password: irc.nickserv_password.clone(),
sasl_password: irc.sasl_password.clone(),
verify_tls: irc.verify_tls.unwrap_or(true),
})));
}
if let Some(ref lk) = config.channels_config.lark {
channels.push(Arc::new(LarkChannel::from_config(lk)));
}
if let Some(ref dt) = config.channels_config.dingtalk {
channels.push(Arc::new(DingTalkChannel::new(
dt.client_id.clone(),
dt.client_secret.clone(),
dt.allowed_users.clone(),
)));
}
if let Some(ref qq) = config.channels_config.qq {
channels.push(Arc::new(QQChannel::new(
qq.app_id.clone(),
qq.app_secret.clone(),
qq.allowed_users.clone(),
)));
}
if channels.is_empty() {
println!("No channels configured. Set up channels in the web UI.");
return Ok(());
}
println!("🦀 Alphahuman Channel Server");
println!(" 🤖 Model: {model}");
let effective_backend = memory::effective_memory_backend_name(
&config.memory.backend,
Some(&config.storage.provider.config),
);
println!(
" 🧠 Memory: {} (auto-save: {})",
effective_backend,
if config.memory.auto_save { "on" } else { "off" }
);
println!(
" 📡 Channels: {}",
channels
.iter()
.map(|c| c.name())
.collect::<Vec<_>>()
.join(", ")
);
println!();
println!(" Listening for messages... (Ctrl+C to stop)");
println!();
crate::alphahuman::health::mark_component_ok("channels");
let initial_backoff_secs = config
.reliability
.channel_initial_backoff_secs
.max(DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS);
let max_backoff_secs = config
.reliability
.channel_max_backoff_secs
.max(DEFAULT_CHANNEL_MAX_BACKOFF_SECS);
// Single message bus — all channels send messages here
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(100);
// Spawn a listener for each channel
let mut handles = Vec::new();
for ch in &channels {
handles.push(spawn_supervised_listener(
ch.clone(),
tx.clone(),
initial_backoff_secs,
max_backoff_secs,
));
}
drop(tx); // Drop our copy so rx closes when all channels stop
let channels_by_name = Arc::new(
channels
.iter()
.map(|ch| (ch.name().to_string(), Arc::clone(ch)))
.collect::<HashMap<_, _>>(),
);
let max_in_flight_messages = compute_max_in_flight_messages(channels.len());
println!(" 🚦 In-flight message limit: {max_in_flight_messages}");
let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
provider_cache_seed.insert(provider_name.clone(), Arc::clone(&provider));
let message_timeout_secs =
effective_channel_message_timeout_secs(config.channels_config.message_timeout_secs);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name,
provider: Arc::clone(&provider),
default_provider: Arc::new(provider_name),
memory: Arc::clone(&mem),
tools_registry: Arc::clone(&tools_registry),
observer,
system_prompt: Arc::new(system_prompt),
model: Arc::new(model.clone()),
temperature,
auto_save_memory: config.memory.auto_save,
max_tool_iterations: config.agent.max_tool_iterations,
min_relevance_score: config.memory.min_relevance_score,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: config.api_key.clone(),
api_url: config.api_url.clone(),
reliability: Arc::new(config.reliability.clone()),
provider_runtime_options,
workspace_dir: Arc::new(config.workspace_dir.clone()),
message_timeout_secs,
multimodal: config.multimodal.clone(),
});
run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await;
// Wait for all channel tasks
for h in handles {
let _ = h.await;
}
Ok(())
}
@@ -0,0 +1,62 @@
//! Supervisor helpers for channel listeners.
use super::super::traits;
use super::super::Channel;
use super::super::context::{
CHANNEL_MAX_IN_FLIGHT_MESSAGES, CHANNEL_MIN_IN_FLIGHT_MESSAGES,
CHANNEL_PARALLELISM_PER_CHANNEL,
};
use std::sync::Arc;
use std::time::Duration;
pub(crate) fn spawn_supervised_listener(
ch: Arc<dyn Channel>,
tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
initial_backoff_secs: u64,
max_backoff_secs: u64,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let component = format!("channel:{}", ch.name());
let mut backoff = initial_backoff_secs.max(1);
let max_backoff = max_backoff_secs.max(backoff);
loop {
crate::alphahuman::health::mark_component_ok(&component);
let result = ch.listen(tx.clone()).await;
if tx.is_closed() {
break;
}
match result {
Ok(()) => {
tracing::warn!("Channel {} exited unexpectedly; restarting", ch.name());
crate::alphahuman::health::mark_component_error(
&component,
"listener exited unexpectedly",
);
// Clean exit — reset backoff since the listener ran successfully
backoff = initial_backoff_secs.max(1);
}
Err(e) => {
tracing::error!("Channel {} error: {e}; restarting", ch.name());
crate::alphahuman::health::mark_component_error(&component, e.to_string());
}
}
crate::alphahuman::health::bump_component_restart(&component);
tokio::time::sleep(Duration::from_secs(backoff)).await;
// Double backoff AFTER sleeping so first error uses initial_backoff
backoff = backoff.saturating_mul(2).min(max_backoff);
}
})
}
pub(crate) fn compute_max_in_flight_messages(channel_count: usize) -> usize {
channel_count
.saturating_mul(CHANNEL_PARALLELISM_PER_CHANNEL)
.clamp(
CHANNEL_MIN_IN_FLIGHT_MESSAGES,
CHANNEL_MAX_IN_FLIGHT_MESSAGES,
)
}
+910
View File
@@ -0,0 +1,910 @@
use crate::alphahuman::channels::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::Client;
use serde::Deserialize;
use std::time::Duration;
use tokio::sync::mpsc;
use uuid::Uuid;
const GROUP_TARGET_PREFIX: &str = "group:";
#[derive(Debug, Clone, PartialEq, Eq)]
enum RecipientTarget {
Direct(String),
Group(String),
}
/// Signal channel using signal-cli daemon's native JSON-RPC + SSE API.
///
/// Connects to a running `signal-cli daemon --http <host:port>`.
/// Listens via SSE at `/api/v1/events` and sends via JSON-RPC at
/// `/api/v1/rpc`.
#[derive(Clone)]
pub struct SignalChannel {
http_url: String,
account: String,
group_id: Option<String>,
allowed_from: Vec<String>,
ignore_attachments: bool,
ignore_stories: bool,
}
// ── signal-cli SSE event JSON shapes ────────────────────────────
#[derive(Debug, Deserialize)]
struct SseEnvelope {
#[serde(default)]
envelope: Option<Envelope>,
}
#[derive(Debug, Deserialize)]
struct Envelope {
#[serde(default)]
source: Option<String>,
#[serde(rename = "sourceNumber", default)]
source_number: Option<String>,
#[serde(rename = "dataMessage", default)]
data_message: Option<DataMessage>,
#[serde(rename = "storyMessage", default)]
story_message: Option<serde_json::Value>,
#[serde(default)]
timestamp: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct DataMessage {
#[serde(default)]
message: Option<String>,
#[serde(default)]
timestamp: Option<u64>,
#[serde(rename = "groupInfo", default)]
group_info: Option<GroupInfo>,
#[serde(default)]
attachments: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Deserialize)]
struct GroupInfo {
#[serde(rename = "groupId", default)]
group_id: Option<String>,
}
impl SignalChannel {
pub fn new(
http_url: String,
account: String,
group_id: Option<String>,
allowed_from: Vec<String>,
ignore_attachments: bool,
ignore_stories: bool,
) -> Self {
let http_url = http_url.trim_end_matches('/').to_string();
Self {
http_url,
account,
group_id,
allowed_from,
ignore_attachments,
ignore_stories,
}
}
fn http_client(&self) -> Client {
let builder = Client::builder().connect_timeout(Duration::from_secs(10));
let builder = crate::alphahuman::config::apply_runtime_proxy_to_builder(builder, "channel.signal");
builder.build().expect("Signal HTTP client should build")
}
/// Effective sender: prefer `sourceNumber` (E.164), fall back to `source`.
fn sender(envelope: &Envelope) -> Option<String> {
envelope
.source_number
.as_deref()
.or(envelope.source.as_deref())
.map(String::from)
}
fn is_sender_allowed(&self, sender: &str) -> bool {
if self.allowed_from.iter().any(|u| u == "*") {
return true;
}
self.allowed_from.iter().any(|u| u == sender)
}
fn is_e164(recipient: &str) -> bool {
let Some(number) = recipient.strip_prefix('+') else {
return false;
};
(2..=15).contains(&number.len()) && number.chars().all(|c| c.is_ascii_digit())
}
/// Check whether a string is a valid UUID (signal-cli uses these for
/// privacy-enabled users who have opted out of sharing their phone number).
fn is_uuid(s: &str) -> bool {
Uuid::parse_str(s).is_ok()
}
fn parse_recipient_target(recipient: &str) -> RecipientTarget {
if let Some(group_id) = recipient.strip_prefix(GROUP_TARGET_PREFIX) {
return RecipientTarget::Group(group_id.to_string());
}
if Self::is_e164(recipient) || Self::is_uuid(recipient) {
RecipientTarget::Direct(recipient.to_string())
} else {
RecipientTarget::Group(recipient.to_string())
}
}
/// Check whether the message targets the configured group.
/// If no `group_id` is configured (None), all DMs and groups are accepted.
/// Use "dm" to filter DMs only.
fn matches_group(&self, data_msg: &DataMessage) -> bool {
let Some(ref expected) = self.group_id else {
return true;
};
match data_msg
.group_info
.as_ref()
.and_then(|g| g.group_id.as_deref())
{
Some(gid) => gid == expected.as_str(),
None => expected.eq_ignore_ascii_case("dm"),
}
}
/// Determine the send target: group id or the sender's number.
fn reply_target(&self, data_msg: &DataMessage, sender: &str) -> String {
if let Some(group_id) = data_msg
.group_info
.as_ref()
.and_then(|g| g.group_id.as_deref())
{
format!("{GROUP_TARGET_PREFIX}{group_id}")
} else {
sender.to_string()
}
}
/// Send a JSON-RPC request to signal-cli daemon.
async fn rpc_request(
&self,
method: &str,
params: serde_json::Value,
) -> anyhow::Result<Option<serde_json::Value>> {
let url = format!("{}/api/v1/rpc", self.http_url);
let id = Uuid::new_v4().to_string();
let body = serde_json::json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": id,
});
let resp = self
.http_client()
.post(&url)
.timeout(Duration::from_secs(30))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
// 201 = success with no body (e.g. typing indicators)
if resp.status().as_u16() == 201 {
return Ok(None);
}
let text = resp.text().await?;
if text.is_empty() {
return Ok(None);
}
let parsed: serde_json::Value = serde_json::from_str(&text)?;
if let Some(err) = parsed.get("error") {
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
let msg = err
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown");
anyhow::bail!("Signal RPC error {code}: {msg}");
}
Ok(parsed.get("result").cloned())
}
/// Process a single SSE envelope, returning a ChannelMessage if valid.
fn process_envelope(&self, envelope: &Envelope) -> Option<ChannelMessage> {
// Skip story messages when configured
if self.ignore_stories && envelope.story_message.is_some() {
return None;
}
let data_msg = envelope.data_message.as_ref()?;
// Skip attachment-only messages when configured
if self.ignore_attachments {
let has_attachments = data_msg.attachments.as_ref().is_some_and(|a| !a.is_empty());
if has_attachments && data_msg.message.is_none() {
return None;
}
}
let text = data_msg.message.as_deref().filter(|t| !t.is_empty())?;
let sender = Self::sender(envelope)?;
if !self.is_sender_allowed(&sender) {
return None;
}
if !self.matches_group(data_msg) {
return None;
}
let target = self.reply_target(data_msg, &sender);
let timestamp = data_msg
.timestamp
.or(envelope.timestamp)
.unwrap_or_else(|| {
u64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
)
.unwrap_or(u64::MAX)
});
Some(ChannelMessage {
id: format!("sig_{timestamp}"),
sender: sender.clone(),
reply_target: target,
content: text.to_string(),
channel: "signal".to_string(),
timestamp: timestamp / 1000, // millis → secs
thread_ts: None,
})
}
}
#[async_trait]
impl Channel for SignalChannel {
fn name(&self) -> &str {
"signal"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let params = match Self::parse_recipient_target(&message.recipient) {
RecipientTarget::Direct(number) => serde_json::json!({
"recipient": [number],
"message": &message.content,
"account": &self.account,
}),
RecipientTarget::Group(group_id) => serde_json::json!({
"groupId": group_id,
"message": &message.content,
"account": &self.account,
}),
};
self.rpc_request("send", params).await?;
Ok(())
}
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
let mut url = reqwest::Url::parse(&format!("{}/api/v1/events", self.http_url))?;
url.query_pairs_mut().append_pair("account", &self.account);
tracing::info!("Signal channel listening via SSE on {}...", self.http_url);
let mut retry_delay_secs = 2u64;
let max_delay_secs = 60u64;
loop {
let resp = self
.http_client()
.get(url.clone())
.header("Accept", "text/event-stream")
.send()
.await;
let resp = match resp {
Ok(r) if r.status().is_success() => r,
Ok(r) => {
let status = r.status();
let body = r.text().await.unwrap_or_default();
tracing::warn!("Signal SSE returned {status}: {body}");
tokio::time::sleep(tokio::time::Duration::from_secs(retry_delay_secs)).await;
retry_delay_secs = (retry_delay_secs * 2).min(max_delay_secs);
continue;
}
Err(e) => {
tracing::warn!("Signal SSE connect error: {e}, retrying...");
tokio::time::sleep(tokio::time::Duration::from_secs(retry_delay_secs)).await;
retry_delay_secs = (retry_delay_secs * 2).min(max_delay_secs);
continue;
}
};
retry_delay_secs = 2;
let mut bytes_stream = resp.bytes_stream();
let mut buffer = String::new();
let mut current_data = String::new();
while let Some(chunk) = bytes_stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
tracing::debug!("Signal SSE chunk error, reconnecting: {e}");
break;
}
};
let text = match String::from_utf8(chunk.to_vec()) {
Ok(t) => t,
Err(e) => {
tracing::debug!("Signal SSE invalid UTF-8, skipping chunk: {}", e);
continue;
}
};
buffer.push_str(&text);
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].trim_end_matches('\r').to_string();
buffer = buffer[newline_pos + 1..].to_string();
// Skip SSE comments (keepalive)
if line.starts_with(':') {
continue;
}
if line.is_empty() {
// Empty line = event boundary, dispatch accumulated data
if !current_data.is_empty() {
match serde_json::from_str::<SseEnvelope>(&current_data) {
Ok(sse) => {
if let Some(ref envelope) = sse.envelope {
if let Some(msg) = self.process_envelope(envelope) {
if tx.send(msg).await.is_err() {
return Ok(());
}
}
}
}
Err(e) => {
tracing::debug!("Signal SSE parse skip: {e}");
}
}
current_data.clear();
}
} else if let Some(data) = line.strip_prefix("data:") {
if !current_data.is_empty() {
current_data.push('\n');
}
current_data.push_str(data.trim_start());
}
// Ignore "event:", "id:", "retry:" lines
}
}
if !current_data.is_empty() {
match serde_json::from_str::<SseEnvelope>(&current_data) {
Ok(sse) => {
if let Some(ref envelope) = sse.envelope {
if let Some(msg) = self.process_envelope(envelope) {
let _ = tx.send(msg).await;
}
}
}
Err(e) => {
tracing::debug!("Signal SSE trailing parse skip: {e}");
}
}
}
tracing::debug!("Signal SSE stream ended, reconnecting...");
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}
async fn health_check(&self) -> bool {
let url = format!("{}/api/v1/check", self.http_url);
let Ok(resp) = self
.http_client()
.get(&url)
.timeout(Duration::from_secs(10))
.send()
.await
else {
return false;
};
resp.status().is_success()
}
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
let params = match Self::parse_recipient_target(recipient) {
RecipientTarget::Direct(number) => serde_json::json!({
"recipient": [number],
"account": &self.account,
}),
RecipientTarget::Group(group_id) => serde_json::json!({
"groupId": group_id,
"account": &self.account,
}),
};
self.rpc_request("sendTyping", params).await?;
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
// signal-cli doesn't have a stop-typing RPC; typing indicators
// auto-expire after ~15s on the client side.
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_channel() -> SignalChannel {
SignalChannel::new(
"http://127.0.0.1:8686".to_string(),
"+1234567890".to_string(),
None,
vec!["+1111111111".to_string()],
false,
false,
)
}
fn make_channel_with_group(group_id: &str) -> SignalChannel {
SignalChannel::new(
"http://127.0.0.1:8686".to_string(),
"+1234567890".to_string(),
Some(group_id.to_string()),
vec!["*".to_string()],
true,
true,
)
}
fn make_envelope(source_number: Option<&str>, message: Option<&str>) -> Envelope {
Envelope {
source: source_number.map(String::from),
source_number: source_number.map(String::from),
data_message: message.map(|m| DataMessage {
message: Some(m.to_string()),
timestamp: Some(1_700_000_000_000),
group_info: None,
attachments: None,
}),
story_message: None,
timestamp: Some(1_700_000_000_000),
}
}
#[test]
fn creates_with_correct_fields() {
let ch = make_channel();
assert_eq!(ch.http_url, "http://127.0.0.1:8686");
assert_eq!(ch.account, "+1234567890");
assert!(ch.group_id.is_none());
assert_eq!(ch.allowed_from.len(), 1);
assert!(!ch.ignore_attachments);
assert!(!ch.ignore_stories);
}
#[test]
fn strips_trailing_slash() {
let ch = SignalChannel::new(
"http://127.0.0.1:8686/".to_string(),
"+1234567890".to_string(),
None,
vec![],
false,
false,
);
assert_eq!(ch.http_url, "http://127.0.0.1:8686");
}
#[test]
fn wildcard_allows_anyone() {
let ch = make_channel_with_group("dm");
assert!(ch.is_sender_allowed("+9999999999"));
}
#[test]
fn specific_sender_allowed() {
let ch = make_channel();
assert!(ch.is_sender_allowed("+1111111111"));
}
#[test]
fn unknown_sender_denied() {
let ch = make_channel();
assert!(!ch.is_sender_allowed("+9999999999"));
}
#[test]
fn empty_allowlist_denies_all() {
let ch = SignalChannel::new(
"http://127.0.0.1:8686".to_string(),
"+1234567890".to_string(),
None,
vec![],
false,
false,
);
assert!(!ch.is_sender_allowed("+1111111111"));
}
#[test]
fn name_returns_signal() {
let ch = make_channel();
assert_eq!(ch.name(), "signal");
}
#[test]
fn matches_group_no_group_id_accepts_all() {
let ch = make_channel();
let dm = DataMessage {
message: Some("hi".to_string()),
timestamp: Some(1000),
group_info: None,
attachments: None,
};
assert!(ch.matches_group(&dm));
let group = DataMessage {
message: Some("hi".to_string()),
timestamp: Some(1000),
group_info: Some(GroupInfo {
group_id: Some("group123".to_string()),
}),
attachments: None,
};
assert!(ch.matches_group(&group));
}
#[test]
fn matches_group_filters_group() {
let ch = make_channel_with_group("group123");
let matching = DataMessage {
message: Some("hi".to_string()),
timestamp: Some(1000),
group_info: Some(GroupInfo {
group_id: Some("group123".to_string()),
}),
attachments: None,
};
assert!(ch.matches_group(&matching));
let non_matching = DataMessage {
message: Some("hi".to_string()),
timestamp: Some(1000),
group_info: Some(GroupInfo {
group_id: Some("other_group".to_string()),
}),
attachments: None,
};
assert!(!ch.matches_group(&non_matching));
}
#[test]
fn matches_group_dm_keyword() {
let ch = make_channel_with_group("dm");
let dm = DataMessage {
message: Some("hi".to_string()),
timestamp: Some(1000),
group_info: None,
attachments: None,
};
assert!(ch.matches_group(&dm));
let group = DataMessage {
message: Some("hi".to_string()),
timestamp: Some(1000),
group_info: Some(GroupInfo {
group_id: Some("group123".to_string()),
}),
attachments: None,
};
assert!(!ch.matches_group(&group));
}
#[test]
fn reply_target_dm() {
let ch = make_channel();
let dm = DataMessage {
message: Some("hi".to_string()),
timestamp: Some(1000),
group_info: None,
attachments: None,
};
assert_eq!(ch.reply_target(&dm, "+1111111111"), "+1111111111");
}
#[test]
fn reply_target_group() {
let ch = make_channel();
let group = DataMessage {
message: Some("hi".to_string()),
timestamp: Some(1000),
group_info: Some(GroupInfo {
group_id: Some("group123".to_string()),
}),
attachments: None,
};
assert_eq!(ch.reply_target(&group, "+1111111111"), "group:group123");
}
#[test]
fn parse_recipient_target_e164_is_direct() {
assert_eq!(
SignalChannel::parse_recipient_target("+1234567890"),
RecipientTarget::Direct("+1234567890".to_string())
);
}
#[test]
fn parse_recipient_target_prefixed_group_is_group() {
assert_eq!(
SignalChannel::parse_recipient_target("group:abc123"),
RecipientTarget::Group("abc123".to_string())
);
}
#[test]
fn parse_recipient_target_uuid_is_direct() {
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
assert_eq!(
SignalChannel::parse_recipient_target(uuid),
RecipientTarget::Direct(uuid.to_string())
);
}
#[test]
fn parse_recipient_target_non_e164_plus_is_group() {
assert_eq!(
SignalChannel::parse_recipient_target("+abc123"),
RecipientTarget::Group("+abc123".to_string())
);
}
#[test]
fn is_uuid_valid() {
assert!(SignalChannel::is_uuid(
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
));
assert!(SignalChannel::is_uuid(
"00000000-0000-0000-0000-000000000000"
));
}
#[test]
fn is_uuid_invalid() {
assert!(!SignalChannel::is_uuid("+1234567890"));
assert!(!SignalChannel::is_uuid("not-a-uuid"));
assert!(!SignalChannel::is_uuid("group:abc123"));
assert!(!SignalChannel::is_uuid(""));
}
#[test]
fn sender_prefers_source_number() {
let env = Envelope {
source: Some("uuid-123".to_string()),
source_number: Some("+1111111111".to_string()),
data_message: None,
story_message: None,
timestamp: Some(1000),
};
assert_eq!(SignalChannel::sender(&env), Some("+1111111111".to_string()));
}
#[test]
fn sender_falls_back_to_source() {
let env = Envelope {
source: Some("uuid-123".to_string()),
source_number: None,
data_message: None,
story_message: None,
timestamp: Some(1000),
};
assert_eq!(SignalChannel::sender(&env), Some("uuid-123".to_string()));
}
#[test]
fn process_envelope_uuid_sender_dm() {
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
let ch = SignalChannel::new(
"http://127.0.0.1:8686".to_string(),
"+1234567890".to_string(),
None,
vec!["*".to_string()],
false,
false,
);
let env = Envelope {
source: Some(uuid.to_string()),
source_number: None,
data_message: Some(DataMessage {
message: Some("Hello from privacy user".to_string()),
timestamp: Some(1_700_000_000_000),
group_info: None,
attachments: None,
}),
story_message: None,
timestamp: Some(1_700_000_000_000),
};
let msg = ch.process_envelope(&env).unwrap();
assert_eq!(msg.sender, uuid);
assert_eq!(msg.reply_target, uuid);
assert_eq!(msg.content, "Hello from privacy user");
// Verify reply routing: UUID sender in DM should route as Direct
let target = SignalChannel::parse_recipient_target(&msg.reply_target);
assert_eq!(target, RecipientTarget::Direct(uuid.to_string()));
}
#[test]
fn process_envelope_uuid_sender_in_group() {
let uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
let ch = SignalChannel::new(
"http://127.0.0.1:8686".to_string(),
"+1234567890".to_string(),
Some("testgroup".to_string()),
vec!["*".to_string()],
false,
false,
);
let env = Envelope {
source: Some(uuid.to_string()),
source_number: None,
data_message: Some(DataMessage {
message: Some("Group msg from privacy user".to_string()),
timestamp: Some(1_700_000_000_000),
group_info: Some(GroupInfo {
group_id: Some("testgroup".to_string()),
}),
attachments: None,
}),
story_message: None,
timestamp: Some(1_700_000_000_000),
};
let msg = ch.process_envelope(&env).unwrap();
assert_eq!(msg.sender, uuid);
assert_eq!(msg.reply_target, "group:testgroup");
// Verify reply routing: group message should still route as Group
let target = SignalChannel::parse_recipient_target(&msg.reply_target);
assert_eq!(target, RecipientTarget::Group("testgroup".to_string()));
}
#[test]
fn sender_none_when_both_missing() {
let env = Envelope {
source: None,
source_number: None,
data_message: None,
story_message: None,
timestamp: None,
};
assert_eq!(SignalChannel::sender(&env), None);
}
#[test]
fn process_envelope_valid_dm() {
let ch = make_channel();
let env = make_envelope(Some("+1111111111"), Some("Hello!"));
let msg = ch.process_envelope(&env).unwrap();
assert_eq!(msg.content, "Hello!");
assert_eq!(msg.sender, "+1111111111");
assert_eq!(msg.channel, "signal");
}
#[test]
fn process_envelope_denied_sender() {
let ch = make_channel();
let env = make_envelope(Some("+9999999999"), Some("Hello!"));
assert!(ch.process_envelope(&env).is_none());
}
#[test]
fn process_envelope_empty_message() {
let ch = make_channel();
let env = make_envelope(Some("+1111111111"), Some(""));
assert!(ch.process_envelope(&env).is_none());
}
#[test]
fn process_envelope_no_data_message() {
let ch = make_channel();
let env = make_envelope(Some("+1111111111"), None);
assert!(ch.process_envelope(&env).is_none());
}
#[test]
fn process_envelope_skips_stories() {
let ch = make_channel_with_group("dm");
let mut env = make_envelope(Some("+1111111111"), Some("story text"));
env.story_message = Some(serde_json::json!({}));
assert!(ch.process_envelope(&env).is_none());
}
#[test]
fn process_envelope_skips_attachment_only() {
let ch = make_channel_with_group("dm");
let env = Envelope {
source: Some("+1111111111".to_string()),
source_number: Some("+1111111111".to_string()),
data_message: Some(DataMessage {
message: None,
timestamp: Some(1_700_000_000_000),
group_info: None,
attachments: Some(vec![serde_json::json!({"contentType": "image/png"})]),
}),
story_message: None,
timestamp: Some(1_700_000_000_000),
};
assert!(ch.process_envelope(&env).is_none());
}
#[test]
fn sse_envelope_deserializes() {
let json = r#"{
"envelope": {
"source": "+1111111111",
"sourceNumber": "+1111111111",
"timestamp": 1700000000000,
"dataMessage": {
"message": "Hello Signal!",
"timestamp": 1700000000000
}
}
}"#;
let sse: SseEnvelope = serde_json::from_str(json).unwrap();
let env = sse.envelope.unwrap();
assert_eq!(env.source_number.as_deref(), Some("+1111111111"));
let dm = env.data_message.unwrap();
assert_eq!(dm.message.as_deref(), Some("Hello Signal!"));
}
#[test]
fn sse_envelope_deserializes_group() {
let json = r#"{
"envelope": {
"sourceNumber": "+2222222222",
"dataMessage": {
"message": "Group msg",
"groupInfo": {
"groupId": "abc123"
}
}
}
}"#;
let sse: SseEnvelope = serde_json::from_str(json).unwrap();
let env = sse.envelope.unwrap();
let dm = env.data_message.unwrap();
assert_eq!(
dm.group_info.as_ref().unwrap().group_id.as_deref(),
Some("abc123")
);
}
#[test]
fn envelope_defaults() {
let json = r#"{}"#;
let env: Envelope = serde_json::from_str(json).unwrap();
assert!(env.source.is_none());
assert!(env.source_number.is_none());
assert!(env.data_message.is_none());
assert!(env.story_message.is_none());
assert!(env.timestamp.is_none());
}
}
+349
View File
@@ -0,0 +1,349 @@
use super::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
/// Slack channel — polls conversations.history via Web API
pub struct SlackChannel {
bot_token: String,
channel_id: Option<String>,
allowed_users: Vec<String>,
}
impl SlackChannel {
pub fn new(bot_token: String, channel_id: Option<String>, allowed_users: Vec<String>) -> Self {
Self {
bot_token,
channel_id,
allowed_users,
}
}
fn http_client(&self) -> reqwest::Client {
crate::alphahuman::config::build_runtime_proxy_client("channel.slack")
}
/// Check if a Slack user ID is in the allowlist.
/// Empty list means deny everyone until explicitly configured.
/// `"*"` means allow everyone.
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
/// Get the bot's own user ID so we can ignore our own messages
async fn get_bot_user_id(&self) -> Option<String> {
let resp: serde_json::Value = self
.http_client()
.get("https://slack.com/api/auth.test")
.bearer_auth(&self.bot_token)
.send()
.await
.ok()?
.json()
.await
.ok()?;
resp.get("user_id")
.and_then(|u| u.as_str())
.map(String::from)
}
/// Resolve the thread identifier for inbound Slack messages.
/// Replies carry `thread_ts` (root thread id); top-level messages only have `ts`.
fn inbound_thread_ts(msg: &serde_json::Value, ts: &str) -> Option<String> {
msg.get("thread_ts")
.and_then(|t| t.as_str())
.or(if ts.is_empty() { None } else { Some(ts) })
.map(str::to_string)
}
}
#[async_trait]
impl Channel for SlackChannel {
fn name(&self) -> &str {
"slack"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
let mut body = serde_json::json!({
"channel": message.recipient,
"text": message.content
});
if let Some(ref ts) = message.thread_ts {
body["thread_ts"] = serde_json::json!(ts);
}
let resp = self
.http_client()
.post("https://slack.com/api/chat.postMessage")
.bearer_auth(&self.bot_token)
.json(&body)
.send()
.await?;
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
if !status.is_success() {
anyhow::bail!("Slack chat.postMessage failed ({status}): {body}");
}
// Slack returns 200 for most app-level errors; check JSON "ok" field
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
if parsed.get("ok") == Some(&serde_json::Value::Bool(false)) {
let err = parsed
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("unknown");
anyhow::bail!("Slack chat.postMessage failed: {err}");
}
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
let channel_id = self
.channel_id
.clone()
.ok_or_else(|| anyhow::anyhow!("Slack channel_id required for listening"))?;
let bot_user_id = self.get_bot_user_id().await.unwrap_or_default();
let mut last_ts = String::new();
tracing::info!("Slack channel listening on #{channel_id}...");
loop {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let mut params = vec![("channel", channel_id.clone()), ("limit", "10".to_string())];
if !last_ts.is_empty() {
params.push(("oldest", last_ts.clone()));
}
let resp = match self
.http_client()
.get("https://slack.com/api/conversations.history")
.bearer_auth(&self.bot_token)
.query(&params)
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!("Slack poll error: {e}");
continue;
}
};
let data: serde_json::Value = match resp.json().await {
Ok(d) => d,
Err(e) => {
tracing::warn!("Slack parse error: {e}");
continue;
}
};
if let Some(messages) = data.get("messages").and_then(|m| m.as_array()) {
// Messages come newest-first, reverse to process oldest first
for msg in messages.iter().rev() {
let ts = msg.get("ts").and_then(|t| t.as_str()).unwrap_or("");
let user = msg
.get("user")
.and_then(|u| u.as_str())
.unwrap_or("unknown");
let text = msg.get("text").and_then(|t| t.as_str()).unwrap_or("");
// Skip bot's own messages
if user == bot_user_id {
continue;
}
// Sender validation
if !self.is_user_allowed(user) {
tracing::warn!("Slack: ignoring message from unauthorized user: {user}");
continue;
}
// Skip empty or already-seen
if text.is_empty() || ts <= last_ts.as_str() {
continue;
}
last_ts = ts.to_string();
let channel_msg = ChannelMessage {
id: format!("slack_{channel_id}_{ts}"),
sender: user.to_string(),
reply_target: channel_id.clone(),
content: text.to_string(),
channel: "slack".to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
thread_ts: Self::inbound_thread_ts(msg, ts),
};
if tx.send(channel_msg).await.is_err() {
return Ok(());
}
}
}
}
}
async fn health_check(&self) -> bool {
self.http_client()
.get("https://slack.com/api/auth.test")
.bearer_auth(&self.bot_token)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slack_channel_name() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]);
assert_eq!(ch.name(), "slack");
}
#[test]
fn slack_channel_with_channel_id() {
let ch = SlackChannel::new("xoxb-fake".into(), Some("C12345".into()), vec![]);
assert_eq!(ch.channel_id, Some("C12345".to_string()));
}
#[test]
fn empty_allowlist_denies_everyone() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]);
assert!(!ch.is_user_allowed("U12345"));
assert!(!ch.is_user_allowed("anyone"));
}
#[test]
fn wildcard_allows_everyone() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["*".into()]);
assert!(ch.is_user_allowed("U12345"));
}
#[test]
fn specific_allowlist_filters() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into(), "U222".into()]);
assert!(ch.is_user_allowed("U111"));
assert!(ch.is_user_allowed("U222"));
assert!(!ch.is_user_allowed("U333"));
}
#[test]
fn allowlist_exact_match_not_substring() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]);
assert!(!ch.is_user_allowed("U1111"));
assert!(!ch.is_user_allowed("U11"));
}
#[test]
fn allowlist_empty_user_id() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]);
assert!(!ch.is_user_allowed(""));
}
#[test]
fn allowlist_case_sensitive() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]);
assert!(ch.is_user_allowed("U111"));
assert!(!ch.is_user_allowed("u111"));
}
#[test]
fn allowlist_wildcard_and_specific() {
let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into(), "*".into()]);
assert!(ch.is_user_allowed("U111"));
assert!(ch.is_user_allowed("anyone"));
}
// ── Message ID edge cases ─────────────────────────────────────
#[test]
fn slack_message_id_format_includes_channel_and_ts() {
// Verify that message IDs follow the format: slack_{channel_id}_{ts}
let ts = "1234567890.123456";
let channel_id = "C12345";
let expected_id = format!("slack_{channel_id}_{ts}");
assert_eq!(expected_id, "slack_C12345_1234567890.123456");
}
#[test]
fn slack_message_id_is_deterministic() {
// Same channel_id + same ts = same ID (prevents duplicates after restart)
let ts = "1234567890.123456";
let channel_id = "C12345";
let id1 = format!("slack_{channel_id}_{ts}");
let id2 = format!("slack_{channel_id}_{ts}");
assert_eq!(id1, id2);
}
#[test]
fn slack_message_id_different_ts_different_id() {
// Different timestamps produce different IDs
let channel_id = "C12345";
let id1 = format!("slack_{channel_id}_1234567890.123456");
let id2 = format!("slack_{channel_id}_1234567890.123457");
assert_ne!(id1, id2);
}
#[test]
fn slack_message_id_different_channel_different_id() {
// Different channels produce different IDs even with same ts
let ts = "1234567890.123456";
let id1 = format!("slack_C12345_{ts}");
let id2 = format!("slack_C67890_{ts}");
assert_ne!(id1, id2);
}
#[test]
fn slack_message_id_no_uuid_randomness() {
// Verify format doesn't contain random UUID components
let ts = "1234567890.123456";
let channel_id = "C12345";
let id = format!("slack_{channel_id}_{ts}");
assert!(!id.contains('-')); // No UUID dashes
assert!(id.starts_with("slack_"));
}
#[test]
fn inbound_thread_ts_prefers_explicit_thread_ts() {
let msg = serde_json::json!({
"ts": "123.002",
"thread_ts": "123.001"
});
let thread_ts = SlackChannel::inbound_thread_ts(&msg, "123.002");
assert_eq!(thread_ts.as_deref(), Some("123.001"));
}
#[test]
fn inbound_thread_ts_falls_back_to_ts() {
let msg = serde_json::json!({
"ts": "123.001"
});
let thread_ts = SlackChannel::inbound_thread_ts(&msg, "123.001");
assert_eq!(thread_ts.as_deref(), Some("123.001"));
}
#[test]
fn inbound_thread_ts_none_when_ts_missing() {
let msg = serde_json::json!({});
let thread_ts = SlackChannel::inbound_thread_ts(&msg, "");
assert_eq!(thread_ts, None);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,436 @@
use crate::alphahuman::channels::{traits, Channel, SendMessage};
use crate::alphahuman::memory::{Memory, MemoryCategory, MemoryEntry};
use crate::alphahuman::providers::{ChatMessage, Provider};
use crate::alphahuman::tools::{Tool, ToolResult};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tempfile::TempDir;
pub(super) fn make_workspace() -> TempDir {
let tmp = TempDir::new().unwrap();
// Create minimal workspace files
std::fs::write(tmp.path().join("SOUL.md"), "# Soul\nBe helpful.").unwrap();
std::fs::write(tmp.path().join("IDENTITY.md"), "# Identity\nName: Alphahuman").unwrap();
std::fs::write(tmp.path().join("USER.md"), "# User\nName: Test User").unwrap();
std::fs::write(
tmp.path().join("AGENTS.md"),
"# Agents\nFollow instructions.",
)
.unwrap();
std::fs::write(tmp.path().join("TOOLS.md"), "# Tools\nUse shell carefully.").unwrap();
std::fs::write(
tmp.path().join("HEARTBEAT.md"),
"# Heartbeat\nCheck status.",
)
.unwrap();
std::fs::write(tmp.path().join("MEMORY.md"), "# Memory\nUser likes Rust.").unwrap();
tmp
}
pub(super) struct DummyProvider;
#[async_trait::async_trait]
impl Provider for DummyProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("ok".to_string())
}
}
#[derive(Default)]
pub(super) struct RecordingChannel {
pub(super) sent_messages: tokio::sync::Mutex<Vec<String>>,
pub(super) start_typing_calls: AtomicUsize,
pub(super) stop_typing_calls: AtomicUsize,
}
#[derive(Default)]
pub(super) struct TelegramRecordingChannel {
pub(super) sent_messages: tokio::sync::Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl Channel for TelegramRecordingChannel {
fn name(&self) -> &str {
"telegram"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
self.sent_messages
.lock()
.await
.push(format!("{}:{}", message.recipient, message.content));
Ok(())
}
async fn listen(
&self,
_tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
) -> anyhow::Result<()> {
Ok(())
}
async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
}
#[async_trait::async_trait]
impl Channel for RecordingChannel {
fn name(&self) -> &str {
"test-channel"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
self.sent_messages
.lock()
.await
.push(format!("{}:{}", message.recipient, message.content));
Ok(())
}
async fn listen(
&self,
_tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
) -> anyhow::Result<()> {
Ok(())
}
async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
self.start_typing_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
self.stop_typing_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
pub(super) struct SlowProvider {
pub(super) delay: Duration,
}
#[async_trait::async_trait]
impl Provider for SlowProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
tokio::time::sleep(self.delay).await;
Ok(format!("echo: {message}"))
}
}
pub(super) struct ToolCallingProvider;
pub(super) fn tool_call_payload() -> String {
r#"<tool_call>
{"name":"mock_price","arguments":{"symbol":"BTC"}}
</tool_call>"#
.to_string()
}
pub(super) fn tool_call_payload_with_alias_tag() -> String {
r#"<toolcall>
{"name":"mock_price","arguments":{"symbol":"BTC"}}
</toolcall>"#
.to_string()
}
#[async_trait::async_trait]
impl Provider for ToolCallingProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok(tool_call_payload())
}
async fn chat_with_history(
&self,
messages: &[ChatMessage],
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
let has_tool_results = messages
.iter()
.any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
if has_tool_results {
Ok("BTC is currently around $65,000 based on latest tool output.".to_string())
} else {
Ok(tool_call_payload())
}
}
}
pub(super) struct ToolCallingAliasProvider;
#[async_trait::async_trait]
impl Provider for ToolCallingAliasProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok(tool_call_payload_with_alias_tag())
}
async fn chat_with_history(
&self,
messages: &[ChatMessage],
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
let has_tool_results = messages
.iter()
.any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
if has_tool_results {
Ok("BTC alias-tag flow resolved to final text output.".to_string())
} else {
Ok(tool_call_payload_with_alias_tag())
}
}
}
pub(super) struct IterativeToolProvider {
pub(super) required_tool_iterations: usize,
}
impl IterativeToolProvider {
pub(super) fn completed_tool_iterations(messages: &[ChatMessage]) -> usize {
messages
.iter()
.filter(|msg| msg.role == "user" && msg.content.contains("[Tool results]"))
.count()
}
}
#[async_trait::async_trait]
impl Provider for IterativeToolProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok(tool_call_payload())
}
async fn chat_with_history(
&self,
messages: &[ChatMessage],
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
let completed_iterations = Self::completed_tool_iterations(messages);
if completed_iterations >= self.required_tool_iterations {
Ok(format!(
"Completed after {completed_iterations} tool iterations."
))
} else {
Ok(tool_call_payload())
}
}
}
#[derive(Default)]
pub(super) struct HistoryCaptureProvider {
pub(super) calls: Mutex<Vec<Vec<(String, String)>>>,
}
#[async_trait::async_trait]
impl Provider for HistoryCaptureProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("fallback".to_string())
}
async fn chat_with_history(
&self,
messages: &[ChatMessage],
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
let snapshot = messages
.iter()
.map(|m| (m.role.clone(), m.content.clone()))
.collect::<Vec<_>>();
let mut calls = self.calls.lock().unwrap_or_else(|e| e.into_inner());
calls.push(snapshot);
Ok(format!("response-{}", calls.len()))
}
}
pub(super) struct MockPriceTool;
#[derive(Default)]
pub(super) struct ModelCaptureProvider {
pub(super) call_count: AtomicUsize,
pub(super) models: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl Provider for ModelCaptureProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok("fallback".to_string())
}
async fn chat_with_history(
&self,
_messages: &[ChatMessage],
model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
self.call_count.fetch_add(1, Ordering::SeqCst);
self.models
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(model.to_string());
Ok("ok".to_string())
}
}
#[async_trait::async_trait]
impl Tool for MockPriceTool {
fn name(&self) -> &str {
"mock_price"
}
fn description(&self) -> &str {
"Return a mocked BTC price"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"symbol": { "type": "string" }
},
"required": ["symbol"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let symbol = args.get("symbol").and_then(serde_json::Value::as_str);
if symbol != Some("BTC") {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("unexpected symbol".to_string()),
});
}
Ok(ToolResult {
success: true,
output: "BTC is $65,000".to_string(),
error: None,
})
}
}
pub(super) struct NoopMemory;
#[async_trait::async_trait]
impl Memory for NoopMemory {
fn name(&self) -> &str {
"noop"
}
async fn store(
&self,
_key: &str,
_content: &str,
_category: MemoryCategory,
_session_id: Option<&str>,
) -> anyhow::Result<()> {
Ok(())
}
async fn recall(
&self,
_query: &str,
_limit: usize,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
Ok(None)
}
async fn list(
&self,
_category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn count(&self) -> anyhow::Result<usize> {
Ok(0)
}
async fn health_check(&self) -> bool {
true
}
}
pub(super) struct AlwaysFailChannel {
pub(super) name: &'static str,
pub(super) calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl Channel for AlwaysFailChannel {
fn name(&self) -> &str {
self.name
}
async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
Ok(())
}
async fn listen(
&self,
_tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
) -> anyhow::Result<()> {
self.calls.fetch_add(1, Ordering::SeqCst);
anyhow::bail!("listen boom")
}
}
@@ -0,0 +1,105 @@
use super::common::{DummyProvider};
use super::super::context::{
compact_sender_history, effective_channel_message_timeout_secs,
is_context_window_overflow_error, should_skip_memory_context_entry, ChannelRuntimeContext,
CHANNEL_HISTORY_COMPACT_CONTENT_CHARS, CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES,
CHANNEL_MESSAGE_TIMEOUT_SECS, MIN_CHANNEL_MESSAGE_TIMEOUT_SECS,
};
use crate::alphahuman::observability::NoopObserver;
use crate::alphahuman::providers::ChatMessage;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[test]
fn effective_channel_message_timeout_secs_clamps_to_minimum() {
assert_eq!(
effective_channel_message_timeout_secs(0),
MIN_CHANNEL_MESSAGE_TIMEOUT_SECS
);
assert_eq!(
effective_channel_message_timeout_secs(15),
MIN_CHANNEL_MESSAGE_TIMEOUT_SECS
);
assert_eq!(effective_channel_message_timeout_secs(300), 300);
}
#[test]
fn context_window_overflow_error_detector_matches_known_messages() {
let overflow_err = anyhow::anyhow!(
"OpenAI Codex stream error: Your input exceeds the context window of this model."
);
assert!(is_context_window_overflow_error(&overflow_err));
let other_err =
anyhow::anyhow!("OpenAI Codex API error (502 Bad Gateway): error code: 502");
assert!(!is_context_window_overflow_error(&other_err));
}
#[test]
fn memory_context_skip_rules_exclude_history_blobs() {
assert!(should_skip_memory_context_entry(
"telegram_123_history",
r#"[{"role":"user"}]"#
));
assert!(!should_skip_memory_context_entry("telegram_123_45", "hi"));
}
#[test]
fn compact_sender_history_keeps_recent_truncated_messages() {
let mut histories = HashMap::new();
let sender = "telegram_u1".to_string();
histories.insert(
sender.clone(),
(0..20)
.map(|idx| {
let content = format!("msg-{idx}-{}", "x".repeat(700));
if idx % 2 == 0 {
ChatMessage::user(content)
} else {
ChatMessage::assistant(content)
}
})
.collect::<Vec<_>>(),
);
let ctx = ChannelRuntimeContext {
channels_by_name: Arc::new(HashMap::new()),
provider: Arc::new(DummyProvider),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(super::common::NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("system".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(histories)),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
provider_runtime_options: crate::alphahuman::providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
};
assert!(compact_sender_history(&ctx, &sender));
let histories = ctx
.conversation_histories
.lock()
.unwrap_or_else(|e| e.into_inner());
let kept = histories
.get(&sender)
.expect("sender history should remain");
assert_eq!(kept.len(), CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
assert!(kept.iter().all(|turn| {
let len = turn.content.chars().count();
len <= CHANNEL_HISTORY_COMPACT_CONTENT_CHARS
|| (len <= CHANNEL_HISTORY_COMPACT_CONTENT_CHARS + 3 && turn.content.ends_with("..."))
}));
}
@@ -0,0 +1,56 @@
use super::common::AlwaysFailChannel;
use super::super::commands::{classify_health_result, ChannelHealthState};
use super::super::runtime::spawn_supervised_listener;
use super::super::{traits, Channel, SendMessage};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
fn classify_health_ok_true() {
let state = classify_health_result(&Ok(true));
assert_eq!(state, ChannelHealthState::Healthy);
}
#[test]
fn classify_health_ok_false() {
let state = classify_health_result(&Ok(false));
assert_eq!(state, ChannelHealthState::Unhealthy);
}
#[tokio::test]
async fn classify_health_timeout() {
let result = tokio::time::timeout(Duration::from_millis(1), async {
tokio::time::sleep(Duration::from_millis(20)).await;
true
})
.await;
let state = classify_health_result(&result);
assert_eq!(state, ChannelHealthState::Timeout);
}
#[tokio::test]
async fn supervised_listener_marks_error_and_restarts_on_failures() {
let calls = Arc::new(AtomicUsize::new(0));
let channel: Arc<dyn Channel> = Arc::new(AlwaysFailChannel {
name: "test-supervised-fail",
calls: Arc::clone(&calls),
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(1);
let handle = spawn_supervised_listener(channel, tx, 1, 1);
tokio::time::sleep(Duration::from_millis(80)).await;
drop(rx);
handle.abort();
let _ = handle.await;
let snapshot = crate::alphahuman::health::snapshot_json();
let component = &snapshot["components"]["channel:test-supervised-fail"];
assert_eq!(component["status"], "error");
assert!(component["restart_count"].as_u64().unwrap_or(0) >= 1);
assert!(component["last_error"]
.as_str()
.unwrap_or("")
.contains("listen boom"));
assert!(calls.load(Ordering::SeqCst) >= 1);
}
@@ -0,0 +1,147 @@
use super::common::make_workspace;
use super::super::prompt::build_system_prompt;
#[test]
fn aieos_identity_from_file() {
use crate::alphahuman::config::IdentityConfig;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let identity_path = tmp.path().join("aieos_identity.json");
// Write AIEOS identity file
let aieos_json = r#"{
"identity": {
"names": {"first": "Nova", "nickname": "Nov"},
"bio": "A helpful AI assistant.",
"origin": "Silicon Valley"
},
"psychology": {
"mbti": "INTJ",
"moral_compass": ["Be helpful", "Do no harm"]
},
"linguistics": {
"style": "concise",
"formality": "casual"
}
}"#;
std::fs::write(&identity_path, aieos_json).unwrap();
// Create identity config pointing to the file
let config = IdentityConfig {
format: "aieos".into(),
aieos_path: Some("aieos_identity.json".into()),
aieos_inline: None,
};
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config), None);
// Should contain AIEOS sections
assert!(prompt.contains("## Identity"));
assert!(prompt.contains("**Name:** Nova"));
assert!(prompt.contains("**Nickname:** Nov"));
assert!(prompt.contains("**Bio:** A helpful AI assistant."));
assert!(prompt.contains("**Origin:** Silicon Valley"));
assert!(prompt.contains("## Personality"));
assert!(prompt.contains("**MBTI:** INTJ"));
assert!(prompt.contains("**Moral Compass:**"));
assert!(prompt.contains("- Be helpful"));
assert!(prompt.contains("## Communication Style"));
assert!(prompt.contains("**Style:** concise"));
assert!(prompt.contains("**Formality Level:** casual"));
// Should NOT contain OpenClaw bootstrap file headers
assert!(!prompt.contains("### SOUL.md"));
assert!(!prompt.contains("### IDENTITY.md"));
assert!(!prompt.contains("[File not found"));
}
#[test]
fn aieos_identity_from_inline() {
use crate::alphahuman::config::IdentityConfig;
let config = IdentityConfig {
format: "aieos".into(),
aieos_path: None,
aieos_inline: Some(r#"{"identity":{"names":{"first":"Claw"}}}"#.into()),
};
let prompt = build_system_prompt(
std::env::temp_dir().as_path(),
"model",
&[],
&[],
Some(&config),
None,
);
assert!(prompt.contains("**Name:** Claw"));
assert!(prompt.contains("## Identity"));
}
#[test]
fn aieos_fallback_to_openclaw_on_parse_error() {
use crate::alphahuman::config::IdentityConfig;
let config = IdentityConfig {
format: "aieos".into(),
aieos_path: Some("nonexistent.json".into()),
aieos_inline: None,
};
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
// Should fall back to OpenClaw format when AIEOS file is not found
// (Error is logged to stderr with filename, not included in prompt)
assert!(prompt.contains("### SOUL.md"));
}
#[test]
fn aieos_empty_uses_openclaw() {
use crate::alphahuman::config::IdentityConfig;
// Format is "aieos" but neither path nor inline is set
let config = IdentityConfig {
format: "aieos".into(),
aieos_path: None,
aieos_inline: None,
};
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
// Should use OpenClaw format (not configured for AIEOS)
assert!(prompt.contains("### SOUL.md"));
assert!(prompt.contains("Be helpful"));
}
#[test]
fn openclaw_format_uses_bootstrap_files() {
use crate::alphahuman::config::IdentityConfig;
let config = IdentityConfig {
format: "openclaw".into(),
aieos_path: Some("identity.json".into()),
aieos_inline: None,
};
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
// Should use OpenClaw format even if aieos_path is set
assert!(prompt.contains("### SOUL.md"));
assert!(prompt.contains("Be helpful"));
assert!(!prompt.contains("## Identity"));
}
#[test]
fn none_identity_config_uses_openclaw() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(prompt.contains("### SOUL.md"));
assert!(prompt.contains("Be helpful"));
}
@@ -0,0 +1,194 @@
use super::common::{HistoryCaptureProvider, NoopMemory, RecordingChannel};
use super::super::context::{build_memory_context, conversation_memory_key, ChannelRuntimeContext, CHANNEL_MESSAGE_TIMEOUT_SECS, MAX_CHANNEL_HISTORY};
use super::super::runtime::process_channel_message;
use super::super::{traits, Channel};
use crate::alphahuman::memory::{Memory, MemoryCategory, SqliteMemory};
use crate::alphahuman::observability::NoopObserver;
use crate::alphahuman::providers::{self, ChatMessage, Provider};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
fn conversation_memory_key_uses_message_id() {
let msg = traits::ChannelMessage {
id: "msg_abc123".into(),
sender: "U123".into(),
reply_target: "C456".into(),
content: "hello".into(),
channel: "slack".into(),
timestamp: 1,
thread_ts: None,
};
assert_eq!(conversation_memory_key(&msg), "slack_U123_msg_abc123");
}
#[test]
fn conversation_memory_key_is_unique_per_message() {
let msg1 = traits::ChannelMessage {
id: "msg_1".into(),
sender: "U123".into(),
reply_target: "C456".into(),
content: "first".into(),
channel: "slack".into(),
timestamp: 1,
thread_ts: None,
};
let msg2 = traits::ChannelMessage {
id: "msg_2".into(),
sender: "U123".into(),
reply_target: "C456".into(),
content: "second".into(),
channel: "slack".into(),
timestamp: 2,
thread_ts: None,
};
assert_ne!(
conversation_memory_key(&msg1),
conversation_memory_key(&msg2)
);
}
#[tokio::test]
async fn autosave_keys_preserve_multiple_conversation_facts() {
let tmp = TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
let msg1 = traits::ChannelMessage {
id: "msg_1".into(),
sender: "U123".into(),
reply_target: "C456".into(),
content: "I'm Paul".into(),
channel: "slack".into(),
timestamp: 1,
thread_ts: None,
};
let msg2 = traits::ChannelMessage {
id: "msg_2".into(),
sender: "U123".into(),
reply_target: "C456".into(),
content: "I'm 45".into(),
channel: "slack".into(),
timestamp: 2,
thread_ts: None,
};
mem.store(
&conversation_memory_key(&msg1),
&msg1.content,
MemoryCategory::Conversation,
None,
)
.await
.unwrap();
mem.store(
&conversation_memory_key(&msg2),
&msg2.content,
MemoryCategory::Conversation,
None,
)
.await
.unwrap();
assert_eq!(mem.count().await.unwrap(), 2);
let recalled = mem.recall("45", 5, None).await.unwrap();
assert!(recalled.iter().any(|entry| entry.content.contains("45")));
}
#[tokio::test]
async fn build_memory_context_includes_recalled_entries() {
let tmp = TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
mem.store("age_fact", "Age is 45", MemoryCategory::Conversation, None)
.await
.unwrap();
let context = build_memory_context(&mem, "age", 0.0).await;
assert!(context.contains("[Memory context]"));
assert!(context.contains("Age is 45"));
}
#[tokio::test]
async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let provider_impl = Arc::new(HistoryCaptureProvider::default());
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: provider_impl.clone(),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx.clone(),
traits::ChannelMessage {
id: "msg-a".to_string(),
sender: "alice".to_string(),
reply_target: "chat-1".to_string(),
content: "hello".to_string(),
channel: "test-channel".to_string(),
timestamp: 1,
thread_ts: None,
},
)
.await;
process_channel_message(
runtime_ctx,
traits::ChannelMessage {
id: "msg-b".to_string(),
sender: "alice".to_string(),
reply_target: "chat-1".to_string(),
content: "follow up".to_string(),
channel: "test-channel".to_string(),
timestamp: 2,
thread_ts: None,
},
)
.await;
let calls = provider_impl
.calls
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].len(), 2);
assert_eq!(calls[0][0].0, "system");
assert_eq!(calls[0][1].0, "user");
assert_eq!(calls[1].len(), 4);
assert_eq!(calls[1][0].0, "system");
assert_eq!(calls[1][1].0, "user");
assert_eq!(calls[1][2].0, "assistant");
assert_eq!(calls[1][3].0, "user");
assert!(calls[1][1].1.contains("hello"));
assert!(calls[1][2].1.contains("response-1"));
assert!(calls[1][3].1.contains("follow up"));
}
// ── AIEOS Identity Tests (Issue #168) ─────────────────────────
@@ -0,0 +1,7 @@
mod common;
mod health;
mod identity;
mod memory;
mod prompt;
mod runtime_dispatch;
mod runtime_tool_calls;
@@ -0,0 +1,248 @@
use super::common::make_workspace;
use super::super::prompt::{build_system_prompt, BOOTSTRAP_MAX_CHARS};
use tempfile::TempDir;
#[test]
fn prompt_contains_all_sections() {
let ws = make_workspace();
let tools = vec![("shell", "Run commands"), ("file_read", "Read files")];
let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, None);
// Section headers
assert!(prompt.contains("## Tools"), "missing Tools section");
assert!(prompt.contains("## Safety"), "missing Safety section");
assert!(prompt.contains("## Workspace"), "missing Workspace section");
assert!(
prompt.contains("## Project Context"),
"missing Project Context"
);
assert!(
prompt.contains("## Current Date & Time"),
"missing Date/Time"
);
assert!(prompt.contains("## Runtime"), "missing Runtime section");
}
#[test]
fn prompt_injects_tools() {
let ws = make_workspace();
let tools = vec![
("shell", "Run commands"),
("memory_recall", "Search memory"),
];
let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None);
assert!(prompt.contains("**shell**"));
assert!(prompt.contains("Run commands"));
assert!(prompt.contains("**memory_recall**"));
}
#[test]
fn prompt_injects_safety() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(prompt.contains("Do not exfiltrate private data"));
assert!(prompt.contains("Do not run destructive commands"));
assert!(prompt.contains("Prefer `trash` over `rm`"));
}
#[test]
fn prompt_injects_workspace_files() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header");
assert!(prompt.contains("Be helpful"), "missing SOUL content");
assert!(prompt.contains("### IDENTITY.md"), "missing IDENTITY.md");
assert!(
prompt.contains("Name: Alphahuman"),
"missing IDENTITY content"
);
assert!(prompt.contains("### USER.md"), "missing USER.md");
assert!(prompt.contains("### AGENTS.md"), "missing AGENTS.md");
assert!(prompt.contains("### TOOLS.md"), "missing TOOLS.md");
// HEARTBEAT.md is intentionally excluded from channel prompts — it's only
// relevant to the heartbeat worker and causes LLMs to emit spurious
// "HEARTBEAT_OK" acknowledgments in channel conversations.
assert!(
!prompt.contains("### HEARTBEAT.md"),
"HEARTBEAT.md should not be in channel prompt"
);
assert!(prompt.contains("### MEMORY.md"), "missing MEMORY.md");
assert!(prompt.contains("User likes Rust"), "missing MEMORY content");
}
#[test]
fn prompt_missing_file_markers() {
let tmp = TempDir::new().unwrap();
// Empty workspace — no files at all
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, None);
assert!(prompt.contains("[File not found: SOUL.md]"));
assert!(prompt.contains("[File not found: AGENTS.md]"));
assert!(prompt.contains("[File not found: IDENTITY.md]"));
}
#[test]
fn prompt_bootstrap_only_if_exists() {
let ws = make_workspace();
// No BOOTSTRAP.md — should not appear
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(
!prompt.contains("### BOOTSTRAP.md"),
"BOOTSTRAP.md should not appear when missing"
);
// Create BOOTSTRAP.md — should appear
std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap();
let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(
prompt2.contains("### BOOTSTRAP.md"),
"BOOTSTRAP.md should appear when present"
);
assert!(prompt2.contains("First run"));
}
#[test]
fn prompt_no_daily_memory_injection() {
let ws = make_workspace();
let memory_dir = ws.path().join("memory");
std::fs::create_dir_all(&memory_dir).unwrap();
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
std::fs::write(
memory_dir.join(format!("{today}.md")),
"# Daily\nSome note.",
)
.unwrap();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
// Daily notes should NOT be in the system prompt (on-demand via tools)
assert!(
!prompt.contains("Daily Notes"),
"daily notes should not be auto-injected"
);
assert!(
!prompt.contains("Some note"),
"daily content should not be in prompt"
);
}
#[test]
fn prompt_runtime_metadata() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None, None);
assert!(prompt.contains("Model: claude-sonnet-4"));
assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS)));
assert!(prompt.contains("Host:"));
}
#[test]
fn prompt_skills_compact_list() {
let ws = make_workspace();
let skills = vec![crate::alphahuman::skills::Skill {
name: "code-review".into(),
description: "Review code for bugs".into(),
version: "1.0.0".into(),
author: None,
tags: vec![],
tools: vec![],
prompts: vec!["Long prompt content that should NOT appear in system prompt".into()],
location: None,
}];
let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);
assert!(prompt.contains("<available_skills>"), "missing skills XML");
assert!(prompt.contains("<name>code-review</name>"));
assert!(prompt.contains("<description>Review code for bugs</description>"));
assert!(prompt.contains("SKILL.md</location>"));
assert!(
prompt.contains("loaded on demand"),
"should mention on-demand loading"
);
// Full prompt content should NOT be dumped
assert!(!prompt.contains("Long prompt content that should NOT appear"));
}
#[test]
fn prompt_truncation() {
let ws = make_workspace();
// Write a file larger than BOOTSTRAP_MAX_CHARS
let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000);
std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(
prompt.contains("truncated at"),
"large files should be truncated"
);
assert!(
!prompt.contains(&big_content),
"full content should not appear"
);
}
#[test]
fn prompt_empty_files_skipped() {
let ws = make_workspace();
std::fs::write(ws.path().join("TOOLS.md"), "").unwrap();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
// Empty file should not produce a header
assert!(
!prompt.contains("### TOOLS.md"),
"empty files should be skipped"
);
}
#[test]
fn channel_log_truncation_is_utf8_safe_for_multibyte_text() {
let msg = "Hello from Alphahuman 🌍. Current status is healthy, and café-style UTF-8 text stays safe in logs.";
// Reproduces the production crash path where channel logs truncate at 80 chars.
let result = std::panic::catch_unwind(|| crate::alphahuman::util::truncate_with_ellipsis(msg, 80));
assert!(
result.is_ok(),
"truncate_with_ellipsis should never panic on UTF-8"
);
let truncated = result.unwrap();
assert!(!truncated.is_empty());
assert!(truncated.is_char_boundary(truncated.len()));
}
#[test]
fn prompt_contains_channel_capabilities() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(
prompt.contains("## Channel Capabilities"),
"missing Channel Capabilities section"
);
assert!(
prompt.contains("running as a Discord bot"),
"missing Discord context"
);
assert!(
prompt.contains("NEVER repeat, describe, or echo credentials"),
"missing security instruction"
);
}
#[test]
fn prompt_workspace_path() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
let workspace_path = ws.path().display().to_string();
assert!(
prompt.contains(&workspace_path),
"workspace path missing from prompt"
);
}
@@ -0,0 +1,140 @@
use super::common::{NoopMemory, RecordingChannel, SlowProvider};
use super::super::context::{ChannelRuntimeContext, CHANNEL_MESSAGE_TIMEOUT_SECS};
use super::super::runtime::{process_channel_message, run_message_dispatch_loop};
use super::super::{traits, Channel};
use crate::alphahuman::observability::NoopObserver;
use crate::alphahuman::providers::{self, Provider};
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[tokio::test]
async fn message_dispatch_processes_messages_in_parallel() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(SlowProvider {
delay: Duration::from_millis(250),
}),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(4);
tx.send(traits::ChannelMessage {
id: "1".to_string(),
sender: "alice".to_string(),
reply_target: "alice".to_string(),
content: "hello".to_string(),
channel: "test-channel".to_string(),
timestamp: 1,
thread_ts: None,
})
.await
.unwrap();
tx.send(traits::ChannelMessage {
id: "2".to_string(),
sender: "bob".to_string(),
reply_target: "bob".to_string(),
content: "world".to_string(),
channel: "test-channel".to_string(),
timestamp: 2,
thread_ts: None,
})
.await
.unwrap();
drop(tx);
let started = Instant::now();
run_message_dispatch_loop(rx, runtime_ctx, 2).await;
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_millis(430),
"expected parallel dispatch (<430ms), got {:?}",
elapsed
);
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 2);
}
#[tokio::test]
async fn process_channel_message_cancels_scoped_typing_task() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(SlowProvider {
delay: Duration::from_millis(20),
}),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx,
traits::ChannelMessage {
id: "typing-msg".to_string(),
sender: "alice".to_string(),
reply_target: "chat-typing".to_string(),
content: "hello".to_string(),
channel: "test-channel".to_string(),
timestamp: 1,
thread_ts: None,
},
)
.await;
let starts = channel_impl.start_typing_calls.load(Ordering::SeqCst);
let stops = channel_impl.stop_typing_calls.load(Ordering::SeqCst);
assert_eq!(starts, 1, "start_typing should be called once");
assert_eq!(stops, 1, "stop_typing should be called once");
}
@@ -0,0 +1,388 @@
use super::common::{
HistoryCaptureProvider, IterativeToolProvider, MockPriceTool, ModelCaptureProvider,
NoopMemory, RecordingChannel, SlowProvider, TelegramRecordingChannel, ToolCallingAliasProvider,
ToolCallingProvider,
};
use super::super::context::{ChannelRuntimeContext, ChannelRouteSelection, CHANNEL_MESSAGE_TIMEOUT_SECS};
use super::super::runtime::{process_channel_message, run_message_dispatch_loop};
use super::super::{traits, Channel, SendMessage};
use crate::alphahuman::observability::NoopObserver;
use crate::alphahuman::providers::{self, ChatMessage, Provider};
use crate::alphahuman::tools::Tool;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
async fn process_channel_message_executes_tool_calls_instead_of_sending_raw_json() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(ToolCallingProvider),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx,
traits::ChannelMessage {
id: "msg-1".to_string(),
sender: "alice".to_string(),
reply_target: "chat-42".to_string(),
content: "What is the BTC price now?".to_string(),
channel: "test-channel".to_string(),
timestamp: 1,
thread_ts: None,
},
)
.await;
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].starts_with("chat-42:"));
assert!(sent_messages[0].contains("BTC is currently around"));
assert!(!sent_messages[0].contains("\"tool_calls\""));
assert!(!sent_messages[0].contains("mock_price"));
}
#[tokio::test]
async fn process_channel_message_executes_tool_calls_with_alias_tags() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(ToolCallingAliasProvider),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx,
traits::ChannelMessage {
id: "msg-2".to_string(),
sender: "bob".to_string(),
reply_target: "chat-84".to_string(),
content: "What is the BTC price now?".to_string(),
channel: "test-channel".to_string(),
timestamp: 2,
thread_ts: None,
},
)
.await;
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].starts_with("chat-84:"));
assert!(sent_messages[0].contains("alias-tag flow resolved"));
assert!(!sent_messages[0].contains("<toolcall>"));
assert!(!sent_messages[0].contains("mock_price"));
}
#[tokio::test]
async fn process_channel_message_handles_models_command_without_llm_call() {
let channel_impl = Arc::new(TelegramRecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let default_provider_impl = Arc::new(ModelCaptureProvider::default());
let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
let fallback_provider_impl = Arc::new(ModelCaptureProvider::default());
let fallback_provider: Arc<dyn Provider> = fallback_provider_impl.clone();
let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
provider_cache_seed.insert("openrouter".to_string(), fallback_provider);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::clone(&default_provider),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("default-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx.clone(),
traits::ChannelMessage {
id: "msg-cmd-1".to_string(),
sender: "alice".to_string(),
reply_target: "chat-1".to_string(),
content: "/models openrouter".to_string(),
channel: "telegram".to_string(),
timestamp: 1,
thread_ts: None,
},
)
.await;
let sent = channel_impl.sent_messages.lock().await;
assert_eq!(sent.len(), 1);
assert!(sent[0].contains("Provider switched to `openrouter`"));
let route_key = "telegram_alice";
let route = runtime_ctx
.route_overrides
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(route_key)
.cloned()
.expect("route should be stored for sender");
assert_eq!(route.provider, "openrouter");
assert_eq!(route.model, "default-model");
assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
assert_eq!(fallback_provider_impl.call_count.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn process_channel_message_uses_route_override_provider_and_model() {
let channel_impl = Arc::new(TelegramRecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let default_provider_impl = Arc::new(ModelCaptureProvider::default());
let default_provider: Arc<dyn Provider> = default_provider_impl.clone();
let routed_provider_impl = Arc::new(ModelCaptureProvider::default());
let routed_provider: Arc<dyn Provider> = routed_provider_impl.clone();
let mut provider_cache_seed: HashMap<String, Arc<dyn Provider>> = HashMap::new();
provider_cache_seed.insert("test-provider".to_string(), Arc::clone(&default_provider));
provider_cache_seed.insert("openrouter".to_string(), routed_provider);
let route_key = "telegram_alice".to_string();
let mut route_overrides = HashMap::new();
route_overrides.insert(
route_key,
ChannelRouteSelection {
provider: "openrouter".to_string(),
model: "route-model".to_string(),
},
);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::clone(&default_provider),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("default-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 5,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
route_overrides: Arc::new(Mutex::new(route_overrides)),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx,
traits::ChannelMessage {
id: "msg-routed-1".to_string(),
sender: "alice".to_string(),
reply_target: "chat-1".to_string(),
content: "hello routed provider".to_string(),
channel: "telegram".to_string(),
timestamp: 2,
thread_ts: None,
},
)
.await;
assert_eq!(default_provider_impl.call_count.load(Ordering::SeqCst), 0);
assert_eq!(routed_provider_impl.call_count.load(Ordering::SeqCst), 1);
assert_eq!(
routed_provider_impl
.models
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_slice(),
&["route-model".to_string()]
);
}
#[tokio::test]
async fn process_channel_message_respects_configured_max_tool_iterations_above_default() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(IterativeToolProvider {
required_tool_iterations: 11,
}),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 12,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx,
traits::ChannelMessage {
id: "msg-iter-success".to_string(),
sender: "alice".to_string(),
reply_target: "chat-iter-success".to_string(),
content: "Loop until done".to_string(),
channel: "test-channel".to_string(),
timestamp: 1,
thread_ts: None,
},
)
.await;
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].starts_with("chat-iter-success:"));
assert!(sent_messages[0].contains("Completed after 11 tool iterations."));
assert!(!sent_messages[0].contains("⚠️ Error:"));
}
#[tokio::test]
async fn process_channel_message_reports_configured_max_tool_iterations_limit() {
let channel_impl = Arc::new(RecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: Arc::new(IterativeToolProvider {
required_tool_iterations: 20,
}),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 3,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::alphahuman::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
multimodal: crate::alphahuman::config::MultimodalConfig::default(),
});
process_channel_message(
runtime_ctx,
traits::ChannelMessage {
id: "msg-iter-fail".to_string(),
sender: "bob".to_string(),
reply_target: "chat-iter-fail".to_string(),
content: "Loop forever".to_string(),
channel: "test-channel".to_string(),
timestamp: 2,
thread_ts: None,
},
)
.await;
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].starts_with("chat-iter-fail:"));
assert!(sent_messages[0].contains("⚠️ Error: Agent exceeded maximum tool iterations (3)"));
}
+215
View File
@@ -0,0 +1,215 @@
use async_trait::async_trait;
/// A message received from or sent to a channel
#[derive(Debug, Clone)]
pub struct ChannelMessage {
pub id: String,
pub sender: String,
pub reply_target: String,
pub content: String,
pub channel: String,
pub timestamp: u64,
/// Platform thread identifier (e.g. Slack `ts`, Discord thread ID).
/// When set, replies should be posted as threaded responses.
pub thread_ts: Option<String>,
}
/// Message to send through a channel
#[derive(Debug, Clone)]
pub struct SendMessage {
pub content: String,
pub recipient: String,
pub subject: Option<String>,
/// Platform thread identifier for threaded replies (e.g. Slack `thread_ts`).
pub thread_ts: Option<String>,
}
impl SendMessage {
/// Create a new message with content and recipient
pub fn new(content: impl Into<String>, recipient: impl Into<String>) -> Self {
Self {
content: content.into(),
recipient: recipient.into(),
subject: None,
thread_ts: None,
}
}
/// Create a new message with content, recipient, and subject
pub fn with_subject(
content: impl Into<String>,
recipient: impl Into<String>,
subject: impl Into<String>,
) -> Self {
Self {
content: content.into(),
recipient: recipient.into(),
subject: Some(subject.into()),
thread_ts: None,
}
}
/// Set the thread identifier for threaded replies.
pub fn in_thread(mut self, thread_ts: Option<String>) -> Self {
self.thread_ts = thread_ts;
self
}
}
/// Core channel trait — implement for any messaging platform
#[async_trait]
pub trait Channel: Send + Sync {
/// Human-readable channel name
fn name(&self) -> &str;
/// Send a message through this channel
async fn send(&self, message: &SendMessage) -> anyhow::Result<()>;
/// Start listening for incoming messages (long-running)
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()>;
/// Check if channel is healthy
async fn health_check(&self) -> bool {
true
}
/// Signal that the bot is processing a response (e.g. "typing" indicator).
/// Implementations should repeat the indicator as needed for their platform.
async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
/// Stop any active typing indicator.
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
/// Whether this channel supports progressive message updates via draft edits.
fn supports_draft_updates(&self) -> bool {
false
}
/// Send an initial draft message. Returns a platform-specific message ID for later edits.
async fn send_draft(&self, _message: &SendMessage) -> anyhow::Result<Option<String>> {
Ok(None)
}
/// Update a previously sent draft message with new accumulated content.
async fn update_draft(
&self,
_recipient: &str,
_message_id: &str,
_text: &str,
) -> anyhow::Result<()> {
Ok(())
}
/// Finalize a draft with the complete response (e.g. apply Markdown formatting).
async fn finalize_draft(
&self,
_recipient: &str,
_message_id: &str,
_text: &str,
) -> anyhow::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct DummyChannel;
#[async_trait]
impl Channel for DummyChannel {
fn name(&self) -> &str {
"dummy"
}
async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
Ok(())
}
async fn listen(
&self,
tx: tokio::sync::mpsc::Sender<ChannelMessage>,
) -> anyhow::Result<()> {
tx.send(ChannelMessage {
id: "1".into(),
sender: "tester".into(),
reply_target: "tester".into(),
content: "hello".into(),
channel: "dummy".into(),
timestamp: 123,
thread_ts: None,
})
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}
#[test]
fn channel_message_clone_preserves_fields() {
let message = ChannelMessage {
id: "42".into(),
sender: "alice".into(),
reply_target: "alice".into(),
content: "ping".into(),
channel: "dummy".into(),
timestamp: 999,
thread_ts: None,
};
let cloned = message.clone();
assert_eq!(cloned.id, "42");
assert_eq!(cloned.sender, "alice");
assert_eq!(cloned.reply_target, "alice");
assert_eq!(cloned.content, "ping");
assert_eq!(cloned.channel, "dummy");
assert_eq!(cloned.timestamp, 999);
}
#[tokio::test]
async fn default_trait_methods_return_success() {
let channel = DummyChannel;
assert!(channel.health_check().await);
assert!(channel.start_typing("bob").await.is_ok());
assert!(channel.stop_typing("bob").await.is_ok());
assert!(channel
.send(&SendMessage::new("hello", "bob"))
.await
.is_ok());
}
#[tokio::test]
async fn default_draft_methods_return_success() {
let channel = DummyChannel;
assert!(!channel.supports_draft_updates());
assert!(channel
.send_draft(&SendMessage::new("draft", "bob"))
.await
.unwrap()
.is_none());
assert!(channel.update_draft("bob", "msg_1", "text").await.is_ok());
assert!(channel
.finalize_draft("bob", "msg_1", "final text")
.await
.is_ok());
}
#[tokio::test]
async fn listen_sends_message_to_channel() {
let channel = DummyChannel;
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
channel.listen(tx).await.unwrap();
let received = rx.recv().await.expect("message should be sent");
assert_eq!(received.sender, "tester");
assert_eq!(received.content, "hello");
assert_eq!(received.channel, "dummy");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,525 @@
//! WhatsApp Web channel using wa-rs (native Rust implementation)
//!
//! This channel provides direct WhatsApp Web integration with:
//! - QR code and pair code linking
//! - End-to-end encryption via Signal Protocol
//! - Full Baileys parity (groups, media, presence, reactions, editing/deletion)
//!
//! # Feature Flag
//!
//! This channel requires the `whatsapp-web` feature flag:
//! ```sh
//! cargo build --features whatsapp-web
//! ```
//!
//! # Configuration
//!
//! ```toml
//! [channels.whatsapp]
//! session_path = "~/.alphahuman/whatsapp-session.db" # Required for Web mode
//! pair_phone = "15551234567" # Optional: for pair code linking
//! allowed_numbers = ["+1234567890", "*"] # Same as Cloud API
//! ```
//!
//! # Runtime Negotiation
//!
//! This channel is automatically selected when `session_path` is set in the config.
//! The Cloud API channel is used when `phone_number_id` is set.
use super::traits::{Channel, ChannelMessage, SendMessage};
use super::whatsapp_storage::RusqliteStore;
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use std::sync::Arc;
use tokio::select;
/// WhatsApp Web channel using wa-rs with custom rusqlite storage
///
/// # Status: Functional Implementation
///
/// This implementation uses the wa-rs Bot with our custom RusqliteStore backend.
///
/// # Configuration
///
/// ```toml
/// [channels.whatsapp]
/// session_path = "~/.alphahuman/whatsapp-session.db"
/// pair_phone = "15551234567" # Optional
/// allowed_numbers = ["+1234567890", "*"]
/// ```
#[cfg(feature = "whatsapp-web")]
pub struct WhatsAppWebChannel {
/// Session database path
session_path: String,
/// Phone number for pair code linking (optional)
pair_phone: Option<String>,
/// Custom pair code (optional)
pair_code: Option<String>,
/// Allowed phone numbers (E.164 format) or "*" for all
allowed_numbers: Vec<String>,
/// Bot handle for shutdown
bot_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
/// Client handle for sending messages and typing indicators
client: Arc<Mutex<Option<Arc<wa_rs::Client>>>>,
/// Message sender channel
tx: Arc<Mutex<Option<tokio::sync::mpsc::Sender<ChannelMessage>>>>,
}
impl WhatsAppWebChannel {
/// Create a new WhatsApp Web channel
///
/// # Arguments
///
/// * `session_path` - Path to the SQLite session database
/// * `pair_phone` - Optional phone number for pair code linking (format: "15551234567")
/// * `pair_code` - Optional custom pair code (leave empty for auto-generated)
/// * `allowed_numbers` - Phone numbers allowed to interact (E.164 format) or "*" for all
#[cfg(feature = "whatsapp-web")]
pub fn new(
session_path: String,
pair_phone: Option<String>,
pair_code: Option<String>,
allowed_numbers: Vec<String>,
) -> Self {
Self {
session_path,
pair_phone,
pair_code,
allowed_numbers,
bot_handle: Arc::new(Mutex::new(None)),
client: Arc::new(Mutex::new(None)),
tx: Arc::new(Mutex::new(None)),
}
}
/// Check if a phone number is allowed (E.164 format: +1234567890)
#[cfg(feature = "whatsapp-web")]
fn is_number_allowed(&self, phone: &str) -> bool {
self.allowed_numbers.is_empty()
|| self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
}
/// Normalize phone number to E.164 format
#[cfg(feature = "whatsapp-web")]
fn normalize_phone(&self, phone: &str) -> String {
let trimmed = phone.trim();
let user_part = trimmed
.split_once('@')
.map(|(user, _)| user)
.unwrap_or(trimmed);
let normalized_user = user_part.trim_start_matches('+');
if user_part.starts_with('+') {
format!("+{normalized_user}")
} else {
format!("+{normalized_user}")
}
}
/// Convert a recipient to a wa-rs JID.
///
/// Supports:
/// - Full JIDs (e.g. "12345@s.whatsapp.net")
/// - E.164-like numbers (e.g. "+1234567890")
#[cfg(feature = "whatsapp-web")]
fn recipient_to_jid(&self, recipient: &str) -> Result<wa_rs_binary::jid::Jid> {
let trimmed = recipient.trim();
if trimmed.is_empty() {
anyhow::bail!("Recipient cannot be empty");
}
if trimmed.contains('@') {
return trimmed
.parse::<wa_rs_binary::jid::Jid>()
.map_err(|e| anyhow!("Invalid WhatsApp JID `{trimmed}`: {e}"));
}
let digits: String = trimmed.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
anyhow::bail!("Recipient `{trimmed}` does not contain a valid phone number");
}
Ok(wa_rs_binary::jid::Jid::pn(digits))
}
}
#[cfg(feature = "whatsapp-web")]
#[async_trait]
impl Channel for WhatsAppWebChannel {
fn name(&self) -> &str {
"whatsapp"
}
async fn send(&self, message: &SendMessage) -> Result<()> {
let client = self.client.lock().clone();
let Some(client) = client else {
anyhow::bail!("WhatsApp Web client not connected. Initialize the bot first.");
};
// Validate recipient is allowed
let normalized = self.normalize_phone(&message.recipient);
if !self.is_number_allowed(&normalized) {
tracing::warn!(
"WhatsApp Web: recipient {} not in allowed list",
message.recipient
);
return Ok(());
}
let to = self.recipient_to_jid(&message.recipient)?;
let outgoing = wa_rs_proto::whatsapp::Message {
conversation: Some(message.content.clone()),
..Default::default()
};
let message_id = client.send_message(to, outgoing).await?;
tracing::debug!(
"WhatsApp Web: sent message to {} (id: {})",
message.recipient,
message_id
);
Ok(())
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
// Store the sender channel for incoming messages
*self.tx.lock() = Some(tx.clone());
use wa_rs::bot::Bot;
use wa_rs::pair_code::PairCodeOptions;
use wa_rs::store::{Device, DeviceStore};
use wa_rs_binary::jid::JidExt as _;
use wa_rs_core::proto_helpers::MessageExt;
use wa_rs_core::types::events::Event;
use wa_rs_tokio_transport::TokioWebSocketTransportFactory;
use wa_rs_ureq_http::UreqHttpClient;
tracing::info!(
"WhatsApp Web channel starting (session: {})",
self.session_path
);
// Initialize storage backend
let storage = RusqliteStore::new(&self.session_path)?;
let backend = Arc::new(storage);
// Check if we have a saved device to load
let mut device = Device::new(backend.clone());
if backend.exists().await? {
tracing::info!("WhatsApp Web: found existing session, loading device");
if let Some(core_device) = backend.load().await? {
device.load_from_serializable(core_device);
} else {
anyhow::bail!("Device exists but failed to load");
}
} else {
tracing::info!(
"WhatsApp Web: no existing session, new device will be created during pairing"
);
};
// Create transport factory
let mut transport_factory = TokioWebSocketTransportFactory::new();
if let Ok(ws_url) = std::env::var("WHATSAPP_WS_URL") {
transport_factory = transport_factory.with_url(ws_url);
}
// Create HTTP client for media operations
let http_client = UreqHttpClient::new();
// Build the bot
let tx_clone = tx.clone();
let allowed_numbers = self.allowed_numbers.clone();
let mut builder = Bot::builder()
.with_backend(backend)
.with_transport_factory(transport_factory)
.with_http_client(http_client)
.on_event(move |event, _client| {
let tx_inner = tx_clone.clone();
let allowed_numbers = allowed_numbers.clone();
async move {
match event {
Event::Message(msg, info) => {
// Extract message content
let text = msg.text_content().unwrap_or("");
let sender = info.source.sender.user().to_string();
let chat = info.source.chat.to_string();
tracing::info!("📨 WhatsApp message from {} in {}: {}", sender, chat, text);
// Check if sender is allowed
let normalized = if sender.starts_with('+') {
sender.clone()
} else {
format!("+{sender}")
};
if allowed_numbers.is_empty()
|| allowed_numbers.iter().any(|n| n == "*" || n == &normalized)
{
if let Err(e) = tx_inner
.send(ChannelMessage {
id: uuid::Uuid::new_v4().to_string(),
channel: "whatsapp".to_string(),
sender: normalized.clone(),
reply_target: normalized.clone(),
content: text.to_string(),
timestamp: chrono::Utc::now().timestamp_millis() as u64,
})
.await
{
tracing::error!("Failed to send message to channel: {}", e);
}
} else {
tracing::warn!("WhatsApp Web: message from {} not in allowed list", normalized);
}
}
Event::Connected(_) => {
tracing::info!("✅ WhatsApp Web connected successfully!");
}
Event::LoggedOut(_) => {
tracing::warn!("❌ WhatsApp Web was logged out!");
}
Event::StreamError(stream_error) => {
tracing::error!("❌ WhatsApp Web stream error: {:?}", stream_error);
}
Event::PairingCode { code, .. } => {
tracing::info!("🔑 Pair code received: {}", code);
tracing::info!("Link your phone by entering this code in WhatsApp > Linked Devices");
}
Event::PairingQrCode { code, .. } => {
tracing::info!("📱 QR code received (scan with WhatsApp > Linked Devices)");
tracing::debug!("QR code: {}", code);
}
_ => {}
}
}
})
;
// Configure pair-code flow when a phone number is provided.
if let Some(ref phone) = self.pair_phone {
tracing::info!("WhatsApp Web: pair-code flow enabled for configured phone number");
builder = builder.with_pair_code(PairCodeOptions {
phone_number: phone.clone(),
custom_code: self.pair_code.clone(),
..Default::default()
});
} else if self.pair_code.is_some() {
tracing::warn!(
"WhatsApp Web: pair_code is set but pair_phone is missing; pair code config is ignored"
);
}
let mut bot = builder.build().await?;
*self.client.lock() = Some(bot.client());
// Run the bot
let bot_handle = bot.run().await?;
// Store the bot handle for later shutdown
*self.bot_handle.lock() = Some(bot_handle);
// Wait for shutdown signal
let (_shutdown_tx, mut shutdown_rx) = tokio::sync::broadcast::channel::<()>(1);
select! {
_ = shutdown_rx.recv() => {
tracing::info!("WhatsApp Web channel shutting down");
}
_ = tokio::signal::ctrl_c() => {
tracing::info!("WhatsApp Web channel received Ctrl+C");
}
}
*self.client.lock() = None;
if let Some(handle) = self.bot_handle.lock().take() {
handle.abort();
}
Ok(())
}
async fn health_check(&self) -> bool {
let bot_handle_guard = self.bot_handle.lock();
bot_handle_guard.is_some()
}
async fn start_typing(&self, recipient: &str) -> Result<()> {
let client = self.client.lock().clone();
let Some(client) = client else {
anyhow::bail!("WhatsApp Web client not connected. Initialize the bot first.");
};
let normalized = self.normalize_phone(recipient);
if !self.is_number_allowed(&normalized) {
tracing::warn!(
"WhatsApp Web: typing target {} not in allowed list",
recipient
);
return Ok(());
}
let to = self.recipient_to_jid(recipient)?;
client
.chatstate()
.send_composing(&to)
.await
.map_err(|e| anyhow!("Failed to send typing state (composing): {e}"))?;
tracing::debug!("WhatsApp Web: start typing for {}", recipient);
Ok(())
}
async fn stop_typing(&self, recipient: &str) -> Result<()> {
let client = self.client.lock().clone();
let Some(client) = client else {
anyhow::bail!("WhatsApp Web client not connected. Initialize the bot first.");
};
let normalized = self.normalize_phone(recipient);
if !self.is_number_allowed(&normalized) {
tracing::warn!(
"WhatsApp Web: typing target {} not in allowed list",
recipient
);
return Ok(());
}
let to = self.recipient_to_jid(recipient)?;
client
.chatstate()
.send_paused(&to)
.await
.map_err(|e| anyhow!("Failed to send typing state (paused): {e}"))?;
tracing::debug!("WhatsApp Web: stop typing for {}", recipient);
Ok(())
}
}
// Stub implementation when feature is not enabled
#[cfg(not(feature = "whatsapp-web"))]
pub struct WhatsAppWebChannel {
_private: (),
}
#[cfg(not(feature = "whatsapp-web"))]
impl WhatsAppWebChannel {
pub fn new(
_session_path: String,
_pair_phone: Option<String>,
_pair_code: Option<String>,
_allowed_numbers: Vec<String>,
) -> Self {
Self { _private: () }
}
}
#[cfg(not(feature = "whatsapp-web"))]
#[async_trait]
impl Channel for WhatsAppWebChannel {
fn name(&self) -> &str {
"whatsapp"
}
async fn send(&self, _message: &SendMessage) -> Result<()> {
anyhow::bail!(
"WhatsApp Web channel requires the 'whatsapp-web' feature. \
Enable with: cargo build --features whatsapp-web"
);
}
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
anyhow::bail!(
"WhatsApp Web channel requires the 'whatsapp-web' feature. \
Enable with: cargo build --features whatsapp-web"
);
}
async fn health_check(&self) -> bool {
false
}
async fn start_typing(&self, _recipient: &str) -> Result<()> {
anyhow::bail!(
"WhatsApp Web channel requires the 'whatsapp-web' feature. \
Enable with: cargo build --features whatsapp-web"
);
}
async fn stop_typing(&self, _recipient: &str) -> Result<()> {
anyhow::bail!(
"WhatsApp Web channel requires the 'whatsapp-web' feature. \
Enable with: cargo build --features whatsapp-web"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "whatsapp-web")]
fn make_channel() -> WhatsAppWebChannel {
WhatsAppWebChannel::new(
"/tmp/test-whatsapp.db".into(),
None,
None,
vec!["+1234567890".into()],
)
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_channel_name() {
let ch = make_channel();
assert_eq!(ch.name(), "whatsapp");
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_number_allowed_exact() {
let ch = make_channel();
assert!(ch.is_number_allowed("+1234567890"));
assert!(!ch.is_number_allowed("+9876543210"));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_number_allowed_wildcard() {
let ch = WhatsAppWebChannel::new("/tmp/test.db".into(), None, None, vec!["*".into()]);
assert!(ch.is_number_allowed("+1234567890"));
assert!(ch.is_number_allowed("+9999999999"));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_number_denied_empty() {
let ch = WhatsAppWebChannel::new("/tmp/test.db".into(), None, None, vec![]);
// Empty allowed_numbers means "allow all" (same behavior as Cloud API)
assert!(ch.is_number_allowed("+1234567890"));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_normalize_phone_adds_plus() {
let ch = make_channel();
assert_eq!(ch.normalize_phone("1234567890"), "+1234567890");
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_normalize_phone_preserves_plus() {
let ch = make_channel();
assert_eq!(ch.normalize_phone("+1234567890"), "+1234567890");
}
#[tokio::test]
#[cfg(feature = "whatsapp-web")]
async fn whatsapp_web_health_check_disconnected() {
let ch = make_channel();
assert!(!ch.health_check().await);
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Tauri-focused daemon configuration wrapper for alphahuman.
use crate::alphahuman::config::{
AuditConfig, AutonomyConfig, ReliabilityConfig, SecretsConfig, SecurityConfig,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Top-level daemon configuration for the Tauri supervisor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
/// Root data directory (defaults to Tauri's `app_data_dir/alphahuman`).
pub data_dir: PathBuf,
/// Workspace directory the agent may operate within.
pub workspace_dir: PathBuf,
/// Autonomy / command-policy settings.
#[serde(default)]
pub autonomy: AutonomyConfig,
/// Security / sandbox settings.
#[serde(default)]
pub security: SecurityConfig,
/// Reliability / backoff settings.
#[serde(default)]
pub reliability: ReliabilityConfig,
/// Encrypted secret store settings.
#[serde(default)]
pub secrets: SecretsConfig,
/// Audit logging settings.
#[serde(default)]
pub audit: AuditConfig,
}
impl DaemonConfig {
/// Build a config that derives paths from the Tauri `app_data_dir`.
pub fn from_app_data_dir(app_data_dir: &std::path::Path) -> Self {
let data_dir = app_data_dir.join("alphahuman");
let workspace_dir = data_dir.join("workspace");
log::info!(
"[alphahuman:config] Initialized config: data_dir={}, workspace_dir={}",
data_dir.display(),
workspace_dir.display()
);
Self {
data_dir,
workspace_dir,
autonomy: AutonomyConfig::default(),
security: SecurityConfig::default(),
reliability: ReliabilityConfig::default(),
secrets: SecretsConfig::default(),
audit: AuditConfig::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn daemon_config_from_app_data_dir() {
let app_data = std::path::PathBuf::from("/tmp/test-alphahuman");
let config = DaemonConfig::from_app_data_dir(&app_data);
assert_eq!(config.data_dir, app_data.join("alphahuman"));
assert_eq!(
config.workspace_dir,
app_data.join("alphahuman").join("workspace")
);
}
}
+70
View File
@@ -0,0 +1,70 @@
pub mod schema;
pub mod daemon;
#[allow(unused_imports)]
pub use daemon::DaemonConfig;
#[allow(unused_imports)]
pub use schema::{
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
ChannelsConfig, ClassificationRule, CloudflareTunnelConfig, ComposioConfig, Config,
CostConfig, CronConfig, CustomTunnelConfig, DelegateAgentConfig, DiscordConfig,
DockerRuntimeConfig, EmbeddingRouteConfig, GatewayConfig, HardwareConfig, HardwareTransport,
HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig,
MemoryConfig, ModelRouteConfig, MultimodalConfig, NgrokTunnelConfig, ObservabilityConfig,
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig,
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig,
StorageProviderConfig, StorageProviderSection, StreamMode, TailscaleTunnelConfig,
TelegramConfig, TunnelConfig, WebSearchConfig, WebhookConfig,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reexported_config_default_is_constructible() {
let config = Config::default();
assert!(config.default_provider.is_some());
assert!(config.default_model.is_some());
assert!(config.default_temperature > 0.0);
}
#[test]
fn reexported_channel_configs_are_constructible() {
let telegram = TelegramConfig {
bot_token: "token".into(),
allowed_users: vec!["alice".into()],
stream_mode: StreamMode::default(),
draft_update_interval_ms: 1000,
mention_only: false,
};
let discord = DiscordConfig {
bot_token: "token".into(),
guild_id: Some("123".into()),
allowed_users: vec![],
listen_to_bots: false,
mention_only: false,
};
let lark = LarkConfig {
app_id: "app-id".into(),
app_secret: "app-secret".into(),
encrypt_key: None,
verification_token: None,
allowed_users: vec![],
use_feishu: false,
receive_mode: crate::alphahuman::config::schema::LarkReceiveMode::Websocket,
port: None,
};
assert_eq!(telegram.allowed_users.len(), 1);
assert_eq!(discord.guild_id.as_deref(), Some("123"));
assert_eq!(lark.app_id, "app-id");
}
}
@@ -0,0 +1,69 @@
//! Agent and delegate agent configuration.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Configuration for a delegate sub-agent used by the `delegate` tool.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DelegateAgentConfig {
/// Provider name (e.g. "ollama", "openrouter", "anthropic")
pub provider: String,
/// Model name
pub model: String,
/// Optional system prompt for the sub-agent
#[serde(default)]
pub system_prompt: Option<String>,
/// Optional API key override
#[serde(default)]
pub api_key: Option<String>,
/// Temperature override
#[serde(default)]
pub temperature: Option<f64>,
/// Max recursion depth for nested delegation
#[serde(default = "default_max_depth")]
pub max_depth: u32,
}
fn default_max_depth() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgentConfig {
/// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models.
#[serde(default)]
pub compact_context: bool,
#[serde(default = "default_agent_max_tool_iterations")]
pub max_tool_iterations: usize,
#[serde(default = "default_agent_max_history_messages")]
pub max_history_messages: usize,
#[serde(default)]
pub parallel_tools: bool,
#[serde(default = "default_agent_tool_dispatcher")]
pub tool_dispatcher: String,
}
fn default_agent_max_tool_iterations() -> usize {
10
}
fn default_agent_max_history_messages() -> usize {
50
}
fn default_agent_tool_dispatcher() -> String {
"auto".into()
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
compact_context: false,
max_tool_iterations: default_agent_max_tool_iterations(),
max_history_messages: default_agent_max_history_messages(),
parallel_tools: false,
tool_dispatcher: default_agent_tool_dispatcher(),
}
}
}
@@ -0,0 +1,91 @@
//! Autonomy and security policy configuration.
use crate::alphahuman::security::AutonomyLevel;
use super::defaults;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AutonomyConfig {
pub level: AutonomyLevel,
pub workspace_only: bool,
pub allowed_commands: Vec<String>,
pub forbidden_paths: Vec<String>,
pub max_actions_per_hour: u32,
pub max_cost_per_day_cents: u32,
#[serde(default = "default_true")]
pub require_approval_for_medium_risk: bool,
#[serde(default = "default_true")]
pub block_high_risk_commands: bool,
#[serde(default = "default_auto_approve")]
pub auto_approve: Vec<String>,
#[serde(default = "default_always_ask")]
pub always_ask: Vec<String>,
}
fn default_true() -> bool {
defaults::default_true()
}
fn default_auto_approve() -> Vec<String> {
vec![
"file_read".into(),
"memory_search".into(),
"memory_list".into(),
"get_time".into(),
"list_dir".into(),
]
}
fn default_always_ask() -> Vec<String> {
vec![]
}
impl Default for AutonomyConfig {
fn default() -> Self {
Self {
level: AutonomyLevel::Supervised,
workspace_only: true,
allowed_commands: vec![
"git".into(),
"npm".into(),
"cargo".into(),
"ls".into(),
"cat".into(),
"grep".into(),
"find".into(),
"echo".into(),
"pwd".into(),
"wc".into(),
"head".into(),
"tail".into(),
],
forbidden_paths: vec![
"/etc".into(),
"/root".into(),
"/home".into(),
"/usr".into(),
"/bin".into(),
"/sbin".into(),
"/lib".into(),
"/opt".into(),
"/boot".into(),
"/dev".into(),
"/proc".into(),
"/sys".into(),
"/var".into(),
"/tmp".into(),
"~/.ssh".into(),
"~/.gnupg".into(),
"~/.aws".into(),
"~/.config".into(),
],
max_actions_per_hour: 20,
max_cost_per_day_cents: 500,
require_approval_for_medium_risk: true,
block_high_risk_commands: true,
auto_approve: default_auto_approve(),
always_ask: default_always_ask(),
}
}
}
@@ -0,0 +1,374 @@
//! Channels configuration (Telegram, Discord, Slack, Matrix, etc.) and security/sandbox.
use crate::alphahuman::channels::email_channel::EmailConfig;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ChannelsConfig {
pub cli: bool,
pub telegram: Option<TelegramConfig>,
pub discord: Option<DiscordConfig>,
pub slack: Option<SlackConfig>,
pub mattermost: Option<MattermostConfig>,
pub webhook: Option<WebhookConfig>,
pub imessage: Option<IMessageConfig>,
pub matrix: Option<MatrixConfig>,
pub signal: Option<SignalConfig>,
pub whatsapp: Option<WhatsAppConfig>,
pub linq: Option<LinqConfig>,
pub email: Option<EmailConfig>,
pub irc: Option<IrcConfig>,
pub lark: Option<LarkConfig>,
pub dingtalk: Option<DingTalkConfig>,
pub qq: Option<QQConfig>,
#[serde(default = "default_channel_message_timeout_secs")]
pub message_timeout_secs: u64,
}
fn default_channel_message_timeout_secs() -> u64 {
300
}
impl Default for ChannelsConfig {
fn default() -> Self {
Self {
cli: true,
telegram: None,
discord: None,
slack: None,
mattermost: None,
webhook: None,
imessage: None,
matrix: None,
signal: None,
whatsapp: None,
linq: None,
email: None,
irc: None,
lark: None,
dingtalk: None,
qq: None,
message_timeout_secs: default_channel_message_timeout_secs(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum StreamMode {
#[default]
Off,
Partial,
}
pub(crate) fn default_draft_update_interval_ms() -> u64 {
1000
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TelegramConfig {
pub bot_token: String,
pub allowed_users: Vec<String>,
#[serde(default)]
pub stream_mode: StreamMode,
#[serde(default = "default_draft_update_interval_ms")]
pub draft_update_interval_ms: u64,
#[serde(default)]
pub mention_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DiscordConfig {
pub bot_token: String,
pub guild_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
#[serde(default)]
pub listen_to_bots: bool,
#[serde(default)]
pub mention_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SlackConfig {
pub bot_token: String,
pub app_token: Option<String>,
pub channel_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MattermostConfig {
pub url: String,
pub bot_token: String,
pub channel_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
#[serde(default)]
pub thread_replies: Option<bool>,
#[serde(default)]
pub mention_only: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WebhookConfig {
pub port: u16,
pub secret: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct IMessageConfig {
pub allowed_contacts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MatrixConfig {
pub homeserver: String,
pub access_token: String,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub device_id: Option<String>,
pub room_id: String,
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SignalConfig {
pub http_url: String,
pub account: String,
#[serde(default)]
pub group_id: Option<String>,
#[serde(default)]
pub allowed_from: Vec<String>,
#[serde(default)]
pub ignore_attachments: bool,
#[serde(default)]
pub ignore_stories: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WhatsAppConfig {
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub phone_number_id: Option<String>,
#[serde(default)]
pub verify_token: Option<String>,
#[serde(default)]
pub app_secret: Option<String>,
#[serde(default)]
pub session_path: Option<String>,
#[serde(default)]
pub pair_phone: Option<String>,
#[serde(default)]
pub pair_code: Option<String>,
#[serde(default)]
pub allowed_numbers: Vec<String>,
}
impl WhatsAppConfig {
pub fn backend_type(&self) -> &'static str {
if self.phone_number_id.is_some() {
"cloud"
} else if self.session_path.is_some() {
"web"
} else {
"cloud"
}
}
pub fn is_cloud_config(&self) -> bool {
self.phone_number_id.is_some() && self.access_token.is_some() && self.verify_token.is_some()
}
pub fn is_web_config(&self) -> bool {
self.session_path.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LinqConfig {
pub api_token: String,
pub from_phone: String,
#[serde(default)]
pub signing_secret: Option<String>,
#[serde(default)]
pub allowed_senders: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct IrcConfig {
pub server: String,
#[serde(default = "default_irc_port")]
pub port: u16,
pub nickname: String,
pub username: Option<String>,
#[serde(default)]
pub channels: Vec<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
pub server_password: Option<String>,
pub nickserv_password: Option<String>,
pub sasl_password: Option<String>,
pub verify_tls: Option<bool>,
}
fn default_irc_port() -> u16 {
6697
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum LarkReceiveMode {
#[default]
Websocket,
Webhook,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LarkConfig {
pub app_id: String,
pub app_secret: String,
#[serde(default)]
pub encrypt_key: Option<String>,
#[serde(default)]
pub verification_token: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
#[serde(default)]
pub use_feishu: bool,
#[serde(default)]
pub receive_mode: LarkReceiveMode,
#[serde(default)]
pub port: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct SecurityConfig {
#[serde(default)]
pub sandbox: SandboxConfig,
#[serde(default)]
pub resources: ResourceLimitsConfig,
#[serde(default)]
pub audit: AuditConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SandboxConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub backend: SandboxBackend,
#[serde(default)]
pub firejail_args: Vec<String>,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
enabled: None,
backend: SandboxBackend::Auto,
firejail_args: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum SandboxBackend {
#[default]
Auto,
Landlock,
Firejail,
Bubblewrap,
Docker,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ResourceLimitsConfig {
#[serde(default = "default_max_memory_mb")]
pub max_memory_mb: u32,
#[serde(default = "default_max_cpu_time_seconds")]
pub max_cpu_time_seconds: u64,
#[serde(default = "default_max_subprocesses")]
pub max_subprocesses: u32,
#[serde(default = "default_memory_monitoring_enabled")]
pub memory_monitoring: bool,
}
fn default_max_memory_mb() -> u32 {
512
}
fn default_max_cpu_time_seconds() -> u64 {
60
}
fn default_max_subprocesses() -> u32 {
10
}
fn default_memory_monitoring_enabled() -> bool {
true
}
impl Default for ResourceLimitsConfig {
fn default() -> Self {
Self {
max_memory_mb: default_max_memory_mb(),
max_cpu_time_seconds: default_max_cpu_time_seconds(),
max_subprocesses: default_max_subprocesses(),
memory_monitoring: default_memory_monitoring_enabled(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuditConfig {
#[serde(default = "default_audit_enabled")]
pub enabled: bool,
#[serde(default = "default_audit_log_path")]
pub log_path: String,
#[serde(default = "default_audit_max_size_mb")]
pub max_size_mb: u32,
#[serde(default)]
pub sign_events: bool,
}
fn default_audit_enabled() -> bool {
true
}
fn default_audit_log_path() -> String {
"audit.log".to_string()
}
fn default_audit_max_size_mb() -> u32 {
100
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
enabled: default_audit_enabled(),
log_path: default_audit_log_path(),
max_size_mb: default_audit_max_size_mb(),
sign_events: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DingTalkConfig {
pub client_id: String,
pub client_secret: String,
#[serde(default)]
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct QQConfig {
pub app_id: String,
pub app_secret: String,
#[serde(default)]
pub allowed_users: Vec<String>,
}
@@ -0,0 +1,6 @@
//! Shared default value helpers used by multiple config structs.
/// Used by gateway, tools, storage/memory, autonomy, runtime for serde defaults.
pub fn default_true() -> bool {
true
}
@@ -0,0 +1,81 @@
//! Gateway (pairing, webhook, rate limits) configuration.
use super::defaults;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GatewayConfig {
#[serde(default = "default_gateway_port")]
pub port: u16,
#[serde(default = "default_gateway_host")]
pub host: String,
#[serde(default = "default_true")]
pub require_pairing: bool,
#[serde(default)]
pub allow_public_bind: bool,
#[serde(default)]
pub paired_tokens: Vec<String>,
#[serde(default = "default_pair_rate_limit")]
pub pair_rate_limit_per_minute: u32,
#[serde(default = "default_webhook_rate_limit")]
pub webhook_rate_limit_per_minute: u32,
#[serde(default)]
pub trust_forwarded_headers: bool,
#[serde(default = "default_gateway_rate_limit_max_keys")]
pub rate_limit_max_keys: usize,
#[serde(default = "default_idempotency_ttl_secs")]
pub idempotency_ttl_secs: u64,
#[serde(default = "default_gateway_idempotency_max_keys")]
pub idempotency_max_keys: usize,
}
fn default_true() -> bool {
defaults::default_true()
}
fn default_gateway_port() -> u16 {
3000
}
fn default_gateway_host() -> String {
"127.0.0.1".into()
}
fn default_pair_rate_limit() -> u32 {
10
}
fn default_webhook_rate_limit() -> u32 {
60
}
fn default_idempotency_ttl_secs() -> u64 {
300
}
fn default_gateway_rate_limit_max_keys() -> usize {
10_000
}
fn default_gateway_idempotency_max_keys() -> usize {
10_000
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
port: default_gateway_port(),
host: default_gateway_host(),
require_pairing: true,
allow_public_bind: false,
paired_tokens: Vec::new(),
pair_rate_limit_per_minute: default_pair_rate_limit(),
webhook_rate_limit_per_minute: default_webhook_rate_limit(),
trust_forwarded_headers: false,
rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
idempotency_ttl_secs: default_idempotency_ttl_secs(),
idempotency_max_keys: default_gateway_idempotency_max_keys(),
}
}
}
@@ -0,0 +1,72 @@
//! Hardware (wizard-driven) configuration.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Hardware transport mode.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
pub enum HardwareTransport {
#[default]
None,
Native,
Serial,
Probe,
}
impl std::fmt::Display for HardwareTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "none"),
Self::Native => write!(f, "native"),
Self::Serial => write!(f, "serial"),
Self::Probe => write!(f, "probe"),
}
}
}
fn default_baud_rate() -> u32 {
115_200
}
/// Wizard-driven hardware configuration for physical world interaction.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HardwareConfig {
/// Whether hardware access is enabled
#[serde(default)]
pub enabled: bool,
/// Transport mode
#[serde(default)]
pub transport: HardwareTransport,
/// Serial port path (e.g. "/dev/ttyACM0")
#[serde(default)]
pub serial_port: Option<String>,
/// Serial baud rate
#[serde(default = "default_baud_rate")]
pub baud_rate: u32,
/// Probe target chip (e.g. "STM32F401RE")
#[serde(default)]
pub probe_target: Option<String>,
/// Enable workspace datasheet RAG (index PDF schematics for AI pin lookups)
#[serde(default)]
pub workspace_datasheets: bool,
}
impl HardwareConfig {
/// Return the active transport mode.
pub fn transport_mode(&self) -> HardwareTransport {
self.transport.clone()
}
}
impl Default for HardwareConfig {
fn default() -> Self {
Self {
enabled: false,
transport: HardwareTransport::None,
serial_port: None,
baud_rate: default_baud_rate(),
probe_target: None,
workspace_datasheets: false,
}
}
}
@@ -0,0 +1,44 @@
//! Heartbeat and cron configuration.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HeartbeatConfig {
pub enabled: bool,
pub interval_minutes: u32,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 30,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CronConfig {
#[serde(default = "default_cron_enabled")]
pub enabled: bool,
#[serde(default = "default_cron_max_run_history")]
pub max_run_history: usize,
}
fn default_cron_enabled() -> bool {
true
}
fn default_cron_max_run_history() -> usize {
50
}
impl Default for CronConfig {
fn default() -> Self {
Self {
enabled: default_cron_enabled(),
max_run_history: default_cron_max_run_history(),
}
}
}
@@ -0,0 +1,208 @@
//! Identity (AIEOS/OpenClaw) and cost tracking configuration.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct IdentityConfig {
/// Identity format: "openclaw" (default) or "aieos"
#[serde(default = "default_identity_format")]
pub format: String,
/// Path to AIEOS JSON file (relative to workspace)
#[serde(default)]
pub aieos_path: Option<String>,
/// Inline AIEOS JSON (alternative to file path)
#[serde(default)]
pub aieos_inline: Option<String>,
}
fn default_identity_format() -> String {
"openclaw".into()
}
impl Default for IdentityConfig {
fn default() -> Self {
Self {
format: default_identity_format(),
aieos_path: None,
aieos_inline: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CostConfig {
/// Enable cost tracking (default: false)
#[serde(default)]
pub enabled: bool,
/// Daily spending limit in USD (default: 10.00)
#[serde(default = "default_daily_limit")]
pub daily_limit_usd: f64,
/// Monthly spending limit in USD (default: 100.00)
#[serde(default = "default_monthly_limit")]
pub monthly_limit_usd: f64,
/// Warn when spending reaches this percentage of limit (default: 80)
#[serde(default = "default_warn_percent")]
pub warn_at_percent: u8,
/// Allow requests to exceed budget with --override flag (default: false)
#[serde(default)]
pub allow_override: bool,
/// Per-model pricing (USD per 1M tokens)
#[serde(default)]
pub prices: HashMap<String, ModelPricing>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ModelPricing {
/// Input price per 1M tokens
#[serde(default)]
pub input: f64,
/// Output price per 1M tokens
#[serde(default)]
pub output: f64,
}
fn default_daily_limit() -> f64 {
10.0
}
fn default_monthly_limit() -> f64 {
100.0
}
fn default_warn_percent() -> u8 {
80
}
impl Default for CostConfig {
fn default() -> Self {
Self {
enabled: false,
daily_limit_usd: default_daily_limit(),
monthly_limit_usd: default_monthly_limit(),
warn_at_percent: default_warn_percent(),
allow_override: false,
prices: get_default_pricing(),
}
}
}
/// Default pricing for popular models (USD per 1M tokens)
fn get_default_pricing() -> HashMap<String, ModelPricing> {
let mut prices = HashMap::new();
prices.insert(
"anthropic/claude-sonnet-4-20250514".into(),
ModelPricing {
input: 3.0,
output: 15.0,
},
);
prices.insert(
"anthropic/claude-opus-4-20250514".into(),
ModelPricing {
input: 15.0,
output: 75.0,
},
);
prices.insert(
"anthropic/claude-3.5-sonnet".into(),
ModelPricing {
input: 3.0,
output: 15.0,
},
);
prices.insert(
"anthropic/claude-3-haiku".into(),
ModelPricing {
input: 0.25,
output: 1.25,
},
);
prices.insert(
"openai/gpt-4o".into(),
ModelPricing {
input: 5.0,
output: 15.0,
},
);
prices.insert(
"openai/gpt-4o-mini".into(),
ModelPricing {
input: 0.15,
output: 0.60,
},
);
prices.insert(
"openai/o1-preview".into(),
ModelPricing {
input: 15.0,
output: 60.0,
},
);
prices.insert(
"google/gemini-2.0-flash".into(),
ModelPricing {
input: 0.10,
output: 0.40,
},
);
prices.insert(
"google/gemini-1.5-pro".into(),
ModelPricing {
input: 1.25,
output: 5.0,
},
);
prices
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct PeripheralsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub boards: Vec<PeripheralBoardConfig>,
#[serde(default)]
pub datasheet_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PeripheralBoardConfig {
pub board: String,
#[serde(default = "default_peripheral_transport")]
pub transport: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default = "default_peripheral_baud")]
pub baud: u32,
}
fn default_peripheral_transport() -> String {
"serial".into()
}
fn default_peripheral_baud() -> u32 {
115_200
}
impl Default for PeripheralBoardConfig {
fn default() -> Self {
Self {
board: String::new(),
transport: default_peripheral_transport(),
path: None,
baud: default_peripheral_baud(),
}
}
}
@@ -0,0 +1,700 @@
//! Config load/save and environment variable overrides.
use super::{
proxy::{
normalize_no_proxy_list, normalize_proxy_url_option, normalize_service_list,
parse_proxy_enabled, parse_proxy_scope, set_runtime_proxy_config, ProxyScope,
},
Config,
};
use crate::alphahuman::providers::{is_glm_alias, is_zai_alias};
use anyhow::{Context, Result};
use directories::UserDirs;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs::{self, File, OpenOptions};
use tokio::io::AsyncWriteExt;
fn default_config_and_workspace_dirs() -> Result<(PathBuf, PathBuf)> {
let config_dir = default_config_dir()?;
Ok((config_dir.clone(), config_dir.join("workspace")))
}
const ACTIVE_WORKSPACE_STATE_FILE: &str = "active_workspace.toml";
#[derive(Debug, Serialize, Deserialize)]
struct ActiveWorkspaceState {
config_dir: String,
}
fn default_config_dir() -> Result<PathBuf> {
let home = UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.context("Could not find home directory")?;
Ok(home.join(".alphahuman"))
}
fn active_workspace_state_path(default_dir: &Path) -> PathBuf {
default_dir.join(ACTIVE_WORKSPACE_STATE_FILE)
}
async fn load_persisted_workspace_dirs(
default_config_dir: &Path,
) -> Result<Option<(PathBuf, PathBuf)>> {
let state_path = active_workspace_state_path(default_config_dir);
if !state_path.exists() {
return Ok(None);
}
let contents = match fs::read_to_string(&state_path).await {
Ok(contents) => contents,
Err(error) => {
tracing::warn!(
"Failed to read active workspace marker {}: {error}",
state_path.display()
);
return Ok(None);
}
};
let state: ActiveWorkspaceState = match toml::from_str(&contents) {
Ok(state) => state,
Err(error) => {
tracing::warn!(
"Failed to parse active workspace marker {}: {error}",
state_path.display()
);
return Ok(None);
}
};
let raw_config_dir = state.config_dir.trim();
if raw_config_dir.is_empty() {
tracing::warn!(
"Ignoring active workspace marker {} because config_dir is empty",
state_path.display()
);
return Ok(None);
}
let parsed_dir = PathBuf::from(raw_config_dir);
let config_dir = if parsed_dir.is_absolute() {
parsed_dir
} else {
default_config_dir.join(parsed_dir)
};
Ok(Some((config_dir.clone(), config_dir.join("workspace"))))
}
pub(crate) async fn persist_active_workspace_config_dir(config_dir: &Path) -> Result<()> {
let default_config_dir = default_config_dir()?;
let state_path = active_workspace_state_path(&default_config_dir);
if config_dir == default_config_dir {
if state_path.exists() {
fs::remove_file(&state_path).await.with_context(|| {
format!(
"Failed to clear active workspace marker: {}",
state_path.display()
)
})?;
}
return Ok(());
}
fs::create_dir_all(&default_config_dir)
.await
.with_context(|| {
format!(
"Failed to create default config directory: {}",
default_config_dir.display()
)
})?;
let state = ActiveWorkspaceState {
config_dir: config_dir.to_string_lossy().into_owned(),
};
let serialized =
toml::to_string_pretty(&state).context("Failed to serialize active workspace marker")?;
let temp_path = default_config_dir.join(format!(
".{ACTIVE_WORKSPACE_STATE_FILE}.tmp-{}",
uuid::Uuid::new_v4()
));
fs::write(&temp_path, serialized).await.with_context(|| {
format!(
"Failed to write temporary active workspace marker: {}",
temp_path.display()
)
})?;
if let Err(error) = fs::rename(&temp_path, &state_path).await {
let _ = fs::remove_file(&temp_path).await;
anyhow::bail!(
"Failed to atomically persist active workspace marker {}: {error}",
state_path.display()
);
}
sync_directory(&default_config_dir).await?;
Ok(())
}
fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf, PathBuf) {
let workspace_config_dir = workspace_dir.to_path_buf();
if workspace_config_dir.join("config.toml").exists() {
return (
workspace_config_dir.clone(),
workspace_config_dir.join("workspace"),
);
}
let legacy_config_dir = workspace_dir
.parent()
.map(|parent| parent.join(".alphahuman"));
if let Some(legacy_dir) = legacy_config_dir {
if legacy_dir.join("config.toml").exists() {
return (legacy_dir, workspace_config_dir);
}
if workspace_dir
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new("workspace"))
{
return (legacy_dir, workspace_config_dir);
}
}
(
workspace_config_dir.clone(),
workspace_config_dir.join("workspace"),
)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ConfigResolutionSource {
EnvWorkspace,
ActiveWorkspaceMarker,
DefaultConfigDir,
}
impl ConfigResolutionSource {
const fn as_str(self) -> &'static str {
match self {
Self::EnvWorkspace => "ALPHAHUMAN_WORKSPACE",
Self::ActiveWorkspaceMarker => "active_workspace.toml",
Self::DefaultConfigDir => "default",
}
}
}
async fn resolve_runtime_config_dirs(
default_alphahuman_dir: &Path,
default_workspace_dir: &Path,
) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
if let Ok(custom_workspace) = std::env::var("ALPHAHUMAN_WORKSPACE") {
if !custom_workspace.is_empty() {
let (alphahuman_dir, workspace_dir) =
resolve_config_dir_for_workspace(&PathBuf::from(custom_workspace));
return Ok((
alphahuman_dir,
workspace_dir,
ConfigResolutionSource::EnvWorkspace,
));
}
}
if let Some((alphahuman_dir, workspace_dir)) =
load_persisted_workspace_dirs(default_alphahuman_dir).await?
{
return Ok((
alphahuman_dir,
workspace_dir,
ConfigResolutionSource::ActiveWorkspaceMarker,
));
}
Ok((
default_alphahuman_dir.to_path_buf(),
default_workspace_dir.to_path_buf(),
ConfigResolutionSource::DefaultConfigDir,
))
}
fn decrypt_optional_secret(
store: &crate::alphahuman::security::SecretStore,
value: &mut Option<String>,
field_name: &str,
) -> Result<()> {
if let Some(raw) = value.clone() {
if crate::alphahuman::security::SecretStore::is_encrypted(&raw) {
*value = Some(
store
.decrypt(&raw)
.with_context(|| format!("Failed to decrypt {field_name}"))?,
);
}
}
Ok(())
}
fn encrypt_optional_secret(
store: &crate::alphahuman::security::SecretStore,
value: &mut Option<String>,
field_name: &str,
) -> Result<()> {
if let Some(raw) = value.clone() {
if !crate::alphahuman::security::SecretStore::is_encrypted(&raw) {
*value = Some(
store
.encrypt(&raw)
.with_context(|| format!("Failed to encrypt {field_name}"))?,
);
}
}
Ok(())
}
#[cfg(unix)]
async fn sync_directory(path: &Path) -> Result<()> {
let dir = File::open(path)
.await
.with_context(|| format!("Failed to open directory for fsync: {}", path.display()))?;
dir.sync_all()
.await
.with_context(|| format!("Failed to fsync directory metadata: {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
async fn sync_directory(_path: &Path) -> Result<()> {
Ok(())
}
impl Config {
pub async fn load_or_init() -> Result<Self> {
let (default_alphahuman_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
let (alphahuman_dir, workspace_dir, resolution_source) =
resolve_runtime_config_dirs(&default_alphahuman_dir, &default_workspace_dir).await?;
let config_path = alphahuman_dir.join("config.toml");
fs::create_dir_all(&alphahuman_dir)
.await
.context("Failed to create config directory")?;
fs::create_dir_all(&workspace_dir)
.await
.context("Failed to create workspace directory")?;
if config_path.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&config_path).await {
if meta.permissions().mode() & 0o004 != 0 {
tracing::warn!(
"Config file {:?} is world-readable (mode {:o}). \
Consider restricting with: chmod 600 {:?}",
config_path,
meta.permissions().mode() & 0o777,
config_path,
);
}
}
}
let contents = fs::read_to_string(&config_path)
.await
.context("Failed to read config file")?;
let mut config: Config = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file {}", config_path.display()))?;
config.config_path = config_path.clone();
config.workspace_dir = workspace_dir;
let store =
crate::alphahuman::security::SecretStore::new(&alphahuman_dir, config.secrets.encrypt);
decrypt_optional_secret(&store, &mut config.api_key, "config.api_key")?;
decrypt_optional_secret(
&store,
&mut config.composio.api_key,
"config.composio.api_key",
)?;
decrypt_optional_secret(
&store,
&mut config.browser.computer_use.api_key,
"config.browser.computer_use.api_key",
)?;
decrypt_optional_secret(
&store,
&mut config.web_search.brave_api_key,
"config.web_search.brave_api_key",
)?;
decrypt_optional_secret(
&store,
&mut config.storage.provider.config.db_url,
"config.storage.provider.config.db_url",
)?;
for agent in config.agents.values_mut() {
decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
}
config.apply_env_overrides();
tracing::info!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
initialized = false,
"Config loaded"
);
Ok(config)
} else {
let mut config = Config::default();
config.config_path = config_path.clone();
config.workspace_dir = workspace_dir;
config.save().await?;
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
let _ = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await;
}
config.apply_env_overrides();
tracing::info!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
initialized = true,
"Config loaded"
);
Ok(config)
}
}
pub fn apply_env_overrides(&mut self) {
if let Ok(key) = std::env::var("ALPHAHUMAN_API_KEY").or_else(|_| std::env::var("API_KEY")) {
if !key.is_empty() {
self.api_key = Some(key);
}
}
if self.default_provider.as_deref().is_some_and(is_glm_alias) {
if let Ok(key) = std::env::var("GLM_API_KEY") {
if !key.is_empty() {
self.api_key = Some(key);
}
}
}
if self.default_provider.as_deref().is_some_and(is_zai_alias) {
if let Ok(key) = std::env::var("ZAI_API_KEY") {
if !key.is_empty() {
self.api_key = Some(key);
}
}
}
if let Ok(provider) = std::env::var("ALPHAHUMAN_PROVIDER") {
if !provider.is_empty() {
self.default_provider = Some(provider);
}
} else if let Ok(provider) = std::env::var("PROVIDER") {
let should_apply_legacy_provider = self.default_provider.as_deref().map_or(true, |c| {
c.trim().eq_ignore_ascii_case("openrouter")
});
if should_apply_legacy_provider && !provider.is_empty() {
self.default_provider = Some(provider);
}
}
if let Ok(model) = std::env::var("ALPHAHUMAN_MODEL").or_else(|_| std::env::var("MODEL")) {
if !model.is_empty() {
self.default_model = Some(model);
}
}
if let Ok(workspace) = std::env::var("ALPHAHUMAN_WORKSPACE") {
if !workspace.is_empty() {
let (_, workspace_dir) = resolve_config_dir_for_workspace(&PathBuf::from(workspace));
self.workspace_dir = workspace_dir;
}
}
if let Ok(port_str) =
std::env::var("ALPHAHUMAN_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
{
if let Ok(port) = port_str.parse::<u16>() {
self.gateway.port = port;
}
}
if let Ok(host) =
std::env::var("ALPHAHUMAN_GATEWAY_HOST").or_else(|_| std::env::var("HOST"))
{
if !host.is_empty() {
self.gateway.host = host;
}
}
if let Ok(val) = std::env::var("ALPHAHUMAN_ALLOW_PUBLIC_BIND") {
self.gateway.allow_public_bind = val == "1" || val.eq_ignore_ascii_case("true");
}
if let Ok(temp_str) = std::env::var("ALPHAHUMAN_TEMPERATURE") {
if let Ok(temp) = temp_str.parse::<f64>() {
if (0.0..=2.0).contains(&temp) {
self.default_temperature = temp;
}
}
}
if let Ok(flag) = std::env::var("ALPHAHUMAN_REASONING_ENABLED")
.or_else(|_| std::env::var("REASONING_ENABLED"))
{
let normalized = flag.trim().to_ascii_lowercase();
match normalized.as_str() {
"1" | "true" | "yes" | "on" => self.runtime.reasoning_enabled = Some(true),
"0" | "false" | "no" | "off" => self.runtime.reasoning_enabled = Some(false),
_ => {}
}
}
if let Ok(enabled) = std::env::var("ALPHAHUMAN_WEB_SEARCH_ENABLED")
.or_else(|_| std::env::var("WEB_SEARCH_ENABLED"))
{
self.web_search.enabled = enabled == "1" || enabled.eq_ignore_ascii_case("true");
}
if let Ok(provider) = std::env::var("ALPHAHUMAN_WEB_SEARCH_PROVIDER")
.or_else(|_| std::env::var("WEB_SEARCH_PROVIDER"))
{
let provider = provider.trim();
if !provider.is_empty() {
self.web_search.provider = provider.to_string();
}
}
if let Ok(api_key) =
std::env::var("ALPHAHUMAN_BRAVE_API_KEY").or_else(|_| std::env::var("BRAVE_API_KEY"))
{
let api_key = api_key.trim();
if !api_key.is_empty() {
self.web_search.brave_api_key = Some(api_key.to_string());
}
}
if let Ok(max_results) = std::env::var("ALPHAHUMAN_WEB_SEARCH_MAX_RESULTS")
.or_else(|_| std::env::var("WEB_SEARCH_MAX_RESULTS"))
{
if let Ok(max_results) = max_results.parse::<usize>() {
if (1..=10).contains(&max_results) {
self.web_search.max_results = max_results;
}
}
}
if let Ok(timeout_secs) = std::env::var("ALPHAHUMAN_WEB_SEARCH_TIMEOUT_SECS")
.or_else(|_| std::env::var("WEB_SEARCH_TIMEOUT_SECS"))
{
if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
if timeout_secs > 0 {
self.web_search.timeout_secs = timeout_secs;
}
}
}
if let Ok(provider) = std::env::var("ALPHAHUMAN_STORAGE_PROVIDER") {
let provider = provider.trim();
if !provider.is_empty() {
self.storage.provider.config.provider = provider.to_string();
}
}
if let Ok(db_url) = std::env::var("ALPHAHUMAN_STORAGE_DB_URL") {
let db_url = db_url.trim();
if !db_url.is_empty() {
self.storage.provider.config.db_url = Some(db_url.to_string());
}
}
if let Ok(timeout_secs) = std::env::var("ALPHAHUMAN_STORAGE_CONNECT_TIMEOUT_SECS") {
if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
if timeout_secs > 0 {
self.storage.provider.config.connect_timeout_secs = Some(timeout_secs);
}
}
}
let explicit_proxy_enabled = std::env::var("ALPHAHUMAN_PROXY_ENABLED")
.ok()
.as_deref()
.and_then(parse_proxy_enabled);
if let Some(enabled) = explicit_proxy_enabled {
self.proxy.enabled = enabled;
}
let mut proxy_url_overridden = false;
if let Ok(proxy_url) =
std::env::var("ALPHAHUMAN_HTTP_PROXY").or_else(|_| std::env::var("HTTP_PROXY"))
{
self.proxy.http_proxy = normalize_proxy_url_option(Some(&proxy_url));
proxy_url_overridden = true;
}
if let Ok(proxy_url) =
std::env::var("ALPHAHUMAN_HTTPS_PROXY").or_else(|_| std::env::var("HTTPS_PROXY"))
{
self.proxy.https_proxy = normalize_proxy_url_option(Some(&proxy_url));
proxy_url_overridden = true;
}
if let Ok(proxy_url) =
std::env::var("ALPHAHUMAN_ALL_PROXY").or_else(|_| std::env::var("ALL_PROXY"))
{
self.proxy.all_proxy = normalize_proxy_url_option(Some(&proxy_url));
proxy_url_overridden = true;
}
if let Ok(no_proxy) =
std::env::var("ALPHAHUMAN_NO_PROXY").or_else(|_| std::env::var("NO_PROXY"))
{
self.proxy.no_proxy = normalize_no_proxy_list(vec![no_proxy]);
}
if explicit_proxy_enabled.is_none()
&& proxy_url_overridden
&& self.proxy.has_any_proxy_url()
{
self.proxy.enabled = true;
}
if let Ok(scope_raw) = std::env::var("ALPHAHUMAN_PROXY_SCOPE") {
if let Some(scope) = parse_proxy_scope(&scope_raw) {
self.proxy.scope = scope;
} else {
tracing::warn!(
scope = %scope_raw,
"Ignoring invalid ALPHAHUMAN_PROXY_SCOPE (valid: environment|alphahuman|services)"
);
}
}
if let Ok(services_raw) = std::env::var("ALPHAHUMAN_PROXY_SERVICES") {
self.proxy.services = normalize_service_list(vec![services_raw]);
}
if let Err(error) = self.proxy.validate() {
tracing::warn!("Invalid proxy configuration ignored: {error}");
self.proxy.enabled = false;
}
if self.proxy.enabled && self.proxy.scope == ProxyScope::Environment {
self.proxy.apply_to_process_env();
}
set_runtime_proxy_config(self.proxy.clone());
}
pub async fn save(&self) -> Result<()> {
let mut config_to_save = self.clone();
let alphahuman_dir = self
.config_path
.parent()
.context("Config path must have a parent directory")?;
let store =
crate::alphahuman::security::SecretStore::new(alphahuman_dir, self.secrets.encrypt);
encrypt_optional_secret(&store, &mut config_to_save.api_key, "config.api_key")?;
encrypt_optional_secret(
&store,
&mut config_to_save.composio.api_key,
"config.composio.api_key",
)?;
encrypt_optional_secret(
&store,
&mut config_to_save.browser.computer_use.api_key,
"config.browser.computer_use.api_key",
)?;
encrypt_optional_secret(
&store,
&mut config_to_save.web_search.brave_api_key,
"config.web_search.brave_api_key",
)?;
encrypt_optional_secret(
&store,
&mut config_to_save.storage.provider.config.db_url,
"config.storage.provider.config.db_url",
)?;
for agent in config_to_save.agents.values_mut() {
encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
}
let toml_str =
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
let parent_dir = self
.config_path
.parent()
.context("Config path must have a parent directory")?;
fs::create_dir_all(parent_dir).await.with_context(|| {
format!(
"Failed to create config directory: {}",
parent_dir.display()
)
})?;
let file_name = self
.config_path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("config.toml");
let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
let backup_path = parent_dir.join(format!("{file_name}.bak"));
let mut temp_file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp_path)
.await
.with_context(|| {
format!(
"Failed to create temporary config file: {}",
temp_path.display()
)
})?;
temp_file
.write_all(toml_str.as_bytes())
.await
.context("Failed to write temporary config contents")?;
temp_file
.sync_all()
.await
.context("Failed to fsync temporary config file")?;
drop(temp_file);
let had_existing_config = self.config_path.exists();
if had_existing_config {
fs::copy(&self.config_path, &backup_path)
.await
.with_context(|| {
format!(
"Failed to create config backup before atomic replace: {}",
backup_path.display()
)
})?;
}
if let Err(e) = fs::rename(&temp_path, &self.config_path).await {
let _ = fs::remove_file(&temp_path).await;
if had_existing_config && backup_path.exists() {
fs::copy(&backup_path, &self.config_path)
.await
.context("Failed to restore config backup")?;
}
anyhow::bail!("Failed to atomically replace config file: {e}");
}
sync_directory(parent_dir).await?;
if had_existing_config {
let _ = fs::remove_file(&backup_path).await;
}
Ok(())
}
}
@@ -0,0 +1,208 @@
//! Configuration schema: types and defaults for config.toml.
//!
//! Split into submodules; this module re-exports the main `Config` and all public types.
mod agent;
mod autonomy;
mod channels;
mod defaults;
mod gateway;
mod hardware;
mod heartbeat_cron;
mod identity_cost;
mod load;
mod observability;
mod proxy;
mod routes;
mod runtime;
mod storage_memory;
mod tools;
mod tunnel;
pub use agent::{AgentConfig, DelegateAgentConfig};
pub use autonomy::AutonomyConfig;
pub use channels::{
AuditConfig, ChannelsConfig, DingTalkConfig, DiscordConfig, IMessageConfig, IrcConfig,
LarkConfig, LarkReceiveMode, MatrixConfig, MattermostConfig, QQConfig, ResourceLimitsConfig,
SandboxBackend, SandboxConfig, SecurityConfig, SignalConfig, SlackConfig, StreamMode,
TelegramConfig, WebhookConfig, WhatsAppConfig,
};
pub use gateway::GatewayConfig;
pub use hardware::{HardwareConfig, HardwareTransport};
pub use heartbeat_cron::{CronConfig, HeartbeatConfig};
pub use identity_cost::{
CostConfig, IdentityConfig, ModelPricing, PeripheralBoardConfig, PeripheralsConfig,
};
pub use observability::ObservabilityConfig;
pub use proxy::{
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
ProxyConfig, ProxyScope,
};
pub use routes::{
ClassificationRule, EmbeddingRouteConfig, ModelRouteConfig, QueryClassificationConfig,
};
pub use runtime::{DockerRuntimeConfig, ReliabilityConfig, RuntimeConfig, SchedulerConfig};
pub use storage_memory::{
MemoryConfig, StorageConfig, StorageProviderConfig, StorageProviderSection,
};
pub use tools::{
BrowserComputerUseConfig, BrowserConfig, ComposioConfig, HttpRequestConfig, MultimodalConfig,
SecretsConfig, WebSearchConfig,
};
pub use tunnel::{
CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig,
TunnelConfig,
};
use directories::UserDirs;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
/// Top-level configuration (config.toml root).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Config {
#[serde(skip)]
pub workspace_dir: PathBuf,
#[serde(skip)]
pub config_path: PathBuf,
pub api_key: Option<String>,
pub api_url: Option<String>,
pub default_provider: Option<String>,
pub default_model: Option<String>,
pub default_temperature: f64,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default)]
pub autonomy: AutonomyConfig,
#[serde(default)]
pub runtime: RuntimeConfig,
#[serde(default)]
pub reliability: ReliabilityConfig,
#[serde(default)]
pub scheduler: SchedulerConfig,
#[serde(default)]
pub agent: AgentConfig,
#[serde(default)]
pub model_routes: Vec<ModelRouteConfig>,
#[serde(default)]
pub embedding_routes: Vec<EmbeddingRouteConfig>,
#[serde(default)]
pub query_classification: QueryClassificationConfig,
#[serde(default)]
pub heartbeat: HeartbeatConfig,
#[serde(default)]
pub cron: CronConfig,
#[serde(default)]
pub channels_config: ChannelsConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub tunnel: TunnelConfig,
#[serde(default)]
pub gateway: GatewayConfig,
#[serde(default)]
pub composio: ComposioConfig,
#[serde(default)]
pub secrets: SecretsConfig,
#[serde(default)]
pub browser: BrowserConfig,
#[serde(default)]
pub http_request: HttpRequestConfig,
#[serde(default)]
pub multimodal: MultimodalConfig,
#[serde(default)]
pub web_search: WebSearchConfig,
#[serde(default)]
pub proxy: ProxyConfig,
#[serde(default)]
pub identity: IdentityConfig,
#[serde(default)]
pub cost: CostConfig,
#[serde(default)]
pub peripherals: PeripheralsConfig,
#[serde(default)]
pub agents: HashMap<String, DelegateAgentConfig>,
#[serde(default)]
pub hardware: HardwareConfig,
}
impl Default for Config {
fn default() -> Self {
let home =
UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
let alphahuman_dir = home.join(".alphahuman");
Self {
workspace_dir: alphahuman_dir.join("workspace"),
config_path: alphahuman_dir.join("config.toml"),
api_key: None,
api_url: None,
default_provider: Some("openrouter".to_string()),
default_model: Some("anthropic/claude-sonnet-4.6".to_string()),
default_temperature: 0.7,
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
runtime: RuntimeConfig::default(),
reliability: ReliabilityConfig::default(),
scheduler: SchedulerConfig::default(),
agent: AgentConfig::default(),
model_routes: Vec::new(),
embedding_routes: Vec::new(),
heartbeat: HeartbeatConfig::default(),
cron: CronConfig::default(),
channels_config: ChannelsConfig::default(),
memory: MemoryConfig::default(),
storage: StorageConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
multimodal: MultimodalConfig::default(),
web_search: WebSearchConfig::default(),
proxy: ProxyConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
peripherals: PeripheralsConfig::default(),
agents: HashMap::new(),
hardware: HardwareConfig::default(),
query_classification: QueryClassificationConfig::default(),
}
}
}
// Load/save and env overrides extend Config in load.rs
@@ -0,0 +1,28 @@
//! Observability (logging, metrics, tracing) configuration.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ObservabilityConfig {
/// "none" | "log" | "prometheus" | "otel"
pub backend: String,
/// OTLP endpoint (e.g. "http://localhost:4318"). Only used when backend = "otel".
#[serde(default)]
pub otel_endpoint: Option<String>,
/// Service name reported to the OTel collector. Defaults to "alphahuman".
#[serde(default)]
pub otel_service_name: Option<String>,
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
backend: "none".into(),
otel_endpoint: None,
otel_service_name: None,
}
}
}
@@ -0,0 +1,492 @@
//! Proxy configuration and runtime proxy client building.
use anyhow::{Context, Result};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};
const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
"provider.anthropic",
"provider.compatible",
"provider.copilot",
"provider.gemini",
"provider.glm",
"provider.ollama",
"provider.openai",
"provider.openrouter",
"channel.dingtalk",
"channel.discord",
"channel.lark",
"channel.matrix",
"channel.mattermost",
"channel.qq",
"channel.signal",
"channel.slack",
"channel.telegram",
"channel.whatsapp",
"tool.browser",
"tool.composio",
"tool.http_request",
"tool.pushover",
"memory.embeddings",
"tunnel.custom",
];
const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] =
&["provider.*", "channel.*", "tool.*", "memory.*", "tunnel.*"];
static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
OnceLock::new();
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ProxyScope {
Environment,
#[default]
Alphahuman,
Services,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ProxyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub http_proxy: Option<String>,
#[serde(default)]
pub https_proxy: Option<String>,
#[serde(default)]
pub all_proxy: Option<String>,
#[serde(default)]
pub no_proxy: Vec<String>,
#[serde(default)]
pub scope: ProxyScope,
#[serde(default)]
pub services: Vec<String>,
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
enabled: false,
http_proxy: None,
https_proxy: None,
all_proxy: None,
no_proxy: Vec::new(),
scope: ProxyScope::Alphahuman,
services: Vec::new(),
}
}
}
impl ProxyConfig {
pub fn supported_service_keys() -> &'static [&'static str] {
SUPPORTED_PROXY_SERVICE_KEYS
}
pub fn supported_service_selectors() -> &'static [&'static str] {
SUPPORTED_PROXY_SERVICE_SELECTORS
}
pub fn has_any_proxy_url(&self) -> bool {
normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
|| normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
|| normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
}
pub fn normalized_services(&self) -> Vec<String> {
normalize_service_list(self.services.clone())
}
pub fn normalized_no_proxy(&self) -> Vec<String> {
normalize_no_proxy_list(self.no_proxy.clone())
}
pub fn validate(&self) -> Result<()> {
for (field, value) in [
("http_proxy", self.http_proxy.as_deref()),
("https_proxy", self.https_proxy.as_deref()),
("all_proxy", self.all_proxy.as_deref()),
] {
if let Some(url) = normalize_proxy_url_option(value) {
validate_proxy_url(field, &url)?;
}
}
for selector in self.normalized_services() {
if !is_supported_proxy_service_selector(&selector) {
anyhow::bail!(
"Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
);
}
}
if self.enabled && !self.has_any_proxy_url() {
anyhow::bail!(
"Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
);
}
if self.enabled
&& self.scope == ProxyScope::Services
&& self.normalized_services().is_empty()
{
anyhow::bail!(
"proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
);
}
Ok(())
}
pub fn should_apply_to_service(&self, service_key: &str) -> bool {
if !self.enabled {
return false;
}
match self.scope {
ProxyScope::Environment => false,
ProxyScope::Alphahuman => true,
ProxyScope::Services => {
let service_key = service_key.trim().to_ascii_lowercase();
if service_key.is_empty() {
return false;
}
self.normalized_services()
.iter()
.any(|selector| service_selector_matches(selector, &service_key))
}
}
}
pub fn apply_to_reqwest_builder(
&self,
mut builder: reqwest::ClientBuilder,
service_key: &str,
) -> reqwest::ClientBuilder {
if !self.should_apply_to_service(service_key) {
return builder;
}
let no_proxy = self.no_proxy_value();
if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
match reqwest::Proxy::all(&url) {
Ok(proxy) => {
builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
}
Err(error) => {
tracing::warn!(
proxy_url = %url,
service_key,
"Ignoring invalid all_proxy URL: {error}"
);
}
}
}
if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
match reqwest::Proxy::http(&url) {
Ok(proxy) => {
builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
}
Err(error) => {
tracing::warn!(
proxy_url = %url,
service_key,
"Ignoring invalid http_proxy URL: {error}"
);
}
}
}
if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
match reqwest::Proxy::https(&url) {
Ok(proxy) => {
builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
}
Err(error) => {
tracing::warn!(
proxy_url = %url,
service_key,
"Ignoring invalid https_proxy URL: {error}"
);
}
}
}
builder
}
pub fn apply_to_process_env(&self) {
set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
let no_proxy_joined = {
let list = self.normalized_no_proxy();
(!list.is_empty()).then(|| list.join(","))
};
set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
}
pub fn clear_process_env() {
clear_proxy_env_pair("HTTP_PROXY");
clear_proxy_env_pair("HTTPS_PROXY");
clear_proxy_env_pair("ALL_PROXY");
clear_proxy_env_pair("NO_PROXY");
}
fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
let joined = {
let list = self.normalized_no_proxy();
(!list.is_empty()).then(|| list.join(","))
};
joined.as_deref().and_then(reqwest::NoProxy::from_string)
}
}
fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
proxy.no_proxy(no_proxy)
}
pub(crate) fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
let value = raw?.trim();
(!value.is_empty()).then(|| value.to_string())
}
pub(crate) fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
normalize_comma_values(values)
}
pub(crate) fn normalize_service_list(values: Vec<String>) -> Vec<String> {
let mut normalized = normalize_comma_values(values)
.into_iter()
.map(|value| value.to_ascii_lowercase())
.collect::<Vec<_>>();
normalized.sort_unstable();
normalized.dedup();
normalized
}
fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
let mut output = Vec::new();
for value in values {
for part in value.split(',') {
let normalized = part.trim();
if normalized.is_empty() {
continue;
}
output.push(normalized.to_string());
}
}
output.sort_unstable();
output.dedup();
output
}
fn is_supported_proxy_service_selector(selector: &str) -> bool {
if SUPPORTED_PROXY_SERVICE_KEYS
.iter()
.any(|known| known.eq_ignore_ascii_case(selector))
{
return true;
}
SUPPORTED_PROXY_SERVICE_SELECTORS
.iter()
.any(|known| known.eq_ignore_ascii_case(selector))
}
fn service_selector_matches(selector: &str, service_key: &str) -> bool {
if selector == service_key {
return true;
}
if let Some(prefix) = selector.strip_suffix(".*") {
return service_key.starts_with(prefix)
&& service_key
.strip_prefix(prefix)
.is_some_and(|suffix| suffix.starts_with('.'));
}
false
}
fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
let parsed = reqwest::Url::parse(url)
.with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
match parsed.scheme() {
"http" | "https" | "socks5" | "socks5h" => {}
scheme => {
anyhow::bail!(
"Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h"
);
}
}
if parsed.host_str().is_none() {
anyhow::bail!("Invalid {field} URL: host is required");
}
Ok(())
}
fn set_proxy_env_pair(key: &str, value: Option<&str>) {
let lowercase_key = key.to_ascii_lowercase();
if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
std::env::set_var(key, &value);
std::env::set_var(lowercase_key, value);
} else {
std::env::remove_var(key);
std::env::remove_var(lowercase_key);
}
}
fn clear_proxy_env_pair(key: &str) {
std::env::remove_var(key);
std::env::remove_var(key.to_ascii_lowercase());
}
fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
}
fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}
fn clear_runtime_proxy_client_cache() {
match runtime_proxy_client_cache().write() {
Ok(mut guard) => {
guard.clear();
}
Err(poisoned) => {
poisoned.into_inner().clear();
}
}
}
fn runtime_proxy_cache_key(
service_key: &str,
timeout_secs: Option<u64>,
connect_timeout_secs: Option<u64>,
) -> String {
format!(
"{}|timeout={}|connect_timeout={}",
service_key.trim().to_ascii_lowercase(),
timeout_secs
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
connect_timeout_secs
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string())
)
}
fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
match runtime_proxy_client_cache().read() {
Ok(guard) => guard.get(cache_key).cloned(),
Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
}
}
fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
match runtime_proxy_client_cache().write() {
Ok(mut guard) => {
guard.insert(cache_key, client);
}
Err(poisoned) => {
poisoned.into_inner().insert(cache_key, client);
}
}
}
pub fn set_runtime_proxy_config(config: ProxyConfig) {
match runtime_proxy_state().write() {
Ok(mut guard) => {
*guard = config;
}
Err(poisoned) => {
*poisoned.into_inner() = config;
}
}
clear_runtime_proxy_client_cache();
}
pub fn runtime_proxy_config() -> ProxyConfig {
match runtime_proxy_state().read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
pub fn apply_runtime_proxy_to_builder(
builder: reqwest::ClientBuilder,
service_key: &str,
) -> reqwest::ClientBuilder {
runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
}
pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
let cache_key = runtime_proxy_cache_key(service_key, None, None);
if let Some(client) = runtime_proxy_cached_client(&cache_key) {
return client;
}
let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
let client = builder.build().unwrap_or_else(|error| {
tracing::warn!(service_key, "Failed to build proxied client: {error}");
reqwest::Client::new()
});
set_runtime_proxy_cached_client(cache_key, client.clone());
client
}
pub fn build_runtime_proxy_client_with_timeouts(
service_key: &str,
timeout_secs: u64,
connect_timeout_secs: u64,
) -> reqwest::Client {
let cache_key =
runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
if let Some(client) = runtime_proxy_cached_client(&cache_key) {
return client;
}
let builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
let builder = apply_runtime_proxy_to_builder(builder, service_key);
let client = builder.build().unwrap_or_else(|error| {
tracing::warn!(
service_key,
"Failed to build proxied timeout client: {error}"
);
reqwest::Client::new()
});
set_runtime_proxy_cached_client(cache_key, client.clone());
client
}
pub(crate) fn parse_proxy_scope(raw: &str) -> Option<ProxyScope> {
match raw.trim().to_ascii_lowercase().as_str() {
"environment" | "env" => Some(ProxyScope::Environment),
"alphahuman" | "internal" | "core" => Some(ProxyScope::Alphahuman),
"services" | "service" => Some(ProxyScope::Services),
_ => None,
}
}
pub(crate) fn parse_proxy_enabled(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
@@ -0,0 +1,47 @@
//! Model routing, embedding routing, and query classification.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ModelRouteConfig {
pub hint: String,
pub provider: String,
pub model: String,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EmbeddingRouteConfig {
pub hint: String,
pub provider: String,
pub model: String,
#[serde(default)]
pub dimensions: Option<usize>,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct QueryClassificationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub rules: Vec<ClassificationRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct ClassificationRule {
pub hint: String,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub patterns: Vec<String>,
#[serde(default)]
pub min_length: Option<usize>,
#[serde(default)]
pub max_length: Option<usize>,
#[serde(default)]
pub priority: i32,
}
@@ -0,0 +1,176 @@
//! Runtime (native/docker), reliability, and scheduler configuration.
use super::defaults;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RuntimeConfig {
#[serde(default = "default_runtime_kind")]
pub kind: String,
#[serde(default)]
pub docker: DockerRuntimeConfig,
#[serde(default)]
pub reasoning_enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DockerRuntimeConfig {
#[serde(default = "default_docker_image")]
pub image: String,
#[serde(default = "default_docker_network")]
pub network: String,
#[serde(default = "default_docker_memory_limit_mb")]
pub memory_limit_mb: Option<u64>,
#[serde(default = "default_docker_cpu_limit")]
pub cpu_limit: Option<f64>,
#[serde(default = "default_true")]
pub read_only_rootfs: bool,
#[serde(default = "default_true")]
pub mount_workspace: bool,
#[serde(default)]
pub allowed_workspace_roots: Vec<String>,
}
fn default_true() -> bool {
defaults::default_true()
}
fn default_runtime_kind() -> String {
"native".into()
}
fn default_docker_image() -> String {
"alpine:3.20".into()
}
fn default_docker_network() -> String {
"none".into()
}
fn default_docker_memory_limit_mb() -> Option<u64> {
Some(512)
}
fn default_docker_cpu_limit() -> Option<f64> {
Some(1.0)
}
impl Default for DockerRuntimeConfig {
fn default() -> Self {
Self {
image: default_docker_image(),
network: default_docker_network(),
memory_limit_mb: default_docker_memory_limit_mb(),
cpu_limit: default_docker_cpu_limit(),
read_only_rootfs: true,
mount_workspace: true,
allowed_workspace_roots: Vec::new(),
}
}
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
kind: default_runtime_kind(),
docker: DockerRuntimeConfig::default(),
reasoning_enabled: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReliabilityConfig {
#[serde(default = "default_provider_retries")]
pub provider_retries: u32,
#[serde(default = "default_provider_backoff_ms")]
pub provider_backoff_ms: u64,
#[serde(default)]
pub fallback_providers: Vec<String>,
#[serde(default)]
pub api_keys: Vec<String>,
#[serde(default)]
pub model_fallbacks: HashMap<String, Vec<String>>,
#[serde(default = "default_channel_backoff_secs")]
pub channel_initial_backoff_secs: u64,
#[serde(default = "default_channel_backoff_max_secs")]
pub channel_max_backoff_secs: u64,
#[serde(default = "default_scheduler_poll_secs")]
pub scheduler_poll_secs: u64,
#[serde(default = "default_scheduler_retries")]
pub scheduler_retries: u32,
}
fn default_provider_retries() -> u32 {
2
}
fn default_provider_backoff_ms() -> u64 {
500
}
fn default_channel_backoff_secs() -> u64 {
2
}
fn default_channel_backoff_max_secs() -> u64 {
60
}
fn default_scheduler_poll_secs() -> u64 {
15
}
fn default_scheduler_retries() -> u32 {
2
}
impl Default for ReliabilityConfig {
fn default() -> Self {
Self {
provider_retries: default_provider_retries(),
provider_backoff_ms: default_provider_backoff_ms(),
fallback_providers: Vec::new(),
api_keys: Vec::new(),
model_fallbacks: HashMap::new(),
channel_initial_backoff_secs: default_channel_backoff_secs(),
channel_max_backoff_secs: default_channel_backoff_max_secs(),
scheduler_poll_secs: default_scheduler_poll_secs(),
scheduler_retries: default_scheduler_retries(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SchedulerConfig {
#[serde(default = "default_scheduler_enabled")]
pub enabled: bool,
#[serde(default = "default_scheduler_max_tasks")]
pub max_tasks: usize,
#[serde(default = "default_scheduler_max_concurrent")]
pub max_concurrent: usize,
}
fn default_scheduler_enabled() -> bool {
true
}
fn default_scheduler_max_tasks() -> usize {
64
}
fn default_scheduler_max_concurrent() -> usize {
4
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
enabled: default_scheduler_enabled(),
max_tasks: default_scheduler_max_tasks(),
max_concurrent: default_scheduler_max_concurrent(),
}
}
}
@@ -0,0 +1,180 @@
//! Storage provider and memory configuration.
use super::defaults;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct StorageConfig {
#[serde(default)]
pub provider: StorageProviderSection,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct StorageProviderSection {
#[serde(default)]
pub config: StorageProviderConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct StorageProviderConfig {
#[serde(default)]
pub provider: String,
#[serde(
default,
alias = "dbURL",
alias = "database_url",
alias = "databaseUrl"
)]
pub db_url: Option<String>,
#[serde(default = "default_storage_schema")]
pub schema: String,
#[serde(default = "default_storage_table")]
pub table: String,
#[serde(default)]
pub connect_timeout_secs: Option<u64>,
}
fn default_storage_schema() -> String {
"public".into()
}
fn default_storage_table() -> String {
"memories".into()
}
impl Default for StorageProviderConfig {
fn default() -> Self {
Self {
provider: String::new(),
db_url: None,
schema: default_storage_schema(),
table: default_storage_table(),
connect_timeout_secs: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[allow(clippy::struct_excessive_bools)]
pub struct MemoryConfig {
pub backend: String,
pub auto_save: bool,
#[serde(default = "default_hygiene_enabled")]
pub hygiene_enabled: bool,
#[serde(default = "default_archive_after_days")]
pub archive_after_days: u32,
#[serde(default = "default_purge_after_days")]
pub purge_after_days: u32,
#[serde(default = "default_conversation_retention_days")]
pub conversation_retention_days: u32,
#[serde(default = "default_embedding_provider")]
pub embedding_provider: String,
#[serde(default = "default_embedding_model")]
pub embedding_model: String,
#[serde(default = "default_embedding_dims")]
pub embedding_dimensions: usize,
#[serde(default = "default_vector_weight")]
pub vector_weight: f64,
#[serde(default = "default_keyword_weight")]
pub keyword_weight: f64,
#[serde(default = "default_min_relevance_score")]
pub min_relevance_score: f64,
#[serde(default = "default_cache_size")]
pub embedding_cache_size: usize,
#[serde(default = "default_chunk_size")]
pub chunk_max_tokens: usize,
#[serde(default)]
pub response_cache_enabled: bool,
#[serde(default = "default_response_cache_ttl")]
pub response_cache_ttl_minutes: u32,
#[serde(default = "default_response_cache_max")]
pub response_cache_max_entries: usize,
#[serde(default)]
pub snapshot_enabled: bool,
#[serde(default)]
pub snapshot_on_hygiene: bool,
#[serde(default = "default_true")]
pub auto_hydrate: bool,
#[serde(default)]
pub sqlite_open_timeout_secs: Option<u64>,
}
fn default_true() -> bool {
defaults::default_true()
}
fn default_embedding_provider() -> String {
"none".into()
}
fn default_hygiene_enabled() -> bool {
true
}
fn default_archive_after_days() -> u32 {
7
}
fn default_purge_after_days() -> u32 {
30
}
fn default_conversation_retention_days() -> u32 {
30
}
fn default_embedding_model() -> String {
"text-embedding-3-small".into()
}
fn default_embedding_dims() -> usize {
1536
}
fn default_vector_weight() -> f64 {
0.7
}
fn default_keyword_weight() -> f64 {
0.3
}
fn default_min_relevance_score() -> f64 {
0.4
}
fn default_cache_size() -> usize {
10_000
}
fn default_chunk_size() -> usize {
512
}
fn default_response_cache_ttl() -> u32 {
60
}
fn default_response_cache_max() -> usize {
5_000
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
backend: "sqlite".into(),
auto_save: true,
hygiene_enabled: default_hygiene_enabled(),
archive_after_days: default_archive_after_days(),
purge_after_days: default_purge_after_days(),
conversation_retention_days: default_conversation_retention_days(),
embedding_provider: default_embedding_provider(),
embedding_model: default_embedding_model(),
embedding_dimensions: default_embedding_dims(),
vector_weight: default_vector_weight(),
keyword_weight: default_keyword_weight(),
min_relevance_score: default_min_relevance_score(),
embedding_cache_size: default_cache_size(),
chunk_max_tokens: default_chunk_size(),
response_cache_enabled: false,
response_cache_ttl_minutes: default_response_cache_ttl(),
response_cache_max_entries: default_response_cache_max(),
snapshot_enabled: false,
snapshot_on_hygiene: false,
auto_hydrate: true,
sqlite_open_timeout_secs: None,
}
}
}
@@ -0,0 +1,230 @@
//! Tool-related config: browser, HTTP, web search, composio, secrets, multimodal.
use super::defaults;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MultimodalConfig {
#[serde(default = "default_multimodal_max_images")]
pub max_images: usize,
#[serde(default = "default_multimodal_max_image_size_mb")]
pub max_image_size_mb: usize,
#[serde(default)]
pub allow_remote_fetch: bool,
}
fn default_multimodal_max_images() -> usize {
4
}
fn default_multimodal_max_image_size_mb() -> usize {
8
}
impl MultimodalConfig {
/// Clamp configured values to safe runtime bounds.
pub fn effective_limits(&self) -> (usize, usize) {
let max_images = self.max_images.clamp(1, 16);
let max_image_size_mb = self.max_image_size_mb.clamp(1, 20);
(max_images, max_image_size_mb)
}
/// Clamp image count to the configured maximum.
pub fn clamp_image_count(&self, count: usize) -> usize {
count.min(self.max_images)
}
}
impl Default for MultimodalConfig {
fn default() -> Self {
Self {
max_images: default_multimodal_max_images(),
max_image_size_mb: default_multimodal_max_image_size_mb(),
allow_remote_fetch: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BrowserComputerUseConfig {
#[serde(default = "default_browser_computer_use_endpoint")]
pub endpoint: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "default_browser_computer_use_timeout_ms")]
pub timeout_ms: u64,
#[serde(default)]
pub allow_remote_endpoint: bool,
#[serde(default)]
pub window_allowlist: Vec<String>,
#[serde(default)]
pub max_coordinate_x: Option<i64>,
#[serde(default)]
pub max_coordinate_y: Option<i64>,
}
fn default_browser_computer_use_endpoint() -> String {
"http://127.0.0.1:8787/v1/actions".into()
}
fn default_browser_computer_use_timeout_ms() -> u64 {
15_000
}
impl Default for BrowserComputerUseConfig {
fn default() -> Self {
Self {
endpoint: default_browser_computer_use_endpoint(),
api_key: None,
timeout_ms: default_browser_computer_use_timeout_ms(),
allow_remote_endpoint: false,
window_allowlist: Vec::new(),
max_coordinate_x: None,
max_coordinate_y: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BrowserConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default)]
pub session_name: Option<String>,
#[serde(default = "default_browser_backend")]
pub backend: String,
#[serde(default = "default_true")]
pub native_headless: bool,
#[serde(default = "default_browser_webdriver_url")]
pub native_webdriver_url: String,
#[serde(default)]
pub native_chrome_path: Option<String>,
#[serde(default)]
pub computer_use: BrowserComputerUseConfig,
}
fn default_true() -> bool {
defaults::default_true()
}
fn default_browser_backend() -> String {
"agent_browser".into()
}
fn default_browser_webdriver_url() -> String {
"http://127.0.0.1:9515".into()
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_domains: Vec::new(),
session_name: None,
backend: default_browser_backend(),
native_headless: default_true(),
native_webdriver_url: default_browser_webdriver_url(),
native_chrome_path: None,
computer_use: BrowserComputerUseConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct HttpRequestConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default = "default_http_max_response_size")]
pub max_response_size: usize,
#[serde(default = "default_http_timeout_secs")]
pub timeout_secs: u64,
}
fn default_http_max_response_size() -> usize {
1_000_000
}
fn default_http_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WebSearchConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_web_search_provider")]
pub provider: String,
#[serde(default)]
pub brave_api_key: Option<String>,
#[serde(default = "default_web_search_max_results")]
pub max_results: usize,
#[serde(default = "default_web_search_timeout_secs")]
pub timeout_secs: u64,
}
fn default_web_search_provider() -> String {
"duckduckgo".into()
}
fn default_web_search_max_results() -> usize {
5
}
fn default_web_search_timeout_secs() -> u64 {
15
}
impl Default for WebSearchConfig {
fn default() -> Self {
Self {
enabled: true,
provider: default_web_search_provider(),
brave_api_key: None,
max_results: default_web_search_max_results(),
timeout_secs: default_web_search_timeout_secs(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ComposioConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "default_entity_id")]
pub entity_id: String,
}
fn default_entity_id() -> String {
"default".into()
}
impl Default for ComposioConfig {
fn default() -> Self {
Self {
enabled: false,
api_key: None,
entity_id: default_entity_id(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SecretsConfig {
#[serde(default = "default_true")]
pub encrypt: bool,
}
impl Default for SecretsConfig {
fn default() -> Self {
Self {
encrypt: defaults::default_true(),
}
}
}
@@ -0,0 +1,54 @@
//! Tunnel (Cloudflare, Tailscale, ngrok, custom) configuration.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TunnelConfig {
pub provider: String,
#[serde(default)]
pub cloudflare: Option<CloudflareTunnelConfig>,
#[serde(default)]
pub tailscale: Option<TailscaleTunnelConfig>,
#[serde(default)]
pub ngrok: Option<NgrokTunnelConfig>,
#[serde(default)]
pub custom: Option<CustomTunnelConfig>,
}
impl Default for TunnelConfig {
fn default() -> Self {
Self {
provider: "none".into(),
cloudflare: None,
tailscale: None,
ngrok: None,
custom: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CloudflareTunnelConfig {
pub token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TailscaleTunnelConfig {
#[serde(default)]
pub funnel: bool,
pub hostname: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct NgrokTunnelConfig {
pub auth_token: String,
pub domain: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CustomTunnelConfig {
pub start_command: String,
pub health_url: Option<String>,
pub url_pattern: Option<String>,
}
+5
View File
@@ -0,0 +1,5 @@
pub mod tracker;
pub mod types;
pub use tracker::CostTracker;
pub use types::{BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod};
+536
View File
@@ -0,0 +1,536 @@
use super::types::{BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod};
use crate::alphahuman::config::CostConfig;
use anyhow::{anyhow, Context, Result};
use chrono::{Datelike, NaiveDate, Utc};
use parking_lot::{Mutex, MutexGuard};
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Cost tracker for API usage monitoring and budget enforcement.
pub struct CostTracker {
config: CostConfig,
storage: Arc<Mutex<CostStorage>>,
session_id: String,
session_costs: Arc<Mutex<Vec<CostRecord>>>,
}
impl CostTracker {
/// Create a new cost tracker.
pub fn new(config: CostConfig, workspace_dir: &Path) -> Result<Self> {
let storage_path = resolve_storage_path(workspace_dir)?;
let storage = CostStorage::new(&storage_path).with_context(|| {
format!("Failed to open cost storage at {}", storage_path.display())
})?;
Ok(Self {
config,
storage: Arc::new(Mutex::new(storage)),
session_id: uuid::Uuid::new_v4().to_string(),
session_costs: Arc::new(Mutex::new(Vec::new())),
})
}
/// Get the session ID.
pub fn session_id(&self) -> &str {
&self.session_id
}
fn lock_storage(&self) -> MutexGuard<'_, CostStorage> {
self.storage.lock()
}
fn lock_session_costs(&self) -> MutexGuard<'_, Vec<CostRecord>> {
self.session_costs.lock()
}
/// Check if a request is within budget.
pub fn check_budget(&self, estimated_cost_usd: f64) -> Result<BudgetCheck> {
if !self.config.enabled {
return Ok(BudgetCheck::Allowed);
}
if !estimated_cost_usd.is_finite() || estimated_cost_usd < 0.0 {
return Err(anyhow!(
"Estimated cost must be a finite, non-negative value"
));
}
let mut storage = self.lock_storage();
let (daily_cost, monthly_cost) = storage.get_aggregated_costs()?;
// Check daily limit
let projected_daily = daily_cost + estimated_cost_usd;
if projected_daily > self.config.daily_limit_usd {
return Ok(BudgetCheck::Exceeded {
current_usd: daily_cost,
limit_usd: self.config.daily_limit_usd,
period: UsagePeriod::Day,
});
}
// Check monthly limit
let projected_monthly = monthly_cost + estimated_cost_usd;
if projected_monthly > self.config.monthly_limit_usd {
return Ok(BudgetCheck::Exceeded {
current_usd: monthly_cost,
limit_usd: self.config.monthly_limit_usd,
period: UsagePeriod::Month,
});
}
// Check warning thresholds
let warn_threshold = f64::from(self.config.warn_at_percent.min(100)) / 100.0;
let daily_warn_threshold = self.config.daily_limit_usd * warn_threshold;
let monthly_warn_threshold = self.config.monthly_limit_usd * warn_threshold;
if projected_daily >= daily_warn_threshold {
return Ok(BudgetCheck::Warning {
current_usd: daily_cost,
limit_usd: self.config.daily_limit_usd,
period: UsagePeriod::Day,
});
}
if projected_monthly >= monthly_warn_threshold {
return Ok(BudgetCheck::Warning {
current_usd: monthly_cost,
limit_usd: self.config.monthly_limit_usd,
period: UsagePeriod::Month,
});
}
Ok(BudgetCheck::Allowed)
}
/// Record a usage event.
pub fn record_usage(&self, usage: TokenUsage) -> Result<()> {
if !self.config.enabled {
return Ok(());
}
if !usage.cost_usd.is_finite() || usage.cost_usd < 0.0 {
return Err(anyhow!(
"Token usage cost must be a finite, non-negative value"
));
}
let record = CostRecord::new(&self.session_id, usage);
// Persist first for durability guarantees.
{
let mut storage = self.lock_storage();
storage.add_record(record.clone())?;
}
// Then update in-memory session snapshot.
let mut session_costs = self.lock_session_costs();
session_costs.push(record);
Ok(())
}
/// Get the current cost summary.
pub fn get_summary(&self) -> Result<CostSummary> {
let (daily_cost, monthly_cost) = {
let mut storage = self.lock_storage();
storage.get_aggregated_costs()?
};
let session_costs = self.lock_session_costs();
let session_cost: f64 = session_costs
.iter()
.map(|record| record.usage.cost_usd)
.sum();
let total_tokens: u64 = session_costs
.iter()
.map(|record| record.usage.total_tokens)
.sum();
let request_count = session_costs.len();
let by_model = build_session_model_stats(&session_costs);
Ok(CostSummary {
session_cost_usd: session_cost,
daily_cost_usd: daily_cost,
monthly_cost_usd: monthly_cost,
total_tokens,
request_count,
by_model,
})
}
/// Get the daily cost for a specific date.
pub fn get_daily_cost(&self, date: NaiveDate) -> Result<f64> {
let storage = self.lock_storage();
storage.get_cost_for_date(date)
}
/// Get the monthly cost for a specific month.
pub fn get_monthly_cost(&self, year: i32, month: u32) -> Result<f64> {
let storage = self.lock_storage();
storage.get_cost_for_month(year, month)
}
}
fn resolve_storage_path(workspace_dir: &Path) -> Result<PathBuf> {
let storage_path = workspace_dir.join("state").join("costs.jsonl");
let legacy_path = workspace_dir.join(".alphahuman").join("costs.db");
if !storage_path.exists() && legacy_path.exists() {
if let Some(parent) = storage_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
if let Err(error) = fs::rename(&legacy_path, &storage_path) {
tracing::warn!(
"Failed to move legacy cost storage from {} to {}: {error}; falling back to copy",
legacy_path.display(),
storage_path.display()
);
fs::copy(&legacy_path, &storage_path).with_context(|| {
format!(
"Failed to copy legacy cost storage from {} to {}",
legacy_path.display(),
storage_path.display()
)
})?;
}
}
Ok(storage_path)
}
fn build_session_model_stats(session_costs: &[CostRecord]) -> HashMap<String, ModelStats> {
let mut by_model: HashMap<String, ModelStats> = HashMap::new();
for record in session_costs {
let entry = by_model
.entry(record.usage.model.clone())
.or_insert_with(|| ModelStats {
model: record.usage.model.clone(),
cost_usd: 0.0,
total_tokens: 0,
request_count: 0,
});
entry.cost_usd += record.usage.cost_usd;
entry.total_tokens += record.usage.total_tokens;
entry.request_count += 1;
}
by_model
}
/// Persistent storage for cost records.
struct CostStorage {
path: PathBuf,
daily_cost_usd: f64,
monthly_cost_usd: f64,
cached_day: NaiveDate,
cached_year: i32,
cached_month: u32,
}
impl CostStorage {
/// Create or open cost storage.
fn new(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
let now = Utc::now();
let mut storage = Self {
path: path.to_path_buf(),
daily_cost_usd: 0.0,
monthly_cost_usd: 0.0,
cached_day: now.date_naive(),
cached_year: now.year(),
cached_month: now.month(),
};
storage.rebuild_aggregates(
storage.cached_day,
storage.cached_year,
storage.cached_month,
)?;
Ok(storage)
}
fn for_each_record<F>(&self, mut on_record: F) -> Result<()>
where
F: FnMut(CostRecord),
{
if !self.path.exists() {
return Ok(());
}
let file = File::open(&self.path)
.with_context(|| format!("Failed to read cost storage from {}", self.path.display()))?;
let reader = BufReader::new(file);
for (line_number, line) in reader.lines().enumerate() {
let raw_line = line.with_context(|| {
format!(
"Failed to read line {} from cost storage {}",
line_number + 1,
self.path.display()
)
})?;
let trimmed = raw_line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<CostRecord>(trimmed) {
Ok(record) => on_record(record),
Err(error) => {
tracing::warn!(
"Skipping malformed cost record at {}:{}: {error}",
self.path.display(),
line_number + 1
);
}
}
}
Ok(())
}
fn rebuild_aggregates(&mut self, day: NaiveDate, year: i32, month: u32) -> Result<()> {
let mut daily_cost = 0.0;
let mut monthly_cost = 0.0;
self.for_each_record(|record| {
let timestamp = record.usage.timestamp.naive_utc();
if timestamp.date() == day {
daily_cost += record.usage.cost_usd;
}
if timestamp.year() == year && timestamp.month() == month {
monthly_cost += record.usage.cost_usd;
}
})?;
self.daily_cost_usd = daily_cost;
self.monthly_cost_usd = monthly_cost;
self.cached_day = day;
self.cached_year = year;
self.cached_month = month;
Ok(())
}
fn ensure_period_cache_current(&mut self) -> Result<()> {
let now = Utc::now();
let day = now.date_naive();
let year = now.year();
let month = now.month();
if day != self.cached_day || year != self.cached_year || month != self.cached_month {
self.rebuild_aggregates(day, year, month)?;
}
Ok(())
}
/// Add a new record.
fn add_record(&mut self, record: CostRecord) -> Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.with_context(|| format!("Failed to open cost storage at {}", self.path.display()))?;
writeln!(file, "{}", serde_json::to_string(&record)?)
.with_context(|| format!("Failed to write cost record to {}", self.path.display()))?;
file.sync_all()
.with_context(|| format!("Failed to sync cost storage at {}", self.path.display()))?;
self.ensure_period_cache_current()?;
let timestamp = record.usage.timestamp.naive_utc();
if timestamp.date() == self.cached_day {
self.daily_cost_usd += record.usage.cost_usd;
}
if timestamp.year() == self.cached_year && timestamp.month() == self.cached_month {
self.monthly_cost_usd += record.usage.cost_usd;
}
Ok(())
}
/// Get aggregated costs for current day and month.
fn get_aggregated_costs(&mut self) -> Result<(f64, f64)> {
self.ensure_period_cache_current()?;
Ok((self.daily_cost_usd, self.monthly_cost_usd))
}
/// Get cost for a specific date.
fn get_cost_for_date(&self, date: NaiveDate) -> Result<f64> {
let mut cost = 0.0;
self.for_each_record(|record| {
if record.usage.timestamp.naive_utc().date() == date {
cost += record.usage.cost_usd;
}
})?;
Ok(cost)
}
/// Get cost for a specific month.
fn get_cost_for_month(&self, year: i32, month: u32) -> Result<f64> {
let mut cost = 0.0;
self.for_each_record(|record| {
let timestamp = record.usage.timestamp.naive_utc();
if timestamp.year() == year && timestamp.month() == month {
cost += record.usage.cost_usd;
}
})?;
Ok(cost)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn enabled_config() -> CostConfig {
CostConfig {
enabled: true,
..Default::default()
}
}
#[test]
fn cost_tracker_initialization() {
let tmp = TempDir::new().unwrap();
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
assert!(!tracker.session_id().is_empty());
}
#[test]
fn budget_check_when_disabled() {
let tmp = TempDir::new().unwrap();
let config = CostConfig {
enabled: false,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
let check = tracker.check_budget(1000.0).unwrap();
assert!(matches!(check, BudgetCheck::Allowed));
}
#[test]
fn record_usage_and_get_summary() {
let tmp = TempDir::new().unwrap();
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
let usage = TokenUsage::new("test/model", 1000, 500, 1.0, 2.0);
tracker.record_usage(usage).unwrap();
let summary = tracker.get_summary().unwrap();
assert_eq!(summary.request_count, 1);
assert!(summary.session_cost_usd > 0.0);
assert_eq!(summary.by_model.len(), 1);
}
#[test]
fn budget_exceeded_daily_limit() {
let tmp = TempDir::new().unwrap();
let config = CostConfig {
enabled: true,
daily_limit_usd: 0.01, // Very low limit
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
// Record a usage that exceeds the limit
let usage = TokenUsage::new("test/model", 10000, 5000, 1.0, 2.0); // ~0.02 USD
tracker.record_usage(usage).unwrap();
let check = tracker.check_budget(0.01).unwrap();
assert!(matches!(check, BudgetCheck::Exceeded { .. }));
}
#[test]
fn summary_by_model_is_session_scoped() {
let tmp = TempDir::new().unwrap();
let storage_path = resolve_storage_path(tmp.path()).unwrap();
if let Some(parent) = storage_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let old_record = CostRecord::new(
"old-session",
TokenUsage::new("legacy/model", 500, 500, 1.0, 1.0),
);
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(storage_path)
.unwrap();
writeln!(file, "{}", serde_json::to_string(&old_record).unwrap()).unwrap();
file.sync_all().unwrap();
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
tracker
.record_usage(TokenUsage::new("session/model", 1000, 1000, 1.0, 1.0))
.unwrap();
let summary = tracker.get_summary().unwrap();
assert_eq!(summary.by_model.len(), 1);
assert!(summary.by_model.contains_key("session/model"));
assert!(!summary.by_model.contains_key("legacy/model"));
}
#[test]
fn malformed_lines_are_ignored_while_loading() {
let tmp = TempDir::new().unwrap();
let storage_path = resolve_storage_path(tmp.path()).unwrap();
if let Some(parent) = storage_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let valid_usage = TokenUsage::new("test/model", 1000, 0, 1.0, 1.0);
let valid_record = CostRecord::new("session-a", valid_usage.clone());
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(storage_path)
.unwrap();
writeln!(file, "{}", serde_json::to_string(&valid_record).unwrap()).unwrap();
writeln!(file, "not-a-json-line").unwrap();
writeln!(file).unwrap();
file.sync_all().unwrap();
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
let today_cost = tracker.get_daily_cost(Utc::now().date_naive()).unwrap();
assert!((today_cost - valid_usage.cost_usd).abs() < f64::EPSILON);
}
#[test]
fn invalid_budget_estimate_is_rejected() {
let tmp = TempDir::new().unwrap();
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
let err = tracker.check_budget(f64::NAN).unwrap_err();
assert!(err
.to_string()
.contains("Estimated cost must be a finite, non-negative value"));
}
}
+193
View File
@@ -0,0 +1,193 @@
use serde::{Deserialize, Serialize};
/// Token usage information from a single API call.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenUsage {
/// Model identifier (e.g., "anthropic/claude-sonnet-4-20250514")
pub model: String,
/// Input/prompt tokens
pub input_tokens: u64,
/// Output/completion tokens
pub output_tokens: u64,
/// Total tokens
pub total_tokens: u64,
/// Calculated cost in USD
pub cost_usd: f64,
/// Timestamp of the request
pub timestamp: chrono::DateTime<chrono::Utc>,
}
impl TokenUsage {
fn sanitize_price(value: f64) -> f64 {
if value.is_finite() && value > 0.0 {
value
} else {
0.0
}
}
/// Create a new token usage record.
pub fn new(
model: impl Into<String>,
input_tokens: u64,
output_tokens: u64,
input_price_per_million: f64,
output_price_per_million: f64,
) -> Self {
let model = model.into();
let input_price_per_million = Self::sanitize_price(input_price_per_million);
let output_price_per_million = Self::sanitize_price(output_price_per_million);
let total_tokens = input_tokens.saturating_add(output_tokens);
// Calculate cost: (tokens / 1M) * price_per_million
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
let cost_usd = input_cost + output_cost;
Self {
model,
input_tokens,
output_tokens,
total_tokens,
cost_usd,
timestamp: chrono::Utc::now(),
}
}
/// Get the total cost.
pub fn cost(&self) -> f64 {
self.cost_usd
}
}
/// Time period for cost aggregation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum UsagePeriod {
Session,
Day,
Month,
}
/// A single cost record for persistent storage.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostRecord {
/// Unique identifier
pub id: String,
/// Token usage details
pub usage: TokenUsage,
/// Session identifier (for grouping)
pub session_id: String,
}
impl CostRecord {
/// Create a new cost record.
pub fn new(session_id: impl Into<String>, usage: TokenUsage) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
usage,
session_id: session_id.into(),
}
}
}
/// Budget enforcement result.
#[derive(Debug, Clone)]
pub enum BudgetCheck {
/// Within budget, request can proceed
Allowed,
/// Warning threshold exceeded but request can proceed
Warning {
current_usd: f64,
limit_usd: f64,
period: UsagePeriod,
},
/// Budget exceeded, request blocked
Exceeded {
current_usd: f64,
limit_usd: f64,
period: UsagePeriod,
},
}
/// Cost summary for reporting.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostSummary {
/// Total cost for the session
pub session_cost_usd: f64,
/// Total cost for the day
pub daily_cost_usd: f64,
/// Total cost for the month
pub monthly_cost_usd: f64,
/// Total tokens used
pub total_tokens: u64,
/// Number of requests
pub request_count: usize,
/// Breakdown by model
pub by_model: std::collections::HashMap<String, ModelStats>,
}
/// Statistics for a specific model.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelStats {
/// Model name
pub model: String,
/// Total cost for this model
pub cost_usd: f64,
/// Total tokens for this model
pub total_tokens: u64,
/// Number of requests for this model
pub request_count: usize,
}
impl Default for CostSummary {
fn default() -> Self {
Self {
session_cost_usd: 0.0,
daily_cost_usd: 0.0,
monthly_cost_usd: 0.0,
total_tokens: 0,
request_count: 0,
by_model: std::collections::HashMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_usage_calculation() {
let usage = TokenUsage::new("test/model", 1000, 500, 3.0, 15.0);
// Expected: (1000/1M)*3 + (500/1M)*15 = 0.003 + 0.0075 = 0.0105
assert!((usage.cost_usd - 0.0105).abs() < 0.0001);
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.total_tokens, 1500);
}
#[test]
fn token_usage_zero_tokens() {
let usage = TokenUsage::new("test/model", 0, 0, 3.0, 15.0);
assert!(usage.cost_usd.abs() < f64::EPSILON);
assert_eq!(usage.total_tokens, 0);
}
#[test]
fn token_usage_negative_or_non_finite_prices_are_clamped() {
let usage = TokenUsage::new("test/model", 1000, 1000, -3.0, f64::NAN);
assert!(usage.cost_usd.abs() < f64::EPSILON);
assert_eq!(usage.total_tokens, 2000);
}
#[test]
fn cost_record_creation() {
let usage = TokenUsage::new("test/model", 100, 50, 1.0, 2.0);
let record = CostRecord::new("session-123", usage);
assert_eq!(record.session_id, "session-123");
assert!(!record.id.is_empty());
assert_eq!(record.usage.model, "test/model");
}
}
+320
View File
@@ -0,0 +1,320 @@
use crate::alphahuman::config::Config;
use crate::alphahuman::security::SecurityPolicy;
use anyhow::Result;
mod schedule;
mod store;
mod types;
pub mod scheduler;
#[allow(unused_imports)]
pub use schedule::{
next_run_for_schedule, normalize_expression, schedule_cron_expression, validate_schedule,
};
#[allow(unused_imports)]
pub use store::{
add_agent_job, add_job, add_shell_job, due_jobs, get_job, list_jobs, list_runs,
record_last_run, record_run, remove_job, reschedule_after_run, update_job,
};
pub use types::{CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, Schedule, SessionTarget};
pub fn add_once(config: &Config, delay: &str, command: &str) -> Result<CronJob> {
let duration = parse_delay(delay)?;
let at = chrono::Utc::now() + duration;
add_once_at(config, at, command)
}
pub fn add_once_at(
config: &Config,
at: chrono::DateTime<chrono::Utc>,
command: &str,
) -> Result<CronJob> {
let schedule = Schedule::At { at };
add_shell_job(config, None, schedule, command)
}
pub fn pause_job(config: &Config, id: &str) -> Result<CronJob> {
update_job(
config,
id,
CronJobPatch {
enabled: Some(false),
..CronJobPatch::default()
},
)
}
pub fn resume_job(config: &Config, id: &str) -> Result<CronJob> {
update_job(
config,
id,
CronJobPatch {
enabled: Some(true),
..CronJobPatch::default()
},
)
}
/// Update an existing cron job using the same rules as the legacy CLI, but without CLI wiring.
pub fn update_cron_job(
config: &Config,
id: &str,
expression: Option<String>,
tz: Option<String>,
command: Option<String>,
name: Option<String>,
) -> Result<CronJob> {
if expression.is_none() && tz.is_none() && command.is_none() && name.is_none() {
anyhow::bail!("At least one of --expression, --tz, --command, or --name must be provided");
}
// Merge expression/tz with the existing schedule so that
// tz alone updates the timezone and expression alone preserves the timezone.
let schedule = if expression.is_some() || tz.is_some() {
let existing = get_job(config, id)?;
let (existing_expr, existing_tz) = match existing.schedule {
Schedule::Cron {
expr,
tz: existing_tz,
} => (expr, existing_tz),
_ => anyhow::bail!("Cannot update expression/tz on a non-cron schedule"),
};
Some(Schedule::Cron {
expr: expression.unwrap_or(existing_expr),
tz: tz.or(existing_tz),
})
} else {
None
};
if let Some(ref cmd) = command {
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
if !security.is_command_allowed(cmd) {
anyhow::bail!("Command blocked by security policy: {cmd}");
}
}
let patch = CronJobPatch {
schedule,
command,
name,
..CronJobPatch::default()
};
update_job(config, id, patch)
}
fn parse_delay(input: &str) -> Result<chrono::Duration> {
let input = input.trim();
if input.is_empty() {
anyhow::bail!("delay must not be empty");
}
let split = input
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(input.len());
let (num, unit) = input.split_at(split);
let amount: i64 = num.parse()?;
let unit = if unit.is_empty() { "m" } else { unit };
let duration = match unit {
"s" => chrono::Duration::seconds(amount),
"m" => chrono::Duration::minutes(amount),
"h" => chrono::Duration::hours(amount),
"d" => chrono::Duration::days(amount),
_ => anyhow::bail!("unsupported delay unit '{unit}', use s/m/h/d"),
};
Ok(duration)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config
}
fn make_job(config: &Config, expr: &str, tz: Option<&str>, cmd: &str) -> CronJob {
add_shell_job(
config,
None,
Schedule::Cron {
expr: expr.into(),
tz: tz.map(Into::into),
},
cmd,
)
.unwrap()
}
fn run_update(
config: &Config,
id: &str,
expression: Option<&str>,
tz: Option<&str>,
command: Option<&str>,
name: Option<&str>,
) -> Result<()> {
update_cron_job(
config,
id,
expression.map(Into::into),
tz.map(Into::into),
command.map(Into::into),
name.map(Into::into),
)
.map(|_| ())
}
#[test]
fn update_changes_command_via_handler() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo original");
run_update(&config, &job.id, None, None, Some("echo updated"), None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.command, "echo updated");
assert_eq!(updated.id, job.id);
}
#[test]
fn update_changes_expression_via_handler() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.expression, "0 9 * * *");
}
#[test]
fn update_changes_name_via_handler() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
run_update(&config, &job.id, None, None, None, Some("new-name")).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.name.as_deref(), Some("new-name"));
}
#[test]
fn update_tz_alone_sets_timezone() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
run_update(
&config,
&job.id,
None,
Some("America/Los_Angeles"),
None,
None,
)
.unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(
updated.schedule,
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: Some("America/Los_Angeles".into()),
}
);
}
#[test]
fn update_expression_preserves_existing_tz() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(
&config,
"*/5 * * * *",
Some("America/Los_Angeles"),
"echo test",
);
run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(
updated.schedule,
Schedule::Cron {
expr: "0 9 * * *".into(),
tz: Some("America/Los_Angeles".into()),
}
);
}
#[test]
fn update_preserves_unchanged_fields() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_shell_job(
&config,
Some("original-name".into()),
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"echo original",
)
.unwrap();
run_update(&config, &job.id, None, None, Some("echo changed"), None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.command, "echo changed");
assert_eq!(updated.name.as_deref(), Some("original-name"));
assert_eq!(updated.expression, "*/5 * * * *");
}
#[test]
fn update_no_flags_fails() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
let result = run_update(&config, &job.id, None, None, None, None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("At least one of"));
}
#[test]
fn update_nonexistent_job_fails() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let result = run_update(
&config,
"nonexistent-id",
None,
None,
Some("echo test"),
None,
);
assert!(result.is_err());
}
#[test]
fn update_security_allows_safe_command() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
assert!(security.is_command_allowed("echo safe"));
}
}
+114
View File
@@ -0,0 +1,114 @@
use crate::alphahuman::cron::Schedule;
use anyhow::{Context, Result};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use cron::Schedule as CronExprSchedule;
use std::str::FromStr;
pub fn next_run_for_schedule(schedule: &Schedule, from: DateTime<Utc>) -> Result<DateTime<Utc>> {
match schedule {
Schedule::Cron { expr, tz } => {
let normalized = normalize_expression(expr)?;
let cron = CronExprSchedule::from_str(&normalized)
.with_context(|| format!("Invalid cron expression: {expr}"))?;
if let Some(tz_name) = tz {
let timezone = chrono_tz::Tz::from_str(tz_name)
.with_context(|| format!("Invalid IANA timezone: {tz_name}"))?;
let localized_from = from.with_timezone(&timezone);
let next_local = cron.after(&localized_from).next().ok_or_else(|| {
anyhow::anyhow!("No future occurrence for expression: {expr}")
})?;
Ok(next_local.with_timezone(&Utc))
} else {
cron.after(&from)
.next()
.ok_or_else(|| anyhow::anyhow!("No future occurrence for expression: {expr}"))
}
}
Schedule::At { at } => Ok(*at),
Schedule::Every { every_ms } => {
if *every_ms == 0 {
anyhow::bail!("Invalid schedule: every_ms must be > 0");
}
let ms = i64::try_from(*every_ms).context("every_ms is too large")?;
let delta = ChronoDuration::milliseconds(ms);
from.checked_add_signed(delta)
.ok_or_else(|| anyhow::anyhow!("every_ms overflowed DateTime"))
}
}
}
pub fn validate_schedule(schedule: &Schedule, now: DateTime<Utc>) -> Result<()> {
match schedule {
Schedule::Cron { expr, .. } => {
let _ = normalize_expression(expr)?;
let _ = next_run_for_schedule(schedule, now)?;
Ok(())
}
Schedule::At { at } => {
if *at <= now {
anyhow::bail!("Invalid schedule: 'at' must be in the future");
}
Ok(())
}
Schedule::Every { every_ms } => {
if *every_ms == 0 {
anyhow::bail!("Invalid schedule: every_ms must be > 0");
}
Ok(())
}
}
}
pub fn schedule_cron_expression(schedule: &Schedule) -> Option<String> {
match schedule {
Schedule::Cron { expr, .. } => Some(expr.clone()),
_ => None,
}
}
pub fn normalize_expression(expression: &str) -> Result<String> {
let expression = expression.trim();
let field_count = expression.split_whitespace().count();
match field_count {
// standard crontab syntax: minute hour day month weekday
5 => Ok(format!("0 {expression}")),
// crate-native syntax includes seconds (+ optional year)
6 | 7 => Ok(expression.to_string()),
_ => anyhow::bail!(
"Invalid cron expression: {expression} (expected 5, 6, or 7 fields, got {field_count})"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn next_run_for_schedule_supports_every_and_at() {
let now = Utc::now();
let every = Schedule::Every { every_ms: 60_000 };
let next = next_run_for_schedule(&every, now).unwrap();
assert!(next > now);
let at = now + ChronoDuration::minutes(10);
let at_schedule = Schedule::At { at };
let next_at = next_run_for_schedule(&at_schedule, now).unwrap();
assert_eq!(next_at, at);
}
#[test]
fn next_run_for_schedule_supports_timezone() {
let from = Utc.with_ymd_and_hms(2026, 2, 16, 0, 0, 0).unwrap();
let schedule = Schedule::Cron {
expr: "0 9 * * *".into(),
tz: Some("America/Los_Angeles".into()),
};
let next = next_run_for_schedule(&schedule, from).unwrap();
assert_eq!(next, Utc.with_ymd_and_hms(2026, 2, 16, 17, 0, 0).unwrap());
}
}
+750
View File
@@ -0,0 +1,750 @@
use crate::alphahuman::channels::{
Channel, DiscordChannel, MattermostChannel, SendMessage, SlackChannel, TelegramChannel,
};
use crate::alphahuman::config::Config;
use crate::alphahuman::cron::{
due_jobs, next_run_for_schedule, record_last_run, record_run, remove_job, reschedule_after_run,
update_job, CronJob, CronJobPatch, DeliveryConfig, JobType, Schedule, SessionTarget,
};
use crate::alphahuman::security::SecurityPolicy;
use anyhow::Result;
use chrono::{DateTime, Utc};
use futures_util::{stream, StreamExt};
use std::process::Stdio;
use std::sync::Arc;
use tokio::process::Command;
use tokio::time::{self, Duration};
const MIN_POLL_SECONDS: u64 = 5;
const SHELL_JOB_TIMEOUT_SECS: u64 = 120;
pub async fn run(config: Config) -> Result<()> {
let poll_secs = config.reliability.scheduler_poll_secs.max(MIN_POLL_SECONDS);
let mut interval = time::interval(Duration::from_secs(poll_secs));
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
));
crate::alphahuman::health::mark_component_ok("scheduler");
loop {
interval.tick().await;
let jobs = match due_jobs(&config, Utc::now()) {
Ok(jobs) => jobs,
Err(e) => {
crate::alphahuman::health::mark_component_error("scheduler", e.to_string());
tracing::warn!("Scheduler query failed: {e}");
continue;
}
};
process_due_jobs(&config, &security, jobs).await;
}
}
pub async fn execute_job_now(config: &Config, job: &CronJob) -> (bool, String) {
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
execute_job_with_retry(config, &security, job).await
}
async fn execute_job_with_retry(
config: &Config,
security: &SecurityPolicy,
job: &CronJob,
) -> (bool, String) {
let mut last_output = String::new();
let retries = config.reliability.scheduler_retries;
let mut backoff_ms = config.reliability.provider_backoff_ms.max(200);
for attempt in 0..=retries {
let (success, output) = match job.job_type {
JobType::Shell => run_job_command(config, security, job).await,
JobType::Agent => run_agent_job(config, job).await,
};
last_output = output;
if success {
return (true, last_output);
}
if last_output.starts_with("blocked by security policy:") {
// Deterministic policy violations are not retryable.
return (false, last_output);
}
if attempt < retries {
let jitter_ms = u64::from(Utc::now().timestamp_subsec_millis() % 250);
time::sleep(Duration::from_millis(backoff_ms + jitter_ms)).await;
backoff_ms = (backoff_ms.saturating_mul(2)).min(30_000);
}
}
(false, last_output)
}
async fn process_due_jobs(config: &Config, security: &Arc<SecurityPolicy>, jobs: Vec<CronJob>) {
let max_concurrent = config.scheduler.max_concurrent.max(1);
let mut in_flight = stream::iter(jobs.into_iter().map(|job| {
let config = config.clone();
let security = Arc::clone(security);
async move { execute_and_persist_job(&config, security.as_ref(), &job).await }
}))
.buffer_unordered(max_concurrent);
while let Some((job_id, success)) = in_flight.next().await {
if !success {
crate::alphahuman::health::mark_component_error("scheduler", format!("job {job_id} failed"));
}
}
}
async fn execute_and_persist_job(
config: &Config,
security: &SecurityPolicy,
job: &CronJob,
) -> (String, bool) {
crate::alphahuman::health::mark_component_ok("scheduler");
warn_if_high_frequency_agent_job(job);
let started_at = Utc::now();
let (success, output) = execute_job_with_retry(config, security, job).await;
let finished_at = Utc::now();
let success = persist_job_result(config, job, success, &output, started_at, finished_at).await;
(job.id.clone(), success)
}
async fn run_agent_job(config: &Config, job: &CronJob) -> (bool, String) {
let name = job.name.clone().unwrap_or_else(|| "cron-job".to_string());
let prompt = job.prompt.clone().unwrap_or_default();
let prefixed_prompt = format!("[cron:{} {name}] {prompt}", job.id);
let model_override = job.model.clone();
let run_result = match job.session_target {
SessionTarget::Main | SessionTarget::Isolated => {
crate::alphahuman::agent::run(
config.clone(),
Some(prefixed_prompt),
None,
model_override,
config.default_temperature,
vec![],
)
.await
}
};
match run_result {
Ok(response) => (
true,
if response.trim().is_empty() {
"agent job executed".to_string()
} else {
response
},
),
Err(e) => (false, format!("agent job failed: {e}")),
}
}
async fn persist_job_result(
config: &Config,
job: &CronJob,
mut success: bool,
output: &str,
started_at: DateTime<Utc>,
finished_at: DateTime<Utc>,
) -> bool {
let duration_ms = (finished_at - started_at).num_milliseconds();
if let Err(e) = deliver_if_configured(config, job, output).await {
if job.delivery.best_effort {
tracing::warn!("Cron delivery failed (best_effort): {e}");
} else {
success = false;
tracing::warn!("Cron delivery failed: {e}");
}
}
let _ = record_run(
config,
&job.id,
started_at,
finished_at,
if success { "ok" } else { "error" },
Some(output),
duration_ms,
);
if is_one_shot_auto_delete(job) {
if success {
if let Err(e) = remove_job(config, &job.id) {
tracing::warn!("Failed to remove one-shot cron job after success: {e}");
}
} else {
let _ = record_last_run(config, &job.id, finished_at, false, output);
if let Err(e) = update_job(
config,
&job.id,
CronJobPatch {
enabled: Some(false),
..CronJobPatch::default()
},
) {
tracing::warn!("Failed to disable failed one-shot cron job: {e}");
}
}
return success;
}
if let Err(e) = reschedule_after_run(config, job, success, output) {
tracing::warn!("Failed to persist scheduler run result: {e}");
}
success
}
fn is_one_shot_auto_delete(job: &CronJob) -> bool {
job.delete_after_run && matches!(job.schedule, Schedule::At { .. })
}
fn warn_if_high_frequency_agent_job(job: &CronJob) {
if !matches!(job.job_type, JobType::Agent) {
return;
}
let too_frequent = match &job.schedule {
Schedule::Every { every_ms } => *every_ms < 5 * 60 * 1000,
Schedule::Cron { .. } => {
let now = Utc::now();
match (
next_run_for_schedule(&job.schedule, now),
next_run_for_schedule(&job.schedule, now + chrono::Duration::seconds(1)),
) {
(Ok(a), Ok(b)) => (b - a).num_minutes() < 5,
_ => false,
}
}
Schedule::At { .. } => false,
};
if too_frequent {
tracing::warn!(
"Cron agent job '{}' is scheduled more frequently than every 5 minutes",
job.id
);
}
}
async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) -> Result<()> {
let delivery: &DeliveryConfig = &job.delivery;
if !delivery.mode.eq_ignore_ascii_case("announce") {
return Ok(());
}
let channel = delivery
.channel
.as_deref()
.ok_or_else(|| anyhow::anyhow!("delivery.channel is required for announce mode"))?;
let target = delivery
.to
.as_deref()
.ok_or_else(|| anyhow::anyhow!("delivery.to is required for announce mode"))?;
match channel.to_ascii_lowercase().as_str() {
"telegram" => {
let tg = config
.channels_config
.telegram
.as_ref()
.ok_or_else(|| anyhow::anyhow!("telegram channel not configured"))?;
let channel = TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.mention_only,
);
channel.send(&SendMessage::new(output, target)).await?;
}
"discord" => {
let dc = config
.channels_config
.discord
.as_ref()
.ok_or_else(|| anyhow::anyhow!("discord channel not configured"))?;
let channel = DiscordChannel::new(
dc.bot_token.clone(),
dc.guild_id.clone(),
dc.allowed_users.clone(),
dc.listen_to_bots,
dc.mention_only,
);
channel.send(&SendMessage::new(output, target)).await?;
}
"slack" => {
let sl = config
.channels_config
.slack
.as_ref()
.ok_or_else(|| anyhow::anyhow!("slack channel not configured"))?;
let channel = SlackChannel::new(
sl.bot_token.clone(),
sl.channel_id.clone(),
sl.allowed_users.clone(),
);
channel.send(&SendMessage::new(output, target)).await?;
}
"mattermost" => {
let mm = config
.channels_config
.mattermost
.as_ref()
.ok_or_else(|| anyhow::anyhow!("mattermost channel not configured"))?;
let channel = MattermostChannel::new(
mm.url.clone(),
mm.bot_token.clone(),
mm.channel_id.clone(),
mm.allowed_users.clone(),
mm.thread_replies.unwrap_or(true),
mm.mention_only.unwrap_or(false),
);
channel.send(&SendMessage::new(output, target)).await?;
}
other => anyhow::bail!("unsupported delivery channel: {other}"),
}
Ok(())
}
fn is_env_assignment(word: &str) -> bool {
word.contains('=')
&& word
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
}
fn strip_wrapping_quotes(token: &str) -> &str {
token.trim_matches(|c| c == '"' || c == '\'')
}
fn forbidden_path_argument(security: &SecurityPolicy, command: &str) -> Option<String> {
let mut normalized = command.to_string();
for sep in ["&&", "||"] {
normalized = normalized.replace(sep, "\x00");
}
for sep in ['\n', ';', '|'] {
normalized = normalized.replace(sep, "\x00");
}
for segment in normalized.split('\x00') {
let tokens: Vec<&str> = segment.split_whitespace().collect();
if tokens.is_empty() {
continue;
}
// Skip leading env assignments and executable token.
let mut idx = 0;
while idx < tokens.len() && is_env_assignment(tokens[idx]) {
idx += 1;
}
if idx >= tokens.len() {
continue;
}
idx += 1;
for token in &tokens[idx..] {
let candidate = strip_wrapping_quotes(token);
if candidate.is_empty() || candidate.starts_with('-') || candidate.contains("://") {
continue;
}
let looks_like_path = candidate.starts_with('/')
|| candidate.starts_with("./")
|| candidate.starts_with("../")
|| candidate.starts_with("~/")
|| candidate.contains('/');
if looks_like_path && !security.is_path_allowed(candidate) {
return Some(candidate.to_string());
}
}
}
None
}
async fn run_job_command(
config: &Config,
security: &SecurityPolicy,
job: &CronJob,
) -> (bool, String) {
run_job_command_with_timeout(
config,
security,
job,
Duration::from_secs(SHELL_JOB_TIMEOUT_SECS),
)
.await
}
async fn run_job_command_with_timeout(
config: &Config,
security: &SecurityPolicy,
job: &CronJob,
timeout: Duration,
) -> (bool, String) {
if !security.can_act() {
return (
false,
"blocked by security policy: autonomy is read-only".to_string(),
);
}
if security.is_rate_limited() {
return (
false,
"blocked by security policy: rate limit exceeded".to_string(),
);
}
if !security.is_command_allowed(&job.command) {
return (
false,
format!(
"blocked by security policy: command not allowed: {}",
job.command
),
);
}
if let Some(path) = forbidden_path_argument(security, &job.command) {
return (
false,
format!("blocked by security policy: forbidden path argument: {path}"),
);
}
if !security.record_action() {
return (
false,
"blocked by security policy: action budget exhausted".to_string(),
);
}
let child = match Command::new("sh")
.arg("-lc")
.arg(&job.command)
.current_dir(&config.workspace_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
{
Ok(child) => child,
Err(e) => return (false, format!("spawn error: {e}")),
};
match time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!(
"status={}\nstdout:\n{}\nstderr:\n{}",
output.status,
stdout.trim(),
stderr.trim()
);
(output.status.success(), combined)
}
Ok(Err(e)) => (false, format!("spawn error: {e}")),
Err(_) => (
false,
format!("job timed out after {}s", timeout.as_secs_f64()),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alphahuman::config::Config;
use crate::alphahuman::cron::{self, DeliveryConfig};
use crate::alphahuman::security::SecurityPolicy;
use chrono::{Duration as ChronoDuration, Utc};
use tempfile::TempDir;
async fn test_config(tmp: &TempDir) -> Config {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
tokio::fs::create_dir_all(&config.workspace_dir)
.await
.unwrap();
config
}
fn test_job(command: &str) -> CronJob {
CronJob {
id: "test-job".into(),
expression: "* * * * *".into(),
schedule: crate::alphahuman::cron::Schedule::Cron {
expr: "* * * * *".into(),
tz: None,
},
command: command.into(),
prompt: None,
name: None,
job_type: JobType::Shell,
session_target: SessionTarget::Isolated,
model: None,
enabled: true,
delivery: DeliveryConfig::default(),
delete_after_run: false,
created_at: Utc::now(),
next_run: Utc::now(),
last_run: None,
last_status: None,
last_output: None,
}
}
#[tokio::test]
async fn run_job_command_success() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp).await;
let job = test_job("echo scheduler-ok");
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
let (success, output) = run_job_command(&config, &security, &job).await;
assert!(success);
assert!(output.contains("scheduler-ok"));
assert!(output.contains("status=exit status: 0"));
}
#[tokio::test]
async fn run_job_command_failure() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp).await;
let job = test_job("ls definitely_missing_file_for_scheduler_test");
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
let (success, output) = run_job_command(&config, &security, &job).await;
assert!(!success);
assert!(output.contains("definitely_missing_file_for_scheduler_test"));
assert!(output.contains("status=exit status:"));
}
#[tokio::test]
async fn run_job_command_times_out() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.autonomy.allowed_commands = vec!["sleep".into()];
let job = test_job("sleep 1");
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
let (success, output) =
run_job_command_with_timeout(&config, &security, &job, Duration::from_millis(50)).await;
assert!(!success);
assert!(output.contains("job timed out after"));
}
#[tokio::test]
async fn run_job_command_blocks_disallowed_command() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.autonomy.allowed_commands = vec!["echo".into()];
let job = test_job("curl https://evil.example");
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
let (success, output) = run_job_command(&config, &security, &job).await;
assert!(!success);
assert!(output.contains("blocked by security policy"));
assert!(output.contains("command not allowed"));
}
#[tokio::test]
async fn run_job_command_blocks_forbidden_path_argument() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.autonomy.allowed_commands = vec!["cat".into()];
let job = test_job("cat /etc/passwd");
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
let (success, output) = run_job_command(&config, &security, &job).await;
assert!(!success);
assert!(output.contains("blocked by security policy"));
assert!(output.contains("forbidden path argument"));
assert!(output.contains("/etc/passwd"));
}
#[tokio::test]
async fn run_job_command_blocks_readonly_mode() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.autonomy.level = crate::alphahuman::security::AutonomyLevel::ReadOnly;
let job = test_job("echo should-not-run");
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
let (success, output) = run_job_command(&config, &security, &job).await;
assert!(!success);
assert!(output.contains("blocked by security policy"));
assert!(output.contains("read-only"));
}
#[tokio::test]
async fn run_job_command_blocks_rate_limited() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.autonomy.max_actions_per_hour = 0;
let job = test_job("echo should-not-run");
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
let (success, output) = run_job_command(&config, &security, &job).await;
assert!(!success);
assert!(output.contains("blocked by security policy"));
assert!(output.contains("rate limit exceeded"));
}
#[tokio::test]
async fn execute_job_with_retry_recovers_after_first_failure() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.reliability.scheduler_retries = 1;
config.reliability.provider_backoff_ms = 1;
config.autonomy.allowed_commands = vec!["sh".into()];
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
tokio::fs::write(
config.workspace_dir.join("retry-once.sh"),
"#!/bin/sh\nif [ -f retry-ok.flag ]; then\n echo recovered\n exit 0\nfi\ntouch retry-ok.flag\nexit 1\n",
)
.await
.unwrap();
let job = test_job("sh ./retry-once.sh");
let (success, output) = execute_job_with_retry(&config, &security, &job).await;
assert!(success);
assert!(output.contains("recovered"));
}
#[tokio::test]
async fn execute_job_with_retry_exhausts_attempts() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp).await;
config.reliability.scheduler_retries = 1;
config.reliability.provider_backoff_ms = 1;
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
let job = test_job("ls always_missing_for_retry_test");
let (success, output) = execute_job_with_retry(&config, &security, &job).await;
assert!(!success);
assert!(output.contains("always_missing_for_retry_test"));
}
#[tokio::test]
async fn run_agent_job_returns_error_without_provider_key() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp).await;
let mut job = test_job("");
job.job_type = JobType::Agent;
job.prompt = Some("Say hello".into());
let (success, output) = run_agent_job(&config, &job).await;
assert!(!success, "Agent job without provider key should fail");
assert!(
!output.is_empty(),
"Expected non-empty error output from failed agent job"
);
}
#[tokio::test]
async fn persist_job_result_records_run_and_reschedules_shell_job() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp).await;
let job = cron::add_job(&config, "*/5 * * * *", "echo ok").unwrap();
let started = Utc::now();
let finished = started + ChronoDuration::milliseconds(10);
let success = persist_job_result(&config, &job, true, "ok", started, finished).await;
assert!(success);
let runs = cron::list_runs(&config, &job.id, 10).unwrap();
assert_eq!(runs.len(), 1);
let updated = cron::get_job(&config, &job.id).unwrap();
assert_eq!(updated.last_status.as_deref(), Some("ok"));
}
#[tokio::test]
async fn persist_job_result_success_deletes_one_shot() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp).await;
let at = Utc::now() + ChronoDuration::minutes(10);
let job = cron::add_agent_job(
&config,
Some("one-shot".into()),
crate::alphahuman::cron::Schedule::At { at },
"Hello",
SessionTarget::Isolated,
None,
None,
true,
)
.unwrap();
let started = Utc::now();
let finished = started + ChronoDuration::milliseconds(10);
let success = persist_job_result(&config, &job, true, "ok", started, finished).await;
assert!(success);
let lookup = cron::get_job(&config, &job.id);
assert!(lookup.is_err());
}
#[tokio::test]
async fn persist_job_result_failure_disables_one_shot() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp).await;
let at = Utc::now() + ChronoDuration::minutes(10);
let job = cron::add_agent_job(
&config,
Some("one-shot".into()),
crate::alphahuman::cron::Schedule::At { at },
"Hello",
SessionTarget::Isolated,
None,
None,
true,
)
.unwrap();
let started = Utc::now();
let finished = started + ChronoDuration::milliseconds(10);
let success = persist_job_result(&config, &job, false, "boom", started, finished).await;
assert!(!success);
let updated = cron::get_job(&config, &job.id).unwrap();
assert!(!updated.enabled);
assert_eq!(updated.last_status.as_deref(), Some("error"));
}
#[tokio::test]
async fn deliver_if_configured_handles_none_and_invalid_channel() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp).await;
let mut job = test_job("echo ok");
assert!(deliver_if_configured(&config, &job, "x").await.is_ok());
job.delivery = DeliveryConfig {
mode: "announce".into(),
channel: Some("invalid".into()),
to: Some("target".into()),
best_effort: true,
};
let err = deliver_if_configured(&config, &job, "x").await.unwrap_err();
assert!(err.to_string().contains("unsupported delivery channel"));
}
}
+773
View File
@@ -0,0 +1,773 @@
use crate::alphahuman::config::Config;
use crate::alphahuman::cron::{
next_run_for_schedule, schedule_cron_expression, validate_schedule, CronJob, CronJobPatch,
CronRun, DeliveryConfig, JobType, Schedule, SessionTarget,
};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use uuid::Uuid;
const MAX_CRON_OUTPUT_BYTES: usize = 16 * 1024;
const TRUNCATED_OUTPUT_MARKER: &str = "\n...[truncated]";
pub fn add_job(config: &Config, expression: &str, command: &str) -> Result<CronJob> {
let schedule = Schedule::Cron {
expr: expression.to_string(),
tz: None,
};
add_shell_job(config, None, schedule, command)
}
pub fn add_shell_job(
config: &Config,
name: Option<String>,
schedule: Schedule,
command: &str,
) -> Result<CronJob> {
let now = Utc::now();
validate_schedule(&schedule, now)?;
let next_run = next_run_for_schedule(&schedule, now)?;
let id = Uuid::new_v4().to_string();
let expression = schedule_cron_expression(&schedule).unwrap_or_default();
let schedule_json = serde_json::to_string(&schedule)?;
with_connection(config, |conn| {
conn.execute(
"INSERT INTO cron_jobs (
id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run
) VALUES (?1, ?2, ?3, ?4, 'shell', NULL, ?5, 'isolated', NULL, 1, ?6, 0, ?7, ?8)",
params![
id,
expression,
command,
schedule_json,
name,
serde_json::to_string(&DeliveryConfig::default())?,
now.to_rfc3339(),
next_run.to_rfc3339(),
],
)
.context("Failed to insert cron shell job")?;
Ok(())
})?;
get_job(config, &id)
}
#[allow(clippy::too_many_arguments)]
pub fn add_agent_job(
config: &Config,
name: Option<String>,
schedule: Schedule,
prompt: &str,
session_target: SessionTarget,
model: Option<String>,
delivery: Option<DeliveryConfig>,
delete_after_run: bool,
) -> Result<CronJob> {
let now = Utc::now();
validate_schedule(&schedule, now)?;
let next_run = next_run_for_schedule(&schedule, now)?;
let id = Uuid::new_v4().to_string();
let expression = schedule_cron_expression(&schedule).unwrap_or_default();
let schedule_json = serde_json::to_string(&schedule)?;
let delivery = delivery.unwrap_or_default();
with_connection(config, |conn| {
conn.execute(
"INSERT INTO cron_jobs (
id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run
) VALUES (?1, ?2, '', ?3, 'agent', ?4, ?5, ?6, ?7, 1, ?8, ?9, ?10, ?11)",
params![
id,
expression,
schedule_json,
prompt,
name,
session_target.as_str(),
model,
serde_json::to_string(&delivery)?,
if delete_after_run { 1 } else { 0 },
now.to_rfc3339(),
next_run.to_rfc3339(),
],
)
.context("Failed to insert cron agent job")?;
Ok(())
})?;
get_job(config, &id)
}
pub fn list_jobs(config: &Config) -> Result<Vec<CronJob>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
FROM cron_jobs ORDER BY next_run ASC",
)?;
let rows = stmt.query_map([], map_cron_job_row)?;
let mut jobs = Vec::new();
for row in rows {
jobs.push(row?);
}
Ok(jobs)
})
}
pub fn get_job(config: &Config, job_id: &str) -> Result<CronJob> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
FROM cron_jobs WHERE id = ?1",
)?;
let mut rows = stmt.query(params![job_id])?;
if let Some(row) = rows.next()? {
map_cron_job_row(row).map_err(Into::into)
} else {
anyhow::bail!("Cron job '{job_id}' not found")
}
})
}
pub fn remove_job(config: &Config, id: &str) -> Result<()> {
let changed = with_connection(config, |conn| {
conn.execute("DELETE FROM cron_jobs WHERE id = ?1", params![id])
.context("Failed to delete cron job")
})?;
if changed == 0 {
anyhow::bail!("Cron job '{id}' not found");
}
println!("✅ Removed cron job {id}");
Ok(())
}
pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
let lim = i64::try_from(config.scheduler.max_tasks.max(1))
.context("Scheduler max_tasks overflows i64")?;
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, expression, command, schedule, job_type, prompt, name, session_target, model,
enabled, delivery, delete_after_run, created_at, next_run, last_run, last_status, last_output
FROM cron_jobs
WHERE enabled = 1 AND next_run <= ?1
ORDER BY next_run ASC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![now.to_rfc3339(), lim], map_cron_job_row)?;
let mut jobs = Vec::new();
for row in rows {
jobs.push(row?);
}
Ok(jobs)
})
}
pub fn update_job(config: &Config, job_id: &str, patch: CronJobPatch) -> Result<CronJob> {
let mut job = get_job(config, job_id)?;
let mut schedule_changed = false;
if let Some(schedule) = patch.schedule {
validate_schedule(&schedule, Utc::now())?;
job.schedule = schedule;
job.expression = schedule_cron_expression(&job.schedule).unwrap_or_default();
schedule_changed = true;
}
if let Some(command) = patch.command {
job.command = command;
}
if let Some(prompt) = patch.prompt {
job.prompt = Some(prompt);
}
if let Some(name) = patch.name {
job.name = Some(name);
}
if let Some(enabled) = patch.enabled {
job.enabled = enabled;
}
if let Some(delivery) = patch.delivery {
job.delivery = delivery;
}
if let Some(model) = patch.model {
job.model = Some(model);
}
if let Some(target) = patch.session_target {
job.session_target = target;
}
if let Some(delete_after_run) = patch.delete_after_run {
job.delete_after_run = delete_after_run;
}
if schedule_changed {
job.next_run = next_run_for_schedule(&job.schedule, Utc::now())?;
}
with_connection(config, |conn| {
conn.execute(
"UPDATE cron_jobs
SET expression = ?1, command = ?2, schedule = ?3, job_type = ?4, prompt = ?5, name = ?6,
session_target = ?7, model = ?8, enabled = ?9, delivery = ?10, delete_after_run = ?11,
next_run = ?12
WHERE id = ?13",
params![
job.expression,
job.command,
serde_json::to_string(&job.schedule)?,
job.job_type.as_str(),
job.prompt,
job.name,
job.session_target.as_str(),
job.model,
if job.enabled { 1 } else { 0 },
serde_json::to_string(&job.delivery)?,
if job.delete_after_run { 1 } else { 0 },
job.next_run.to_rfc3339(),
job.id,
],
)
.context("Failed to update cron job")?;
Ok(())
})?;
get_job(config, job_id)
}
pub fn record_last_run(
config: &Config,
job_id: &str,
finished_at: DateTime<Utc>,
success: bool,
output: &str,
) -> Result<()> {
let status = if success { "ok" } else { "error" };
let bounded_output = truncate_cron_output(output);
with_connection(config, |conn| {
conn.execute(
"UPDATE cron_jobs
SET last_run = ?1, last_status = ?2, last_output = ?3
WHERE id = ?4",
params![finished_at.to_rfc3339(), status, bounded_output, job_id],
)
.context("Failed to update cron last run fields")?;
Ok(())
})
}
pub fn reschedule_after_run(
config: &Config,
job: &CronJob,
success: bool,
output: &str,
) -> Result<()> {
let now = Utc::now();
let next_run = next_run_for_schedule(&job.schedule, now)?;
let status = if success { "ok" } else { "error" };
let bounded_output = truncate_cron_output(output);
with_connection(config, |conn| {
conn.execute(
"UPDATE cron_jobs
SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4
WHERE id = ?5",
params![
next_run.to_rfc3339(),
now.to_rfc3339(),
status,
bounded_output,
job.id
],
)
.context("Failed to update cron job run state")?;
Ok(())
})
}
pub fn record_run(
config: &Config,
job_id: &str,
started_at: DateTime<Utc>,
finished_at: DateTime<Utc>,
status: &str,
output: Option<&str>,
duration_ms: i64,
) -> Result<()> {
let bounded_output = output.map(truncate_cron_output);
with_connection(config, |conn| {
// Wrap INSERT + pruning DELETE in an explicit transaction so that
// if the DELETE fails, the INSERT is rolled back and the run table
// cannot grow unboundedly.
let tx = conn.unchecked_transaction()?;
tx.execute(
"INSERT INTO cron_runs (job_id, started_at, finished_at, status, output, duration_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
job_id,
started_at.to_rfc3339(),
finished_at.to_rfc3339(),
status,
bounded_output.as_deref(),
duration_ms,
],
)
.context("Failed to insert cron run")?;
let keep = config.cron.max_run_history.max(1) as i64;
tx.execute(
"DELETE FROM cron_runs
WHERE job_id = ?1
AND id NOT IN (
SELECT id FROM cron_runs
WHERE job_id = ?1
ORDER BY started_at DESC, id DESC
LIMIT ?2
)",
params![job_id, keep],
)
.context("Failed to prune cron run history")?;
tx.commit()
.context("Failed to commit cron run transaction")?;
Ok(())
})
}
fn truncate_cron_output(output: &str) -> String {
if output.len() <= MAX_CRON_OUTPUT_BYTES {
return output.to_string();
}
if MAX_CRON_OUTPUT_BYTES <= TRUNCATED_OUTPUT_MARKER.len() {
return TRUNCATED_OUTPUT_MARKER.to_string();
}
let mut cutoff = MAX_CRON_OUTPUT_BYTES - TRUNCATED_OUTPUT_MARKER.len();
while cutoff > 0 && !output.is_char_boundary(cutoff) {
cutoff -= 1;
}
let mut truncated = output[..cutoff].to_string();
truncated.push_str(TRUNCATED_OUTPUT_MARKER);
truncated
}
pub fn list_runs(config: &Config, job_id: &str, limit: usize) -> Result<Vec<CronRun>> {
with_connection(config, |conn| {
let lim = i64::try_from(limit.max(1)).context("Run history limit overflow")?;
let mut stmt = conn.prepare(
"SELECT id, job_id, started_at, finished_at, status, output, duration_ms
FROM cron_runs
WHERE job_id = ?1
ORDER BY started_at DESC, id DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![job_id, lim], |row| {
Ok(CronRun {
id: row.get(0)?,
job_id: row.get(1)?,
started_at: parse_rfc3339(&row.get::<_, String>(2)?)
.map_err(sql_conversion_error)?,
finished_at: parse_rfc3339(&row.get::<_, String>(3)?)
.map_err(sql_conversion_error)?,
status: row.get(4)?,
output: row.get(5)?,
duration_ms: row.get(6)?,
})
})?;
let mut runs = Vec::new();
for row in rows {
runs.push(row?);
}
Ok(runs)
})
}
fn parse_rfc3339(raw: &str) -> Result<DateTime<Utc>> {
let parsed = DateTime::parse_from_rfc3339(raw)
.with_context(|| format!("Invalid RFC3339 timestamp in cron DB: {raw}"))?;
Ok(parsed.with_timezone(&Utc))
}
fn sql_conversion_error(err: anyhow::Error) -> rusqlite::Error {
rusqlite::Error::ToSqlConversionFailure(err.into())
}
fn map_cron_job_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CronJob> {
let expression: String = row.get(1)?;
let schedule_raw: Option<String> = row.get(3)?;
let schedule =
decode_schedule(schedule_raw.as_deref(), &expression).map_err(sql_conversion_error)?;
let delivery_raw: Option<String> = row.get(10)?;
let delivery = decode_delivery(delivery_raw.as_deref()).map_err(sql_conversion_error)?;
let next_run_raw: String = row.get(13)?;
let last_run_raw: Option<String> = row.get(14)?;
let created_at_raw: String = row.get(12)?;
Ok(CronJob {
id: row.get(0)?,
expression,
schedule,
command: row.get(2)?,
job_type: JobType::parse(&row.get::<_, String>(4)?),
prompt: row.get(5)?,
name: row.get(6)?,
session_target: SessionTarget::parse(&row.get::<_, String>(7)?),
model: row.get(8)?,
enabled: row.get::<_, i64>(9)? != 0,
delivery,
delete_after_run: row.get::<_, i64>(11)? != 0,
created_at: parse_rfc3339(&created_at_raw).map_err(sql_conversion_error)?,
next_run: parse_rfc3339(&next_run_raw).map_err(sql_conversion_error)?,
last_run: match last_run_raw {
Some(raw) => Some(parse_rfc3339(&raw).map_err(sql_conversion_error)?),
None => None,
},
last_status: row.get(15)?,
last_output: row.get(16)?,
})
}
fn decode_schedule(schedule_raw: Option<&str>, expression: &str) -> Result<Schedule> {
if let Some(raw) = schedule_raw {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return serde_json::from_str(trimmed)
.with_context(|| format!("Failed to parse cron schedule JSON: {trimmed}"));
}
}
if expression.trim().is_empty() {
anyhow::bail!("Missing schedule and legacy expression for cron job")
}
Ok(Schedule::Cron {
expr: expression.to_string(),
tz: None,
})
}
fn decode_delivery(delivery_raw: Option<&str>) -> Result<DeliveryConfig> {
if let Some(raw) = delivery_raw {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return serde_json::from_str(trimmed)
.with_context(|| format!("Failed to parse cron delivery JSON: {trimmed}"));
}
}
Ok(DeliveryConfig::default())
}
fn add_column_if_missing(conn: &Connection, name: &str, sql_type: &str) -> Result<()> {
let mut stmt = conn.prepare("PRAGMA table_info(cron_jobs)")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let col_name: String = row.get(1)?;
if col_name == name {
return Ok(());
}
}
// Drop the statement/rows before executing ALTER to release any locks
drop(rows);
drop(stmt);
// Tolerate "duplicate column name" errors to handle the race where
// another process adds the column between our PRAGMA check and ALTER.
match conn.execute(
&format!("ALTER TABLE cron_jobs ADD COLUMN {name} {sql_type}"),
[],
) {
Ok(_) => Ok(()),
Err(rusqlite::Error::SqliteFailure(err, Some(ref msg)))
if msg.contains("duplicate column name") =>
{
tracing::debug!("Column cron_jobs.{name} already exists (concurrent migration): {err}");
Ok(())
}
Err(e) => Err(e).with_context(|| format!("Failed to add cron_jobs.{name}")),
}
}
fn with_connection<T>(config: &Config, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
let db_path = config.workspace_dir.join("cron").join("jobs.db");
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create cron directory: {}", parent.display()))?;
}
let conn = Connection::open(&db_path)
.with_context(|| format!("Failed to open cron DB: {}", db_path.display()))?;
conn.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS cron_jobs (
id TEXT PRIMARY KEY,
expression TEXT NOT NULL,
command TEXT NOT NULL,
schedule TEXT,
job_type TEXT NOT NULL DEFAULT 'shell',
prompt TEXT,
name TEXT,
session_target TEXT NOT NULL DEFAULT 'isolated',
model TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
delivery TEXT,
delete_after_run INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
next_run TEXT NOT NULL,
last_run TEXT,
last_status TEXT,
last_output TEXT
);
CREATE INDEX IF NOT EXISTS idx_cron_jobs_next_run ON cron_jobs(next_run);
CREATE TABLE IF NOT EXISTS cron_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT NOT NULL,
status TEXT NOT NULL,
output TEXT,
duration_ms INTEGER,
FOREIGN KEY (job_id) REFERENCES cron_jobs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cron_runs_job_id ON cron_runs(job_id);
CREATE INDEX IF NOT EXISTS idx_cron_runs_started_at ON cron_runs(started_at);
CREATE INDEX IF NOT EXISTS idx_cron_runs_job_started ON cron_runs(job_id, started_at);",
)
.context("Failed to initialize cron schema")?;
add_column_if_missing(&conn, "schedule", "TEXT")?;
add_column_if_missing(&conn, "job_type", "TEXT NOT NULL DEFAULT 'shell'")?;
add_column_if_missing(&conn, "prompt", "TEXT")?;
add_column_if_missing(&conn, "name", "TEXT")?;
add_column_if_missing(&conn, "session_target", "TEXT NOT NULL DEFAULT 'isolated'")?;
add_column_if_missing(&conn, "model", "TEXT")?;
add_column_if_missing(&conn, "enabled", "INTEGER NOT NULL DEFAULT 1")?;
add_column_if_missing(&conn, "delivery", "TEXT")?;
add_column_if_missing(&conn, "delete_after_run", "INTEGER NOT NULL DEFAULT 0")?;
f(&conn)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::alphahuman::config::Config;
use chrono::Duration as ChronoDuration;
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config
}
#[test]
fn add_job_accepts_five_field_expression() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "*/5 * * * *", "echo ok").unwrap();
assert_eq!(job.expression, "*/5 * * * *");
assert_eq!(job.command, "echo ok");
assert!(matches!(job.schedule, Schedule::Cron { .. }));
}
#[test]
fn add_list_remove_roundtrip() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "*/10 * * * *", "echo roundtrip").unwrap();
let listed = list_jobs(&config).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, job.id);
remove_job(&config, &job.id).unwrap();
assert!(list_jobs(&config).unwrap().is_empty());
}
#[test]
fn due_jobs_filters_by_timestamp_and_enabled() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "* * * * *", "echo due").unwrap();
let due_now = due_jobs(&config, Utc::now()).unwrap();
assert!(due_now.is_empty(), "new job should not be due immediately");
let far_future = Utc::now() + ChronoDuration::days(365);
let due_future = due_jobs(&config, far_future).unwrap();
assert_eq!(due_future.len(), 1, "job should be due in far future");
let _ = update_job(
&config,
&job.id,
CronJobPatch {
enabled: Some(false),
..CronJobPatch::default()
},
)
.unwrap();
let due_after_disable = due_jobs(&config, far_future).unwrap();
assert!(due_after_disable.is_empty());
}
#[test]
fn due_jobs_respects_scheduler_max_tasks_limit() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.scheduler.max_tasks = 2;
let _ = add_job(&config, "* * * * *", "echo due-1").unwrap();
let _ = add_job(&config, "* * * * *", "echo due-2").unwrap();
let _ = add_job(&config, "* * * * *", "echo due-3").unwrap();
let far_future = Utc::now() + ChronoDuration::days(365);
let due = due_jobs(&config, far_future).unwrap();
assert_eq!(due.len(), 2);
}
#[test]
fn reschedule_after_run_persists_last_status_and_last_run() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "*/15 * * * *", "echo run").unwrap();
reschedule_after_run(&config, &job, false, "failed output").unwrap();
let listed = list_jobs(&config).unwrap();
let stored = listed.iter().find(|j| j.id == job.id).unwrap();
assert_eq!(stored.last_status.as_deref(), Some("error"));
assert!(stored.last_run.is_some());
assert_eq!(stored.last_output.as_deref(), Some("failed output"));
}
#[test]
fn migration_falls_back_to_legacy_expression() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
with_connection(&config, |conn| {
conn.execute(
"INSERT INTO cron_jobs (id, expression, command, created_at, next_run)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
"legacy-id",
"*/5 * * * *",
"echo legacy",
Utc::now().to_rfc3339(),
(Utc::now() + ChronoDuration::minutes(5)).to_rfc3339(),
],
)?;
conn.execute(
"UPDATE cron_jobs SET schedule = NULL WHERE id = 'legacy-id'",
[],
)?;
Ok(())
})
.unwrap();
let job = get_job(&config, "legacy-id").unwrap();
assert!(matches!(job.schedule, Schedule::Cron { .. }));
}
#[test]
fn record_and_prune_runs() {
let tmp = TempDir::new().unwrap();
let mut config = test_config(&tmp);
config.cron.max_run_history = 2;
let job = add_job(&config, "*/5 * * * *", "echo ok").unwrap();
let base = Utc::now();
for idx in 0..3 {
let start = base + ChronoDuration::seconds(idx);
let end = start + ChronoDuration::milliseconds(100);
record_run(&config, &job.id, start, end, "ok", Some("done"), 100).unwrap();
}
let runs = list_runs(&config, &job.id, 10).unwrap();
assert_eq!(runs.len(), 2);
}
#[test]
fn remove_job_cascades_run_history() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "*/5 * * * *", "echo ok").unwrap();
let start = Utc::now();
record_run(
&config,
&job.id,
start,
start + ChronoDuration::milliseconds(5),
"ok",
Some("ok"),
5,
)
.unwrap();
remove_job(&config, &job.id).unwrap();
let runs = list_runs(&config, &job.id, 10).unwrap();
assert!(runs.is_empty());
}
#[test]
fn record_run_truncates_large_output() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "*/5 * * * *", "echo trunc").unwrap();
let output = "x".repeat(MAX_CRON_OUTPUT_BYTES + 512);
record_run(
&config,
&job.id,
Utc::now(),
Utc::now(),
"ok",
Some(&output),
1,
)
.unwrap();
let runs = list_runs(&config, &job.id, 1).unwrap();
let stored = runs[0].output.as_deref().unwrap_or_default();
assert!(stored.ends_with(TRUNCATED_OUTPUT_MARKER));
assert!(stored.len() <= MAX_CRON_OUTPUT_BYTES);
}
#[test]
fn reschedule_after_run_truncates_last_output() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_job(&config, "*/5 * * * *", "echo trunc").unwrap();
let output = "y".repeat(MAX_CRON_OUTPUT_BYTES + 1024);
reschedule_after_run(&config, &job, false, &output).unwrap();
let stored = get_job(&config, &job.id).unwrap();
let last_output = stored.last_output.as_deref().unwrap_or_default();
assert!(last_output.ends_with(TRUNCATED_OUTPUT_MARKER));
assert!(last_output.len() <= MAX_CRON_OUTPUT_BYTES);
}
}
+140
View File
@@ -0,0 +1,140 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum JobType {
#[default]
Shell,
Agent,
}
impl JobType {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Shell => "shell",
Self::Agent => "agent",
}
}
pub(crate) fn parse(raw: &str) -> Self {
if raw.eq_ignore_ascii_case("agent") {
Self::Agent
} else {
Self::Shell
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SessionTarget {
#[default]
Isolated,
Main,
}
impl SessionTarget {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Isolated => "isolated",
Self::Main => "main",
}
}
pub(crate) fn parse(raw: &str) -> Self {
if raw.eq_ignore_ascii_case("main") {
Self::Main
} else {
Self::Isolated
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Schedule {
Cron {
expr: String,
#[serde(default)]
tz: Option<String>,
},
At {
at: DateTime<Utc>,
},
Every {
every_ms: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeliveryConfig {
#[serde(default)]
pub mode: String,
#[serde(default)]
pub channel: Option<String>,
#[serde(default)]
pub to: Option<String>,
#[serde(default = "default_true")]
pub best_effort: bool,
}
impl Default for DeliveryConfig {
fn default() -> Self {
Self {
mode: "none".to_string(),
channel: None,
to: None,
best_effort: true,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronJob {
pub id: String,
pub expression: String,
pub schedule: Schedule,
pub command: String,
pub prompt: Option<String>,
pub name: Option<String>,
pub job_type: JobType,
pub session_target: SessionTarget,
pub model: Option<String>,
pub enabled: bool,
pub delivery: DeliveryConfig,
pub delete_after_run: bool,
pub created_at: DateTime<Utc>,
pub next_run: DateTime<Utc>,
pub last_run: Option<DateTime<Utc>>,
pub last_status: Option<String>,
pub last_output: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronRun {
pub id: i64,
pub job_id: String,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
pub status: String,
pub output: Option<String>,
pub duration_ms: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CronJobPatch {
pub schedule: Option<Schedule>,
pub command: Option<String>,
pub prompt: Option<String>,
pub name: Option<String>,
pub enabled: Option<bool>,
pub delivery: Option<DeliveryConfig>,
pub model: Option<String>,
pub session_target: Option<SessionTarget>,
pub delete_after_run: Option<bool>,
}
+438
View File
@@ -0,0 +1,438 @@
//! Daemon supervisor adapted for Tauri.
//!
//! Runs alongside the existing QuickJS runtime, TDLib, and Socket.io systems.
//! Uses `CancellationToken` for lifecycle management (Tauri controls shutdown).
//! Periodically emits health snapshots as Tauri events.
use anyhow::Result;
use chrono::Utc;
use std::future::Future;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter};
use tokio::task::JoinHandle;
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use crate::alphahuman::config::{Config, DaemonConfig};
/// How often the state writer emits health snapshots (seconds).
const STATUS_FLUSH_SECONDS: u64 = 5;
/// Handle to the running daemon, stored as Tauri managed state.
pub struct DaemonHandle {
pub cancel: CancellationToken,
}
/// Run the daemon supervisor. Non-blocking — call via `tauri::async_runtime::spawn`.
///
/// The supervisor:
/// 1. Marks the "daemon" health component as OK
/// 2. Spawns a state writer that emits `alphahuman:health` Tauri events
/// 3. Waits for the cancellation token to be triggered (on app exit)
/// 4. Aborts all supervised tasks
pub async fn run(
config: DaemonConfig,
app_handle: AppHandle,
cancel: CancellationToken,
) -> Result<()> {
let initial_backoff = config.reliability.channel_initial_backoff_secs.max(1);
let max_backoff = config
.reliability
.channel_max_backoff_secs
.max(initial_backoff);
// Ensure data and workspace directories exist
let _ = tokio::fs::create_dir_all(&config.data_dir).await;
let _ = tokio::fs::create_dir_all(&config.workspace_dir).await;
crate::alphahuman::health::mark_component_ok("daemon");
let mut handles: Vec<JoinHandle<()>> = vec![];
// State writer: periodically emits health snapshots as Tauri events
{
let app = app_handle.clone();
let data_dir = config.data_dir.clone();
let cancel_clone = cancel.clone();
handles.push(tokio::spawn(async move {
spawn_state_writer(app, data_dir, cancel_clone).await;
}));
}
log::info!("[alphahuman] Daemon supervisor started");
log::info!(
"[alphahuman] data_dir: {}",
config.data_dir.display()
);
log::info!(
"[alphahuman] backoff: {}s initial, {}s max",
initial_backoff,
max_backoff
);
// Wait for cancellation (Tauri exit)
cancel.cancelled().await;
crate::alphahuman::health::mark_component_error("daemon", "shutdown requested");
log::info!("[alphahuman] Daemon supervisor shutting down");
for handle in &handles {
handle.abort();
}
for handle in handles {
let _ = handle.await;
}
Ok(())
}
/// Run the full Alphahuman daemon supervisor within alphahuman.
///
/// Uses a cancellation token for controlled shutdown inside the Tauri process.
pub async fn run_full(
config: Config,
host: String,
port: u16,
cancel: CancellationToken,
) -> Result<()> {
let initial_backoff = config.reliability.channel_initial_backoff_secs.max(1);
let max_backoff = config
.reliability
.channel_max_backoff_secs
.max(initial_backoff);
crate::alphahuman::health::mark_component_ok("daemon");
if config.heartbeat.enabled {
let _ =
crate::alphahuman::heartbeat::engine::HeartbeatEngine::ensure_heartbeat_file(
&config.workspace_dir,
)
.await;
}
let mut handles: Vec<JoinHandle<()>> =
vec![spawn_state_writer_full(config.clone(), cancel.clone())];
{
let gateway_cfg = config.clone();
let gateway_host = host.clone();
handles.push(spawn_component_supervisor(
"gateway",
initial_backoff,
max_backoff,
move || {
let cfg = gateway_cfg.clone();
let host = gateway_host.clone();
async move {
crate::alphahuman::gateway::run_gateway(&host, port, cfg).await
}
},
));
}
{
if has_supervised_channels(&config) {
let channels_cfg = config.clone();
handles.push(spawn_component_supervisor(
"channels",
initial_backoff,
max_backoff,
move || {
let cfg = channels_cfg.clone();
async move { crate::alphahuman::channels::start_channels(cfg).await }
},
));
} else {
crate::alphahuman::health::mark_component_ok("channels");
log::info!("No real-time channels configured; channel supervisor disabled");
}
}
if config.heartbeat.enabled {
let heartbeat_cfg = config.clone();
handles.push(spawn_component_supervisor(
"heartbeat",
initial_backoff,
max_backoff,
move || {
let cfg = heartbeat_cfg.clone();
async move { run_heartbeat_worker(cfg).await }
},
));
}
if config.cron.enabled {
let scheduler_cfg = config.clone();
handles.push(spawn_component_supervisor(
"scheduler",
initial_backoff,
max_backoff,
move || {
let cfg = scheduler_cfg.clone();
async move { crate::alphahuman::cron::scheduler::run(cfg).await }
},
));
} else {
crate::alphahuman::health::mark_component_ok("scheduler");
log::info!("Cron disabled; scheduler supervisor not started");
}
log::info!("[alphahuman] Alphahuman daemon started");
log::info!("[alphahuman] Gateway: http://{host}:{port}");
log::info!("[alphahuman] Components: gateway, channels, heartbeat, scheduler");
cancel.cancelled().await;
crate::alphahuman::health::mark_component_error("daemon", "shutdown requested");
for handle in &handles {
handle.abort();
}
for handle in handles {
let _ = handle.await;
}
Ok(())
}
pub(crate) fn state_file_path(config: &Config) -> PathBuf {
config
.config_path
.parent()
.map_or_else(|| PathBuf::from("."), PathBuf::from)
.join("daemon_state.json")
}
fn spawn_state_writer_full(config: Config, cancel: CancellationToken) -> JoinHandle<()> {
tokio::spawn(async move {
let path = state_file_path(&config);
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let mut interval = tokio::time::interval(Duration::from_secs(STATUS_FLUSH_SECONDS));
loop {
tokio::select! {
_ = interval.tick() => {},
_ = cancel.cancelled() => break,
}
let mut json = crate::alphahuman::health::snapshot_json();
if let Some(obj) = json.as_object_mut() {
obj.insert(
"written_at".into(),
serde_json::json!(Utc::now().to_rfc3339()),
);
}
let data = serde_json::to_vec_pretty(&json).unwrap_or_else(|_| b"{}".to_vec());
let _ = tokio::fs::write(&path, data).await;
}
})
}
async fn run_heartbeat_worker(config: Config) -> Result<()> {
let observer: std::sync::Arc<dyn crate::alphahuman::observability::Observer> =
std::sync::Arc::from(crate::alphahuman::observability::create_observer(
&config.observability,
));
let engine = crate::alphahuman::heartbeat::engine::HeartbeatEngine::new(
config.heartbeat.clone(),
config.workspace_dir.clone(),
observer,
);
let interval_mins = config.heartbeat.interval_minutes.max(5);
let mut interval =
tokio::time::interval(Duration::from_secs(u64::from(interval_mins) * 60));
loop {
interval.tick().await;
let tasks = engine.collect_tasks().await?;
if tasks.is_empty() {
continue;
}
for task in tasks {
let prompt = format!("[Heartbeat Task] {task}");
let temp = config.default_temperature;
if let Err(e) = crate::alphahuman::agent::run(
config.clone(),
Some(prompt),
None,
None,
temp,
vec![],
)
.await
{
crate::alphahuman::health::mark_component_error("heartbeat", e.to_string());
log::warn!("Heartbeat task failed: {e}");
} else {
crate::alphahuman::health::mark_component_ok("heartbeat");
}
}
}
}
fn has_supervised_channels(config: &Config) -> bool {
let crate::alphahuman::config::ChannelsConfig {
cli: _, // `cli` is not used in the web UI
webhook: _, // Managed by the gateway
telegram,
discord,
slack,
mattermost,
imessage,
matrix,
signal,
whatsapp,
email,
irc,
lark,
dingtalk,
linq,
qq,
..
} = &config.channels_config;
telegram.is_some()
|| discord.is_some()
|| slack.is_some()
|| mattermost.is_some()
|| imessage.is_some()
|| matrix.is_some()
|| signal.is_some()
|| whatsapp.is_some()
|| email.is_some()
|| irc.is_some()
|| lark.is_some()
|| dingtalk.is_some()
|| linq.is_some()
|| qq.is_some()
}
/// Periodically emit health snapshots as Tauri events and write to disk.
async fn spawn_state_writer(
app_handle: AppHandle,
data_dir: std::path::PathBuf,
cancel: CancellationToken,
) {
let state_path = data_dir.join("daemon_state.json");
if let Some(parent) = state_path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let mut interval = tokio::time::interval(Duration::from_secs(STATUS_FLUSH_SECONDS));
loop {
tokio::select! {
_ = interval.tick() => {},
_ = cancel.cancelled() => break,
}
let mut json = crate::alphahuman::health::snapshot_json();
if let Some(obj) = json.as_object_mut() {
obj.insert(
"written_at".into(),
serde_json::json!(Utc::now().to_rfc3339()),
);
}
// Emit Tauri event for frontend consumption
let _ = app_handle.emit("alphahuman:health", &json);
// Also persist to disk
let data =
serde_json::to_vec_pretty(&json).unwrap_or_else(|_| b"{}".to_vec());
let _ = tokio::fs::write(&state_path, data).await;
}
}
/// Spawn a supervised component with exponential backoff on failure.
///
/// The component function is called repeatedly. On failure, the supervisor
/// waits with exponential backoff before restarting. On clean exit, it
/// resets backoff and restarts immediately (unexpected exit is still an error).
pub fn spawn_component_supervisor<F, Fut>(
name: &'static str,
initial_backoff_secs: u64,
max_backoff_secs: u64,
mut run_component: F,
) -> JoinHandle<()>
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
tokio::spawn(async move {
let mut backoff = initial_backoff_secs.max(1);
let max_backoff = max_backoff_secs.max(backoff);
loop {
crate::alphahuman::health::mark_component_ok(name);
match run_component().await {
Ok(()) => {
crate::alphahuman::health::mark_component_error(
name,
"component exited unexpectedly",
);
log::warn!("Daemon component '{name}' exited unexpectedly");
// Clean exit — reset backoff since the component ran successfully
backoff = initial_backoff_secs.max(1);
}
Err(e) => {
crate::alphahuman::health::mark_component_error(name, e.to_string());
log::error!("Daemon component '{name}' failed: {e}");
}
}
crate::alphahuman::health::bump_component_restart(name);
tokio::time::sleep(Duration::from_secs(backoff)).await;
// Double backoff AFTER sleeping so first error uses initial_backoff
backoff = backoff.saturating_mul(2).min(max_backoff);
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn supervisor_marks_error_and_restart_on_failure() {
let handle = spawn_component_supervisor("th-daemon-test-fail", 1, 1, || async {
anyhow::bail!("boom")
});
tokio::time::sleep(Duration::from_millis(50)).await;
handle.abort();
let _ = handle.await;
let snapshot = crate::alphahuman::health::snapshot_json();
let component = &snapshot["components"]["th-daemon-test-fail"];
assert_eq!(component["status"], "error");
assert!(component["restart_count"].as_u64().unwrap_or(0) >= 1);
assert!(component["last_error"]
.as_str()
.unwrap_or("")
.contains("boom"));
}
#[tokio::test]
async fn supervisor_marks_unexpected_exit_as_error() {
let handle =
spawn_component_supervisor("th-daemon-test-exit", 1, 1, || async { Ok(()) });
tokio::time::sleep(Duration::from_millis(50)).await;
handle.abort();
let _ = handle.await;
let snapshot = crate::alphahuman::health::snapshot_json();
let component = &snapshot["components"]["th-daemon-test-exit"];
assert_eq!(component["status"], "error");
assert!(component["restart_count"].as_u64().unwrap_or(0) >= 1);
assert!(component["last_error"]
.as_str()
.unwrap_or("")
.contains("component exited unexpectedly"));
}
}
+870
View File
@@ -0,0 +1,870 @@
//! Diagnostic checks for Alphahuman configuration, workspace health, and daemon state.
use crate::alphahuman::config::Config;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::Path;
const DAEMON_STALE_SECONDS: i64 = 30;
const SCHEDULER_STALE_SECONDS: i64 = 120;
const CHANNEL_STALE_SECONDS: i64 = 300;
const COMMAND_VERSION_PREVIEW_CHARS: usize = 60;
// ── Diagnostic item ──────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Ok,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticItem {
pub severity: Severity,
pub category: String,
pub message: String,
}
impl DiagnosticItem {
fn ok(category: impl Into<String>, msg: impl Into<String>) -> Self {
Self {
severity: Severity::Ok,
category: category.into(),
message: msg.into(),
}
}
fn warn(category: impl Into<String>, msg: impl Into<String>) -> Self {
Self {
severity: Severity::Warn,
category: category.into(),
message: msg.into(),
}
}
fn error(category: impl Into<String>, msg: impl Into<String>) -> Self {
Self {
severity: Severity::Error,
category: category.into(),
message: msg.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorSummary {
pub ok: usize,
pub warnings: usize,
pub errors: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorReport {
pub items: Vec<DiagnosticItem>,
pub summary: DoctorSummary,
}
// ── Public entry point ───────────────────────────────────────────
pub fn run(config: &Config) -> Result<DoctorReport> {
let mut items: Vec<DiagnosticItem> = Vec::new();
check_config_semantics(config, &mut items);
check_workspace(config, &mut items);
check_daemon_state(config, &mut items);
check_environment(&mut items);
let errors = items
.iter()
.filter(|i| i.severity == Severity::Error)
.count();
let warnings = items
.iter()
.filter(|i| i.severity == Severity::Warn)
.count();
let ok = items.iter().filter(|i| i.severity == Severity::Ok).count();
Ok(DoctorReport {
items,
summary: DoctorSummary { ok, warnings, errors },
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ModelProbeOutcome {
Ok,
Skipped,
AuthOrAccess,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelProbeEntry {
pub provider: String,
pub outcome: ModelProbeOutcome,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelProbeSummary {
pub ok: usize,
pub skipped: usize,
pub auth_or_access: usize,
pub errors: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelProbeReport {
pub entries: Vec<ModelProbeEntry>,
pub summary: ModelProbeSummary,
}
fn classify_model_probe_error(err_message: &str) -> ModelProbeOutcome {
let lower = err_message.to_lowercase();
if lower.contains("does not support live model discovery") {
return ModelProbeOutcome::Skipped;
}
if [
"401",
"403",
"429",
"unauthorized",
"forbidden",
"api key",
"token",
"insufficient balance",
"insufficient quota",
"plan does not include",
"rate limit",
]
.iter()
.any(|hint| lower.contains(hint))
{
return ModelProbeOutcome::AuthOrAccess;
}
ModelProbeOutcome::Error
}
fn doctor_model_targets(provider_override: Option<&str>) -> Vec<String> {
if let Some(provider) = provider_override.map(str::trim).filter(|p| !p.is_empty()) {
return vec![provider.to_string()];
}
crate::alphahuman::providers::list_providers()
.into_iter()
.map(|provider| provider.name.to_string())
.collect()
}
pub fn run_models(
config: &Config,
provider_override: Option<&str>,
use_cache: bool,
) -> Result<ModelProbeReport> {
let targets = doctor_model_targets(provider_override);
if targets.is_empty() {
anyhow::bail!("No providers available for model probing");
}
let mut entries = Vec::new();
let mut ok_count = 0usize;
let mut skipped_count = 0usize;
let mut auth_count = 0usize;
let mut error_count = 0usize;
for provider_name in &targets {
match crate::alphahuman::onboard::run_models_refresh(config, Some(provider_name), !use_cache)
{
Ok(_) => {
ok_count += 1;
entries.push(ModelProbeEntry {
provider: provider_name.clone(),
outcome: ModelProbeOutcome::Ok,
message: None,
});
}
Err(error) => {
let error_text = format_error_chain(&error);
let outcome = classify_model_probe_error(&error_text);
match outcome {
ModelProbeOutcome::Skipped => skipped_count += 1,
ModelProbeOutcome::AuthOrAccess => auth_count += 1,
ModelProbeOutcome::Error => error_count += 1,
ModelProbeOutcome::Ok => ok_count += 1,
}
entries.push(ModelProbeEntry {
provider: provider_name.clone(),
outcome,
message: Some(truncate_for_display(&error_text, 160)),
});
}
}
}
if provider_override.is_some() && ok_count == 0 {
anyhow::bail!("Model probe failed for target provider");
}
Ok(ModelProbeReport {
entries,
summary: ModelProbeSummary {
ok: ok_count,
skipped: skipped_count,
auth_or_access: auth_count,
errors: error_count,
},
})
}
// ── Config semantic validation ───────────────────────────────────
fn check_config_semantics(config: &Config, items: &mut Vec<DiagnosticItem>) {
let cat = "config";
// Config file exists
if config.config_path.exists() {
items.push(DiagnosticItem::ok(
cat,
format!("config file: {}", config.config_path.display()),
));
} else {
items.push(DiagnosticItem::error(
cat,
format!("config file not found: {}", config.config_path.display()),
));
}
// Provider validity
if let Some(ref provider) = config.default_provider {
if let Some(reason) = provider_validation_error(provider) {
items.push(DiagnosticItem::error(
cat,
format!("default provider \"{provider}\" is invalid: {reason}"),
));
} else {
items.push(DiagnosticItem::ok(
cat,
format!("provider \"{provider}\" is valid"),
));
}
} else {
items.push(DiagnosticItem::error(cat, "no default_provider configured"));
}
// API key presence
if config.default_provider.as_deref() != Some("ollama") {
if config.api_key.is_some() {
items.push(DiagnosticItem::ok(cat, "API key configured"));
} else {
items.push(DiagnosticItem::warn(
cat,
"no api_key set (may rely on env vars or provider defaults)",
));
}
}
// Model configured
if config.default_model.is_some() {
items.push(DiagnosticItem::ok(
cat,
format!(
"default model: {}",
config.default_model.as_deref().unwrap_or("?")
),
));
} else {
items.push(DiagnosticItem::warn(cat, "no default_model configured"));
}
// Temperature range
if config.default_temperature >= 0.0 && config.default_temperature <= 2.0 {
items.push(DiagnosticItem::ok(
cat,
format!(
"temperature {:.1} (valid range 0.0-2.0)",
config.default_temperature
),
));
} else {
items.push(DiagnosticItem::error(
cat,
format!(
"temperature {:.1} is out of range (expected 0.0-2.0)",
config.default_temperature
),
));
}
// Gateway port range
let port = config.gateway.port;
if port > 0 {
items.push(DiagnosticItem::ok(cat, format!("gateway port: {port}")));
} else {
items.push(DiagnosticItem::error(cat, "gateway port is 0 (invalid)"));
}
// Reliability: fallback providers
for fb in &config.reliability.fallback_providers {
if let Some(reason) = provider_validation_error(fb) {
items.push(DiagnosticItem::warn(
cat,
format!("fallback provider \"{fb}\" is invalid: {reason}"),
));
}
}
// Model routes validation
for route in &config.model_routes {
if route.hint.is_empty() {
items.push(DiagnosticItem::warn(cat, "model route with empty hint"));
}
if let Some(reason) = provider_validation_error(&route.provider) {
items.push(DiagnosticItem::warn(
cat,
format!(
"model route \"{}\" uses invalid provider \"{}\": {}",
route.hint, route.provider, reason
),
));
}
if route.model.is_empty() {
items.push(DiagnosticItem::warn(
cat,
format!("model route \"{}\" has empty model", route.hint),
));
}
}
// Embedding routes validation
for route in &config.embedding_routes {
if route.hint.trim().is_empty() {
items.push(DiagnosticItem::warn(cat, "embedding route with empty hint"));
}
if let Some(reason) = embedding_provider_validation_error(&route.provider) {
items.push(DiagnosticItem::warn(
cat,
format!(
"embedding route \"{}\" uses invalid provider \"{}\": {}",
route.hint, route.provider, reason
),
));
}
if route.model.trim().is_empty() {
items.push(DiagnosticItem::warn(
cat,
format!("embedding route \"{}\" has empty model", route.hint),
));
}
if route.dimensions.is_some_and(|value| value == 0) {
items.push(DiagnosticItem::warn(
cat,
format!(
"embedding route \"{}\" has invalid dimensions=0",
route.hint
),
));
}
}
if let Some(hint) = config
.memory
.embedding_model
.strip_prefix("hint:")
.map(str::trim)
.filter(|value| !value.is_empty())
{
if !config
.embedding_routes
.iter()
.any(|route| route.hint.trim() == hint)
{
items.push(DiagnosticItem::warn(
cat,
format!(
"memory.embedding_model uses hint \"{hint}\" but no matching [[embedding_routes]] entry exists"
),
));
}
}
// Channel: at least one configured
let cc = &config.channels_config;
let has_channel = cc.telegram.is_some()
|| cc.discord.is_some()
|| cc.slack.is_some()
|| cc.imessage.is_some()
|| cc.matrix.is_some()
|| cc.whatsapp.is_some()
|| cc.email.is_some()
|| cc.irc.is_some()
|| cc.lark.is_some()
|| cc.webhook.is_some();
if has_channel {
items.push(DiagnosticItem::ok(cat, "at least one channel configured"));
} else {
items.push(DiagnosticItem::warn(
cat,
"no channels configured - configure one in the UI",
));
}
// Delegate agents: provider validity
let mut agent_names: Vec<_> = config.agents.keys().collect();
agent_names.sort();
for name in agent_names {
let agent = config.agents.get(name).unwrap();
if let Some(reason) = provider_validation_error(&agent.provider) {
items.push(DiagnosticItem::warn(
cat,
format!(
"agent \"{name}\" uses invalid provider \"{}\": {}",
agent.provider, reason
),
));
}
}
}
fn provider_validation_error(name: &str) -> Option<String> {
match crate::alphahuman::providers::create_provider(name, None) {
Ok(_) => None,
Err(err) => Some(
err.to_string()
.lines()
.next()
.unwrap_or("invalid provider")
.into(),
),
}
}
fn embedding_provider_validation_error(name: &str) -> Option<String> {
let normalized = name.trim();
if normalized.eq_ignore_ascii_case("none") || normalized.eq_ignore_ascii_case("openai") {
return None;
}
let Some(url) = normalized.strip_prefix("custom:") else {
return Some("supported values: none, openai, custom:<url>".into());
};
let url = url.trim();
if url.is_empty() {
return Some("custom provider requires a non-empty URL after 'custom:'".into());
}
match reqwest::Url::parse(url) {
Ok(parsed) if matches!(parsed.scheme(), "http" | "https") => None,
Ok(parsed) => Some(format!(
"custom provider URL must use http/https, got '{}'",
parsed.scheme()
)),
Err(err) => Some(format!("invalid custom provider URL: {err}")),
}
}
// ── Workspace integrity ──────────────────────────────────────────
fn check_workspace(config: &Config, items: &mut Vec<DiagnosticItem>) {
let cat = "workspace";
let ws = &config.workspace_dir;
if ws.exists() {
items.push(DiagnosticItem::ok(
cat,
format!("directory exists: {}", ws.display()),
));
} else {
items.push(DiagnosticItem::error(
cat,
format!("directory missing: {}", ws.display()),
));
return;
}
// Writable check
let probe = workspace_probe_path(ws);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
{
Ok(mut probe_file) => {
let write_result = probe_file.write_all(b"probe");
drop(probe_file);
let _ = std::fs::remove_file(&probe);
match write_result {
Ok(()) => items.push(DiagnosticItem::ok(cat, "directory is writable")),
Err(e) => items.push(DiagnosticItem::error(
cat,
format!("directory write probe failed: {e}"),
)),
}
}
Err(e) => {
items.push(DiagnosticItem::error(
cat,
format!("directory is not writable: {e}"),
));
}
}
// Minimal workspace folders
let mem_dir = ws.join("memory");
if mem_dir.exists() {
items.push(DiagnosticItem::ok(
cat,
format!("memory directory: {}", mem_dir.display()),
));
} else {
items.push(DiagnosticItem::warn(
cat,
format!("memory directory missing: {}", mem_dir.display()),
));
}
// Check for config templates or docs
let prompt = ws.join("SYSTEM.md");
if prompt.exists() {
items.push(DiagnosticItem::ok(
cat,
format!("SYSTEM prompt: {}", prompt.display()),
));
} else {
items.push(DiagnosticItem::warn(
cat,
format!("SYSTEM prompt missing: {}", prompt.display()),
));
}
// Disk space warning (best-effort)
if let Some(avail_mb) = available_disk_space_mb(ws) {
if avail_mb < 512 {
items.push(DiagnosticItem::warn(
cat,
format!("low disk space: {avail_mb} MB free"),
));
} else {
items.push(DiagnosticItem::ok(
cat,
format!("disk space OK: {avail_mb} MB free"),
));
}
}
}
fn available_disk_space_mb(path: &Path) -> Option<u64> {
#[cfg(target_os = "windows")]
{
let _ = path;
return None;
}
#[cfg(not(target_os = "windows"))]
{
let output = std::process::Command::new("df")
.arg("-m")
.arg(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_df_available_mb(&stdout)
}
}
fn parse_df_available_mb(stdout: &str) -> Option<u64> {
let line = stdout.lines().rev().find(|line| !line.trim().is_empty())?;
let avail = line.split_whitespace().nth(3)?;
avail.parse::<u64>().ok()
}
fn workspace_probe_path(workspace_dir: &Path) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
workspace_dir.join(format!(
".alphahuman_doctor_probe_{}_{}",
std::process::id(),
nanos
))
}
// ── Daemon state ────────────────────────────────────────────────
fn check_daemon_state(config: &Config, items: &mut Vec<DiagnosticItem>) {
let cat = "daemon";
let state_file = crate::alphahuman::daemon::state_file_path(config);
if !state_file.exists() {
items.push(DiagnosticItem::error(
cat,
format!(
"state file not found: {} - is the daemon running?",
state_file.display()
),
));
return;
}
let raw = match std::fs::read_to_string(&state_file) {
Ok(r) => r,
Err(e) => {
items.push(DiagnosticItem::error(cat, format!("cannot read state file: {e}")));
return;
}
};
let snapshot: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
items.push(DiagnosticItem::error(cat, format!("invalid state JSON: {e}")));
return;
}
};
// Daemon heartbeat freshness
let updated_at = snapshot
.get("updated_at")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if let Ok(ts) = DateTime::parse_from_rfc3339(updated_at) {
let age = Utc::now()
.signed_duration_since(ts.with_timezone(&Utc))
.num_seconds();
if age <= DAEMON_STALE_SECONDS {
items.push(DiagnosticItem::ok(cat, format!("heartbeat fresh ({age}s ago)")));
} else {
items.push(DiagnosticItem::error(
cat,
format!("heartbeat stale ({age}s ago)"),
));
}
} else {
items.push(DiagnosticItem::error(
cat,
format!("invalid daemon timestamp: {updated_at}"),
));
}
// Components
if let Some(components) = snapshot
.get("components")
.and_then(serde_json::Value::as_object)
{
// Scheduler
if let Some(scheduler) = components.get("scheduler") {
let scheduler_ok = scheduler
.get("status")
.and_then(serde_json::Value::as_str)
.is_some_and(|s| s == "ok");
let scheduler_age = scheduler
.get("last_ok")
.and_then(serde_json::Value::as_str)
.and_then(parse_rfc3339)
.map_or(i64::MAX, |dt| {
Utc::now().signed_duration_since(dt).num_seconds()
});
if scheduler_ok && scheduler_age <= SCHEDULER_STALE_SECONDS {
items.push(DiagnosticItem::ok(
cat,
format!("scheduler healthy (last ok {scheduler_age}s ago)"),
));
} else {
items.push(DiagnosticItem::error(
cat,
format!("scheduler unhealthy (ok={scheduler_ok}, age={scheduler_age}s)"),
));
}
} else {
items.push(DiagnosticItem::warn(cat, "scheduler component not tracked yet"));
}
// Channels
let mut channel_count = 0u32;
let mut stale = 0u32;
for (name, component) in components {
if !name.starts_with("channel:") {
continue;
}
channel_count += 1;
let status_ok = component
.get("status")
.and_then(serde_json::Value::as_str)
.is_some_and(|s| s == "ok");
let age = component
.get("last_ok")
.and_then(serde_json::Value::as_str)
.and_then(parse_rfc3339)
.map_or(i64::MAX, |dt| {
Utc::now().signed_duration_since(dt).num_seconds()
});
if status_ok && age <= CHANNEL_STALE_SECONDS {
items.push(DiagnosticItem::ok(cat, format!("{name} fresh ({age}s ago)")));
} else {
stale += 1;
items.push(DiagnosticItem::error(
cat,
format!("{name} stale (ok={status_ok}, age={age}s)"),
));
}
}
if channel_count == 0 {
items.push(DiagnosticItem::warn(cat, "no channel components tracked yet"));
} else if stale > 0 {
items.push(DiagnosticItem::warn(
cat,
format!("{channel_count} channels, {stale} stale"),
));
}
}
}
// ── Environment checks ───────────────────────────────────────────
fn check_environment(items: &mut Vec<DiagnosticItem>) {
let cat = "environment";
// git
check_command_available("git", &["--version"], cat, items);
// Shell
let shell = std::env::var("SHELL").unwrap_or_default();
if shell.is_empty() {
items.push(DiagnosticItem::warn(cat, "$SHELL not set"));
} else {
items.push(DiagnosticItem::ok(cat, format!("shell: {shell}")));
}
// HOME
if std::env::var("HOME").is_ok() || std::env::var("USERPROFILE").is_ok() {
items.push(DiagnosticItem::ok(cat, "home directory env set"));
} else {
items.push(DiagnosticItem::error(
cat,
"neither $HOME nor $USERPROFILE is set",
));
}
// Optional tools
check_command_available("curl", &["--version"], cat, items);
}
fn check_command_available(cmd: &str, args: &[&str], cat: &'static str, items: &mut Vec<DiagnosticItem>) {
match std::process::Command::new(cmd)
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
{
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("(unknown)")
.to_string();
items.push(DiagnosticItem::ok(cat, format!("{cmd}: {version}")));
}
Ok(output) => {
let preview = String::from_utf8_lossy(&output.stderr)
.lines()
.next()
.unwrap_or("(failed)")
.to_string();
items.push(DiagnosticItem::warn(
cat,
format!("{cmd} not available ({preview})"),
));
}
Err(err) => {
items.push(DiagnosticItem::warn(
cat,
format!("{cmd} not available ({err})"),
));
}
}
}
// ── Helpers ──────────────────────────────────────────────────────
fn parse_rfc3339(input: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(input)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
fn format_error_chain(err: &anyhow::Error) -> String {
let mut out = err.to_string();
let mut cursor = err.source();
while let Some(source) = cursor {
out.push_str(": ");
out.push_str(&source.to_string());
cursor = source.source();
}
out
}
fn truncate_for_display(text: &str, max_len: usize) -> String {
if text.chars().count() <= max_len {
return text.to_string();
}
let mut out = String::new();
for (idx, ch) in text.chars().enumerate() {
if idx >= max_len {
break;
}
out.push(ch);
}
out.push_str("...");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_validation_warns_no_channels() {
let config = Config::default();
let mut items = vec![];
check_config_semantics(&config, &mut items);
let ch_item = items.iter().find(|i| i.message.contains("channel"));
assert!(ch_item.is_some());
assert_eq!(ch_item.unwrap().severity, Severity::Warn);
}
#[test]
fn provider_validation_detects_invalid() {
let reason = provider_validation_error("imaginary");
assert!(reason.is_some());
}
#[test]
fn truncate_for_display_short() {
let s = "hello";
assert_eq!(truncate_for_display(s, 10), s);
}
#[test]
fn truncate_for_display_long() {
let s = "abcdefghijklmnopqrstuvwxyz";
let truncated = truncate_for_display(s, 5);
assert!(truncated.starts_with("abcde"));
assert!(truncated.ends_with("..."));
}
}
@@ -0,0 +1,65 @@
//! Client IP parsing and rate-limit key derivation.
use axum::http::HeaderMap;
use std::net::{IpAddr, SocketAddr};
/// Parse a client IP from a header value, tolerating quotes and socket formats.
pub fn parse_client_ip(value: &str) -> Option<IpAddr> {
let value = value.trim().trim_matches('"').trim();
if value.is_empty() {
return None;
}
if let Ok(ip) = value.parse::<IpAddr>() {
return Some(ip);
}
if let Ok(addr) = value.parse::<SocketAddr>() {
return Some(addr.ip());
}
let value = value.trim_matches(['[', ']']);
value.parse::<IpAddr>().ok()
}
/// Extract the first valid client IP from forwarding headers.
pub fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
if let Some(xff) = headers.get("X-Forwarded-For").and_then(|v| v.to_str().ok()) {
for candidate in xff.split(',') {
if let Some(ip) = parse_client_ip(candidate) {
return Some(ip);
}
}
}
headers
.get("X-Real-IP")
.and_then(|v| v.to_str().ok())
.and_then(parse_client_ip)
}
/// Resolve a stable client key for rate limiting.
pub fn client_key_from_request(
connect_info: Option<SocketAddr>,
headers: &HeaderMap,
trust_forwarded_headers: bool,
) -> String {
if trust_forwarded_headers {
if let Some(forwarded) = forwarded_client_ip(headers) {
return forwarded.to_string();
}
}
connect_info
.map(|addr| addr.ip().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
/// Normalize configured key counts, ensuring a non-zero default.
pub fn normalize_max_keys(configured: usize, fallback: usize) -> usize {
if configured == 0 {
fallback.max(1)
} else {
configured
}
}
@@ -0,0 +1,39 @@
//! Gateway constants and key helpers.
use crate::alphahuman::channels::traits::ChannelMessage;
use sha2::{Digest, Sha256};
use uuid::Uuid;
/// Maximum request body size (64KB) — prevents memory exhaustion.
pub const MAX_BODY_SIZE: usize = 65_536;
/// Request timeout (30s) — prevents slow-loris attacks.
pub const REQUEST_TIMEOUT_SECS: u64 = 30;
/// Sliding window used by gateway rate limiting.
pub const RATE_LIMIT_WINDOW_SECS: u64 = 60;
/// Fallback max distinct client keys tracked in gateway rate limiter.
pub const RATE_LIMIT_MAX_KEYS_DEFAULT: usize = 10_000;
/// Fallback max distinct idempotency keys retained in gateway memory.
pub const IDEMPOTENCY_MAX_KEYS_DEFAULT: usize = 10_000;
/// How often the rate limiter sweeps stale IP entries from its map.
pub const RATE_LIMITER_SWEEP_INTERVAL_SECS: u64 = 300; // 5 minutes
/// Unique memory key for webhook messages.
pub fn webhook_memory_key() -> String {
format!("webhook_msg_{}", Uuid::new_v4())
}
/// Memory key for WhatsApp messages.
pub fn whatsapp_memory_key(msg: &ChannelMessage) -> String {
format!("whatsapp_{}_{}", msg.sender, msg.id)
}
/// Memory key for Linq messages.
pub fn linq_memory_key(msg: &ChannelMessage) -> String {
format!("linq_{}_{}", msg.sender, msg.id)
}
/// Hash a webhook secret using SHA-256 (hex-encoded).
pub fn hash_webhook_secret(value: &str) -> String {
let digest = Sha256::digest(value.as_bytes());
hex::encode(digest)
}
@@ -0,0 +1,43 @@
//! Health and metrics endpoints.
use crate::alphahuman::gateway::state::AppState;
use axum::{
extract::State,
http::{header, StatusCode},
response::{IntoResponse, Json},
};
/// Prometheus content type for text exposition format.
pub const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";
/// GET /health — always public (no secrets leaked).
pub async fn handle_health(State(state): State<AppState>) -> impl IntoResponse {
let body = serde_json::json!({
"status": "ok",
"paired": state.pairing.is_paired(),
"runtime": crate::alphahuman::health::snapshot_json(),
});
Json(body)
}
/// GET /metrics — Prometheus text exposition format.
pub async fn handle_metrics(State(state): State<AppState>) -> impl IntoResponse {
let body = if let Some(prom) = state
.observer
.as_ref()
.as_any()
.downcast_ref::<crate::alphahuman::observability::PrometheusObserver>()
{
prom.encode()
} else {
String::from(
"# Prometheus backend not enabled. Set [observability] backend = \"prometheus\" in config.\n",
)
};
(
StatusCode::OK,
[(header::CONTENT_TYPE, PROMETHEUS_CONTENT_TYPE)],
body,
)
}
@@ -0,0 +1,124 @@
//! Linq webhook handlers.
use super::webhook::run_gateway_chat_with_multimodal;
use crate::alphahuman::channels::SendMessage;
use crate::alphahuman::channels::traits::Channel;
use crate::alphahuman::gateway::state::AppState;
use crate::alphahuman::memory::MemoryCategory;
use crate::alphahuman::util::truncate_with_ellipsis;
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
/// POST /linq — incoming message webhook (iMessage/RCS/SMS via Linq).
pub async fn handle_linq_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let Some(ref linq) = state.linq else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Linq not configured"})),
);
};
let body_str = String::from_utf8_lossy(&body);
// ── Security: Verify X-Webhook-Signature if signing_secret is configured ──
if let Some(ref signing_secret) = state.linq_signing_secret {
let timestamp = headers
.get("X-Webhook-Timestamp")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let signature = headers
.get("X-Webhook-Signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !crate::alphahuman::channels::linq::verify_linq_signature(
signing_secret,
&body_str,
timestamp,
signature,
) {
tracing::warn!(
"Linq webhook signature verification failed (signature: {})",
if signature.is_empty() { "missing" } else { "invalid" }
);
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Invalid signature"})),
);
}
}
// Parse JSON body
let Ok(payload) = serde_json::from_slice::<serde_json::Value>(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid JSON payload"})),
);
};
// Parse messages from the webhook payload
let messages = linq.parse_webhook_payload(&payload);
if messages.is_empty() {
// Acknowledge the webhook even if no messages (could be status/delivery events)
return (StatusCode::OK, Json(serde_json::json!({"status": "ok"})));
}
// Process each message
let provider_label = state
.config
.lock()
.default_provider
.clone()
.unwrap_or_else(|| "unknown".to_string());
for msg in &messages {
tracing::info!(
"Linq message from {}: {}",
msg.sender,
truncate_with_ellipsis(&msg.content, 50)
);
// Auto-save to memory
if state.auto_save {
let key = crate::alphahuman::gateway::constants::linq_memory_key(msg);
let _ = state
.mem
.store(&key, &msg.content, MemoryCategory::Conversation, None)
.await;
}
// Call the LLM
match run_gateway_chat_with_multimodal(&state, &provider_label, &msg.content).await {
Ok(response) => {
// Send reply via Linq
if let Err(e) = linq
.send(&SendMessage::new(response, &msg.reply_target))
.await
{
tracing::error!("Failed to send Linq reply: {e}");
}
}
Err(e) => {
tracing::error!("LLM error for Linq message: {e:#}");
let _ = linq
.send(&SendMessage::new(
"Sorry, I couldn't process your message right now.",
&msg.reply_target,
))
.await;
}
}
}
// Acknowledge the webhook
(StatusCode::OK, Json(serde_json::json!({"status": "ok"})))
}
@@ -0,0 +1,13 @@
//! Gateway HTTP handlers grouped by concern.
mod health;
mod linq;
mod pair;
mod webhook;
mod whatsapp;
pub use health::{handle_health, handle_metrics, PROMETHEUS_CONTENT_TYPE};
pub use linq::handle_linq_webhook;
pub use pair::{handle_pair, persist_pairing_tokens};
pub use webhook::handle_webhook;
pub use whatsapp::{handle_whatsapp_message, handle_whatsapp_verify, verify_whatsapp_signature};
@@ -0,0 +1,96 @@
//! Pairing endpoints and token persistence.
use crate::alphahuman::config::Config;
use crate::alphahuman::gateway::client::client_key_from_request;
use crate::alphahuman::gateway::constants::RATE_LIMIT_WINDOW_SECS;
use crate::alphahuman::gateway::state::AppState;
use crate::alphahuman::security::pairing::PairingGuard;
use anyhow::{Context, Result};
use axum::{
extract::{ConnectInfo, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
use std::net::SocketAddr;
use std::sync::Arc;
/// POST /pair — exchange one-time code for bearer token.
#[axum::debug_handler]
pub async fn handle_pair(
State(state): State<AppState>,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> impl IntoResponse {
let rate_key = client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers);
if !state.rate_limiter.allow_pair(&rate_key) {
tracing::warn!("/pair rate limit exceeded");
let err = serde_json::json!({
"error": "Too many pairing requests. Please retry later.",
"retry_after": RATE_LIMIT_WINDOW_SECS,
});
return (StatusCode::TOO_MANY_REQUESTS, Json(err));
}
let code = headers
.get("X-Pairing-Code")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
match state.pairing.try_pair(code).await {
Ok(Some(token)) => {
tracing::info!("🔐 New client paired successfully");
if let Err(err) = persist_pairing_tokens(state.config.clone(), &state.pairing).await {
tracing::error!("🔐 Pairing succeeded but token persistence failed: {err:#}");
let body = serde_json::json!({
"paired": true,
"persisted": false,
"token": token,
"message": "Paired for this process, but failed to persist token to config.toml. Check config path and write permissions.",
});
return (StatusCode::OK, Json(body));
}
let body = serde_json::json!({
"paired": true,
"persisted": true,
"token": token,
"message": "Save this token — use it as Authorization: Bearer <token>"
});
(StatusCode::OK, Json(body))
}
Ok(None) => {
tracing::warn!("🔐 Pairing attempt with invalid code");
let err = serde_json::json!({"error": "Invalid pairing code"});
(StatusCode::FORBIDDEN, Json(err))
}
Err(lockout_secs) => {
tracing::warn!(
"🔐 Pairing locked out — too many failed attempts ({lockout_secs}s remaining)"
);
let err = serde_json::json!({
"error": format!("Too many failed attempts. Try again in {lockout_secs}s."),
"retry_after": lockout_secs
});
(StatusCode::TOO_MANY_REQUESTS, Json(err))
}
}
}
pub async fn persist_pairing_tokens(
config: Arc<parking_lot::Mutex<Config>>,
pairing: &PairingGuard,
) -> Result<()> {
let paired_tokens = pairing.tokens();
// This is needed because parking_lot's guard is not Send so we clone the inner
// this should be removed once async mutexes are used everywhere
let mut updated_cfg = { config.lock().clone() };
updated_cfg.gateway.paired_tokens = paired_tokens;
updated_cfg
.save()
.await
.context("Failed to persist paired tokens to config.toml")?;
// Keep shared runtime config in sync with persisted tokens.
*config.lock() = updated_cfg;
Ok(())
}
@@ -0,0 +1,261 @@
//! Webhook endpoint handlers.
use crate::alphahuman::gateway::client::client_key_from_request;
use crate::alphahuman::gateway::constants::{
hash_webhook_secret, webhook_memory_key, RATE_LIMIT_WINDOW_SECS,
};
use crate::alphahuman::gateway::models::WebhookBody;
use crate::alphahuman::gateway::state::AppState;
use crate::alphahuman::memory::MemoryCategory;
use crate::alphahuman::providers::{self, ChatMessage, ProviderCapabilityError};
use crate::alphahuman::security::pairing::constant_time_eq;
use axum::{
extract::{ConnectInfo, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
use std::net::SocketAddr;
use std::time::Instant;
/// Execute a gateway chat request with multimodal support.
pub(crate) async fn run_gateway_chat_with_multimodal(
state: &AppState,
provider_label: &str,
message: &str,
) -> anyhow::Result<String> {
let user_messages = vec![ChatMessage::user(message)];
let image_marker_count = crate::alphahuman::multimodal::count_image_markers(&user_messages);
if image_marker_count > 0 && !state.provider.supports_vision() {
return Err(ProviderCapabilityError {
provider: provider_label.to_string(),
capability: "vision".to_string(),
message: format!(
"received {image_marker_count} image marker(s), but this provider does not support vision input"
),
}
.into());
}
// Keep webhook/gateway prompts aligned with channel behavior by injecting
// workspace-aware system context before model invocation.
let system_prompt = {
let config_guard = state.config.lock();
crate::alphahuman::channels::build_system_prompt(
&config_guard.workspace_dir,
&state.model,
&[], // tools - empty for simple chat
&[], // skills
Some(&config_guard.identity),
None, // bootstrap_max_chars - use default
)
};
let mut messages = Vec::with_capacity(1 + user_messages.len());
messages.push(ChatMessage::system(system_prompt));
messages.extend(user_messages);
let multimodal_config = state.config.lock().multimodal.clone();
let prepared =
crate::alphahuman::multimodal::prepare_messages_for_provider(&messages, &multimodal_config)
.await?;
state
.provider
.chat_with_history(&prepared.messages, &state.model, state.temperature)
.await
}
/// POST /webhook — main webhook endpoint.
pub async fn handle_webhook(
State(state): State<AppState>,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
body: Result<Json<WebhookBody>, axum::extract::rejection::JsonRejection>,
) -> impl IntoResponse {
let rate_key = client_key_from_request(Some(peer_addr), &headers, state.trust_forwarded_headers);
if !state.rate_limiter.allow_webhook(&rate_key) {
tracing::warn!("/webhook rate limit exceeded");
let err = serde_json::json!({
"error": "Too many webhook requests. Please retry later.",
"retry_after": RATE_LIMIT_WINDOW_SECS,
});
return (StatusCode::TOO_MANY_REQUESTS, Json(err));
}
// ── Bearer token auth (pairing) ──
if state.pairing.require_pairing() {
let auth = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = auth.strip_prefix("Bearer ").unwrap_or("");
if !state.pairing.is_authenticated(token) {
tracing::warn!("Webhook: rejected — not paired / invalid bearer token");
let err = serde_json::json!({
"error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
});
return (StatusCode::UNAUTHORIZED, Json(err));
}
}
// ── Webhook secret auth (optional, additional layer) ──
if let Some(ref secret_hash) = state.webhook_secret_hash {
let header_hash = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(hash_webhook_secret);
match header_hash {
Some(val) if constant_time_eq(&val, secret_hash.as_ref()) => {}
_ => {
tracing::warn!("Webhook: rejected request — invalid or missing X-Webhook-Secret");
let err =
serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
return (StatusCode::UNAUTHORIZED, Json(err));
}
}
}
// ── Parse body ──
let Json(webhook_body) = match body {
Ok(b) => b,
Err(e) => {
tracing::warn!("Webhook JSON parse error: {e}");
let err = serde_json::json!({
"error": "Invalid JSON body. Expected: {\"message\": \"...\"}"
});
return (StatusCode::BAD_REQUEST, Json(err));
}
};
let message = match webhook_body
.message
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(value) => value,
None => {
let err = serde_json::json!({
"error": "Invalid JSON body. Expected: {\"message\": \"...\"}"
});
return (StatusCode::BAD_REQUEST, Json(err));
}
};
// ── Idempotency (optional) ──
if let Some(idempotency_key) = headers
.get("X-Idempotency-Key")
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
{
if !state.idempotency_store.record_if_new(idempotency_key) {
tracing::info!("Webhook duplicate ignored (idempotency key: {idempotency_key})");
let body = serde_json::json!({
"status": "duplicate",
"idempotent": true,
"message": "Request already processed for this idempotency key"
});
return (StatusCode::OK, Json(body));
}
}
if state.auto_save {
let key = webhook_memory_key();
let _ = state
.mem
.store(&key, message, MemoryCategory::Conversation, None)
.await;
}
let provider_label = state
.config
.lock()
.default_provider
.clone()
.unwrap_or_else(|| "unknown".to_string());
let model_label = state.model.clone();
let started_at = Instant::now();
state
.observer
.record_event(&crate::alphahuman::observability::ObserverEvent::AgentStart {
provider: provider_label.clone(),
model: model_label.clone(),
});
state
.observer
.record_event(&crate::alphahuman::observability::ObserverEvent::LlmRequest {
provider: provider_label.clone(),
model: model_label.clone(),
messages_count: 1,
});
match run_gateway_chat_with_multimodal(&state, &provider_label, message).await {
Ok(response) => {
let duration = started_at.elapsed();
state
.observer
.record_event(&crate::alphahuman::observability::ObserverEvent::LlmResponse {
provider: provider_label.clone(),
model: model_label.clone(),
duration,
success: true,
error_message: None,
});
state.observer.record_metric(
&crate::alphahuman::observability::traits::ObserverMetric::RequestLatency(duration),
);
state
.observer
.record_event(&crate::alphahuman::observability::ObserverEvent::AgentEnd {
provider: provider_label,
model: model_label,
duration,
tokens_used: None,
cost_usd: None,
});
let body = serde_json::json!({"response": response, "model": state.model});
(StatusCode::OK, Json(body))
}
Err(e) => {
let duration = started_at.elapsed();
let sanitized = providers::sanitize_api_error(&e.to_string());
state
.observer
.record_event(&crate::alphahuman::observability::ObserverEvent::LlmResponse {
provider: provider_label.clone(),
model: model_label.clone(),
duration,
success: false,
error_message: Some(sanitized.clone()),
});
state.observer.record_metric(
&crate::alphahuman::observability::traits::ObserverMetric::RequestLatency(duration),
);
state
.observer
.record_event(&crate::alphahuman::observability::ObserverEvent::Error {
component: "gateway".to_string(),
message: sanitized.clone(),
});
state
.observer
.record_event(&crate::alphahuman::observability::ObserverEvent::AgentEnd {
provider: provider_label,
model: model_label,
duration,
tokens_used: None,
cost_usd: None,
});
tracing::error!("Webhook provider error: {}", sanitized);
let err = serde_json::json!({"error": "LLM request failed"});
(StatusCode::INTERNAL_SERVER_ERROR, Json(err))
}
}
}
@@ -0,0 +1,166 @@
//! WhatsApp webhook handlers and signature verification.
use super::webhook::run_gateway_chat_with_multimodal;
use crate::alphahuman::channels::SendMessage;
use crate::alphahuman::channels::traits::Channel;
use crate::alphahuman::gateway::models::WhatsAppVerifyQuery;
use crate::alphahuman::gateway::state::AppState;
use crate::alphahuman::memory::MemoryCategory;
use crate::alphahuman::security::pairing::constant_time_eq;
use crate::alphahuman::util::truncate_with_ellipsis;
use axum::{
body::Bytes,
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json},
};
/// GET /whatsapp — Meta webhook verification.
pub async fn handle_whatsapp_verify(
State(state): State<AppState>,
Query(params): Query<WhatsAppVerifyQuery>,
) -> impl IntoResponse {
let Some(ref wa) = state.whatsapp else {
return (StatusCode::NOT_FOUND, "WhatsApp not configured".to_string());
};
// Verify the token matches (constant-time comparison to prevent timing attacks)
let token_matches = params
.verify_token
.as_deref()
.is_some_and(|t| constant_time_eq(t, wa.verify_token()));
if params.mode.as_deref() == Some("subscribe") && token_matches {
if let Some(ch) = params.challenge.clone() {
tracing::info!("WhatsApp webhook verified successfully");
return (StatusCode::OK, ch);
}
return (StatusCode::BAD_REQUEST, "Missing hub.challenge".to_string());
}
tracing::warn!("WhatsApp webhook verification failed — token mismatch");
(StatusCode::FORBIDDEN, "Forbidden".to_string())
}
/// Verify `WhatsApp` webhook signature (`X-Hub-Signature-256`).
/// Returns true if the signature is valid, false otherwise.
/// See: <https://developers.facebook.com/docs/graph-api/webhooks/getting-started#verification-requests>
pub fn verify_whatsapp_signature(app_secret: &str, body: &[u8], signature_header: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
// Signature format: "sha256=<hex_signature>"
let Some(hex_sig) = signature_header.strip_prefix("sha256=") else {
return false;
};
// Decode hex signature
let Ok(expected) = hex::decode(hex_sig) else {
return false;
};
// Compute HMAC-SHA256
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(app_secret.as_bytes()) else {
return false;
};
mac.update(body);
// Constant-time comparison
mac.verify_slice(&expected).is_ok()
}
/// POST /whatsapp — incoming message webhook.
pub async fn handle_whatsapp_message(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let Some(ref wa) = state.whatsapp else {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "WhatsApp not configured"})),
);
};
// ── Security: Verify X-Hub-Signature-256 if app_secret is configured ──
if let Some(ref app_secret) = state.whatsapp_app_secret {
let signature = headers
.get("X-Hub-Signature-256")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !verify_whatsapp_signature(app_secret, &body, signature) {
tracing::warn!(
"WhatsApp webhook signature verification failed (signature: {})",
if signature.is_empty() { "missing" } else { "invalid" }
);
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Invalid signature"})),
);
}
}
// Parse JSON body
let Ok(payload) = serde_json::from_slice::<serde_json::Value>(&body) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid JSON payload"})),
);
};
// Parse messages from the webhook payload
let messages = wa.parse_webhook_payload(&payload);
if messages.is_empty() {
// Acknowledge the webhook even if no messages (could be status updates)
return (StatusCode::OK, Json(serde_json::json!({"status": "ok"})));
}
// Process each message
let provider_label = state
.config
.lock()
.default_provider
.clone()
.unwrap_or_else(|| "unknown".to_string());
for msg in &messages {
tracing::info!(
"WhatsApp message from {}: {}",
msg.sender,
truncate_with_ellipsis(&msg.content, 50)
);
// Auto-save to memory
if state.auto_save {
let key = crate::alphahuman::gateway::constants::whatsapp_memory_key(msg);
let _ = state
.mem
.store(&key, &msg.content, MemoryCategory::Conversation, None)
.await;
}
match run_gateway_chat_with_multimodal(&state, &provider_label, &msg.content).await {
Ok(response) => {
// Send reply via WhatsApp
if let Err(e) = wa
.send(&SendMessage::new(response, &msg.reply_target))
.await
{
tracing::error!("Failed to send WhatsApp reply: {e}");
}
}
Err(e) => {
tracing::error!("LLM error for WhatsApp message: {e:#}");
let _ = wa
.send(&SendMessage::new(
"Sorry, I couldn't process your message right now.",
&msg.reply_target,
))
.await;
}
}
}
// Acknowledge the webhook
(StatusCode::OK, Json(serde_json::json!({"status": "ok"})))
}
+14
View File
@@ -0,0 +1,14 @@
//! Axum-based HTTP gateway with proper HTTP/1.1 compliance, body limits, and timeouts.
mod client;
mod constants;
mod handlers;
mod models;
mod rate_limit;
mod server;
mod state;
#[cfg(test)]
mod tests;
pub use server::run_gateway;

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