Merge remote-tracking branch 'origin/main' into fix/dispatch-outbound-busy-loop

This commit is contained in:
Yasuhiro Matsumoto 2026-02-24 22:58:08 +09:00
commit fca74c51e8
No known key found for this signature in database
GPG key ID: F2EA90DF2C146D1E
204 changed files with 32079 additions and 4404 deletions

View file

@ -5,6 +5,7 @@
# ANTHROPIC_API_KEY=sk-ant-xxx
# OPENAI_API_KEY=sk-xxx
# GEMINI_API_KEY=xxx
# CEREBRAS_API_KEY=xxx
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...

28
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,28 @@
---
name: Bug report
about: Report a bug or unexpected behavior
title: "[BUG]"
labels: bug
assignees: ''
---
## Quick Summary
## Environment & Tools
- **PicoClaw Version:** (e.g., v0.1.2 or commit hash)
- **Go Version:** (e.g., go 1.22)
- **AI Model & Provider:** (e.g., GPT-4o via OpenAI / DeepSeek via SiliconFlow)
- **Operating System:** (e.g., Ubuntu 22.04 / macOS / Android Termux)
- **Channels:** (e.g., Discord, Telegram, Feishu, ...)
## 📸 Steps to Reproduce
1.
2.
3.
## ❌ Actual Behavior
## ✅ Expected Behavior
## 💬 Additional Context

View file

@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest a new idea or improvement
title: "[Feature]"
labels: enhancement
assignees: ''
---
## 🎯 The Goal / Use Case
## 💡 Proposed Solution
## 🛠 Potential Implementation (Optional)
## 🚦 Impact & Roadmap Alignment
- [ ] This is a Core Feature
- [ ] This is a Nice-to-Have / Enhancement
- [ ] This aligns with the current Roadmap
## 🔄 Alternatives Considered
## 💬 Additional Context

View file

@ -0,0 +1,26 @@
---
name: General Task / Todo
about: A specific piece of work like doc, refactoring, or maintenance.
title: "[Task]"
labels: ''
assignees: ''
---
## 📝 Objective
## 📋 To-Do List
- [ ] Step 1
- [ ] Step 2
- [ ] Step 3
## 🎯 Definition of Done (Acceptance Criteria)
- [ ] Documentation is updated in the README/docs folder.
- [ ] Code follows project linting standards.
- [ ] (If applicable) Basic tests pass.
## 💡 Context / Motivation
## 🔗 Related Issues / PRs
- Fixes #
- Relates to #

43
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,43 @@
## 📝 Description
<!-- Please briefly describe the changes and purpose of this PR -->
## 🗣️ Type of Change
- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 📖 Documentation update
- [ ] ⚡ Code refactoring (no functional changes, no api changes)
## 🤖 AI Code Generation
- [ ] 🤖 Fully AI-generated (100% AI, 0% Human)
- [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
- [ ] 👨‍💻 Mostly Human-written (Human lead, AI assisted or none)
## 🔗 Related Issue
<!-- Please link the related issue(s) (e.g., Fixes #123, Closes #456) -->
## 📚 Technical Context (Skip for Docs)
- **Reference URL:**
- **Reasoning:**
## 🧪 Test Environment
- **Hardware:** <!-- e.g. Raspberry Pi 5, Orange Pi, PC-->
- **OS:** <!-- e.g. Debian 12, Ubuntu 22.04 -->
- **Model/Provider:** <!-- e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3 -->
- **Channels:** <!-- e.g. Discord, Telegram, Feishu, ... -->
## 📸 Evidence (Optional)
<details>
<summary>Click to view Logs/Screenshots</summary>
<!-- Please paste relevant screenshots or logs here -->
</details>
## ☑️ Checklist
- [ ] My code/docs follow the style of this project.
- [ ] I have performed a self-review of my own changes.
- [ ] I have updated the documentation accordingly.

View file

@ -2,24 +2,19 @@ name: build
on:
push:
branches: ["main"]
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: fmt
run: |
make fmt
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
- name: Build
run: make build-all

View file

@ -1,12 +1,18 @@
name: 🐳 Build & Push Docker Image
on:
release:
types: [published]
workflow_call:
inputs:
tag:
description: "Release tag"
required: true
type: string
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/picoclaw
GHCR_REGISTRY: ghcr.io
GHCR_IMAGE_NAME: ${{ github.repository_owner }}/picoclaw
DOCKERHUB_REGISTRY: docker.io
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
jobs:
build:
@ -19,7 +25,9 @@ jobs:
steps:
# ── Checkout ──────────────────────────────
- name: 📥 Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag }}
# ── Docker Buildx ─────────────────────────
- name: 🔧 Set up Docker Buildx
@ -27,36 +35,42 @@ jobs:
# ── Login to GHCR ─────────────────────────
- name: 🔑 Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# ── Metadata (tags & labels) ──────────────
- name: 🏷️ Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
# ── Login to Docker Hub ────────────────────
- name: 🔑 Login to Docker Hub
uses: docker/login-action@v3
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value={{date 'YYYYMMDD-HHmmss'}},enable={{is_default_branch}}
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# ── Metadata (tags & labels) ──────────────
- name: 🏷️ Prepare image tags
id: tags
shell: bash
run: |
tag="${{ inputs.tag }}"
echo "ghcr_tag=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT"
echo "ghcr_latest=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
echo "dockerhub_tag=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT"
echo "dockerhub_latest=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
# ── Build & Push ──────────────────────────
- name: 🚀 Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
push: true
tags: |
${{ steps.tags.outputs.ghcr_tag }}
${{ steps.tags.outputs.ghcr_latest }}
${{ steps.tags.outputs.dockerhub_tag }}
${{ steps.tags.outputs.dockerhub_latest }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/riscv64

View file

@ -1,52 +1,38 @@
name: pr-check
name: PR
on:
pull_request:
pull_request: { }
jobs:
fmt-check:
lint:
name: Linter
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check formatting
run: |
make fmt
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
vet:
runs-on: ubuntu-latest
needs: fmt-check
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Run go generate
run: go generate ./...
- name: Run go vet
run: go vet ./...
- name: Golangci Lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.10.1
test:
name: Tests
runs-on: ubuntu-latest
needs: fmt-check
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.mod
@ -55,4 +41,3 @@ jobs:
- name: Run go test
run: go test ./...

View file

@ -26,74 +26,77 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Create and push tag
shell: bash
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${{ inputs.tag }}" -m "Release ${{ inputs.tag }}"
git push origin "${{ inputs.tag }}"
git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
git push origin "$RELEASE_TAG"
build-binaries:
name: Build Release Binaries
release:
name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest
steps:
- name: Checkout tag
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Setup Go from go.mod
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build all binaries
run: make build-all
- name: Generate checksums
shell: bash
run: |
shasum -a 256 build/picoclaw-* > build/sha256sums.txt
- name: Upload release binaries artifact
uses: actions/upload-artifact@v4
with:
name: picoclaw-binaries
path: |
build/picoclaw-*
build/sha256sums.txt
if-no-files-found: error
create-release:
name: Create GitHub Release
needs: [create-tag, build-binaries]
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Checkout tag
uses: actions/checkout@v6
with:
path: release-artifacts
fetch-depth: 0
ref: ${{ inputs.tag }}
- name: Show downloaded files
run: ls -R release-artifacts
- name: Create release
uses: softprops/action-gh-release@v2
- name: Setup Go from go.mod
id: setup-go
uses: actions/setup-go@v6
with:
tag_name: ${{ inputs.tag }}
name: ${{ inputs.tag }}
draft: ${{ inputs.draft }}
prerelease: ${{ inputs.prerelease }}
files: |
release-artifacts/**/*
generate_release_notes: true
go-version-file: go.mod
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: ~> v2
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
- name: Apply release flags
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release edit "${{ inputs.tag }}" \
--draft=${{ inputs.draft }} \
--prerelease=${{ inputs.prerelease }}

184
.golangci.yaml Normal file
View file

@ -0,0 +1,184 @@
version: "2"
linters:
default: all
disable:
# TODO: Tweak for current project needs
- containedctx
- cyclop
- depguard
- dupl
- dupword
- err113
- exhaustruct
- funcorder
- gochecknoglobals
- godot
- intrange
- ireturn
- nlreturn
- noctx
- noinlineerr
- nonamedreturns
- tagliatelle
- testpackage
- varnamelen
- wrapcheck
- wsl
- wsl_v5
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
- bodyclose
- contextcheck
- dogsled
- embeddedstructfieldcheck
- errcheck
- errchkjson
- errorlint
- exhaustive
- forbidigo
- forcetypeassert
- funlen
- gochecknoinits
- gocognit
- goconst
- gocritic
- gocyclo
- godox
- goprintffuncname
- gosec
- ineffassign
- lll
- maintidx
- misspell
- mnd
- modernize
- nakedret
- nestif
- nilnil
- paralleltest
- perfsprint
- prealloc
- predeclared
- revive
- staticcheck
- tagalign
- testifylint
- thelper
- unparam
- usestdlibvars
- usetesting
- wastedassign
- whitespace
settings:
errcheck:
check-type-assertions: true
check-blank: true
exhaustive:
default-signifies-exhaustive: true
funlen:
lines: 120
statements: 40
gocognit:
min-complexity: 25
gocyclo:
min-complexity: 20
govet:
enable-all: true
disable:
- fieldalignment
lll:
line-length: 120
tab-width: 4
misspell:
locale: US
mnd:
checks:
- argument
- assign
- case
- condition
- operation
- return
nakedret:
max-func-lines: 3
revive:
enable-all-rules: true
rules:
- name: add-constant
disabled: true
- name: argument-limit
arguments:
- 7
severity: warning
- name: banned-characters
disabled: true
- name: cognitive-complexity
disabled: true
- name: comment-spacings
arguments:
- nolint
severity: warning
- name: cyclomatic
disabled: true
- name: file-header
disabled: true
- name: function-result-limit
arguments:
- 3
severity: warning
- name: function-length
disabled: true
- name: line-length-limit
disabled: true
- name: max-public-structs
disabled: true
- name: modifies-value-receiver
disabled: true
- name: package-comments
disabled: true
- name: unused-receiver
disabled: true
exclusions:
generated: lax
rules:
- linters:
- lll
source: '^//go:generate '
- linters:
- funlen
- maintidx
- gocognit
- gocyclo
path: _test\.go$
- linters:
- nolintlint
path: 'pkg/tools/(i2c\.go|spi\.go)$'
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gci
- gofmt
- gofumpt
- goimports
- golines
settings:
gci:
sections:
- standard
- default
- localmodule
custom-order: true
gofmt:
simplify: true
rewrite-rules:
- pattern: "interface{}"
replacement: "any"
- pattern: "a[b:len(a)]"
replacement: "a[b:]"
golines:
max-len: 120

View file

@ -5,10 +5,20 @@ version: 2
before:
hooks:
- go mod tidy
- go generate ./cmd/picoclaw
builds:
- env:
- id: picoclaw
env:
- CGO_ENABLED=0
tags:
- stdjson
ldflags:
- -s -w
- -X main.version={{ .Version }}
- -X main.gitCommit={{ .ShortCommit }}
- -X main.buildTime={{ .Date }}
- -X main.goVersion={{ .Env.GOVERSION }}
goos:
- linux
- windows
@ -26,6 +36,22 @@ builds:
- goos: windows
goarch: arm
dockers_v2:
- id: picoclaw
dockerfile: Dockerfile.goreleaser
ids:
- picoclaw
images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}"
tags:
- "{{ .Tag }}"
- "latest"
platforms:
- linux/amd64
- linux/arm64
- linux/riscv64
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
@ -48,10 +74,10 @@ changelog:
- "^docs:"
- "^test:"
upx:
- enabled: true
compress: best
lzma: true
# upx:
# - enabled: true
# compress: best
# lzma: true
release:
footer: >-

302
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,302 @@
# Contributing to PicoClaw
Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing.
PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Ways to Contribute](#ways-to-contribute)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Making Changes](#making-changes)
- [AI-Assisted Contributions](#ai-assisted-contributions)
- [Pull Request Process](#pull-request-process)
- [Branch Strategy](#branch-strategy)
- [Code Review](#code-review)
- [Communication](#communication)
---
## Code of Conduct
We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated.
---
## Ways to Contribute
- **Bug reports** — Open an issue using the bug report template.
- **Feature requests** — Open an issue using the feature request template; discuss before implementing.
- **Code** — Fix bugs or implement features. See the workflow below.
- **Documentation** — Improve READMEs, docs, inline comments, or translations.
- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results.
For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
---
## Getting Started
1. **Fork** the repository on GitHub.
2. **Clone** your fork locally:
```bash
git clone https://github.com/<your-username>/picoclaw.git
cd picoclaw
```
3. Add the upstream remote:
```bash
git remote add upstream https://github.com/sipeed/picoclaw.git
```
---
## Development Setup
### Prerequisites
- Go 1.25 or later
- `make`
### Build
```bash
make build # Build binary (runs go generate first)
make generate # Run go generate only
make check # Full pre-commit check: deps + fmt + vet + test
```
### Running Tests
```bash
make test # Run all tests
go test -run TestName -v ./pkg/session/ # Run a single test
go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
```
### Code Style
```bash
make fmt # Format code
make vet # Static analysis
make lint # Full linter run
```
All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
---
## Making Changes
### Branching
Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch:
```bash
git checkout main
git pull upstream main
git checkout -b your-feature-branch
```
Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`.
### Commits
- Write clear, concise commit messages in English.
- Use the imperative mood: "Add retry logic" not "Added retry logic".
- Reference the related issue when relevant: `Fix session leak (#123)`.
- Keep commits focused. One logical change per commit is preferred.
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
### Keeping Up to Date
Rebase your branch onto upstream `main` before opening a PR:
```bash
git fetch upstream
git rebase upstream/main
```
---
## AI-Assisted Contributions
PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools.
### Disclosure Is Required
Every PR must disclose AI involvement using the PR template's **🤖 AI Code Generation** section. There are three levels:
| Level | Description |
|---|---|
| 🤖 Fully AI-generated | AI wrote the code; contributor reviewed and validated it |
| 🛠️ Mostly AI-generated | AI produced the draft; contributor made significant modifications |
| 👨‍💻 Mostly Human-written | Contributor led; AI provided suggestions or none at all |
Honest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution.
### You Are Responsible for What You Submit
Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must:
- **Read and understand** every line of the generated code.
- **Test it** in a real environment (see the Test Environment section of the PR template).
- **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully.
- **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax.
PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review.
### AI-Generated Code Quality Standards
AI-generated contributions are held to the **same quality bar** as human-written code:
- It must pass all CI checks (`make check`).
- It must be idiomatic Go and consistent with the existing codebase style.
- It must not introduce unnecessary abstractions, dead code, or over-engineering.
- It must include or update tests where appropriate.
### Security Review
AI-generated code requires extra security scrutiny. Pay special attention to:
- File path handling and sandbox escapes (see commit `244eb0b` for a real example)
- External input validation in channel handlers and tool implementations
- Credential or secret handling
- Command execution (`exec.Command`, shell invocations)
If you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help.
---
## Pull Request Process
### Before Opening a PR
- [ ] Run `make check` and ensure it passes locally.
- [ ] Fill in the PR template completely, including the AI disclosure section.
- [ ] Link any related issue(s) in the PR description.
- [ ] Keep the PR focused. Avoid bundling unrelated changes together.
### PR Template Sections
The PR template asks for:
- **Description** — What does this change do and why?
- **Type of Change** — Bug fix, feature, docs, or refactor.
- **AI Code Generation** — Disclosure of AI involvement (required).
- **Related Issue** — Link to the issue this addresses.
- **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs).
- **Test Environment** — Hardware, OS, model/provider, and channels used for testing.
- **Evidence** — Optional logs or screenshots demonstrating the change works.
- **Checklist** — Self-review confirmation.
### PR Size
Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs.
---
## Branch Strategy
### Long-Lived Branches
- **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging.
- **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`.
### Requirements to Merge into `main`
A PR can only be merged when all of the following are satisfied:
1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green.
2. **Reviewer approval** — At least one maintainer has approved the PR.
3. **No unresolved review comments** — All review threads must be resolved.
4. **PR template is complete** — Including AI disclosure and test environment.
### Who Can Merge
Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access.
### Merge Strategy
We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.:
```
feat: Add Ollama provider support (#491)
```
If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion.
### Release Branches
When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point:
- **New features are not backported.** The release branch receives no new functionality after it is cut.
- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release.
If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers.
Release branches have stricter protections than `main` and are never directly pushed to under any circumstances.
---
## Code Review
### For Contributors
- Respond to review comments within a reasonable time. If you need more time, say so.
- When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested").
- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too.
- Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge.
### For Reviewers
Review for:
1. **Correctness** — Does the code do what it claims? Are there edge cases?
2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers.
3. **Architecture** — Is the approach consistent with the existing design?
4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity?
5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful?
Be constructive and specific. "This could have a race condition if two goroutines call this concurrently — consider using a mutex here" is better than "this looks wrong".
### Reviewer List
Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table.
|Function| Reviewer|
|--- |--- |
|Provider|@yinwm |
|Channel |@yinwm |
|Agent |@lxowalle|
|Tools |@lxowalle|
|SKill ||
|MCP ||
|Optimization|@lxowalle|
|Security||
|AI CI |@imguoguo|
|UX ||
|Document||
---
## Communication
- **GitHub Issues** — Bug reports, feature requests, design discussions.
- **GitHub Discussions** — General questions, ideas, community conversation.
- **Pull Request comments** — Code-specific feedback.
- **Wechat&Discord** — We will invite you when you have at least one merged PR
When in doubt, open an issue before writing code. It costs little and prevents wasted effort.
---
## A Note on the Project's AI-Driven Origin
PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome.
We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict.
Thank you for contributing!

303
CONTRIBUTING.zh.md Normal file
View file

@ -0,0 +1,303 @@
# 参与贡献 PicoClaw
感谢你对 PicoClaw 的关注!本项目是一个社区驱动的开源项目,目标是构建 轻量灵活,人人可用 的个人AI助手。我们欢迎一切形式的贡献Bug 修复、新功能、文档、翻译和测试。
PicoClaw 本身在很大程度上是借助 AI 辅助开发的——我们拥抱这种方式,并围绕它构建了贡献流程。
## 目录
- [行为准则](#行为准则)
- [贡献方式](#贡献方式)
- [快速开始](#快速开始)
- [开发环境配置](#开发环境配置)
- [提交修改](#提交修改)
- [AI 辅助贡献](#ai-辅助贡献)
- [Pull Request 流程](#pull-request-流程)
- [分支策略](#分支策略)
- [代码审查](#代码审查)
- [沟通渠道](#沟通渠道)
---
## 行为准则
我们致力于维护一个友好、互相尊重的社区环境。请保持善意、建设性的态度,并善意地理解他人。任何形式的骚扰或歧视均不被接受。
---
## 贡献方式
- **Bug 反馈** — 使用 Bug 报告模板提交 Issue。
- **功能建议** — 使用功能请求模板提交 Issue建议在开始实现前先进行讨论。
- **代码贡献** — 修复 Bug 或实现新功能,参见下方工作流程。
- **文档改进** — 完善 README、文档、代码注释或翻译。
- **测试与验证** — 在新硬件、新渠道或新 LLM 提供商上运行 PicoClaw 并反馈结果。
对于较大的新功能,请先提交 Issue 讨论设计方案,再动手写代码。这能避免无效投入,也确保与项目方向保持一致。
---
## 快速开始
1. 在 GitHub 上 **Fork** 本仓库。
2. 将你的 Fork **克隆**到本地:
```bash
git clone https://github.com/<你的用户名>/picoclaw.git
cd picoclaw
```
3. 添加上游远程仓库:
```bash
git remote add upstream https://github.com/sipeed/picoclaw.git
```
---
## 开发环境配置
### 前置依赖
- Go 1.25 或更高版本
- `make`
### 构建
```bash
make build # 构建二进制文件(会先执行 go generate
make generate # 仅执行 go generate
make check # 完整的提交前检查deps + fmt + vet + test
```
### 运行测试
```bash
make test # 运行所有测试
go test -run TestName -v ./pkg/session/ # 运行单个测试
go test -bench=. -benchmem -run='^$' ./... # 运行基准测试
```
### 代码风格
```bash
make fmt # 格式化代码
make vet # 静态分析
make lint # 完整的 lint 检查
```
所有 CI 检查通过后 PR 才能被合并。推送代码前请先在本地运行 `make check`,提前发现问题。
---
## 提交修改
### 分支管理
始终从 `main` 分支切出,并在 PR 中以 `main` 为目标分支。不要直接向 `main` 或任何 `release/*` 分支推送代码:
```bash
git checkout main
git pull upstream main
git checkout -b 你的功能分支名
```
请使用描述性的分支名,例如:`fix/telegram-timeout``feat/ollama-provider``docs/contributing-guide`
### Commit 规范
- 使用英文撰写清晰、简洁的 commit 信息。
- 使用祈使句:写 "Add retry logic",而不是 "Added retry logic"。
- 有关联 Issue 时请引用:`Fix session leak (#123)`
- 保持 commit 专注,每个 commit 只做一件事。
- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
### 保持与上游同步
提 PR 前,请将你的分支变基到上游 `main`
```bash
git fetch upstream
git rebase upstream/main
```
---
## AI 辅助贡献
PicoClaw 在很大程度上借助 AI 辅助开发,我们完全拥抱这种开发方式。但贡献者必须清楚地了解自己在使用 AI 工具时所承担的责任。
### 必须披露 AI 使用情况
每个 PR 都必须通过 PR 模板中的 **🤖 AI 代码生成** 部分披露 AI 参与情况,共分三个级别:
| 级别 | 说明 |
|---|---|
| 🤖 完全由 AI 生成 | AI 编写代码,贡献者负责审查和验证 |
| 🛠️ 主要由 AI 生成 | AI 起草,贡献者做了较大修改 |
| 👨‍💻 主要由人工编写 | 贡献者主导AI 仅提供辅助或未使用 AI |
我们期望你诚实填写。三种级别均可接受,没有任何歧视——重要的是贡献的质量。
### 你对提交的代码负全责
使用 AI 生成代码并不能减轻你作为贡献者的责任。在提交含有 AI 生成代码的 PR 之前,你必须:
- **逐行阅读并理解**生成的代码。
- **在真实环境中测试**(参见 PR 模板中的测试环境部分)。
- **检查安全问题** — AI 模型可能生成存在安全隐患的代码(如路径穿越、注入攻击、凭据泄露等),请仔细审查。
- **验证正确性** — AI 生成的逻辑可能听起来合理但实际上是错误的,请验证行为,而不仅仅是语法。
如果明显可以看出贡献者没有阅读或测试 AI 生成的代码,该 PR 将被直接关闭,不予审查。
### AI 生成代码的质量标准
AI 生成的代码与人工编写的代码遵循**相同的质量要求**
- 必须通过所有 CI 检查(`make check`)。
- 必须符合 Go 惯用写法,并与现有代码库的风格保持一致。
- 不得引入不必要的抽象、死代码或过度设计。
- 须在适当的地方包含或更新测试。
### 安全审查
AI 生成的代码需要格外仔细的安全审查。请特别关注以下方面:
- 文件路径处理与沙箱逃逸(项目历史中的 commit `244eb0b` 就是真实案例)
- channel 处理器和 tool 实现中的外部输入校验
- 凭据或密钥的处理
- 命令执行(`exec.Command`、shell 调用等)
如果你不确定某段 AI 生成代码是否安全,请在 PR 中说明——审查者会帮助判断。
---
## Pull Request 流程
### 提 PR 前的检查
- [ ] 在本地运行 `make check` 并确认通过。
- [ ] 完整填写 PR 模板,包括 AI 披露部分。
- [ ] 在 PR 描述中关联相关 Issue。
- [ ] 保持 PR 专注,避免将不相关的修改混在一起。
### PR 模板各部分说明
PR 模板要求填写:
- **描述** — 这个改动做了什么,为什么要做?
- **变更类型** — Bug 修复、新功能、文档或重构。
- **AI 代码生成** — AI 参与情况披露(必填)。
- **关联 Issue** — 此 PR 解决的 Issue 链接。
- **技术背景** — 参考链接和设计理由(纯文档类 PR 可跳过)。
- **测试环境** — 用于测试的硬件、操作系统、模型/提供商和渠道。
- **验证证据** — 可选的日志或截图,用于证明改动有效。
- **检查清单** — 自我审查确认。
### PR 规模
请尽量提交小而易于审查的 PR。一个涉及 5 个文件共 200 行改动的 PR远比涉及 30 个文件共 2000 行改动的 PR 容易审查。如果你的功能较大,可以考虑将其拆分为一系列逻辑完整的小 PR。
---
## 分支策略
### 长期分支
- **`main`** — 活跃开发分支。所有功能 PR 均以 `main` 为目标。该分支受保护:禁止直接推送,合并前必须获得至少一名维护者的批准。
- **`release/x.y`** — 稳定发布分支,在某个版本准备发布时从 `main` 切出。这些分支的保护级别高于 `main`
### 合并到 `main` 的前提条件
PR 必须同时满足以下所有条件,才能被合并:
1. **CI 全部通过** — 所有 GitHub Actions 工作流lint、test、build均为绿色。
2. **获得审查者批准** — 至少一名维护者已批准该 PR。
3. **无未解决的审查意见** — 所有审查讨论线程均已关闭。
4. **PR 模板填写完整** — 包括 AI 披露和测试环境信息。
### 谁可以合并
只有维护者才能合并 PR。贡献者不能合并自己的 PR即使拥有写权限也不行。
### 合并策略
为保持 `main` 历史清晰可读,我们对大多数 PR 使用 **Squash Merge**。每个合并的 PR 变为一个包含 PR 编号的单独 commit例如
```
feat: Add Ollama provider support (#491)
```
如果一个 PR 包含多个独立、结构清晰、能讲述完整故事的 commit维护者可视情况使用普通 merge。
### Release 分支
当某个版本准备就绪时,维护者会从 `main` 切出 `release/x.y` 分支。此后:
- **新功能不会被回溯backport。** Release 分支切出后,不再接收任何新功能。
- **安全修复和关键 Bug 修复会被 cherry-pick 进来。**`main` 上的某个修复属于安全漏洞、数据丢失或崩溃类问题,维护者会将相关 commit cherry-pick 到受影响的 `release/x.y` 分支,并发布补丁版本。
如果你认为 `main` 上的某个修复应该被回溯到某个 release 分支,请在 PR 描述中注明,或单独开一个 Issue 说明。最终决定由维护者做出。
Release 分支的保护级别高于 `main`,在任何情况下均不允许直接推送。
---
## 代码审查
### 对贡献者的建议
- 在合理时间内回复审查意见。如果需要更多时间,请告知。
- 更新 PR 以响应反馈时,简要说明改动内容(例如:"按建议改用了 `sync.RWMutex`")。
- 如果你不同意某条反馈,请礼貌地阐述你的理由——审查者也可能有判断失误的时候。
- 审查开始后请不要 force push——这会让审查者难以追踪变化。请使用额外的 commit维护者在合并时会进行 squash。
### 对审查者的建议
审查重点:
1. **正确性** — 代码是否实现了其声称的功能?是否存在边界情况?
2. **安全性** — 对 AI 生成代码、tool 实现和 channel 处理器尤其需要关注。
3. **架构** — 实现方式是否与现有设计一致?
4. **简洁性** — 是否有更简单的方案?是否引入了不必要的复杂度?
5. **测试** — 改动是否有测试覆盖?现有测试是否仍然有意义?
请给出建设性且具体的反馈。"如果两个 goroutine 同时调用这个函数可能会有竞态条件,建议在这里加一个 mutex" 远比 "这里看起来有问题" 更有帮助。
### 审查者列表
提交对应PR后可以参考下表联系对应的审查人员沟通
|Function| Reviewer|
|--- |--- |
|Provider|@yinwm |
|Channel |@yinwm |
|Agent |@lxowalle|
|Tools |@lxowalle|
|SKill ||
|MCP ||
|Optimization|@lxowalle|
|Security||
|AI CI |@imguoguo|
|UX ||
|Document||
---
## 沟通渠道
- **GitHub Issues** — Bug 报告、功能建议、设计讨论。
- **GitHub Discussions** — 一般性问题、想法交流、社区讨论。
- **Pull Request 评论** — 与具体代码相关的反馈。
- **Wechat&Discord** — 当你有至少一个已合并的PR后我们会邀请你加入开发者交流群
有疑问时,请先开 Issue 讨论,再动手写代码。这几乎没有成本,却能避免大量无效投入。
---
## 关于本项目的 AI 驱动起源
PicoClaw 的架构在人工监督下,经由 AI 辅助完成了大量设计和实现工作。如果你发现某处看起来奇怪或过度设计,这可能是该过程留下的痕迹——欢迎提 Issue 讨论。
我们相信,负责任地使用 AI 辅助开发能产生优秀的成果。我们同样相信,人类必须对自己提交的内容负责。这两点并不矛盾。
感谢你的贡献!

View file

@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
FROM golang:1.26.0-alpine AS builder
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make
@ -22,10 +22,21 @@ FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata curl
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost:18790/health || exit 1
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
# Create picoclaw home directory
# Create non-root user and group
RUN addgroup -g 1000 picoclaw && \
adduser -D -u 1000 -G picoclaw picoclaw
# Switch to non-root user
USER picoclaw
# Run onboard to create initial directories and config
RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]

10
Dockerfile.goreleaser Normal file
View file

@ -0,0 +1,10 @@
FROM alpine:3.21
ARG TARGETPLATFORM
RUN apk add --no-cache ca-certificates tzdata
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]

View file

@ -11,16 +11,20 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)"
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
# Go variables
GO?=go
GOFLAGS?=-v
GO?=CGO_ENABLED=0 go
GOFLAGS?=-v -tags stdjson
# Golangci-lint
GOLANGCI_LINT?=golangci-lint
# Installation
INSTALL_PREFIX?=$(HOME)/.local
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
INSTALL_TMP_SUFFIX=.new
# Workspace and Skills
PICOCLAW_HOME?=$(HOME)/.picoclaw
@ -39,6 +43,8 @@ ifeq ($(UNAME_S),Linux)
ARCH=amd64
else ifeq ($(UNAME_M),aarch64)
ARCH=arm64
else ifeq ($(UNAME_M),loongarch64)
ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
ARCH=riscv64
else
@ -84,6 +90,7 @@ build-all: generate
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@ -93,8 +100,10 @@ build-all: generate
install: build
@echo "Installing $(BINARY_NAME)..."
@mkdir -p $(INSTALL_BIN_DIR)
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
# Copy binary with temporary suffix to ensure atomic update
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Installation complete!"
@ -119,23 +128,39 @@ clean:
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
## fmt: Format Go code
## vet: Run go vet for static analysis
vet:
@$(GO) vet ./...
## fmt: Format Go code
## test: Test Go code
test:
@$(GO) test ./...
## fmt: Format Go code
fmt:
@$(GO) fmt ./...
@$(GOLANGCI_LINT) fmt
## deps: Update dependencies
## lint: Run linters
lint:
@$(GOLANGCI_LINT) run
## fix: Fix linting issues
fix:
@$(GOLANGCI_LINT) run --fix
## deps: Download dependencies
deps:
@$(GO) mod download
@$(GO) mod verify
## update-deps: Update dependencies
update-deps:
@$(GO) get -u ./...
@$(GO) mod tidy
## check: Run vet, fmt, and verify dependencies
check: deps fmt vet test
## run: Build and run picoclaw
run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
@ -148,7 +173,7 @@ help:
@echo " make [target]"
@echo ""
@echo "Targets:"
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
@grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
@echo ""
@echo "Examples:"
@echo " make build # Build for current platform"

1131
README.fr.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 皮皮虾,我们走</h3>
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ</h3>
<h3></h3>
<p>
@ -12,7 +12,7 @@
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
</p>
**日本語** | [English](README.md)
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
</div>
@ -39,7 +39,7 @@
</table>
## 📢 ニュース
2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 皮皮虾,我们走
2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ
## ✨ 特徴
@ -133,6 +133,10 @@ vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キ
# 3. ビルドと起動
docker compose --profile gateway up -d
> [!TIP]
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
# 4. ログ確認
docker compose logs -f picoclaw-gateway
@ -162,7 +166,7 @@ docker compose --profile gateway up -d
> [!TIP]
> `~/.picoclaw/config.json` に API キーを設定してください。
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
**1. 初期化**
@ -174,19 +178,24 @@ picoclaw onboard
```json
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
}
],
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
"model_name": "gpt4"
}
},
"providers": {
"openrouter": {
"api_key": "xxx",
"api_base": "https://openrouter.ai/api/v1"
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"allow_from": []
}
},
"tools": {
@ -194,7 +203,15 @@ picoclaw onboard
"search": {
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"tavily": {
"enabled": false,
"api_key": "YOUR_TAVILY_API_KEY",
"max_results": 5
}
},
"cron": {
"exec_timeout_minutes": 5
}
},
"heartbeat": {
@ -207,11 +224,11 @@ picoclaw onboard
**3. API キーの取得**
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
**3. チャット**
**4. チャット**
```bash
picoclaw agent -m "What is 2+2?"
@ -223,7 +240,7 @@ picoclaw agent -m "What is 2+2?"
## 💬 チャットアプリ
Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話できます
| チャネル | セットアップ |
|---------|------------|
@ -232,6 +249,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
| **QQ** | 簡単AppID + AppSecret |
| **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL |
| **WeCom** | 普通CorpID + Webhook設定 |
<details>
<summary><b>Telegram</b>(推奨)</summary>
@ -250,7 +268,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["YOUR_USER_ID"]
"allow_from": ["YOUR_USER_ID"]
}
}
}
@ -290,7 +308,7 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["YOUR_USER_ID"]
"allow_from": ["YOUR_USER_ID"]
}
}
}
@ -427,6 +445,87 @@ picoclaw gateway
</details>
<details>
<summary><b>WeCom (企業微信)</b></summary>
PicoClaw は2種類の WeCom 統合をサポートしています:
**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応
**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応
詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。
**クイックセットアップ - WeCom Bot:**
**1. ボットを作成**
* WeCom 管理コンソール → グループチャット → グループボットを追加
* Webhook URL をコピー(形式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`
**2. 設定**
```json
{
"channels": {
"wecom": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom",
"allow_from": []
}
}
}
```
**クイックセットアップ - WeCom App:**
**1. アプリを作成**
* WeCom 管理コンソール → アプリ管理 → アプリを作成
* **AgentId****Secret** をコピー
* "マイ会社" ページで **CorpID** をコピー
**2. メッセージ受信を設定**
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
* URL を `http://your-server:18792/webhook/wecom-app` に設定
* **Token****EncodingAESKey** を生成
**3. 設定**
```json
{
"channels": {
"wecom_app": {
"enabled": true,
"corp_id": "wwxxxxxxxxxxxxxxxx",
"corp_secret": "YOUR_CORP_SECRET",
"agent_id": 1000002,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": []
}
}
}
```
**4. 起動**
```bash
picoclaw gateway
```
> **注意**: WeCom App は Webhook コールバック用にポート 18792 を開放する必要があります。本番環境では HTTPS 用のリバースプロキシを使用してください。
</details>
## ⚙️ 設定
設定ファイル: `~/.picoclaw/config.json`
@ -618,6 +717,22 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
### プロバイダー
> [!NOTE]
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
| プロバイダー | 用途 | API キー取得先 |
| --- | --- | --- |
| `gemini` | LLMGemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLMZhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
| `openrouter`(要テスト) | LLM推奨、全モデルにアクセス可能 | [openrouter.ai](https://openrouter.ai) |
| `anthropic`(要テスト) | LLMClaude 直接) | [console.anthropic.com](https://console.anthropic.com) |
| `openai`(要テスト) | LLMGPT 直接) | [platform.openai.com](https://platform.openai.com) |
| `deepseek`(要テスト) | LLMDeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **音声文字起こし**Whisper | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLMCerebras 直接) | [cerebras.ai](https://cerebras.ai) |
### 基本設定
1. **設定ファイルの作成:**
@ -663,17 +778,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
},
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-xxx"
"api_key": "sk-or-v1-xxx"
},
"groq": {
"apiKey": "gsk_xxx"
"api_key": "gsk_xxx"
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "123456:ABC...",
"allowFrom": ["123456789"]
"allow_from": ["123456789"]
},
"discord": {
"enabled": true,
@ -685,18 +800,21 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
},
"feishu": {
"enabled": false,
"appId": "cli_xxx",
"appSecret": "xxx",
"encryptKey": "",
"verificationToken": "",
"allowFrom": []
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
}
},
"tools": {
"web": {
"search": {
"apiKey": "BSA..."
"api_key": "BSA..."
}
},
"cron": {
"exec_timeout_minutes": 5
}
},
"heartbeat": {
@ -708,6 +826,163 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
</details>
### モデル設定 (model_list)
> **新機能!** PicoClaw は現在 **モデル中心** の設定アプローチを採用しています。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで、新しいプロバイダーを追加できます—**コードの変更は一切不要!**
この設計は、柔軟なプロバイダー選択による **マルチエージェントサポート** も可能にします:
- **異なるエージェント、異なるプロバイダー** : 各エージェントは独自の LLM プロバイダーを使用可能
- **フォールバックモデル** : 耐障性のため、プライマリモデルとフォールバックモデルを設定可能
- **ロードバランシング** : 複数のエンドポイントにリクエストを分散
- **集中設定管理** : すべてのプロバイダーを一箇所で管理
#### 📋 サポートされているすべてのベンダー
| ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API キー |
|-------------|-----------------|---------------------|----------|---------|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) |
| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) |
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) |
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) |
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) |
| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [キーを取得](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
#### 基本設定
```json
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-ant-your-key"
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_key": "your-zhipu-key"
}
],
"agents": {
"defaults": {
"model": "gpt-5.2"
}
}
}
```
#### ベンダー別の例
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_key": "sk-..."
}
```
**Zhipu AI (GLM)**
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_key": "your-key"
}
```
**Anthropic (OAuth使用)**
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"auth_method": "oauth"
}
```
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
#### ロードバランシング
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
```json
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
]
}
```
#### 従来の `providers` 設定からの移行
古い `providers` 設定は**非推奨**ですが、後方互換性のためにサポートされています。
**旧設定(非推奨):**
```json
{
"providers": {
"zhipu": {
"api_key": "your-key",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
}
},
"agents": {
"defaults": {
"provider": "zhipu",
"model": "glm-4.7"
}
}
}
```
**新設定(推奨):**
```json
{
"model_list": [
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_key": "your-key"
}
],
"agents": {
"defaults": {
"model": "glm-4.7"
}
}
}
```
詳細な移行ガイドは、[docs/migration/model-list-migration.md](docs/migration/model-list-migration.md) を参照してください。
## CLI リファレンス
| コマンド | 説明 |
@ -729,20 +1004,25 @@ Discord: https://discord.gg/V4sAZ9XWpN
## 🐛 トラブルシューティング
### Web 検索で「API 配置问题」と表示される
### Web 検索で「API 設定の問題」と表示される
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
Web 検索を有効にするには:
1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
2. `~/.picoclaw/config.json` に追加:
```json
{
"tools": {
"web": {
"search": {
"brave": {
"enabled": true,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
}
}
}
@ -765,5 +1045,8 @@ Web 検索を有効にするには:
|---------|--------|------------|
| **OpenRouter** | 月 200K トークン | 複数モデルClaude, GPT-4 など) |
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
| **Groq** | 無料枠あり | 高速推論Llama, Mixtral |
| **Cerebras** | 無料枠あり | 高速推論Llama, Qwen など) |

446
README.md
View file

@ -12,9 +12,13 @@
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<br>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | **English**
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
</div>
---
@ -42,13 +46,17 @@
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
>
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
>
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (1020MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
## 📢 News
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we cant wait to have you on board!
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClawLet's Go
@ -96,6 +104,24 @@
</tr>
</table>
### 📱 Run on old Android Phones
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
1. **Install Termux** (Available on F-Droid or Google Play).
2. **Execute cmds**
```bash
# Note: Replace v0.1.1 with the latest version from the Releases page
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
chmod +x picoclaw-linux-arm64
pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard
```
And then follow the instructions in the "Quick Start" section to complete the configuration!
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
### 🐜 Innovative Low-Footprint Deploy
PicoClaw can be deployed on almost any Linux device!
@ -148,6 +174,10 @@ vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
# 3. Build & Start
docker compose --profile gateway up -d
> [!TIP]
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
# 4. Check logs
docker compose logs -f picoclaw-gateway
@ -177,7 +207,7 @@ docker compose --profile gateway up -d
> [!TIP]
> Set your API key in `~/.picoclaw/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize**
@ -192,18 +222,24 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
}
},
"providers": {
"openrouter": {
"api_key": "xxx",
"api_base": "https://openrouter.ai/api/v1"
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "your-api-key"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "your-anthropic-key"
}
},
],
"tools": {
"web": {
"brave": {
@ -211,6 +247,11 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"tavily": {
"enabled": false,
"api_key": "YOUR_TAVILY_API_KEY",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
@ -220,10 +261,12 @@ picoclaw onboard
}
```
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
**3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
> **Note**: See `config.example.json` for a complete configuration template.
@ -239,7 +282,7 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
| Channel | Setup |
| ------------ | ---------------------------------- |
@ -248,6 +291,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
| **WeCom** | Medium (CorpID + webhook setup) |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
@ -266,7 +310,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["YOUR_USER_ID"]
"allow_from": ["YOUR_USER_ID"]
}
}
}
@ -297,7 +341,6 @@ picoclaw gateway
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID**
* Discord Settings → Advanced → enable **Developer Mode**
* Right-click your avatar → **Copy User ID**
@ -309,7 +352,8 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["YOUR_USER_ID"]
"allow_from": ["YOUR_USER_ID"],
"mention_only": false
}
}
}
@ -322,6 +366,10 @@ picoclaw gateway
* Bot Permissions: `Send Messages`, `Read Message History`
* Open the generated invite URL and add the bot to your server
**Optional: Mention-only mode**
Set `"mention_only": true` to make the bot respond only when @-mentioned. Useful for shared servers where you want the bot to respond only when explicitly called.
**6. Run**
```bash
@ -387,14 +435,13 @@ picoclaw gateway
}
```
> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
**3. Run**
```bash
picoclaw gateway
```
</details>
<details>
@ -447,6 +494,86 @@ picoclaw gateway
</details>
<details>
<summary><b>WeCom (企业微信)</b></summary>
PicoClaw supports two types of WeCom integration:
**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats
**Option 2: WeCom App (自建应用)** - More features, proactive messaging
See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions.
**Quick Setup - WeCom Bot:**
**1. Create a bot**
* Go to WeCom Admin Console → Group Chat → Add Group Bot
* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
**2. Configure**
```json
{
"channels": {
"wecom": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom",
"allow_from": []
}
}
}
```
**Quick Setup - WeCom App:**
**1. Create an app**
* Go to WeCom Admin Console → App Management → Create App
* Copy **AgentId** and **Secret**
* Go to "My Company" page, copy **CorpID**
**2. Configure receive message**
* In App details, click "Receive Message" → "Set API"
* Set URL to `http://your-server:18792/webhook/wecom-app`
* Generate **Token** and **EncodingAESKey**
**3. Configure**
```json
{
"channels": {
"wecom_app": {
"enabled": true,
"corp_id": "wwxxxxxxxxxxxxxxxx",
"corp_secret": "YOUR_CORP_SECRET",
"agent_id": 1000002,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": []
}
}
}
```
**4. Run**
```bash
picoclaw gateway
```
> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS.
</details>
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
@ -493,23 +620,23 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
}
```
| Option | Default | Description |
|--------|---------|-------------|
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
| Option | Default | Description |
| ----------------------- | ----------------------- | ----------------------------------------- |
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
#### Protected Tools
When `restrict_to_workspace: true`, the following tools are sandboxed:
| Tool | Function | Restriction |
|------|----------|-------------|
| `read_file` | Read files | Only files within workspace |
| `write_file` | Write files | Only files within workspace |
| `list_dir` | List directories | Only directories within workspace |
| `edit_file` | Edit files | Only files within workspace |
| `append_file` | Append to files | Only files within workspace |
| `exec` | Execute commands | Command paths must be within workspace |
| Tool | Function | Restriction |
| ------------- | ---------------- | -------------------------------------- |
| `read_file` | Read files | Only files within workspace |
| `write_file` | Write files | Only files within workspace |
| `list_dir` | List directories | Only directories within workspace |
| `edit_file` | Edit files | Only files within workspace |
| `append_file` | Append to files | Only files within workspace |
| `exec` | Execute commands | Command paths must be within workspace |
#### Additional Exec Protection
@ -562,11 +689,11 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
The `restrict_to_workspace` setting applies consistently across all execution paths:
| Execution Path | Security Boundary |
|----------------|-------------------|
| Main Agent | `restrict_to_workspace` ✅ |
| Execution Path | Security Boundary |
| ---------------- | ---------------------------- |
| Main Agent | `restrict_to_workspace` |
| Subagent / Spawn | Inherits same restriction ✅ |
| Heartbeat tasks | Inherits same restriction ✅ |
| Heartbeat tasks | Inherits same restriction ✅ |
All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.
@ -592,21 +719,23 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
# Periodic Tasks
## Quick Tasks (respond directly)
- Report current time
## Long Tasks (use spawn for async)
- Search the web for AI news and summarize
- Check email and report important messages
```
**Key behaviors:**
| Feature | Description |
|---------|-------------|
| **spawn** | Creates async subagent, doesn't block heartbeat |
| **Independent context** | Subagent has its own context, no session history |
| **message tool** | Subagent communicates with user directly via message tool |
| **Non-blocking** | After spawning, heartbeat continues to next task |
| Feature | Description |
| ----------------------- | --------------------------------------------------------- |
| **spawn** | Creates async subagent, doesn't block heartbeat |
| **Independent context** | Subagent has its own context, no session history |
| **message tool** | Subagent communicates with user directly via message tool |
| **Non-blocking** | After spawning, heartbeat continues to next task |
#### How Subagent Communication Works
@ -637,10 +766,10 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
}
```
| Option | Default | Description |
|--------|---------|-------------|
| `enabled` | `true` | Enable/disable heartbeat |
| `interval` | `30` | Check interval in minutes (min: 5) |
| Option | Default | Description |
| ---------- | ------- | ---------------------------------- |
| `enabled` | `true` | Enable/disable heartbeat |
| `interval` | `30` | Check interval in minutes (min: 5) |
**Environment variables:**
@ -652,15 +781,220 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
> [!NOTE]
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
| Provider | Purpose | Get API Key |
| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| Provider | Purpose | Get API Key |
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
### Model Configuration (model_list)
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
This design also enables **multi-agent support** with flexible provider selection:
- **Different agents, different providers**: Each agent can use its own LLM provider
- **Model fallbacks**: Configure primary and fallback models for resilience
- **Load balancing**: Distribute requests across multiple endpoints
- **Centralized configuration**: Manage all providers in one place
#### 📋 All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
#### Basic Configuration
```json
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-ant-your-key"
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_key": "your-zhipu-key"
}
],
"agents": {
"defaults": {
"model": "gpt-5.2"
}
}
}
```
#### Vendor-Specific Examples
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_key": "sk-..."
}
```
**智谱 AI (GLM)**
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_key": "your-key"
}
```
**DeepSeek**
```json
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat",
"api_key": "sk-..."
}
```
**Anthropic (with API key)**
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-ant-your-key"
}
```
> Run `picoclaw auth login --provider anthropic` to paste your API token.
**Ollama (local)**
```json
{
"model_name": "llama3",
"model": "ollama/llama3"
}
```
**Custom Proxy/API**
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_key": "sk-..."
}
```
#### Load Balancing
Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
```json
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
]
}
```
#### Migration from Legacy `providers` Config
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
**Old Config (deprecated):**
```json
{
"providers": {
"zhipu": {
"api_key": "your-key",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
}
},
"agents": {
"defaults": {
"provider": "zhipu",
"model": "glm-4.7"
}
}
}
```
**New Config (recommended):**
```json
{
"model_list": [
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_key": "your-key"
}
],
"agents": {
"defaults": {
"model": "glm-4.7"
}
}
}
```
For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
### Provider Architecture
PicoClaw routes providers by protocol family:
- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints.
- Anthropic protocol: Claude-native API behavior.
- Codex/OAuth path: OpenAI OAuth/token authentication route.
This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
<details>
<summary><b>Zhipu</b></summary>
@ -757,6 +1091,9 @@ picoclaw agent -m "Hello"
"enabled": true,
"max_results": 5
}
},
"cron": {
"exec_timeout_minutes": 5
}
},
"heartbeat": {
@ -794,13 +1131,13 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
PRs welcome! The codebase is intentionally small and readable. 🤗
Roadmap coming soon...
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
Developer group building, Entry Requirement: At least 1 Merged PR.
Developer group building, join after your first merged PR!
User Groups:
discord: <https://discord.gg/V4sAZ9XWpN>
discord: <https://discord.gg/V4sAZ9XWpN>
<img src="assets/wechat.png" alt="PicoClaw" width="512">
@ -853,3 +1190,4 @@ This happens when another instance of the bot is running. Make sure only one `pi
| **Zhipu** | 200K tokens/month | Best for Chinese users |
| **Brave Search** | 2000 queries/month | Web search functionality |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |

1126
README.pt-br.md Normal file

File diff suppressed because it is too large Load diff

1096
README.vi.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,8 @@
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
</p>
**中文** | [日本語](README.ja.md) | [English](README.md)
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
</div>
---
@ -42,14 +43,17 @@
> [!CAUTION]
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**
> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
> * **注意:** picoclaw正在初期的快速功能开发阶段可能有尚未修复的网络安全问题在1.0正式版发布前,请不要将其部署到生产环境中
>
> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**
> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
> - **注意:** picoclaw正在初期的快速功能开发阶段可能有尚未修复的网络安全问题在1.0正式版发布前,请不要将其部署到生产环境中
> - **注意:** picoclaw最近合并了大量PRs近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
## 📢 新闻 (News)
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars** 感谢社区的支持由于正值中国春节假期PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
@ -67,12 +71,12 @@
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
| | OpenClaw | NanoBot | **PicoClaw** |
| --- | --- | --- | --- |
| **语言** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB** |
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**</br>**低至 $10** |
| | OpenClaw | NanoBot | **PicoClaw** |
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
| **语言** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB** |
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**</br>**低至 $10** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
@ -98,13 +102,31 @@
</tr>
</table>
### 📱 在手机上轻松运行
picoclaw 可以将你10年前的老旧手机废物利用变身成为你的AI助理快速指南:
1. 先去应用商店下载安装Termux
2. 打开后执行指令
```bash
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
chmod +x picoclaw-linux-arm64
pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard
```
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
### 🐜 创新的低占用部署
PicoClaw 几乎可以部署在任何 Linux 设备上!
* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
@ -151,6 +173,9 @@ vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
# 3. 构建并启动
docker compose --profile gateway up -d
> [!TIP]
**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`
# 4. 查看日志
docker compose logs -f picoclaw-gateway
@ -183,7 +208,7 @@ docker compose --profile gateway up -d
> [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
**1. 初始化 (Initialize)**
@ -199,34 +224,50 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
}
},
"providers": {
"openrouter": {
"api_key": "xxx",
"api_base": "https://openrouter.ai/api/v1"
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "your-api-key"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "your-anthropic-key"
}
},
],
"tools": {
"web": {
"search": {
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"tavily": {
"enabled": false,
"api_key": "YOUR_TAVILY_API_KEY",
"max_results": 5
}
},
"cron": {
"exec_timeout_minutes": 5
}
}
}
```
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
**3. 获取 API Key**
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
> **注意**: 完整的配置模板请参考 `config.example.json`
@ -243,176 +284,28 @@ picoclaw agent -m "2+2 等于几?"
## 💬 聊天应用集成 (Chat Apps)
通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| 渠道 | 设置难度 |
| --- | --- |
| **Telegram** | 简单 (仅需 token) |
| **Discord** | 简单 (bot token + intents) |
| **QQ** | 简单 (AppID + AppSecret) |
| **钉钉 (DingTalk)** | 中等 (app credentials) |
### 核心渠道
<details>
<summary><b>Telegram</b> (推荐)</summary>
**1. 创建机器人**
* 打开 Telegram搜索 `@BotFather`
* 发送 `/newbot`,按照提示操作
* 复制 token
**2. 配置**
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["YOUR_USER_ID"]
}
}
}
```
> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
**3. 运行**
```bash
picoclaw gateway
```
</details>
<details>
<summary><b>Discord</b></summary>
**1. 创建机器人**
* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
* Create an application → Bot → Add Bot
* 复制 bot token
**2. 开启 Intents**
* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
**3. 获取您的 User ID**
* Discord 设置 → Advanced → 开启 **Developer Mode**
* 右键点击您的头像 → **Copy User ID**
**4. 配置**
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["YOUR_USER_ID"]
}
}
}
```
**5. 邀请机器人**
* OAuth2 → URL Generator
* Scopes: `bot`
* Bot Permissions: `Send Messages`, `Read Message History`
* 打开生成的邀请 URL将机器人添加到您的服务器
**6. 运行**
```bash
picoclaw gateway
```
</details>
<details>
<summary><b>QQ</b></summary>
**1. 创建机器人**
* 前往 [QQ 开放平台](https://q.qq.com/#)
* 创建应用 → 获取 **AppID** 和 **AppSecret**
**2. 配置**
```json
{
"channels": {
"qq": {
"enabled": true,
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"allow_from": []
}
}
}
```
> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
**3. 运行**
```bash
picoclaw gateway
```
</details>
<details>
<summary><b>钉钉 (DingTalk)</b></summary>
**1. 创建机器人**
* 前往 [开放平台](https://open.dingtalk.com/)
* 创建内部应用
* 复制 Client ID 和 Client Secret
**2. 配置**
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
}
}
}
```
> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
**3. 运行**
```bash
picoclaw gateway
```
</details>
| 渠道 | 设置难度 | 特性说明 | 文档链接 |
| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
| **Discord** | ⭐ 简单 | Socket Mode支持群组/私信Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
| **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) |
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai)
\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
## ⚙️ 配置详解
@ -448,7 +341,6 @@ PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md`
- Check my email for important messages
- Review my calendar for upcoming events
- Check the weather forecast
```
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
@ -461,22 +353,23 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
# Periodic Tasks
## Quick Tasks (respond directly)
- Report current time
## Long Tasks (use spawn for async)
- Search the web for AI news and summarize
- Check email and report important messages
```
**关键行为:**
| 特性 | 描述 |
| --- | --- |
| **spawn** | 创建异步子 Agent不阻塞主心跳进程 |
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
| 特性 | 描述 |
| ---------------- | ---------------------------------------- |
| **spawn** | 创建异步子 Agent不阻塞主心跳进程 |
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
#### 子 Agent 通信原理
@ -506,40 +399,234 @@ Agent 读取 HEARTBEAT.md
"interval": 30
}
}
```
| 选项 | 默认值 | 描述 |
| --- | --- | --- |
| `enabled` | `true` | 启用/禁用心跳 |
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
| 选项 | 默认值 | 描述 |
| ---------- | ------ | ---------------------------- |
| `enabled` | `true` | 启用/禁用心跳 |
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
**环境变量:**
* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
### 提供商 (Providers)
> [!NOTE]
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 GroqTelegram 语音消息将被自动转录为文字。
| 提供商 | 用途 | 获取 API Key |
| --- | --- | --- |
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
| 提供商 | 用途 | 获取 API Key |
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
### 模型配置 (model_list)
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider
- **模型回退Fallback**:配置主模型和备用模型,提高可靠性
- **负载均衡**:在多个 API 端点之间分配请求
- **集中化配置**:在一个地方管理所有 provider
#### 📋 所有支持的厂商
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
#### 基础配置示例
```json
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-ant-your-key"
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_key": "your-zhipu-key"
}
],
"agents": {
"defaults": {
"model": "gpt-5.2"
}
}
}
```
#### 各厂商配置示例
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_key": "sk-..."
}
```
**智谱 AI (GLM)**
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_key": "your-key"
}
```
**DeepSeek**
```json
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat",
"api_key": "sk-..."
}
```
**Anthropic (使用 OAuth)**
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"auth_method": "oauth"
}
```
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
**Ollama (本地)**
```json
{
"model_name": "llama3",
"model": "ollama/llama3"
}
```
**自定义代理/API**
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_key": "sk-..."
}
```
#### 负载均衡
为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询:
```json
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
]
}
```
#### 从旧的 `providers` 配置迁移
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
**旧配置(已弃用):**
```json
{
"providers": {
"zhipu": {
"api_key": "your-key",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
}
},
"agents": {
"defaults": {
"provider": "zhipu",
"model": "glm-4.7"
}
}
}
```
**新配置(推荐):**
```json
{
"model_list": [
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"api_key": "your-key"
}
],
"agents": {
"defaults": {
"model": "glm-4.7"
}
}
}
```
详细的迁移指南请参考 [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)。
<details>
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
**1. 获取 API key 和 base URL**
* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
**2. 配置**
@ -558,10 +645,9 @@ Agent 读取 HEARTBEAT.md
"zhipu": {
"api_key": "Your API Key",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
},
},
}
}
}
```
**3. 运行**
@ -622,9 +708,18 @@ picoclaw agent -m "你好"
},
"tools": {
"web": {
"search": {
"api_key": "BSA..."
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
}
},
"cron": {
"exec_timeout_minutes": 5
}
},
"heartbeat": {
@ -632,30 +727,29 @@ picoclaw agent -m "你好"
"interval": 30
}
}
```
</details>
## CLI 命令行参考
| 命令 | 描述 |
| --- | --- |
| `picoclaw onboard` | 初始化配置和工作区 |
| `picoclaw agent -m "..."` | 与 Agent 对话 |
| `picoclaw agent` | 交互式聊天模式 |
| `picoclaw gateway` | 启动网关 (Gateway) |
| `picoclaw status` | 显示状态 |
| `picoclaw cron list` | 列出所有定时任务 |
| `picoclaw cron add ...` | 添加定时任务 |
| 命令 | 描述 |
| ------------------------- | ------------------ |
| `picoclaw onboard` | 初始化配置和工作区 |
| `picoclaw agent -m "..."` | 与 Agent 对话 |
| `picoclaw agent` | 交互式聊天模式 |
| `picoclaw gateway` | 启动网关 (Gateway) |
| `picoclaw status` | 显示状态 |
| `picoclaw cron list` | 列出所有定时任务 |
| `picoclaw cron add ...` | 添加定时任务 |
### 定时任务 / 提醒 (Scheduled Tasks)
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
@ -669,7 +763,7 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
用户群组:
Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
<img src="assets/wechat.png" alt="PicoClaw" width="512">
@ -681,24 +775,27 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
启用网络搜索:
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
2. 添加到 `~/.picoclaw/config.json`:
```json
{
"tools": {
"web": {
"search": {
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
}
}
}
}
```
### 遇到内容过滤错误 (Content Filtering Errors)
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
@ -716,4 +813,5 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |

116
ROADMAP.md Normal file
View file

@ -0,0 +1,116 @@
# 🦐 PicoClaw Roadmap
> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity
---
## 🚀 1. Core Optimization: Extreme Lightweight
*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.*
* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346)
* **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB.
* **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size.
* **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures.
## 🛡️ 2. Security Hardening: Defense in Depth
*Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.*
* **Input Defense & Permission Control**
* **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation.
* **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries.
* **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services).
* **Sandboxing & Isolation**
* **Filesystem Sandbox**: Restrict file R/W operations to specific directories only.
* **Context Isolation**: Prevent data leakage between different user sessions or channels.
* **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs.
* **Authentication & Secrets**
* **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage.
* **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows.
## 🔌 3. Connectivity: Protocol-First Architecture
*Connect every model, reach every platform.*
* **Provider**
* [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)*
* **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference).
* **Online Models**: Continued support for frontier closed-source models.
* **Channel**
* **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
* **Standards**: Support for the **OneBot** protocol.
* [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.
* **Skill Marketplace**
* [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries.
## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI
*Beyond conversation—focusing on action and collaboration.*
* **Operations**
* [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**.
* [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook.
* [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop).
* **Multi-Agent Collaboration**
* [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement
* [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart).
* [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network.
* [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms.
## 📚 5. Developer Experience (DevEx) & Documentation
*Lowering the barrier to entry so anyone can deploy in minutes.*
* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350)
* Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step.
* **Comprehensive Documentation**
* **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android.
* **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels.
* **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations).
## 🤖 6. Engineering: AI-Powered Open Source
*Born from Vibe Coding, we continue to use AI to accelerate development.*
* **AI-Enhanced CI/CD**
* Integrate AI for automated Code Review, Linting, and PR Labeling.
* **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean.
* **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes.
## 🎨 7. Brand & Community
* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design!
* *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes."
---
### 🤝 Call for Contributions
We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together!

BIN
assets/termux.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 147 KiB

181
cmd/picoclaw/cmd_agent.go Normal file
View file

@ -0,0 +1,181 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/chzyer/readline"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
func agentCmd() {
message := ""
sessionKey := "cli:default"
modelOverride := ""
args := os.Args[2:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--debug", "-d":
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
case "-m", "--message":
if i+1 < len(args) {
message = args[i+1]
i++
}
case "-s", "--session":
if i+1 < len(args) {
sessionKey = args[i+1]
i++
}
case "--model", "-model":
if i+1 < len(args) {
modelOverride = args[i+1]
i++
}
}
}
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
}
if modelOverride != "" {
cfg.Agents.Defaults.ModelName = modelOverride
}
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
fmt.Printf("Error creating provider: %v\n", err)
os.Exit(1)
}
// Use the resolved model ID from provider creation
if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
map[string]any{
"tools_count": startupInfo["tools"].(map[string]any)["count"],
"skills_total": startupInfo["skills"].(map[string]any)["total"],
"skills_available": startupInfo["skills"].(map[string]any)["available"],
})
if message != "" {
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("\n%s %s\n", logo, response)
} else {
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
interactiveMode(agentLoop, sessionKey)
}
}
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", logo)
rl, err := readline.NewEx(&readline.Config{
Prompt: prompt,
HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
HistoryLimit: 100,
InterruptPrompt: "^C",
EOFPrompt: "exit",
})
if err != nil {
fmt.Printf("Error initializing readline: %v\n", err)
fmt.Println("Falling back to simple input mode...")
simpleInteractiveMode(agentLoop, sessionKey)
return
}
defer rl.Close()
for {
line, err := rl.Readline()
if err != nil {
if err == readline.ErrInterrupt || err == io.EOF {
fmt.Println("\nGoodbye!")
return
}
fmt.Printf("Error reading input: %v\n", err)
continue
}
input := strings.TrimSpace(line)
if input == "" {
continue
}
if input == "exit" || input == "quit" {
fmt.Println("Goodbye!")
return
}
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("\n%s %s\n\n", logo, response)
}
}
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Printf("%s You: ", logo)
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
fmt.Println("\nGoodbye!")
return
}
fmt.Printf("Error reading input: %v\n", err)
continue
}
input := strings.TrimSpace(line)
if input == "" {
continue
}
if input == "exit" || input == "quit" {
fmt.Println("Goodbye!")
return
}
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("\n%s %s\n\n", logo, response)
}
}

512
cmd/picoclaw/cmd_auth.go Normal file
View file

@ -0,0 +1,512 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
func authCmd() {
if len(os.Args) < 3 {
authHelp()
return
}
switch os.Args[2] {
case "login":
authLoginCmd()
case "logout":
authLogoutCmd()
case "status":
authStatusCmd()
case "models":
authModelsCmd()
default:
fmt.Printf("Unknown auth command: %s\n", os.Args[2])
authHelp()
}
}
func authHelp() {
fmt.Println("\nAuth commands:")
fmt.Println(" login Login via OAuth or paste token")
fmt.Println(" logout Remove stored credentials")
fmt.Println(" status Show current auth status")
fmt.Println(" models List available Antigravity models")
fmt.Println()
fmt.Println("Login options:")
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
fmt.Println(" --device-code Use device code flow (for headless environments)")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" picoclaw auth login --provider openai")
fmt.Println(" picoclaw auth login --provider openai --device-code")
fmt.Println(" picoclaw auth login --provider anthropic")
fmt.Println(" picoclaw auth login --provider google-antigravity")
fmt.Println(" picoclaw auth models")
fmt.Println(" picoclaw auth logout --provider openai")
fmt.Println(" picoclaw auth status")
}
func authLoginCmd() {
provider := ""
useDeviceCode := false
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--provider", "-p":
if i+1 < len(args) {
provider = args[i+1]
i++
}
case "--device-code":
useDeviceCode = true
}
}
if provider == "" {
fmt.Println("Error: --provider is required")
fmt.Println(supportedProvidersMsg)
return
}
switch provider {
case "openai":
authLoginOpenAI(useDeviceCode)
case "anthropic":
authLoginPasteToken(provider)
case "google-antigravity", "antigravity":
authLoginGoogleAntigravity()
default:
fmt.Printf("Unsupported provider: %s\n", provider)
fmt.Println(supportedProvidersMsg)
}
}
func authLoginOpenAI(useDeviceCode bool) {
cfg := auth.OpenAIOAuthConfig()
var cred *auth.AuthCredential
var err error
if useDeviceCode {
cred, err = auth.LoginDeviceCode(cfg)
} else {
cred, err = auth.LoginBrowser(cfg)
}
if err != nil {
fmt.Printf("Login failed: %v\n", err)
os.Exit(1)
}
if err = auth.SetCredential("openai", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
appCfg, err := loadConfig()
if err == nil {
// Update Providers (legacy format)
appCfg.Providers.OpenAI.AuthMethod = "oauth"
// Update or add openai in ModelList
foundOpenAI := false
for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "oauth"
foundOpenAI = true
break
}
}
// If no openai in ModelList, add it
if !foundOpenAI {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
AuthMethod: "oauth",
})
}
// Update default model to use OpenAI
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
}
}
fmt.Println("Login successful!")
if cred.AccountID != "" {
fmt.Printf("Account: %s\n", cred.AccountID)
}
fmt.Println("Default model set to: gpt-5.2")
}
func authLoginGoogleAntigravity() {
cfg := auth.GoogleAntigravityOAuthConfig()
cred, err := auth.LoginBrowser(cfg)
if err != nil {
fmt.Printf("Login failed: %v\n", err)
os.Exit(1)
}
cred.Provider = "google-antigravity"
// Fetch user email from Google userinfo
email, err := fetchGoogleUserEmail(cred.AccessToken)
if err != nil {
fmt.Printf("Warning: could not fetch email: %v\n", err)
} else {
cred.Email = email
fmt.Printf("Email: %s\n", email)
}
// Fetch Cloud Code Assist project ID
projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken)
if err != nil {
fmt.Printf("Warning: could not fetch project ID: %v\n", err)
fmt.Println("You may need Google Cloud Code Assist enabled on your account.")
} else {
cred.ProjectID = projectID
fmt.Printf("Project: %s\n", projectID)
}
if err = auth.SetCredential("google-antigravity", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
appCfg, err := loadConfig()
if err == nil {
// Update Providers (legacy format, for backward compatibility)
appCfg.Providers.Antigravity.AuthMethod = "oauth"
// Update or add antigravity in ModelList
foundAntigravity := false
for i := range appCfg.ModelList {
if isAntigravityModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "oauth"
foundAntigravity = true
break
}
}
// If no antigravity in ModelList, add it
if !foundAntigravity {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: "oauth",
})
}
// Update default model
appCfg.Agents.Defaults.ModelName = "gemini-flash"
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
}
}
fmt.Println("\n✓ Google Antigravity login successful!")
fmt.Println("Default model set to: gemini-flash")
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
}
func fetchGoogleUserEmail(accessToken string) (string, error) {
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo request failed: %s", string(body))
}
var userInfo struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &userInfo); err != nil {
return "", err
}
return userInfo.Email, nil
}
func authLoginPasteToken(provider string) {
cred, err := auth.LoginPasteToken(provider, os.Stdin)
if err != nil {
fmt.Printf("Login failed: %v\n", err)
os.Exit(1)
}
if err = auth.SetCredential(provider, cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
appCfg, err := loadConfig()
if err == nil {
switch provider {
case "anthropic":
appCfg.Providers.Anthropic.AuthMethod = "token"
// Update ModelList
found := false
for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "token"
found = true
break
}
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
AuthMethod: "token",
})
}
// Update default model
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList
found := false
for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "token"
found = true
break
}
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
AuthMethod: "token",
})
}
// Update default model
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
}
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
}
}
fmt.Printf("Token saved for %s!\n", provider)
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
}
func authLogoutCmd() {
provider := ""
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--provider", "-p":
if i+1 < len(args) {
provider = args[i+1]
i++
}
}
}
if provider != "" {
if err := auth.DeleteCredential(provider); err != nil {
fmt.Printf("Failed to remove credentials: %v\n", err)
os.Exit(1)
}
appCfg, err := loadConfig()
if err == nil {
// Clear AuthMethod in ModelList
for i := range appCfg.ModelList {
switch provider {
case "openai":
if isOpenAIModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = ""
}
case "anthropic":
if isAnthropicModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = ""
}
case "google-antigravity", "antigravity":
if isAntigravityModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = ""
}
}
}
// Clear AuthMethod in Providers (legacy)
switch provider {
case "openai":
appCfg.Providers.OpenAI.AuthMethod = ""
case "anthropic":
appCfg.Providers.Anthropic.AuthMethod = ""
case "google-antigravity", "antigravity":
appCfg.Providers.Antigravity.AuthMethod = ""
}
config.SaveConfig(getConfigPath(), appCfg)
}
fmt.Printf("Logged out from %s\n", provider)
} else {
if err := auth.DeleteAllCredentials(); err != nil {
fmt.Printf("Failed to remove credentials: %v\n", err)
os.Exit(1)
}
appCfg, err := loadConfig()
if err == nil {
// Clear all AuthMethods in ModelList
for i := range appCfg.ModelList {
appCfg.ModelList[i].AuthMethod = ""
}
// Clear all AuthMethods in Providers (legacy)
appCfg.Providers.OpenAI.AuthMethod = ""
appCfg.Providers.Anthropic.AuthMethod = ""
appCfg.Providers.Antigravity.AuthMethod = ""
config.SaveConfig(getConfigPath(), appCfg)
}
fmt.Println("Logged out from all providers")
}
}
func authStatusCmd() {
store, err := auth.LoadStore()
if err != nil {
fmt.Printf("Error loading auth store: %v\n", err)
return
}
if len(store.Credentials) == 0 {
fmt.Println("No authenticated providers.")
fmt.Println("Run: picoclaw auth login --provider <name>")
return
}
fmt.Println("\nAuthenticated Providers:")
fmt.Println("------------------------")
for provider, cred := range store.Credentials {
status := "active"
if cred.IsExpired() {
status = "expired"
} else if cred.NeedsRefresh() {
status = "needs refresh"
}
fmt.Printf(" %s:\n", provider)
fmt.Printf(" Method: %s\n", cred.AuthMethod)
fmt.Printf(" Status: %s\n", status)
if cred.AccountID != "" {
fmt.Printf(" Account: %s\n", cred.AccountID)
}
if cred.Email != "" {
fmt.Printf(" Email: %s\n", cred.Email)
}
if cred.ProjectID != "" {
fmt.Printf(" Project: %s\n", cred.ProjectID)
}
if !cred.ExpiresAt.IsZero() {
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
}
}
}
func authModelsCmd() {
cred, err := auth.GetCredential("google-antigravity")
if err != nil || cred == nil {
fmt.Println("Not logged in to Google Antigravity.")
fmt.Println("Run: picoclaw auth login --provider google-antigravity")
return
}
// Refresh token if needed
if cred.NeedsRefresh() && cred.RefreshToken != "" {
oauthCfg := auth.GoogleAntigravityOAuthConfig()
refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg)
if refreshErr == nil {
cred = refreshed
_ = auth.SetCredential("google-antigravity", cred)
}
}
projectID := cred.ProjectID
if projectID == "" {
fmt.Println("No project ID stored. Try logging in again.")
return
}
fmt.Printf("Fetching models for project: %s\n\n", projectID)
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
if err != nil {
fmt.Printf("Error fetching models: %v\n", err)
return
}
if len(models) == 0 {
fmt.Println("No models available.")
return
}
fmt.Println("Available Antigravity Models:")
fmt.Println("-----------------------------")
for _, m := range models {
status := "✓"
if m.IsExhausted {
status = "✗ (quota exhausted)"
}
name := m.ID
if m.DisplayName != "" {
name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName)
}
fmt.Printf(" %s %s\n", status, name)
}
}
// isAntigravityModel checks if a model string belongs to antigravity provider
func isAntigravityModel(model string) bool {
return model == "antigravity" ||
model == "google-antigravity" ||
strings.HasPrefix(model, "antigravity/") ||
strings.HasPrefix(model, "google-antigravity/")
}
// isOpenAIModel checks if a model string belongs to openai provider
func isOpenAIModel(model string) bool {
return model == "openai" ||
strings.HasPrefix(model, "openai/")
}
// isAnthropicModel checks if a model string belongs to anthropic provider
func isAnthropicModel(model string) bool {
return model == "anthropic" ||
strings.HasPrefix(model, "anthropic/")
}

227
cmd/picoclaw/cmd_cron.go Normal file
View file

@ -0,0 +1,227 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/sipeed/picoclaw/pkg/cron"
)
func cronCmd() {
if len(os.Args) < 3 {
cronHelp()
return
}
subcommand := os.Args[2]
// Load config to get workspace path
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
switch subcommand {
case "list":
cronListCmd(cronStorePath)
case "add":
cronAddCmd(cronStorePath)
case "remove":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw cron remove <job_id>")
return
}
cronRemoveCmd(cronStorePath, os.Args[3])
case "enable":
cronEnableCmd(cronStorePath, false)
case "disable":
cronEnableCmd(cronStorePath, true)
default:
fmt.Printf("Unknown cron command: %s\n", subcommand)
cronHelp()
}
}
func cronHelp() {
fmt.Println("\nCron commands:")
fmt.Println(" list List all scheduled jobs")
fmt.Println(" add Add a new scheduled job")
fmt.Println(" remove <id> Remove a job by ID")
fmt.Println(" enable <id> Enable a job")
fmt.Println(" disable <id> Disable a job")
fmt.Println()
fmt.Println("Add options:")
fmt.Println(" -n, --name Job name")
fmt.Println(" -m, --message Message for agent")
fmt.Println(" -e, --every Run every N seconds")
fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
fmt.Println(" -d, --deliver Deliver response to channel")
fmt.Println(" --to Recipient for delivery")
fmt.Println(" --channel Channel for delivery")
}
func cronListCmd(storePath string) {
cs := cron.NewCronService(storePath, nil)
jobs := cs.ListJobs(true) // Show all jobs, including disabled
if len(jobs) == 0 {
fmt.Println("No scheduled jobs.")
return
}
fmt.Println("\nScheduled Jobs:")
fmt.Println("----------------")
for _, job := range jobs {
var schedule string
if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
} else if job.Schedule.Kind == "cron" {
schedule = job.Schedule.Expr
} else {
schedule = "one-time"
}
nextRun := "scheduled"
if job.State.NextRunAtMS != nil {
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
nextRun = nextTime.Format("2006-01-02 15:04")
}
status := "enabled"
if !job.Enabled {
status = "disabled"
}
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
fmt.Printf(" Schedule: %s\n", schedule)
fmt.Printf(" Status: %s\n", status)
fmt.Printf(" Next run: %s\n", nextRun)
}
}
func cronAddCmd(storePath string) {
name := ""
message := ""
var everySec *int64
cronExpr := ""
deliver := false
channel := ""
to := ""
args := os.Args[3:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "-n", "--name":
if i+1 < len(args) {
name = args[i+1]
i++
}
case "-m", "--message":
if i+1 < len(args) {
message = args[i+1]
i++
}
case "-e", "--every":
if i+1 < len(args) {
var sec int64
fmt.Sscanf(args[i+1], "%d", &sec)
everySec = &sec
i++
}
case "-c", "--cron":
if i+1 < len(args) {
cronExpr = args[i+1]
i++
}
case "-d", "--deliver":
deliver = true
case "--to":
if i+1 < len(args) {
to = args[i+1]
i++
}
case "--channel":
if i+1 < len(args) {
channel = args[i+1]
i++
}
}
}
if name == "" {
fmt.Println("Error: --name is required")
return
}
if message == "" {
fmt.Println("Error: --message is required")
return
}
if everySec == nil && cronExpr == "" {
fmt.Println("Error: Either --every or --cron must be specified")
return
}
var schedule cron.CronSchedule
if everySec != nil {
everyMS := *everySec * 1000
schedule = cron.CronSchedule{
Kind: "every",
EveryMS: &everyMS,
}
} else {
schedule = cron.CronSchedule{
Kind: "cron",
Expr: cronExpr,
}
}
cs := cron.NewCronService(storePath, nil)
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
if err != nil {
fmt.Printf("Error adding job: %v\n", err)
return
}
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
}
func cronRemoveCmd(storePath, jobID string) {
cs := cron.NewCronService(storePath, nil)
if cs.RemoveJob(jobID) {
fmt.Printf("✓ Removed job %s\n", jobID)
} else {
fmt.Printf("✗ Job %s not found\n", jobID)
}
}
func cronEnableCmd(storePath string, disable bool) {
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw cron enable/disable <job_id>")
return
}
jobID := os.Args[3]
cs := cron.NewCronService(storePath, nil)
enabled := !disable
job := cs.EnableJob(jobID, enabled)
if job != nil {
status := "enabled"
if disable {
status = "disabled"
}
fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
} else {
fmt.Printf("✗ Job %s not found\n", jobID)
}
}

248
cmd/picoclaw/cmd_gateway.go Normal file
View file

@ -0,0 +1,248 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice"
)
func gatewayCmd() {
// Check for --debug flag
args := os.Args[2:]
for _, arg := range args {
if arg == "--debug" || arg == "-d" {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
break
}
}
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
}
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
fmt.Printf("Error creating provider: %v\n", err)
os.Exit(1)
}
// Use the resolved model ID from provider creation
if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info
fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]any)
skillsInfo := startupInfo["skills"].(map[string]any)
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" • Skills: %d/%d available\n",
skillsInfo["available"],
skillsInfo["total"])
// Log to file as well
logger.InfoCF("agent", "Agent initialized",
map[string]any{
"tools_count": toolsInfo["count"],
"skills_total": skillsInfo["total"],
"skills_available": skillsInfo["available"],
})
// Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
cronService := setupCronTool(
agentLoop,
msgBus,
cfg.WorkspacePath(),
cfg.Agents.Defaults.RestrictToWorkspace,
execTimeout,
cfg,
)
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled,
)
heartbeatService.SetBus(msgBus)
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
// Use cli:direct as fallback if no valid channel
if channel == "" || chatID == "" {
channel, chatID = "cli", "direct"
}
// Use ProcessHeartbeat - no session history, each heartbeat is independent
var response string
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
}
if response == "HEARTBEAT_OK" {
return tools.SilentResult("Heartbeat OK")
}
// For heartbeat, always return silent - the subagent result will be
// sent to user via processSystemMessage when the async task completes
return tools.SilentResult(response)
})
channelManager, err := channels.NewManager(cfg, msgBus)
if err != nil {
fmt.Printf("Error creating channel manager: %v\n", err)
os.Exit(1)
}
// Inject channel manager into agent loop for command handling
agentLoop.SetChannelManager(channelManager)
var transcriber *voice.GroqTranscriber
groqAPIKey := cfg.Providers.Groq.APIKey
if groqAPIKey == "" {
for _, mc := range cfg.ModelList {
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
groqAPIKey = mc.APIKey
break
}
}
}
if groqAPIKey != "" {
transcriber = voice.NewGroqTranscriber(groqAPIKey)
logger.InfoC("voice", "Groq voice transcription enabled")
}
if transcriber != nil {
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
tc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
}
}
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
dc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Discord channel")
}
}
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
sc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Slack channel")
}
}
}
enabledChannels := channelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
} else {
fmt.Println("⚠ Warning: No channels enabled")
}
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := cronService.Start(); err != nil {
fmt.Printf("Error starting cron service: %v\n", err)
}
fmt.Println("✓ Cron service started")
if err := heartbeatService.Start(); err != nil {
fmt.Printf("Error starting heartbeat service: %v\n", err)
}
fmt.Println("✓ Heartbeat service started")
stateManager := state.NewManager(cfg.WorkspacePath())
deviceService := devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager)
deviceService.SetBus(msgBus)
if err := deviceService.Start(ctx); err != nil {
fmt.Printf("Error starting device service: %v\n", err)
} else if cfg.Devices.Enabled {
fmt.Println("✓ Device event service started")
}
if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
}
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
go func() {
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
}
}()
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
go agentLoop.Run(ctx)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
<-sigChan
fmt.Println("\nShutting down...")
cancel()
healthServer.Stop(context.Background())
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
agentLoop.Stop()
channelManager.StopAll(ctx)
fmt.Println("✓ Gateway stopped")
}
func setupCronTool(
agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus,
workspace string,
restrict bool,
execTimeout time.Duration,
cfg *config.Config,
) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
agentLoop.RegisterTool(cronTool)
// Set the onJob handler
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
result := cronTool.ExecuteJob(context.Background(), job)
return result, nil
})
return cronService
}

View file

@ -0,0 +1,81 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"fmt"
"os"
"github.com/sipeed/picoclaw/pkg/migrate"
)
func migrateCmd() {
if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
migrateHelp()
return
}
opts := migrate.Options{}
args := os.Args[2:]
for i := 0; i < len(args); i++ {
switch args[i] {
case "--dry-run":
opts.DryRun = true
case "--config-only":
opts.ConfigOnly = true
case "--workspace-only":
opts.WorkspaceOnly = true
case "--force":
opts.Force = true
case "--refresh":
opts.Refresh = true
case "--openclaw-home":
if i+1 < len(args) {
opts.OpenClawHome = args[i+1]
i++
}
case "--picoclaw-home":
if i+1 < len(args) {
opts.PicoClawHome = args[i+1]
i++
}
default:
fmt.Printf("Unknown flag: %s\n", args[i])
migrateHelp()
os.Exit(1)
}
}
result, err := migrate.Run(opts)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
if !opts.DryRun {
migrate.PrintSummary(result)
}
}
func migrateHelp() {
fmt.Println("\nMigrate from OpenClaw to PicoClaw")
fmt.Println()
fmt.Println("Usage: picoclaw migrate [options]")
fmt.Println()
fmt.Println("Options:")
fmt.Println(" --dry-run Show what would be migrated without making changes")
fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
fmt.Println(" --config-only Only migrate config, skip workspace files")
fmt.Println(" --workspace-only Only migrate workspace files, skip config")
fmt.Println(" --force Skip confirmation prompts")
fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
fmt.Println(" picoclaw migrate --force Migrate without confirmation")
}

108
cmd/picoclaw/cmd_onboard.go Normal file
View file

@ -0,0 +1,108 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/sipeed/picoclaw/pkg/config"
)
//go:generate cp -r ../../workspace .
//go:embed workspace
var embeddedFiles embed.FS
func onboard() {
configPath := getConfigPath()
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Config already exists at %s\n", configPath)
fmt.Print("Overwrite? (y/n): ")
var response string
fmt.Scanln(&response)
if response != "y" {
fmt.Println("Aborted.")
return
}
}
cfg := config.DefaultConfig()
if err := config.SaveConfig(configPath, cfg); err != nil {
fmt.Printf("Error saving config: %v\n", err)
os.Exit(1)
}
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
fmt.Printf("%s picoclaw is ready!\n", logo)
fmt.Println("\nNext steps:")
fmt.Println(" 1. Add your API key to", configPath)
fmt.Println("")
fmt.Println(" Recommended:")
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
fmt.Println(" - Ollama: https://ollama.com (local, free)")
fmt.Println("")
fmt.Println(" See README.md for 17+ supported providers.")
fmt.Println("")
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("Failed to create target directory: %w", err)
}
// Walk through all files in embed.FS
err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip directories
if d.IsDir() {
return nil
}
// Read embedded file
data, err := embeddedFiles.ReadFile(path)
if err != nil {
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
}
new_path, err := filepath.Rel("workspace", path)
if err != nil {
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
}
// Build target file path
targetPath := filepath.Join(targetDir, new_path)
// Ensure target file's directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
}
// Write file
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
}
return nil
})
return err
}
func createWorkspaceTemplates(workspace string) {
err := copyEmbeddedToTarget(workspace)
if err != nil {
fmt.Printf("Error copying workspace templates: %v\n", err)
}
}

305
cmd/picoclaw/cmd_skills.go Normal file
View file

@ -0,0 +1,305 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
)
func skillsHelp() {
fmt.Println("\nSkills commands:")
fmt.Println(" list List installed skills")
fmt.Println(" install <repo> Install skill from GitHub")
fmt.Println(" install-builtin Install all builtin skills to workspace")
fmt.Println(" list-builtin List available builtin skills")
fmt.Println(" remove <name> Remove installed skill")
fmt.Println(" search Search available skills")
fmt.Println(" show <name> Show skill details")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" picoclaw skills list")
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
fmt.Println(" picoclaw skills install-builtin")
fmt.Println(" picoclaw skills list-builtin")
fmt.Println(" picoclaw skills remove weather")
fmt.Println(" picoclaw skills install --registry clawhub github")
}
func skillsListCmd(loader *skills.SkillsLoader) {
allSkills := loader.ListSkills()
if len(allSkills) == 0 {
fmt.Println("No skills installed.")
return
}
fmt.Println("\nInstalled Skills:")
fmt.Println("------------------")
for _, skill := range allSkills {
fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
if skill.Description != "" {
fmt.Printf(" %s\n", skill.Description)
}
}
}
func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills install <github-repo>")
fmt.Println(" picoclaw skills install --registry <name> <slug>")
return
}
// Check for --registry flag.
if os.Args[3] == "--registry" {
if len(os.Args) < 6 {
fmt.Println("Usage: picoclaw skills install --registry <name> <slug>")
fmt.Println("Example: picoclaw skills install --registry clawhub github")
return
}
registryName := os.Args[4]
slug := os.Args[5]
skillsInstallFromRegistry(cfg, registryName, slug)
return
}
// Default: install from GitHub (backward compatible).
repo := os.Args[3]
fmt.Printf("Installing skill from %s...\n", repo)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
os.Exit(1)
}
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
}
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
err := utils.ValidateSkillIdentifier(registryName)
if err != nil {
fmt.Printf("\u2717 Invalid registry name: %v\n", err)
os.Exit(1)
}
err = utils.ValidateSkillIdentifier(slug)
if err != nil {
fmt.Printf("\u2717 Invalid slug: %v\n", err)
os.Exit(1)
}
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
})
registry := registryMgr.GetRegistry(registryName)
if registry == nil {
fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
os.Exit(1)
}
workspace := cfg.WorkspacePath()
targetDir := filepath.Join(workspace, "skills", slug)
if _, err = os.Stat(targetDir); err == nil {
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
os.Exit(1)
}
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
if err != nil {
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
}
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
os.Exit(1)
}
if result.IsMalwareBlocked {
rmErr := os.RemoveAll(targetDir)
if rmErr != nil {
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
}
fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
os.Exit(1)
}
if result.IsSuspicious {
fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
}
fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
if result.Summary != "" {
fmt.Printf(" %s\n", result.Summary)
}
}
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
fmt.Printf("Removing skill '%s'...\n", skillName)
if err := installer.Uninstall(skillName); err != nil {
fmt.Printf("✗ Failed to remove skill: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
}
func skillsInstallBuiltinCmd(workspace string) {
builtinSkillsDir := "./picoclaw/skills"
workspaceSkillsDir := filepath.Join(workspace, "skills")
fmt.Printf("Copying builtin skills to workspace...\n")
skillsToInstall := []string{
"weather",
"news",
"stock",
"calculator",
}
for _, skillName := range skillsToInstall {
builtinPath := filepath.Join(builtinSkillsDir, skillName)
workspacePath := filepath.Join(workspaceSkillsDir, skillName)
if _, err := os.Stat(builtinPath); err != nil {
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
continue
}
if err := os.MkdirAll(workspacePath, 0o755); err != nil {
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
continue
}
if err := copyDirectory(builtinPath, workspacePath); err != nil {
fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
}
}
fmt.Println("\n✓ All builtin skills installed!")
fmt.Println("Now you can use them in your workspace.")
}
func skillsListBuiltinCmd() {
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
fmt.Println("\nAvailable Builtin Skills:")
fmt.Println("-----------------------")
entries, err := os.ReadDir(builtinSkillsDir)
if err != nil {
fmt.Printf("Error reading builtin skills: %v\n", err)
return
}
if len(entries) == 0 {
fmt.Println("No builtin skills available.")
return
}
for _, entry := range entries {
if entry.IsDir() {
skillName := entry.Name()
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
description := "No description"
if _, err := os.Stat(skillFile); err == nil {
data, err := os.ReadFile(skillFile)
if err == nil {
content := string(data)
if idx := strings.Index(content, "\n"); idx > 0 {
firstLine := content[:idx]
if strings.Contains(firstLine, "description:") {
descLine := strings.Index(content[idx:], "\n")
if descLine > 0 {
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
}
}
}
}
}
status := "✓"
fmt.Printf(" %s %s\n", status, entry.Name())
if description != "" {
fmt.Printf(" %s\n", description)
}
}
}
}
func skillsSearchCmd(installer *skills.SkillInstaller) {
fmt.Println("Searching for available skills...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
availableSkills, err := installer.ListAvailableSkills(ctx)
if err != nil {
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
return
}
if len(availableSkills) == 0 {
fmt.Println("No skills available.")
return
}
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
fmt.Println("--------------------")
for _, skill := range availableSkills {
fmt.Printf(" 📦 %s\n", skill.Name)
fmt.Printf(" %s\n", skill.Description)
fmt.Printf(" Repo: %s\n", skill.Repository)
if skill.Author != "" {
fmt.Printf(" Author: %s\n", skill.Author)
}
if len(skill.Tags) > 0 {
fmt.Printf(" Tags: %v\n", skill.Tags)
}
fmt.Println()
}
}
func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
content, ok := loader.LoadSkill(skillName)
if !ok {
fmt.Printf("✗ Skill '%s' not found\n", skillName)
return
}
fmt.Printf("\n📦 Skill: %s\n", skillName)
fmt.Println("----------------------")
fmt.Println(content)
}

102
cmd/picoclaw/cmd_status.go Normal file
View file

@ -0,0 +1,102 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"fmt"
"os"
"github.com/sipeed/picoclaw/pkg/auth"
)
func statusCmd() {
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
configPath := getConfigPath()
fmt.Printf("%s picoclaw Status\n", logo)
fmt.Printf("Version: %s\n", formatVersion())
build, _ := formatBuildInfo()
if build != "" {
fmt.Printf("Build: %s\n", build)
}
fmt.Println()
if _, err := os.Stat(configPath); err == nil {
fmt.Println("Config:", configPath, "✓")
} else {
fmt.Println("Config:", configPath, "✗")
}
workspace := cfg.WorkspacePath()
if _, err := os.Stat(workspace); err == nil {
fmt.Println("Workspace:", workspace, "✓")
} else {
fmt.Println("Workspace:", workspace, "✗")
}
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
hasGemini := cfg.Providers.Gemini.APIKey != ""
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
hasQwen := cfg.Providers.Qwen.APIKey != ""
hasGroq := cfg.Providers.Groq.APIKey != ""
hasVLLM := cfg.Providers.VLLM.APIBase != ""
hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
hasNvidia := cfg.Providers.Nvidia.APIKey != ""
hasOllama := cfg.Providers.Ollama.APIBase != ""
status := func(enabled bool) string {
if enabled {
return "✓"
}
return "not set"
}
fmt.Println("OpenRouter API:", status(hasOpenRouter))
fmt.Println("Anthropic API:", status(hasAnthropic))
fmt.Println("OpenAI API:", status(hasOpenAI))
fmt.Println("Gemini API:", status(hasGemini))
fmt.Println("Zhipu API:", status(hasZhipu))
fmt.Println("Qwen API:", status(hasQwen))
fmt.Println("Groq API:", status(hasGroq))
fmt.Println("Moonshot API:", status(hasMoonshot))
fmt.Println("DeepSeek API:", status(hasDeepSeek))
fmt.Println("VolcEngine API:", status(hasVolcEngine))
fmt.Println("Nvidia API:", status(hasNvidia))
if hasVLLM {
fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
} else {
fmt.Println("vLLM/Local: not set")
}
if hasOllama {
fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
} else {
fmt.Println("Ollama: not set")
}
store, _ := auth.LoadStore()
if store != nil && len(store.Credentials) > 0 {
fmt.Println("\nOAuth/Token Auth:")
for provider, cred := range store.Credentials {
status := "authenticated"
if cred.IsExpired() {
status = "expired"
} else if cred.NeedsRefresh() {
status = "needs refresh"
}
fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,22 +3,67 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model": "glm-4.7",
"model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-ant-your-key",
"api_base": "https://api.anthropic.com/v1"
},
{
"model_name": "gemini",
"model": "antigravity/gemini-2.0-flash",
"auth_method": "oauth"
},
{
"model_name": "deepseek",
"model": "deepseek/deepseek-chat",
"api_key": "sk-your-deepseek-key"
},
{
"model_name": "loadbalanced-gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-key1",
"api_base": "https://api1.example.com/v1"
},
{
"model_name": "loadbalanced-gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
}
],
"channels": {
"telegram": {
"enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"proxy": "",
"allow_from": ["YOUR_USER_ID"]
"allow_from": [
"YOUR_USER_ID"
]
},
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
"allow_from": [],
"mention_only": false
},
"qq": {
"enabled": false,
"app_id": "YOUR_QQ_APP_ID",
"app_secret": "YOUR_QQ_APP_SECRET",
"allow_from": []
},
"maixcam": {
@ -68,16 +113,44 @@
"reconnect_interval": 5,
"group_trigger_prefix": [],
"allow_from": []
},
"wecom": {
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom",
"allow_from": [],
"reply_timeout": 5
},
"wecom_app": {
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
"enabled": false,
"corp_id": "YOUR_CORP_ID",
"corp_secret": "YOUR_CORP_SECRET",
"agent_id": 1000002,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5
}
},
"providers": {
"_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version",
"anthropic": {
"api_key": "",
"api_base": ""
},
"openai": {
"api_key": "",
"api_base": ""
"api_base": "",
"web_search": true
},
"openrouter": {
"api_key": "sk-or-v1-xxx",
@ -107,13 +180,61 @@
"moonshot": {
"api_key": "sk-xxx",
"api_base": ""
},
"qwen": {
"api_key": "sk-xxx",
"api_base": ""
},
"ollama": {
"api_key": "",
"api_base": "http://localhost:11434/v1"
},
"cerebras": {
"api_key": "",
"api_base": ""
},
"volcengine": {
"api_key": "",
"api_base": ""
},
"mistral": {
"api_key": "",
"api_base": "https://api.mistral.ai/v1"
}
},
"tools": {
"web": {
"search": {
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
},
"perplexity": {
"enabled": false,
"api_key": "pplx-xxx",
"max_results": 5
}
},
"cron": {
"exec_timeout_minutes": 5
},
"exec": {
"enable_deny_patterns": false,
"custom_deny_patterns": []
},
"skills": {
"registries": {
"clawhub": {
"enabled": true,
"base_url": "https://clawhub.ai",
"search_path": "/api/v1/search",
"skills_path": "/api/v1/skills",
"download_path": "/api/v1/download"
}
}
}
},
@ -126,7 +247,7 @@
"monitor_usb": true
},
"gateway": {
"host": "0.0.0.0",
"host": "127.0.0.1",
"port": 18790
}
}

View file

@ -10,9 +10,12 @@ services:
container_name: picoclaw-agent
profiles:
- agent
# Uncomment to access host network; leave commented unless needed.
#extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
- ./config/config.json:/root/.picoclaw/config.json:ro
- picoclaw-workspace:/root/.picoclaw/workspace
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
entrypoint: ["picoclaw", "agent"]
stdin_open: true
tty: true
@ -29,11 +32,14 @@ services:
restart: unless-stopped
profiles:
- gateway
# Uncomment to access host network; leave commented unless needed.
#extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
# Configuration file
- ./config/config.json:/root/.picoclaw/config.json:ro
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
# Persistent workspace (sessions, memory, logs)
- picoclaw-workspace:/root/.picoclaw/workspace
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
command: ["gateway"]
volumes:

807
docs/ANTIGRAVITY_AUTH.md Normal file
View file

@ -0,0 +1,807 @@
# Antigravity Authentication & Integration Guide
## Overview
**Antigravity** (Google Cloud Code Assist) is a Google-backed AI model provider that offers access to models like Claude Opus 4.6 and Gemini through Google's Cloud infrastructure. This document provides a complete guide on how authentication works, how to fetch models, and how to implement a new provider in PicoClaw.
---
## Table of Contents
1. [Authentication Flow](#authentication-flow)
2. [OAuth Implementation Details](#oauth-implementation-details)
3. [Token Management](#token-management)
4. [Models List Fetching](#models-list-fetching)
5. [Usage Tracking](#usage-tracking)
6. [Provider Plugin Structure](#provider-plugin-structure)
7. [Integration Requirements](#integration-requirements)
8. [API Endpoints](#api-endpoints)
9. [Configuration](#configuration)
10. [Creating a New Provider in PicoClaw](#creating-a-new-provider-in-picoclaw)
---
## Authentication Flow
### 1. OAuth 2.0 with PKCE
Antigravity uses **OAuth 2.0 with PKCE (Proof Key for Code Exchange)** for secure authentication:
```
┌─────────────┐ ┌─────────────────┐
│ Client │ ───(1) Generate PKCE Pair────────> │ │
│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │
│ │ │ Server │
│ │ <──(3) Redirect with Code───────── │ │
│ │ └─────────────────┘
│ │ ───(4) Exchange Code for Tokens──> │ Token URL │
│ │ │ │
│ │ <──(5) Access + Refresh Tokens──── │ │
└─────────────┘ └─────────────────┘
```
### 2. Detailed Steps
#### Step 1: Generate PKCE Parameters
```typescript
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
```
#### Step 2: Build Authorization URL
```typescript
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
function buildAuthUrl(params: { challenge: string; state: string }): string {
const url = new URL(AUTH_URL);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("response_type", "code");
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", SCOPES.join(" "));
url.searchParams.set("code_challenge", params.challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", params.state);
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");
return url.toString();
}
```
**Required Scopes:**
```typescript
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
];
```
#### Step 3: Handle OAuth Callback
**Automatic Mode (Local Development):**
- Start a local HTTP server on port 51121
- Wait for the redirect from Google
- Extract the authorization code from the query parameters
**Manual Mode (Remote/Headless):**
- Display the authorization URL to the user
- User completes authentication in their browser
- User pastes the full redirect URL back into the terminal
- Parse the code from the pasted URL
#### Step 4: Exchange Code for Tokens
```typescript
const TOKEN_URL = "https://oauth2.googleapis.com/token";
async function exchangeCode(params: {
code: string;
verifier: string;
}): Promise<{ access: string; refresh: string; expires: number }> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code: params.code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: params.verifier,
}),
});
const data = await response.json();
return {
access: data.access_token,
refresh: data.refresh_token,
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer
};
}
```
#### Step 5: Fetch Additional User Data
**User Email:**
```typescript
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
const response = await fetch(
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const data = await response.json();
return data.email;
}
```
**Project ID (Required for API calls):**
```typescript
async function fetchProjectId(accessToken: string): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
const response = await fetch(
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
{
method: "POST",
headers,
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
}
);
const data = await response.json();
return data.cloudaicompanionProject || "rising-fact-p41fc"; // Default fallback
}
```
---
## OAuth Implementation Details
### Client Credentials
**Important:** These are base64-encoded in the source code for sync with pi-ai:
```typescript
const decode = (s: string) => Buffer.from(s, "base64").toString();
const CLIENT_ID = decode(
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="
);
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
```
### OAuth Flow Modes
1. **Automatic Flow** (Local machines with browser):
- Opens browser automatically
- Local callback server captures redirect
- No user interaction required after initial auth
2. **Manual Flow** (Remote/headless/WSL2):
- URL displayed for manual copy-paste
- User completes auth in external browser
- User pastes full redirect URL back
```typescript
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2Sync();
}
```
---
## Token Management
### Auth Profile Structure
```typescript
type OAuthCredential = {
type: "oauth";
provider: "google-antigravity";
access: string; // Access token
refresh: string; // Refresh token
expires: number; // Expiration timestamp (ms since epoch)
email?: string; // User email
projectId?: string; // Google Cloud project ID
};
```
### Token Refresh
The credential includes a refresh token that can be used to obtain new access tokens when the current one expires. The expiration is set with a 5-minute buffer to prevent race conditions.
---
## Models List Fetching
### Fetch Available Models
```typescript
const BASE_URL = "https://cloudcode-pa.googleapis.com";
async function fetchAvailableModels(
accessToken: string,
projectId: string
): Promise<Model[]> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "antigravity",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
};
const response = await fetch(
`${BASE_URL}/v1internal:fetchAvailableModels`,
{
method: "POST",
headers,
body: JSON.stringify({ project: projectId }),
}
);
const data = await response.json();
// Returns models with quota information
return Object.entries(data.models).map(([modelId, modelInfo]) => ({
id: modelId,
displayName: modelInfo.displayName,
quotaInfo: {
remainingFraction: modelInfo.quotaInfo?.remainingFraction,
resetTime: modelInfo.quotaInfo?.resetTime,
isExhausted: modelInfo.quotaInfo?.isExhausted,
},
}));
}
```
### Response Format
```typescript
type FetchAvailableModelsResponse = {
models?: Record<string, {
displayName?: string;
quotaInfo?: {
remainingFraction?: number | string;
resetTime?: string; // ISO 8601 timestamp
isExhausted?: boolean;
};
}>;
};
```
---
## Usage Tracking
### Fetch Usage Data
```typescript
export async function fetchAntigravityUsage(
token: string,
timeoutMs: number
): Promise<ProviderUsageSnapshot> {
// 1. Fetch credits and plan info
const loadCodeAssistRes = await fetch(
`${BASE_URL}/v1internal:loadCodeAssist`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: {
ideType: "ANTIGRAVITY",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
}
);
// Extract credits info
const { availablePromptCredits, planInfo, currentTier } = data;
// 2. Fetch model quotas
const modelsRes = await fetch(
`${BASE_URL}/v1internal:fetchAvailableModels`,
{
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ project: projectId }),
}
);
// Build usage windows
return {
provider: "google-antigravity",
displayName: "Google Antigravity",
windows: [
{ label: "Credits", usedPercent: calculateUsedPercent(available, monthly) },
// Individual model quotas...
],
plan: currentTier?.name || planType,
};
}
```
### Usage Response Structure
```typescript
type ProviderUsageSnapshot = {
provider: "google-antigravity";
displayName: string;
windows: UsageWindow[];
plan?: string;
error?: string;
};
type UsageWindow = {
label: string; // "Credits" or model ID
usedPercent: number; // 0-100
resetAt?: number; // Timestamp when quota resets
};
```
---
## Provider Plugin Structure
### Plugin Definition
```typescript
const antigravityPlugin = {
id: "google-antigravity-auth",
name: "Google Antigravity Auth",
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
configSchema: emptyPluginConfigSchema(),
register(api: PicoClawPluginApi) {
api.registerProvider({
id: "google-antigravity",
label: "Google Antigravity",
docsPath: "/providers/models",
aliases: ["antigravity"],
auth: [
{
id: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx: ProviderAuthContext) => {
// OAuth implementation here
},
},
],
});
},
};
```
### ProviderAuthContext
```typescript
type ProviderAuthContext = {
config: PicoClawConfig;
agentDir?: string;
workspaceDir?: string;
prompter: WizardPrompter; // UI prompts/notifications
runtime: RuntimeEnv; // Logging, etc.
isRemote: boolean; // Whether running remotely
openUrl: (url: string) => Promise<void>; // Browser opener
oauth: {
createVpsAwareHandlers: Function;
};
};
```
### ProviderAuthResult
```typescript
type ProviderAuthResult = {
profiles: Array<{
profileId: string;
credential: AuthProfileCredential;
}>;
configPatch?: Partial<PicoClawConfig>;
defaultModel?: string;
notes?: string[];
};
```
---
## Integration Requirements
### 1. Required Environment/Dependencies
- Go ≥ 1.21
- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
- `crypto` and `net/http` standard library packages
### 2. Required Headers for API Calls
```typescript
const REQUIRED_HEADERS = {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "antigravity", // or "google-api-nodejs-client/9.15.1"
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
};
// For loadCodeAssist calls, also include:
const CLIENT_METADATA = {
ideType: "ANTIGRAVITY", // or "IDE_UNSPECIFIED"
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
};
```
### 3. Model Schema Sanitization
Antigravity uses Gemini-compatible models, so tool schemas must be sanitized:
```typescript
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"examples",
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
// Clean schema before sending
function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
// Remove unsupported keywords
// Ensure top-level has type: "object"
// Flatten anyOf/oneOf unions
}
```
### 4. Thinking Block Handling (Claude Models)
For Antigravity Claude models, thinking blocks require special handling:
```typescript
const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
export function sanitizeAntigravityThinkingBlocks(
messages: AgentMessage[]
): AgentMessage[] {
// Validate thinking signatures
// Normalize signature fields
// Discard unsigned thinking blocks
}
```
---
## API Endpoints
### Authentication Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth authorization |
| `https://oauth2.googleapis.com/token` | POST | Token exchange |
| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | User info (email) |
### Cloud Code Assist Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Load project info, credits, plan |
| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | List available models with quotas |
| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Chat streaming endpoint |
**API Request Format (Chat):**
The `v1internal:streamGenerateContent` endpoint expects an envelope wrapping the standard Gemini request:
```json
{
"project": "your-project-id",
"model": "model-id",
"request": {
"contents": [...],
"systemInstruction": {...},
"generationConfig": {...},
"tools": [...]
},
"requestType": "agent",
"userAgent": "antigravity",
"requestId": "agent-timestamp-random"
}
```
**API Response Format (SSE):**
Each SSE message (`data: {...}`) is wrapped in a `response` field:
```json
{
"response": {
"candidates": [...],
"usageMetadata": {...},
"modelVersion": "...",
"responseId": "..."
},
"traceId": "...",
"metadata": {}
}
```
---
## Configuration
### config.json Configuration
```json
{
"model_list": [
{
"model_name": "gemini-flash",
"model": "antigravity/gemini-3-flash",
"auth_method": "oauth"
}
],
"agents": {
"defaults": {
"model": "gemini-flash"
}
}
}
```
### Auth Profile Storage
Auth profiles are stored in `~/.picoclaw/auth.json`:
```json
{
"credentials": {
"google-antigravity": {
"access_token": "ya29...",
"refresh_token": "1//...",
"expires_at": "2026-01-01T00:00:00Z",
"provider": "google-antigravity",
"auth_method": "oauth",
"email": "user@example.com",
"project_id": "my-project-id"
}
}
}
```
---
## Creating a New Provider in PicoClaw
PicoClaw providers are implemented as Go packages under `pkg/providers/`. To add a new provider:
### Step-by-Step Implementation
#### 1. Create Provider File
Create a new Go file in `pkg/providers/`:
```
pkg/providers/
└── your_provider.go
```
#### 2. Implement the Provider Interface
Your provider must implement the `Provider` interface defined in `pkg/providers/types.go`:
```go
package providers
type YourProvider struct {
apiKey string
apiBase string
}
func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
if apiBase == "" {
apiBase = "https://api.your-provider.com/v1"
}
return &YourProvider{apiKey: apiKey, apiBase: apiBase}
}
func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
// Implement chat completion with streaming
}
```
#### 3. Register in the Factory
Add your provider to the protocol switch in `pkg/providers/factory.go`:
```go
case "your-provider":
return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
```
#### 4. Add Default Config (Optional)
Add a default entry in `pkg/config/defaults.go`:
```go
{
ModelName: "your-model",
Model: "your-provider/model-name",
APIKey: "",
},
```
#### 5. Add Auth Support (Optional)
If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:
```go
case "your-provider":
authLoginYourProvider()
```
#### 6. Configure via `config.json`
```json
{
"model_list": [
{
"model_name": "your-model",
"model": "your-provider/model-name",
"api_key": "your-api-key",
"api_base": "https://api.your-provider.com/v1"
}
]
}
```
---
## Testing Your Implementation
### CLI Commands
```bash
# Authenticate with a provider
picoclaw auth login --provider your-provider
# List models (for Antigravity)
picoclaw auth models
# Start the gateway
picoclaw gateway
# Run an agent with a specific model
picoclaw agent -m "Hello" --model your-model
```
### Environment Variables for Testing
```bash
# Override default model
export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
# Override provider settings
export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
```
---
## References
- **Source Files:**
- `pkg/providers/antigravity_provider.go` - Antigravity provider implementation
- `pkg/auth/oauth.go` - OAuth flow implementation
- `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
- `pkg/providers/factory.go` - Provider factory and protocol routing
- `pkg/providers/types.go` - Provider interface definitions
- `cmd/picoclaw/cmd_auth.go` - Auth CLI commands
- **Documentation:**
- `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide
- `docs/migration/model-list-migration.md` - Migration guide
---
## Notes
1. **Google Cloud Project:** Antigravity requires Gemini for Google Cloud to be enabled on your Google Cloud project
2. **Quotas:** Uses Google Cloud project quotas (not separate billing)
3. **Model Access:** Available models depend on your Google Cloud project configuration
4. **Thinking Blocks:** Claude models via Antigravity require special handling of thinking blocks with signatures
5. **Schema Sanitization:** Tool schemas must be sanitized to remove unsupported JSON Schema keywords
---
---
## Common Error Handling
### 1. Rate Limiting (HTTP 429)
Antigravity returns a 429 error when project/model quotas are exhausted. The error response often contains a `quotaResetDelay` in the `details` field.
**Example 429 Error:**
```json
{
"error": {
"code": 429,
"message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.",
"status": "RESOURCE_EXHAUSTED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"metadata": {
"quotaResetDelay": "4h30m28.060903746s"
}
}
]
}
}
```
### 2. Empty Responses (Restricted Models)
Some models might show up in the available models list but return an empty response (200 OK but empty SSE stream). This usually happens for preview or restricted models that the current project doesn't have permission to use.
**Treatment:** Treat empty responses as errors informing the user that the model might be restricted or invalid for their project.
---
## Troubleshooting
### "Token expired"
- Refresh OAuth tokens: `picoclaw auth login --provider antigravity`
### "Gemini for Google Cloud is not enabled"
- Enable the API in your Google Cloud Console
### "Project not found"
- Ensure your Google Cloud project has the necessary APIs enabled
- Check that the project ID is correctly fetched during authentication
### Models not appearing in list
- Verify OAuth authentication completed successfully
- Check auth profile storage: `~/.picoclaw/auth.json`
- Re-run `picoclaw auth login --provider antigravity`

70
docs/ANTIGRAVITY_USAGE.md Normal file
View file

@ -0,0 +1,70 @@
# Using Antigravity Provider in PicoClaw
This guide explains how to set up and use the **Antigravity** (Google Cloud Code Assist) provider in PicoClaw.
## Prerequisites
1. A Google account.
2. Google Cloud Code Assist enabled (usually available via the "Gemini for Google Cloud" onboarding).
## 1. Authentication
To authenticate with Antigravity, run the following command:
```bash
picoclaw auth login --provider antigravity
```
### Manual Authentication (Headless/VPS)
If you are running on a server (Coolify/Docker) and cannot reach `localhost`, follow these steps:
1. Run the command above.
2. Copy the URL provided and open it in your local browser.
3. Complete the login.
4. Your browser will redirect to a `localhost:51121` URL (which will fail to load).
5. **Copy that final URL** from your browser's address bar.
6. **Paste it back into the terminal** where PicoClaw is waiting.
PicoClaw will extract the authorization code and complete the process automatically.
## 2. Managing Models
### List Available Models
To see which models your project has access to and check their quotas:
```bash
picoclaw auth models
```
### Switch Models
You can change the default model in `~/.picoclaw/config.json` or override it via the CLI:
```bash
# Override for a single command
picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
```
## 3. Real-world Usage (Coolify/Docker)
If you are deploying via Coolify or Docker, follow these steps to test:
1. **Environment Variables**:
* `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
2. **Authentication persistence**:
If you've logged in locally, you can copy your credentials to the server:
```bash
scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
```
*Alternatively*, run the `auth login` command once on the server if you have terminal access.
## 4. Troubleshooting
* **Empty Response**: If a model returns an empty reply, it may be restricted for your project. Try `gemini-3-flash` or `claude-opus-4-6-thinking`.
* **429 Rate Limit**: Antigravity has strict quotas. PicoClaw will display the "reset time" in the error message if you hit a limit.
* **404 Not Found**: Ensure you are using a model ID from the `picoclaw auth models` list. Use the short ID (e.g., `gemini-3-flash`) not the full path.
## 5. Summary of Working Models
Based on testing, the following models are most reliable:
* `gemini-3-flash` (Fast, highly available)
* `gemini-2.5-flash-lite` (Lightweight)
* `claude-opus-4-6-thinking` (Powerful, includes reasoning)

View file

@ -0,0 +1,33 @@
# 钉钉
钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
## 配置
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ------------- | ------ | ---- | -------------------------------- |
| enabled | bool | 是 | 是否启用钉钉频道 |
| client_id | string | 是 | 钉钉应用的 Client ID |
| client_secret | string | 是 | 钉钉应用的 Client Secret |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
## 设置流程
1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
2. 创建一个企业内部应用
3. 从应用设置中获取 Client ID 和 Client Secret
4. 配置OAuth和事件订阅(如需要)
5. 将 Client ID 和 Client Secret 填入配置文件中

View file

@ -0,0 +1,35 @@
# Discord
Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。
## 配置
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"mention_only": false
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ------------ | ------ | ---- | -------------------------------- |
| enabled | bool | 是 | 是否启用 Discord 频道 |
| token | string | 是 | Discord 机器人 Token |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
| mention_only | bool | 否 | 是否仅响应提及机器人的消息 |
## 设置流程
1. 前往 [Discord 开发者门户](https://discord.com/developers/applications) 创建一个新的应用
2. 启用 Intents:
- Message Content Intent
- Server Members Intent
3. 获取 Bot Token
4. 将 Bot Token 填入配置文件中
5. 邀请机器人加入服务器并授予必要权限(例如发送消息、读取消息历史等)

View file

@ -0,0 +1,37 @@
# 飞书
飞书国际版名称Lark是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
## 配置
```json
{
"channels": {
"feishu": {
"enabled": true,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ------------------ | ------ | ---- | -------------------------------- |
| enabled | bool | 是 | 是否启用飞书频道 |
| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
| app_secret | string | 是 | 飞书应用的 App Secret |
| encrypt_key | string | 否 | 事件回调加密密钥 |
| verification_token | string | 否 | 用于Webhook事件验证的Token |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
## 设置流程
1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
2. 获取 App ID 和 App Secret
3. 配置事件订阅和Webhook URL
4. 设置加密(可选,生产环境建议启用)
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中

View file

@ -0,0 +1,41 @@
# Line
PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。
## 配置
```json
{
"channels": {
"line": {
"enabled": true,
"channel_secret": "YOUR_CHANNEL_SECRET",
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line",
"allow_from": []
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| -------------------- | ------ | ---- | ------------------------------------------ |
| enabled | bool | 是 | 是否启用 LINE Channel |
| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) |
| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) |
| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
## 设置流程
1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
2. 获取 Channel Secret 和 Channel Access Token
3. 配置Webhook:
- Line要求Webhook必须使用HTTPS协议因此需要部署一个支持HTTPS的服务器或者使用反向代理工具如ngrok将本地服务器暴露到公网
- 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`
- 启用 Webhook 并验证 URL
4. 将 Channel Secret 和 Channel Access Token 填入配置文件中

View file

@ -0,0 +1,31 @@
# MaixCam
MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。
## 配置
```json
{
"channels": {
"maixcam": {
"enabled": true,
"server_address": "0.0.0.0:8899",
"allow_from": []
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| -------------- | ------ | ---- | -------------------------------- |
| enabled | bool | 是 | 是否启用 MaixCam 频道 |
| server_address | string | 是 | TCP 服务器监听地址和端口 |
| allow_from | array | 否 | 设备ID白名单空表示允许所有设备 |
## 使用场景
MaixCam 通道使 PicoClaw 能够作为边缘设备的 AI 后端运行:
- **智能监控** MaixCAM 发送图像帧PicoClaw 通过视觉模型进行分析
- **物联网控制** 设备发送传感器数据PicoClaw 协调响应
- **离线AI** :在本地网络部署 PicoClaw 实现低延迟推理

View file

@ -0,0 +1,31 @@
# OneBot
OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai提供了统一的接口。它使用 WebSocket 进行通信。
## 配置
```json
{
"channels": {
"onebot": {
"enabled": true,
"ws_url": "ws://localhost:8080",
"access_token": "",
"allow_from": []
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ------------ | ------ | ---- | -------------------------------- |
| enabled | bool | 是 | 是否启用 OneBot 频道 |
| ws_url | string | 是 | OneBot 服务器的 WebSocket URL |
| access_token | string | 否 | 连接 OneBot 服务器的访问令牌 |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
## 设置流程
1. 部署一个 OneBot 兼容的实现(例如napcat)
2. 配置 OneBot 实现以启用 WebSocket 服务并设置访问令牌(如果需要)
3. 将 WebSocket URL 和访问令牌填入配置文件中

View file

@ -0,0 +1,32 @@
# QQ
PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
## 配置
```json
{
"channels": {
"qq": {
"enabled": true,
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"allow_from": []
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------- | ------ | ---- | -------------------------------- |
| enabled | bool | 是 | 是否启用 QQ Channel |
| app_id | string | 是 | QQ 机器人应用的 App ID |
| app_secret | string | 是 | QQ 机器人应用的 App Secret |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
## 设置流程
1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人
2. 通过仪表盘获取 App ID 和 App Secret
3. 开启机器人沙箱模式, 将用户和群添加到沙箱中
4. 将 App ID 和 App Secret 填入配置文件中

View file

@ -0,0 +1,33 @@
# Slack
Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。
## 配置
```json
{
"channels": {
"slack": {
"enabled": true,
"bot_token": "xoxb-...",
"app_token": "xapp-...",
"allow_from": []
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------- | ------ | ---- | -------------------------------------------------------- |
| enabled | bool | 是 | 是否启用 Slack 频道 |
| bot_token | string | 是 | Slack 机器人的 Bot User OAuth Token (以 xoxb- 开头) |
| app_token | string | 是 | Slack 应用的 Socket Mode App Level Token (以 xapp- 开头) |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
## 设置流程
1. 前往 [Slack API](https://api.slack.com/) 创建一个新的 Slack 应用
2. 启用 Socket Mode 并获取 App Level Token
3. 添加 Bot Token Scopes(例如`chat:write``im:history`等)
4. 安装应用到工作区并获取 Bot User OAuth Token
5. 将 Bot Token 和 App Token 填入配置文件中

View file

@ -0,0 +1,33 @@
# Telegram
Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。
## 配置
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"],
"proxy": ""
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------- | ------ | ---- | --------------------------------------------------------- |
| enabled | bool | 是 | 是否启用 Telegram 频道 |
| token | string | 是 | Telegram 机器人 API Token |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 |
| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |
## 设置流程
1. 在 Telegram 中搜索 `@BotFather`
2. 发送 `/newbot` 命令并按照提示创建新机器人
3. 获取 HTTP API Token
4. 将 Token 填入配置文件中
5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)

View file

@ -0,0 +1,47 @@
# 企业微信自建应用
企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。
## 配置
```json
{
"channels": {
"wecom_app": {
"enabled": true,
"corp_id": "wwxxxxxxxxxxxxxxxx",
"corp_secret": "YOUR_CORP_SECRET",
"agent_id": 1000002,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------------- | ------ | ---- | ---------------------------------------- |
| corp_id | string | 是 | 企业 ID |
| corp_secret | string | 是 | 应用程序密钥 |
| agent_id | int | 是 | 应用程序代理 ID |
| token | string | 是 | 回调验证令牌 |
| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
| webhook_host | string | 否 | HTTP 服务器绑定地址 |
| webhook_port | int | 否 | HTTP 服务器端口默认18792 |
| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app |
| allow_from | array | 否 | 用户 ID 白名单 |
| reply_timeout | int | 否 | 回复超时时间(秒) |
## 设置流程
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/)
2. 进入“应用管理” -> “创建应用”
3. 获取企业 ID (CorpID) 和应用 Secret
4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
5. 设置回调 URL 为 `http://<your-server-ip>:<port>/webhook/wecom-app`
6. 将 CorpID, Secret, AgentID 等信息填入配置文件

View file

@ -0,0 +1,41 @@
# 企业微信机器人
企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。
## 配置
```json
{
"channels": {
"wecom": {
"enabled": true,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_host": "0.0.0.0",
"webhook_port": 18793,
"webhook_path": "/webhook/wecom",
"allow_from": [],
"reply_timeout": 5
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| ---------------- | ------ | ---- | -------------------------------------------- |
| token | string | 是 | 签名验证代币 |
| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
| webhook_host | string | 否 | HTTP 服务器绑定地址默认0.0.0.0 |
| webhook_port | int | 否 | HTTP 服务器端口默认18793 |
| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom |
| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
| reply_timeout | int | 否 | 回复超时时间单位默认值5 |
## 设置流程
1. 在企业微信群中添加机器人
2. 获取 Webhook URL
3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
4. 将相关信息填入配置文件

View file

@ -0,0 +1,174 @@
# Provider Architecture Refactoring - Test Suite Summary
This document summarizes the complete test suite designed for the Provider architecture refactoring.
## Test File Structure
```
pkg/
├── config/
│ ├── model_config_test.go # US-001, US-002: ModelConfig struct and GetModelConfig tests
│ └── migration_test.go # US-003: Backward compatibility and migration tests
├── providers/
│ ├── factory_test.go # US-004, US-005: Provider factory tests
│ └── factory_provider_test.go # Factory provider integration tests
```
---
## Test Case Checklist
### 1. `pkg/config/model_config_test.go` - Configuration Parsing Tests
| Test Name | Purpose | PRD Reference |
|-----------|---------|---------------|
| `TestModelConfig_Parsing` | Verify ModelConfig JSON parsing | US-001 |
| `TestModelConfig_ModelListInConfig` | Verify model_list parsing in Config | US-001 |
| `TestModelConfig_Validation` | Verify required field validation | US-001 |
| `TestConfig_GetModelConfig_Found` | Verify GetModelConfig finds model | US-002 |
| `TestConfig_GetModelConfig_NotFound` | Verify GetModelConfig returns error | US-002 |
| `TestConfig_GetModelConfig_EmptyModelList` | Verify empty model_list handling | US-002 |
| `TestConfig_BackwardCompatibility_ProvidersToModelList` | Verify old config conversion | US-003 |
| `TestConfig_DeprecationWarning` | Verify deprecation warning | US-003 |
| `TestModelConfig_ProtocolExtraction` | Verify protocol prefix extraction | US-004 |
| `TestConfig_ModelNameUniqueness` | Verify model_name uniqueness | US-001 |
### 2. `pkg/config/migration_test.go` - Migration Tests
| Test Name | Purpose | PRD Reference |
|-----------|---------|---------------|
| `TestConvertProvidersToModelList_OpenAI` | OpenAI config conversion | US-003 |
| `TestConvertProvidersToModelList_Anthropic` | Anthropic config conversion | US-003 |
| `TestConvertProvidersToModelList_MultipleProviders` | Multiple provider conversion | US-003 |
| `TestConvertProvidersToModelList_EmptyProviders` | Empty providers handling | US-003 |
| `TestConvertProvidersToModelList_GitHubCopilot` | GitHub Copilot conversion | US-003 |
| `TestConvertProvidersToModelList_Antigravity` | Antigravity conversion | US-003 |
| `TestGenerateModelName_*` | Model name generation | US-003 |
| `TestHasProvidersConfig_*` | Detect old config existence | US-003 |
| `TestValidateMigration_*` | Migration validation | US-003 |
| `TestMigrateConfig_DryRun` | Dry run migration | US-003 |
| `TestMigrateConfig_Actual` | Actual migration | US-003 |
### 3. `pkg/providers/registry_test.go` - Load Balancing Tests
| Test Name | Purpose | PRD Reference |
|-----------|---------|---------------|
| `TestModelRegistry_SingleConfig` | Single config returns same result | US-006 |
| `TestModelRegistry_RoundRobinSelection` | 3-config round-robin selection | US-006 |
| `TestModelRegistry_RoundRobinTwoConfigs` | 2-config round-robin selection | US-006 |
| `TestModelRegistry_ConcurrentAccess` | Concurrent access thread safety | US-006 |
| `TestModelRegistry_RaceDetection` | Data race detection | US-006 |
| `TestModelRegistry_ModelNotFound` | Model not found error | US-006 |
| `TestModelRegistry_EmptyRegistry` | Empty registry handling | US-006 |
| `TestModelRegistry_MultipleModels` | Multiple model registration | US-006 |
| `TestModelRegistry_MixedSingleAndMultiple` | Single/multiple config mix | US-006 |
| `TestModelRegistry_CaseSensitiveModelNames` | Case sensitivity | US-006 |
### 4. `pkg/providers/factory/factory_test.go` - Provider Factory Tests
| Test Name | Purpose | PRD Reference |
|-----------|---------|---------------|
| `TestCreateProviderFromConfig_OpenAI` | Create OpenAI provider | US-004 |
| `TestCreateProviderFromConfig_OpenAIDefault` | Default openai protocol | US-004 |
| `TestCreateProviderFromConfig_Anthropic` | Create Anthropic provider | US-004 |
| `TestCreateProviderFromConfig_Antigravity` | Create Antigravity provider | US-004 |
| `TestCreateProviderFromConfig_ClaudeCLI` | Create Claude CLI provider | US-004 |
| `TestCreateProviderFromConfig_CodexCLI` | Create Codex CLI provider | US-004 |
| `TestCreateProviderFromConfig_GitHubCopilot` | Create GitHub Copilot provider | US-004 |
| `TestCreateProviderFromConfig_UnknownProtocol` | Unknown protocol error handling | US-004 |
| `TestCreateProviderFromConfig_MissingAPIKey` | Missing API key error | US-004 |
| `TestExtractProtocol` | Protocol prefix extraction | US-004 |
| `TestCreateProvider_UsesModelList` | Create using model_list | US-005 |
| `TestCreateProvider_FallbackToProviders` | Fallback to providers | US-005 |
| `TestCreateProvider_PriorityModelListOverProviders` | model_list priority | US-005 |
### 5. `pkg/providers/integration_test.go` - E2E Integration Tests
| Test Name | Purpose | PRD Reference |
|-----------|---------|---------------|
| `TestE2E_OpenAICompatibleProvider_NoCodeChange` | Zero-code provider addition | Goal |
| `TestE2E_LoadBalancing_RoundRobin` | Load balancing actual effect | US-006 |
| `TestE2E_BackwardCompatibility_OldProvidersConfig` | Old config compatibility | US-003 |
| `TestE2E_ErrorHandling_ModelNotFound` | Model not found | FR-30 |
| `TestE2E_ErrorHandling_MissingAPIKey` | Missing API key | FR-31 |
| `TestE2E_ErrorHandling_InvalidAPIBase` | Invalid API base | FR-30 |
| `TestE2E_ToolCalls_OpenAICompatible` | Tool call support | - |
| `TestE2E_AntigravityProvider` | Antigravity provider | US-004 |
| `TestE2E_ClaudeCLIProvider` | Claude CLI provider | US-004 |
### 6. Performance Tests
| Test Name | Purpose |
|-----------|---------|
| `BenchmarkCreateProviderFromConfig` | Provider creation performance |
| `BenchmarkGetModelConfig` | Model lookup performance |
| `BenchmarkGetModelConfigParallel` | Concurrent lookup performance |
---
## Running Tests
```bash
# Run all tests
go test ./pkg/... -v
# Run with data race detection
go test ./pkg/... -race
# Run specific package tests
go test ./pkg/config -v
go test ./pkg/providers -v
# Run E2E tests
go test ./pkg/providers -run TestE2E -v
# Run performance tests
go test ./pkg/providers -bench=. -benchmem
```
---
## PRD Acceptance Criteria Mapping
| PRD Acceptance Criteria | Test Cases |
|------------------------|------------|
| US-001: Add ModelConfig struct | `TestModelConfig_Parsing`, `TestModelConfig_Validation` |
| US-001: model_name unique | `TestConfig_ModelNameUniqueness` |
| US-002: GetModelConfig method | `TestConfig_GetModelConfig_*` |
| US-003: Auto-convert providers | `TestConvertProvidersToModelList_*` |
| US-003: Deprecation warning | `TestConfig_DeprecationWarning` |
| US-003: Existing tests pass | (existing test files unchanged) |
| US-004: Protocol prefix factory | `TestExtractProtocol`, `TestCreateProviderFromConfig_*` |
| US-004: Default prefix openai | `TestCreateProviderFromConfig_OpenAIDefault` |
| US-005: CreateProvider uses factory | `TestCreateProvider_*` |
| US-006: Round-robin selection | `TestModelRegistry_RoundRobin*` |
| US-006: Thread-safe atomic | `TestModelRegistry_RaceDetection` |
---
## Recommended Implementation Order
1. **Phase 1: Configuration Structure** (US-001, US-002)
- Implement `ModelConfig` struct
- Implement `GetModelConfig` method
- Run `model_config_test.go`
2. **Phase 2: Protocol Factory** (US-004)
- Implement `CreateProviderFromConfig`
- Implement `ExtractProtocol`
- Run `factory_test.go`
3. **Phase 3: Load Balancing** (US-006)
- Implement `ModelRegistry`
- Implement round-robin selection
- Run `registry_test.go` (with `-race`)
4. **Phase 4: Backward Compatibility** (US-003, US-005)
- Implement `ConvertProvidersToModelList`
- Refactor `CreateProvider`
- Run `migration_test.go`
- Verify existing tests pass
5. **Phase 5: E2E Verification**
- Run `integration_test.go`
- Manual testing with `config.example.json`

View file

@ -0,0 +1,334 @@
# Provider Architecture Refactoring Design
> Issue: #283
> Discussion: #122
> Branch: feat/refactor-provider-by-protocol
## 1. Current Problems
### 1.1 Configuration Structure Issues
**Current State**: Each Provider requires a predefined field in `ProvidersConfig`
```go
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI ProviderConfig `json:"openai"`
DeepSeek ProviderConfig `json:"deepseek"`
Qwen ProviderConfig `json:"qwen"`
Cerebras ProviderConfig `json:"cerebras"`
VolcEngine ProviderConfig `json:"volcengine"`
// ... every new provider requires changes here
}
```
**Problems**:
- Adding a new Provider requires modifying Go code (struct definition)
- `CreateProvider` function in `http_provider.go` has 200+ lines of switch-case
- Most Providers are OpenAI-compatible, but code is duplicated
### 1.2 Code Bloat Trend
Recent PRs demonstrate this issue:
| PR | Provider | Code Changes |
|----|----------|--------------|
| #365 | Qwen | +17 lines to http_provider.go |
| #333 | Cerebras | +17 lines to http_provider.go |
| #368 | Volcengine | +18 lines to http_provider.go |
Each OpenAI-compatible Provider requires:
1. Modify `config.go` to add configuration field
2. Modify `http_provider.go` to add switch case
3. Update documentation
### 1.3 Agent-Provider Coupling
```json
{
"agents": {
"defaults": {
"provider": "deepseek", // need to know provider name
"model": "deepseek-chat"
}
}
}
```
Problem: Agent needs to know both `provider` and `model`, adding complexity.
---
## 2. New Approach: model_list
### 2.1 Core Principles
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
1. **Model-centric**: Users care about models, not providers
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
### 2.2 New Configuration Structure
```json
{
"model_list": [
{
"model_name": "deepseek-chat",
"model": "openai/deepseek-chat",
"api_base": "https://api.deepseek.com/v1",
"api_key": "sk-xxx"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"api_key": "sk-xxx"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-xxx"
},
{
"model_name": "gemini-3-flash",
"model": "antigravity/gemini-3-flash",
"auth_method": "oauth"
},
{
"model_name": "my-company-llm",
"model": "openai/company-model-v1",
"api_base": "https://llm.company.com/v1",
"api_key": "xxx"
}
],
"agents": {
"defaults": {
"model": "deepseek-chat",
"max_tokens": 8192,
"temperature": 0.7
}
}
}
```
### 2.3 Go Struct Definition
```go
type Config struct {
ModelList []ModelConfig `json:"model_list"` // new
Providers ProvidersConfig `json:"providers"` // old, deprecated
Agents AgentsConfig `json:"agents"`
Channels ChannelsConfig `json:"channels"`
// ...
}
type ModelConfig struct {
// Required
ModelName string `json:"model_name"` // user-facing name (alias)
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
// Common config
APIBase string `json:"api_base,omitempty"`
APIKey string `json:"api_key,omitempty"`
Proxy string `json:"proxy,omitempty"`
// Special provider config
AuthMethod string `json:"auth_method,omitempty"` // oauth, token
ConnectMode string `json:"connect_mode,omitempty"` // stdio, grpc
// Optional optimizations
RPM int `json:"rpm,omitempty"` // rate limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // max_tokens or max_completion_tokens
}
```
### 2.4 Protocol Recognition
Identify protocol via prefix in `model` field:
| Prefix | Protocol | Description |
|--------|----------|-------------|
| `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. |
| `anthropic/` | Anthropic | Claude series specific |
| `antigravity/` | Antigravity | Google Cloud Code Assist |
| `gemini/` | Gemini | Google Gemini native API (if needed) |
---
## 3. Design Rationale
### 3.1 Problems Solved
| Problem | Old Approach | New Approach |
|---------|--------------|--------------|
| Add OpenAI-compatible Provider | Change 3 code locations | Add one config entry |
| Agent specifies model | Need provider + model | Only need model |
| Code duplication | Each Provider duplicates logic | Share protocol implementation |
| Multi-Agent support | Complex | Naturally compatible |
### 3.2 Multi-Agent Compatibility
```json
{
"model_list": [...],
"agents": {
"defaults": {
"model": "deepseek-chat"
},
"coder": {
"model": "gpt-5.2",
"system_prompt": "You are a coding assistant..."
},
"translator": {
"model": "claude-sonnet-4.6"
}
}
}
```
Each Agent only needs to specify `model` (corresponds to `model_name` in `model_list`).
### 3.3 Industry Comparison
**LiteLLM** (most mature open-source LLM Proxy) uses similar design:
```yaml
model_list:
- model_name: gpt-4o
litellm_params:
model: openai/gpt-5.2
api_key: xxx
- model_name: my-custom
litellm_params:
model: openai/custom-model
api_base: https://my-api.com/v1
```
---
## 4. Migration Plan
### 4.1 Phase 1: Compatibility Period (v1.x)
Support both `providers` and `model_list`:
```go
func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
// Prefer new config
if len(c.ModelList) > 0 {
return c.findModelByName(modelName)
}
// Backward compatibility with old config
if !c.Providers.IsEmpty() {
logger.Warn("'providers' config is deprecated, please migrate to 'model_list'")
return c.convertFromProviders(modelName)
}
return nil, fmt.Errorf("model %s not found", modelName)
}
```
### 4.2 Phase 2: Warning Period (late v1.x)
- Print more prominent warnings at startup
- Provide automatic migration script
- Mark `providers` as deprecated in documentation
### 4.3 Phase 3: Removal Period (v2.0)
- Completely remove `providers` support
- Remove `agents.defaults.provider` field
- Only support `model_list`
### 4.4 Configuration Migration Example
**Old Config**:
```json
{
"providers": {
"deepseek": {
"api_key": "sk-xxx",
"api_base": "https://api.deepseek.com/v1"
}
},
"agents": {
"defaults": {
"provider": "deepseek",
"model": "deepseek-chat"
}
}
}
```
**New Config**:
```json
{
"model_list": [
{
"model_name": "deepseek-chat",
"model": "openai/deepseek-chat",
"api_base": "https://api.deepseek.com/v1",
"api_key": "sk-xxx"
}
],
"agents": {
"defaults": {
"model": "deepseek-chat"
}
}
}
```
---
## 5. Implementation Checklist
### 5.1 Configuration Layer
- [ ] Add `ModelConfig` struct
- [ ] Add `Config.ModelList` field
- [ ] Implement `GetModelConfig(modelName)` method
- [ ] Implement old config compatibility conversion
- [ ] Add `model_name` uniqueness validation
### 5.2 Provider Layer
- [ ] Create `pkg/providers/factory/` directory
- [ ] Implement `CreateProviderFromModelConfig()`
- [ ] Refactor `http_provider.go` to `openai/provider.go`
- [ ] Maintain backward compatibility for old `CreateProvider()`
### 5.3 Testing
- [ ] New config unit tests
- [ ] Old config compatibility tests
- [ ] Integration tests
### 5.4 Documentation
- [ ] Update README
- [ ] Update config.example.json
- [ ] Write migration guide
---
## 6. Risks and Mitigations
| Risk | Mitigation |
|------|------------|
| Breaking existing configs | Compatibility period keeps old config working |
| User migration cost | Provide automatic migration script |
| Special Provider incompatibility | Keep `auth_method` and other extension fields |
---
## 7. References
- [LiteLLM Config Documentation](https://docs.litellm.ai/docs/proxy/configs)
- [One-API GitHub](https://github.com/songquanpeng/one-api)
- Discussion #122: Refactor Provider Architecture

View file

@ -0,0 +1,219 @@
# Migration Guide: From `providers` to `model_list`
This guide explains how to migrate from the legacy `providers` configuration to the new `model_list` format.
## Why Migrate?
The new `model_list` configuration offers several advantages:
- **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only
- **Load balancing**: Configure multiple endpoints for the same model
- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc.
- **Cleaner configuration**: Model-centric instead of vendor-centric
## Timeline
| Version | Status |
|---------|--------|
| v1.x | `model_list` introduced, `providers` deprecated but functional |
| v1.x+1 | Prominent deprecation warnings, migration tool available |
| v2.0 | `providers` configuration removed |
## Before and After
### Before: Legacy `providers` Configuration
```json
{
"providers": {
"openai": {
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
},
"anthropic": {
"api_key": "sk-ant-your-key"
},
"deepseek": {
"api_key": "sk-your-deepseek-key"
}
},
"agents": {
"defaults": {
"provider": "openai",
"model": "gpt-5.2"
}
}
}
```
### After: New `model_list` Configuration
```json
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"api_key": "sk-ant-your-key"
},
{
"model_name": "deepseek",
"model": "deepseek/deepseek-chat",
"api_key": "sk-your-deepseek-key"
}
],
"agents": {
"defaults": {
"model": "gpt4"
}
}
}
```
## Protocol Prefixes
The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| Prefix | Description | Example |
|--------|-------------|---------|
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |
| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |
| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |
| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` |
| `groq/` | Groq API | `groq/llama-3.1-70b` |
| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` |
| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` |
| `qwen/` | Alibaba Qwen | `qwen/qwen-max` |
| `zhipu/` | Zhipu AI | `zhipu/glm-4` |
| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` |
| `ollama/` | Ollama (local) | `ollama/llama3` |
| `vllm/` | vLLM (local) | `vllm/my-model` |
| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` |
| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` |
| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` |
**Note**: If no prefix is specified, `openai/` is used as the default.
## ModelConfig Fields
| Field | Required | Description |
|-------|----------|-------------|
| `model_name` | Yes | User-facing alias for the model |
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
| `api_base` | No | API endpoint URL |
| `api_key` | No* | API authentication key |
| `proxy` | No | HTTP proxy URL |
| `auth_method` | No | Authentication method: `oauth`, `token` |
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
| `rpm` | No | Requests per minute limit |
| `max_tokens_field` | No | Field name for max tokens |
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
## Load Balancing
Configure multiple endpoints for the same model to distribute load:
```json
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-key1",
"api_base": "https://api1.example.com/v1"
},
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
},
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-key3",
"api_base": "https://api3.example.com/v1"
}
]
}
```
When you request model `gpt4`, requests will be distributed across all three endpoints using round-robin selection.
## Adding a New OpenAI-Compatible Provider
With `model_list`, adding a new provider requires zero code changes:
```json
{
"model_list": [
{
"model_name": "my-custom-llm",
"model": "openai/my-model-v1",
"api_key": "your-api-key",
"api_base": "https://api.your-provider.com/v1"
}
]
}
```
Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL.
## Backward Compatibility
During the migration period, your existing `providers` configuration will continue to work:
1. If `model_list` is empty and `providers` has data, the system auto-converts internally
2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"`
3. All existing functionality remains unchanged
## Migration Checklist
- [ ] Identify all providers you're currently using
- [ ] Create `model_list` entries for each provider
- [ ] Use appropriate protocol prefixes
- [ ] Update `agents.defaults.model` to reference the new `model_name`
- [ ] Test that all models work correctly
- [ ] Remove or comment out the old `providers` section
## Troubleshooting
### Model not found error
```
model "xxx" not found in model_list or providers
```
**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`.
### Unknown protocol error
```
unknown protocol "xxx" in model "xxx/model-name"
```
**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above.
### Missing API key error
```
api_key or api_base is required for HTTP-based protocol "xxx"
```
**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers.
## Need Help?
- [GitHub Issues](https://github.com/sipeed/picoclaw/issues)
- [Discussion #122](https://github.com/sipeed/picoclaw/discussions/122): Original proposal

143
docs/tools_configuration.md Normal file
View file

@ -0,0 +1,143 @@
# Tools Configuration
PicoClaw's tools configuration is located in the `tools` field of `config.json`.
## Directory Structure
```json
{
"tools": {
"web": { ... },
"exec": { ... },
"cron": { ... },
"skills": { ... }
}
}
```
## Web Tools
Web tools are used for web search and fetching.
### Brave
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | bool | false | Enable Brave search |
| `api_key` | string | - | Brave Search API key |
| `max_results` | int | 5 | Maximum number of results |
### DuckDuckGo
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | bool | true | Enable DuckDuckGo search |
| `max_results` | int | 5 | Maximum number of results |
### Perplexity
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | bool | false | Enable Perplexity search |
| `api_key` | string | - | Perplexity API key |
| `max_results` | int | 5 | Maximum number of results |
## Exec Tool
The exec tool is used to execute shell commands.
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
### Functionality
- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked
### Default Blocked Command Patterns
By default, PicoClaw blocks the following dangerous commands:
- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s`
- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*`
- System operations: `shutdown`, `reboot`, `poweroff`
- Command substitution: `$()`, `${}`, backticks
- Pipe to shell: `| sh`, `| bash`
- Privilege escalation: `sudo`, `chmod`, `chown`
- Process control: `pkill`, `killall`, `kill -9`
- Remote operations: `curl | sh`, `wget | sh`, `ssh`
- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user`
- Containers: `docker run`, `docker exec`
- Git: `git push`, `git force`
- Other: `eval`, `source *.sh`
### Configuration Example
```json
{
"tools": {
"exec": {
"enable_deny_patterns": true,
"custom_deny_patterns": [
"\\brm\\s+-r\\b",
"\\bkillall\\s+python"
]
}
}
}
```
## Cron Tool
The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
## Skills Tool
The skills tool configures skill discovery and installation via registries like ClawHub.
### Registries
| Config | Type | Default | Description |
|--------|------|---------|-------------|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
### Configuration Example
```json
{
"tools": {
"skills": {
"registries": {
"clawhub": {
"enabled": true,
"base_url": "https://clawhub.ai",
"search_path": "/api/v1/search",
"skills_path": "/api/v1/skills",
"download_path": "/api/v1/download"
}
}
}
}
}
```
## Environment Variables
All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_<SECTION>_<KEY>`:
For example:
- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
Note: Array-type environment variables are not currently supported and must be set via the config file.

View file

@ -0,0 +1,117 @@
# 企业微信自建应用 (WeCom App) 配置指南
本文档介绍如何在 PicoClaw 中配置企业微信自建应用 (wecom-app) 通道。
## 功能特性
| 功能 | 支持状态 |
|------|---------|
| 被动接收消息 | ✅ |
| 主动发送消息 | ✅ |
| 私聊 | ✅ |
| 群聊 | ❌ |
## 配置步骤
### 1. 企业微信后台配置
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
2. 进入"应用管理" → 选择自建应用
3. 记录以下信息:
- **AgentId**: 应用详情页显示
- **Secret**: 点击"查看"获取
4. 进入"我的企业"页面,记录 **企业ID** (CorpID)
### 2. 接收消息配置
1. 在应用详情页,点击"接收消息"的"设置API接收"
2. 填写以下信息:
- **URL**: `http://your-server:18792/webhook/wecom-app`
- **Token**: 随机生成或自定义(用于签名验证)
- **EncodingAESKey**: 点击"随机生成"生成43字符的密钥
3. 点击"保存"时,企业微信会发送验证请求
### 3. PicoClaw 配置
`config.json` 中添加以下配置:
```json
{
"channels": {
"wecom_app": {
"enabled": true,
"corp_id": "wwxxxxxxxxxxxxxxxx", // 企业ID
"corp_secret": "xxxxxxxxxxxxxxxxxxxxxxxx", // 应用Secret
"agent_id": 1000002, // 应用AgentId
"token": "your_token", // 接收消息配置的Token
"encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey
"webhook_host": "0.0.0.0",
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5
}
}
}
```
## 常见问题
### 1. 回调URL验证失败
**症状**: 企业微信保存API接收消息时提示验证失败
**检查项**:
- 确认服务器防火墙已开放 18792 端口
- 确认 `corp_id``token``encoding_aes_key` 配置正确
- 查看 PicoClaw 日志是否有请求到达
### 2. 中文消息解密失败
**症状**: 发送中文消息时出现 `invalid padding size` 错误
**原因**: 企业微信使用非标准的 PKCS7 填充32字节块大小
**解决**: 确保使用最新版本的 PicoClaw已修复此问题。
### 3. 端口冲突
**症状**: 启动时提示端口已被占用
**解决**: 修改 `webhook_port` 为其他端口,如 18794
## 技术细节
### 加密算法
- **算法**: AES-256-CBC
- **密钥**: EncodingAESKey Base64解码后的32字节
- **IV**: AESKey的前16字节
- **填充**: PKCS7块大小为32字节非标准16字节
- **消息格式**: XML
### 消息结构
解密后的消息格式:
```
random(16B) + msg_len(4B) + msg + receiveid
```
其中 `receiveid` 对于自建应用是 `corp_id`
## 调试
启用调试模式查看详细日志:
```bash
picoclaw gateway --debug
```
关键日志标识:
- `wecom_app`: WeCom App 通道相关日志
- `wecom_common`: 加密解密相关日志
## 参考文档
- [企业微信官方文档 - 接收消息](https://developer.work.weixin.qq.com/document/path/96211)
- [企业微信官方加解密库](https://github.com/sbzhu/weworkapi_golang)

10
go.mod
View file

@ -15,11 +15,16 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/slack-go/slack v0.17.3
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
golang.org/x/oauth2 v0.35.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
@ -28,9 +33,9 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.1.23
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@ -47,5 +52,4 @@ require (
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)

6
go.sum
View file

@ -58,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -78,9 +80,11 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
@ -102,6 +106,7 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
@ -242,6 +247,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=

View file

@ -80,7 +80,7 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
}
@ -96,7 +96,9 @@ func (cb *ContextBuilder) buildToolsSection() string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
sb.WriteString("**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n")
sb.WriteString(
"**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
)
sb.WriteString("You have access to the following tools:\n\n")
for _, s := range summaries {
sb.WriteString(s)
@ -146,18 +148,24 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
"IDENTITY.md",
}
var result string
var sb strings.Builder
for _, filename := range bootstrapFiles {
filePath := filepath.Join(cb.workspace, filename)
if data, err := os.ReadFile(filePath); err == nil {
result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
}
}
return result
return sb.String()
}
func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message {
func (cb *ContextBuilder) BuildMessages(
history []providers.Message,
summary string,
currentMessage string,
media []string,
channel, chatID string,
) []providers.Message {
messages := []providers.Message{}
systemPrompt := cb.BuildSystemPrompt()
@ -169,7 +177,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
// Log system prompt summary for debugging (debug mode only)
logger.DebugCF("agent", "System prompt built",
map[string]interface{}{
map[string]any{
"total_chars": len(systemPrompt),
"total_lines": strings.Count(systemPrompt, "\n") + 1,
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
@ -181,7 +189,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
preview = preview[:500] + "... (truncated)"
}
logger.DebugCF("agent", "System prompt preview",
map[string]interface{}{
map[string]any{
"preview": preview,
})
@ -189,16 +197,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
}
//This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM
// --- INICIO DEL FIX ---
//Diegox-17
for len(history) > 0 && (history[0].Role == "tool") {
logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error",
map[string]interface{}{"role": history[0].Role})
history = history[1:]
}
//Diegox-17
// --- FIN DEL FIX ---
history = sanitizeHistoryForProvider(history)
messages = append(messages, providers.Message{
Role: "system",
@ -207,15 +206,66 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
messages = append(messages, history...)
messages = append(messages, providers.Message{
Role: "user",
Content: currentMessage,
})
if strings.TrimSpace(currentMessage) != "" {
messages = append(messages, providers.Message{
Role: "user",
Content: currentMessage,
})
}
return messages
}
func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
func sanitizeHistoryForProvider(history []providers.Message) []providers.Message {
if len(history) == 0 {
return history
}
sanitized := make([]providers.Message, 0, len(history))
for _, msg := range history {
switch msg.Role {
case "tool":
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
continue
}
last := sanitized[len(sanitized)-1]
if last.Role != "assistant" || len(last.ToolCalls) == 0 {
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
continue
}
sanitized = append(sanitized, msg)
case "assistant":
if len(msg.ToolCalls) > 0 {
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
continue
}
prev := sanitized[len(sanitized)-1]
if prev.Role != "user" && prev.Role != "tool" {
logger.DebugCF(
"agent",
"Dropping assistant tool-call turn with invalid predecessor",
map[string]any{"prev_role": prev.Role},
)
continue
}
}
sanitized = append(sanitized, msg)
default:
sanitized = append(sanitized, msg)
}
}
return sanitized
}
func (cb *ContextBuilder) AddToolResult(
messages []providers.Message,
toolCallID, toolName, result string,
) []providers.Message {
messages = append(messages, providers.Message{
Role: "tool",
Content: result,
@ -224,7 +274,11 @@ func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID
return messages
}
func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message {
func (cb *ContextBuilder) AddAssistantMessage(
messages []providers.Message,
content string,
toolCalls []map[string]any,
) []providers.Message {
msg := providers.Message{
Role: "assistant",
Content: content,
@ -234,33 +288,14 @@ func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, cont
return messages
}
func (cb *ContextBuilder) loadSkills() string {
allSkills := cb.skillsLoader.ListSkills()
if len(allSkills) == 0 {
return ""
}
var skillNames []string
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
content := cb.skillsLoader.LoadSkillsForContext(skillNames)
if content == "" {
return ""
}
return "# Skill Definitions\n\n" + content
}
// GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()
skillNames := make([]string, 0, len(allSkills))
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
return map[string]interface{}{
return map[string]any{
"total": len(allSkills),
"available": len(allSkills),
"names": skillNames,

159
pkg/agent/instance.go Normal file
View file

@ -0,0 +1,159 @@
package agent
import (
"os"
"path/filepath"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools"
)
// AgentInstance represents a fully configured agent with its own workspace,
// session manager, context builder, and tool registry.
type AgentInstance struct {
ID string
Name string
Model string
Fallbacks []string
Workspace string
MaxIterations int
MaxTokens int
Temperature float64
ContextWindow int
Provider providers.LLMProvider
Sessions *session.SessionManager
ContextBuilder *ContextBuilder
Tools *tools.ToolRegistry
Subagents *config.SubagentsConfig
SkillsFilter []string
Candidates []providers.FallbackCandidate
}
// NewAgentInstance creates an agent instance from config.
func NewAgentInstance(
agentCfg *config.AgentConfig,
defaults *config.AgentDefaults,
cfg *config.Config,
provider providers.LLMProvider,
) *AgentInstance {
workspace := resolveAgentWorkspace(agentCfg, defaults)
os.MkdirAll(workspace, 0o755)
model := resolveAgentModel(agentCfg, defaults)
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
restrict := defaults.RestrictToWorkspace
toolsRegistry := tools.NewToolRegistry()
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir)
contextBuilder := NewContextBuilder(workspace)
contextBuilder.SetToolsRegistry(toolsRegistry)
agentID := routing.DefaultAgentID
agentName := ""
var subagents *config.SubagentsConfig
var skillsFilter []string
if agentCfg != nil {
agentID = routing.NormalizeAgentID(agentCfg.ID)
agentName = agentCfg.Name
subagents = agentCfg.Subagents
skillsFilter = agentCfg.Skills
}
maxIter := defaults.MaxToolIterations
if maxIter == 0 {
maxIter = 20
}
maxTokens := defaults.MaxTokens
if maxTokens == 0 {
maxTokens = 8192
}
temperature := 0.7
if defaults.Temperature != nil {
temperature = *defaults.Temperature
}
// Resolve fallback candidates
modelCfg := providers.ModelConfig{
Primary: model,
Fallbacks: fallbacks,
}
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
return &AgentInstance{
ID: agentID,
Name: agentName,
Model: model,
Fallbacks: fallbacks,
Workspace: workspace,
MaxIterations: maxIter,
MaxTokens: maxTokens,
Temperature: temperature,
ContextWindow: maxTokens,
Provider: provider,
Sessions: sessionsManager,
ContextBuilder: contextBuilder,
Tools: toolsRegistry,
Subagents: subagents,
SkillsFilter: skillsFilter,
Candidates: candidates,
}
}
// resolveAgentWorkspace determines the workspace directory for an agent.
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
return expandHome(strings.TrimSpace(agentCfg.Workspace))
}
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
return expandHome(defaults.Workspace)
}
home, _ := os.UserHomeDir()
id := routing.NormalizeAgentID(agentCfg.ID)
return filepath.Join(home, ".picoclaw", "workspace-"+id)
}
// resolveAgentModel resolves the primary model for an agent.
func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
return strings.TrimSpace(agentCfg.Model.Primary)
}
return defaults.GetModelName()
}
// resolveAgentFallbacks resolves the fallback models for an agent.
func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
return agentCfg.Model.Fallbacks
}
return defaults.ModelFallbacks
}
func expandHome(path string) string {
if path == "" {
return path
}
if path[0] == '~' {
home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' {
return home + path[1:]
}
return home
}
return path
}

View file

@ -0,0 +1,95 @@
package agent
import (
"os"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
},
},
}
configuredTemp := 1.0
cfg.Agents.Defaults.Temperature = &configuredTemp
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.MaxTokens != 1234 {
t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
}
if agent.Temperature != 1.0 {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0)
}
}
func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
},
},
}
configuredTemp := 0.0
cfg.Agents.Defaults.Temperature = &configuredTemp
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.Temperature != 0.0 {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.0)
}
}
func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.Temperature != 0.7 {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
}
}

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,7 @@ package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
@ -13,20 +14,6 @@ import (
"github.com/sipeed/picoclaw/pkg/tools"
)
// mockProvider is a simple mock LLM provider for testing
type mockProvider struct{}
func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
return &providers.LLMResponse{
Content: "Mock response",
ToolCalls: []providers.ToolCall{},
}, nil
}
func (m *mockProvider) GetDefaultModel() string {
return "mock-model"
}
func TestRecordLastChannel(t *testing.T) {
// Create temp workspace
tmpDir, err := os.MkdirTemp("", "agent-test-*")
@ -184,7 +171,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
// Verify tool is registered by checking it doesn't panic on GetStartupInfo
// (actual tool retrieval is tested in tools package tests)
info := al.GetStartupInfo()
toolsInfo := info["tools"].(map[string]interface{})
toolsInfo := info["tools"].(map[string]any)
toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list
@ -259,7 +246,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
al.RegisterTool(testTool)
info := al.GetStartupInfo()
toolsInfo := info["tools"].(map[string]interface{})
toolsInfo := info["tools"].(map[string]any)
toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list
@ -306,7 +293,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
t.Fatal("Expected 'tools' key in startup info")
}
toolsMap, ok := toolsInfo.(map[string]interface{})
toolsMap, ok := toolsInfo.(map[string]any)
if !ok {
t.Fatal("Expected 'tools' to be a map")
}
@ -362,7 +349,13 @@ type simpleMockProvider struct {
response string
}
func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
func (m *simpleMockProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
return &providers.LLMResponse{
Content: m.response,
ToolCalls: []providers.ToolCall{},
@ -384,14 +377,14 @@ func (m *mockCustomTool) Description() string {
return "Mock custom tool for testing"
}
func (m *mockCustomTool) Parameters() map[string]interface{} {
return map[string]interface{}{
func (m *mockCustomTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]interface{}{},
"properties": map[string]any{},
}
}
func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.SilentResult("Custom tool executed")
}
@ -409,14 +402,14 @@ func (m *mockContextualTool) Description() string {
return "Mock contextual tool"
}
func (m *mockContextualTool) Parameters() map[string]interface{} {
return map[string]interface{}{
func (m *mockContextualTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]interface{}{},
"properties": map[string]any{},
}
}
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.SilentResult("Contextual tool executed")
}
@ -527,3 +520,114 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
t.Errorf("Expected 'Command output: hello world', got: %s", response)
}
}
// failFirstMockProvider fails on the first N calls with a specific error
type failFirstMockProvider struct {
failures int
currentCall int
failError error
successResp string
}
func (m *failFirstMockProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
m.currentCall++
if m.currentCall <= m.failures {
return nil, m.failError
}
return &providers.LLMResponse{
Content: m.successResp,
ToolCalls: []providers.ToolCall{},
}, nil
}
func (m *failFirstMockProvider) GetDefaultModel() string {
return "mock-fail-model"
}
// TestAgentLoop_ContextExhaustionRetry verify that the agent retries on context errors
func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
// Create a provider that fails once with a context error
contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens")
provider := &failFirstMockProvider{
failures: 1,
failError: contextErr,
successResp: "Recovered from context error",
}
al := NewAgentLoop(cfg, msgBus, provider)
// Inject some history to simulate a full context
sessionKey := "test-session-context"
// Create dummy history
history := []providers.Message{
{Role: "system", Content: "System prompt"},
{Role: "user", Content: "Old message 1"},
{Role: "assistant", Content: "Old response 1"},
{Role: "user", Content: "Old message 2"},
{Role: "assistant", Content: "Old response 2"},
{Role: "user", Content: "Trigger message"},
}
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("No default agent found")
}
defaultAgent.Sessions.SetHistory(sessionKey, history)
// Call ProcessDirectWithChannel
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
response, err := al.ProcessDirectWithChannel(
context.Background(),
"Trigger message",
sessionKey,
"test",
"test-chat",
)
if err != nil {
t.Fatalf("Expected success after retry, got error: %v", err)
}
if response != "Recovered from context error" {
t.Errorf("Expected 'Recovered from context error', got '%s'", response)
}
// We expect 2 calls: 1st failed, 2nd succeeded
if provider.currentCall != 2 {
t.Errorf("Expected 2 calls (1 fail + 1 success), got %d", provider.currentCall)
}
// Check final history length
finalHistory := defaultAgent.Sessions.GetHistory(sessionKey)
// We verify that the history has been modified (compressed)
// Original length: 6
// Expected behavior: compression drops ~50% of history (mid slice)
// We can assert that the length is NOT what it would be without compression.
// Without compression: 6 + 1 (new user msg) + 1 (assistant msg) = 8
if len(finalHistory) >= 8 {
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
}
}

View file

@ -10,6 +10,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
@ -29,7 +30,7 @@ func NewMemoryStore(workspace string) *MemoryStore {
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
// Ensure memory directory exists
os.MkdirAll(memoryDir, 0755)
os.MkdirAll(memoryDir, 0o755)
return &MemoryStore{
workspace: workspace,
@ -57,7 +58,7 @@ func (ms *MemoryStore) ReadLongTerm() string {
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error {
return os.WriteFile(ms.memoryFile, []byte(content), 0644)
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
}
// ReadToday reads today's daily note.
@ -77,7 +78,7 @@ func (ms *MemoryStore) AppendToday(content string) error {
// Ensure month directory exists
monthDir := filepath.Dir(todayFile)
os.MkdirAll(monthDir, 0755)
os.MkdirAll(monthDir, 0o755)
var existingContent string
if data, err := os.ReadFile(todayFile); err == nil {
@ -94,13 +95,14 @@ func (ms *MemoryStore) AppendToday(content string) error {
newContent = existingContent + "\n" + content
}
return os.WriteFile(todayFile, []byte(newContent), 0644)
return os.WriteFile(todayFile, []byte(newContent), 0o644)
}
// GetRecentDailyNotes returns daily notes from the last N days.
// Contents are joined with "---" separator.
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
var notes []string
var sb strings.Builder
first := true
for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -i)
@ -109,53 +111,41 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
if data, err := os.ReadFile(filePath); err == nil {
notes = append(notes, string(data))
if !first {
sb.WriteString("\n\n---\n\n")
}
sb.Write(data)
first = false
}
}
if len(notes) == 0 {
return ""
}
// Join with separator
var result string
for i, note := range notes {
if i > 0 {
result += "\n\n---\n\n"
}
result += note
}
return result
return sb.String()
}
// GetMemoryContext returns formatted memory context for the agent prompt.
// Includes long-term memory and recent daily notes.
func (ms *MemoryStore) GetMemoryContext() string {
var parts []string
// Long-term memory
longTerm := ms.ReadLongTerm()
if longTerm != "" {
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
}
// Recent daily notes (last 3 days)
recentNotes := ms.GetRecentDailyNotes(3)
if recentNotes != "" {
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
}
if len(parts) == 0 {
if longTerm == "" && recentNotes == "" {
return ""
}
// Join parts with separator
var result string
for i, part := range parts {
if i > 0 {
result += "\n\n---\n\n"
}
result += part
var sb strings.Builder
if longTerm != "" {
sb.WriteString("## Long-term Memory\n\n")
sb.WriteString(longTerm)
}
return fmt.Sprintf("# Memory\n\n%s", result)
if recentNotes != "" {
if longTerm != "" {
sb.WriteString("\n\n---\n\n")
}
sb.WriteString("## Recent Daily Notes\n\n")
sb.WriteString(recentNotes)
}
return sb.String()
}

View file

@ -0,0 +1,26 @@
package agent
import (
"context"
"github.com/sipeed/picoclaw/pkg/providers"
)
type mockProvider struct{}
func (m *mockProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
return &providers.LLMResponse{
Content: "Mock response",
ToolCalls: []providers.ToolCall{},
}, nil
}
func (m *mockProvider) GetDefaultModel() string {
return "mock-model"
}

114
pkg/agent/registry.go Normal file
View file

@ -0,0 +1,114 @@
package agent
import (
"sync"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
)
// AgentRegistry manages multiple agent instances and routes messages to them.
type AgentRegistry struct {
agents map[string]*AgentInstance
resolver *routing.RouteResolver
mu sync.RWMutex
}
// NewAgentRegistry creates a registry from config, instantiating all agents.
func NewAgentRegistry(
cfg *config.Config,
provider providers.LLMProvider,
) *AgentRegistry {
registry := &AgentRegistry{
agents: make(map[string]*AgentInstance),
resolver: routing.NewRouteResolver(cfg),
}
agentConfigs := cfg.Agents.List
if len(agentConfigs) == 0 {
implicitAgent := &config.AgentConfig{
ID: "main",
Default: true,
}
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
registry.agents["main"] = instance
logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
} else {
for i := range agentConfigs {
ac := &agentConfigs[i]
id := routing.NormalizeAgentID(ac.ID)
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
registry.agents[id] = instance
logger.InfoCF("agent", "Registered agent",
map[string]any{
"agent_id": id,
"name": ac.Name,
"workspace": instance.Workspace,
"model": instance.Model,
})
}
}
return registry
}
// GetAgent returns the agent instance for a given ID.
func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
id := routing.NormalizeAgentID(agentID)
agent, ok := r.agents[id]
return agent, ok
}
// ResolveRoute determines which agent handles the message.
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
return r.resolver.ResolveRoute(input)
}
// ListAgentIDs returns all registered agent IDs.
func (r *AgentRegistry) ListAgentIDs() []string {
r.mu.RLock()
defer r.mu.RUnlock()
ids := make([]string, 0, len(r.agents))
for id := range r.agents {
ids = append(ids, id)
}
return ids
}
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
parent, ok := r.GetAgent(parentAgentID)
if !ok {
return false
}
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
return false
}
targetNorm := routing.NormalizeAgentID(targetAgentID)
for _, allowed := range parent.Subagents.AllowAgents {
if allowed == "*" {
return true
}
if routing.NormalizeAgentID(allowed) == targetNorm {
return true
}
}
return false
}
// GetDefaultAgent returns the default agent instance.
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
r.mu.RLock()
defer r.mu.RUnlock()
if agent, ok := r.agents["main"]; ok {
return agent
}
for _, agent := range r.agents {
return agent
}
return nil
}

205
pkg/agent/registry_test.go Normal file
View file

@ -0,0 +1,205 @@
package agent
import (
"context"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
type mockRegistryProvider struct{}
func (m *mockRegistryProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
options map[string]any,
) (*providers.LLMResponse, error) {
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
}
func (m *mockRegistryProvider) GetDefaultModel() string {
return "mock-model"
}
func testCfg(agents []config.AgentConfig) *config.Config {
return &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: "/tmp/picoclaw-test-registry",
Model: "gpt-4",
MaxTokens: 8192,
MaxToolIterations: 10,
},
List: agents,
},
}
}
func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
cfg := testCfg(nil)
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
ids := registry.ListAgentIDs()
if len(ids) != 1 || ids[0] != "main" {
t.Errorf("expected implicit main agent, got %v", ids)
}
agent, ok := registry.GetAgent("main")
if !ok || agent == nil {
t.Fatal("expected to find 'main' agent")
}
if agent.ID != "main" {
t.Errorf("agent.ID = %q, want 'main'", agent.ID)
}
}
func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{ID: "sales", Default: true, Name: "Sales Bot"},
{ID: "support", Name: "Support Bot"},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
ids := registry.ListAgentIDs()
if len(ids) != 2 {
t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
}
sales, ok := registry.GetAgent("sales")
if !ok || sales == nil {
t.Fatal("expected to find 'sales' agent")
}
if sales.Name != "Sales Bot" {
t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
}
support, ok := registry.GetAgent("support")
if !ok || support == nil {
t.Fatal("expected to find 'support' agent")
}
}
func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{ID: "my-agent", Default: true},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, ok := registry.GetAgent("My-Agent")
if !ok || agent == nil {
t.Fatal("expected to find agent with normalized ID")
}
if agent.ID != "my-agent" {
t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
}
}
func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{ID: "alpha"},
{ID: "beta", Default: true},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
// GetDefaultAgent first checks for "main", then returns any
agent := registry.GetDefaultAgent()
if agent == nil {
t.Fatal("expected a default agent")
}
}
func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{
ID: "parent",
Default: true,
Subagents: &config.SubagentsConfig{
AllowAgents: []string{"child1", "child2"},
},
},
{ID: "child1"},
{ID: "child2"},
{ID: "restricted"},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
if !registry.CanSpawnSubagent("parent", "child1") {
t.Error("expected parent to be allowed to spawn child1")
}
if !registry.CanSpawnSubagent("parent", "child2") {
t.Error("expected parent to be allowed to spawn child2")
}
if registry.CanSpawnSubagent("parent", "restricted") {
t.Error("expected parent to NOT be allowed to spawn restricted")
}
if registry.CanSpawnSubagent("child1", "child2") {
t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
}
}
func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{
ID: "admin",
Default: true,
Subagents: &config.SubagentsConfig{
AllowAgents: []string{"*"},
},
},
{ID: "any-agent"},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
if !registry.CanSpawnSubagent("admin", "any-agent") {
t.Error("expected wildcard to allow spawning any agent")
}
if !registry.CanSpawnSubagent("admin", "nonexistent") {
t.Error("expected wildcard to allow spawning even nonexistent agents")
}
}
func TestAgentInstance_Model(t *testing.T) {
model := &config.AgentModelConfig{Primary: "claude-opus"}
cfg := testCfg([]config.AgentConfig{
{ID: "custom", Default: true, Model: model},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, _ := registry.GetAgent("custom")
if agent.Model != "claude-opus" {
t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
}
}
func TestAgentInstance_FallbackInheritance(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{ID: "inherit", Default: true},
})
cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, _ := registry.GetAgent("inherit")
if len(agent.Fallbacks) != 2 {
t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
}
}
func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
model := &config.AgentModelConfig{
Primary: "gpt-4",
Fallbacks: []string{}, // explicitly empty = disable
}
cfg := testCfg([]config.AgentConfig{
{ID: "no-fallback", Default: true, Model: model},
})
cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, _ := registry.GetAgent("no-fallback")
if len(agent.Fallbacks) != 0 {
t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
}
}

View file

@ -1,6 +1,7 @@
package auth
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
@ -11,6 +12,7 @@ import (
"net"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strconv"
@ -19,11 +21,13 @@ import (
)
type OAuthProviderConfig struct {
Issuer string
ClientID string
Scopes string
Originator string
Port int
Issuer string
ClientID string
ClientSecret string // Required for Google OAuth (confidential client)
TokenURL string // Override token endpoint (Google uses a different URL than issuer)
Scopes string
Originator string
Port int
}
func OpenAIOAuthConfig() OAuthProviderConfig {
@ -36,6 +40,32 @@ func OpenAIOAuthConfig() OAuthProviderConfig {
}
}
// GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity).
// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
// These are the same client credentials used by the OpenCode antigravity plugin.
clientID := decodeBase64(
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
)
clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
return OAuthProviderConfig{
Issuer: "https://accounts.google.com/o/oauth2/v2",
TokenURL: "https://oauth2.googleapis.com/token",
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs",
Port: 51121,
}
}
func decodeBase64(s string) string {
data, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return s
}
return string(data)
}
func generateState() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
@ -101,8 +131,22 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
}
fmt.Println("If you're running in a headless environment, use: picoclaw auth login --provider openai --device-code")
fmt.Println("Waiting for authentication in browser...")
fmt.Printf(
"Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n",
cfg.Port,
)
fmt.Println(
"please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.",
)
fmt.Println("Waiting for authentication (browser or manual paste)...")
// Start manual input in a goroutine
manualCh := make(chan string)
go func() {
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
manualCh <- strings.TrimSpace(input)
}()
select {
case result := <-resultCh:
@ -110,6 +154,22 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
return nil, result.err
}
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
case manualInput := <-manualCh:
if manualInput == "" {
return nil, fmt.Errorf("manual input cancelled")
}
// Extract code from URL if it's a full URL
code := manualInput
if strings.Contains(manualInput, "?") {
u, err := url.Parse(manualInput)
if err == nil {
code = u.Query().Get("code")
}
}
if code == "" {
return nil, fmt.Errorf("could not find authorization code in input")
}
return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("authentication timed out after 5 minutes")
}
@ -200,8 +260,11 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
deviceResp.Interval = 5
}
fmt.Printf("\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
cfg.Issuer, deviceResp.UserCode)
fmt.Printf(
"\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
cfg.Issuer,
deviceResp.UserCode,
)
deadline := time.After(15 * time.Minute)
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
@ -269,8 +332,16 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
"refresh_token": {cred.RefreshToken},
"scope": {"openid profile email"},
}
if cfg.ClientSecret != "" {
data.Set("client_secret", cfg.ClientSecret)
}
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
tokenURL := cfg.Issuer + "/oauth/token"
if cfg.TokenURL != "" {
tokenURL = cfg.TokenURL
}
resp, err := http.PostForm(tokenURL, data)
if err != nil {
return nil, fmt.Errorf("refreshing token: %w", err)
}
@ -281,7 +352,23 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
return nil, fmt.Errorf("token refresh failed: %s", string(body))
}
return parseTokenResponse(body, cred.Provider)
refreshed, err := parseTokenResponse(body, cred.Provider)
if err != nil {
return nil, err
}
if refreshed.RefreshToken == "" {
refreshed.RefreshToken = cred.RefreshToken
}
if refreshed.AccountID == "" {
refreshed.AccountID = cred.AccountID
}
if cred.Email != "" && refreshed.Email == "" {
refreshed.Email = cred.Email
}
if cred.ProjectID != "" && refreshed.ProjectID == "" {
refreshed.ProjectID = cred.ProjectID
}
return refreshed, nil
}
func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
@ -290,18 +377,35 @@ func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
params := url.Values{
"response_type": {"code"},
"client_id": {cfg.ClientID},
"redirect_uri": {redirectURI},
"scope": {cfg.Scopes},
"code_challenge": {pkce.CodeChallenge},
"code_challenge_method": {"S256"},
"id_token_add_organizations": {"true"},
"codex_cli_simplified_flow": {"true"},
"state": {state},
"response_type": {"code"},
"client_id": {cfg.ClientID},
"redirect_uri": {redirectURI},
"scope": {cfg.Scopes},
"code_challenge": {pkce.CodeChallenge},
"code_challenge_method": {"S256"},
"state": {state},
}
if cfg.Originator != "" {
params.Set("originator", cfg.Originator)
isGoogle := strings.Contains(strings.ToLower(cfg.Issuer), "accounts.google.com")
if isGoogle {
// Google OAuth requires these for refresh token support
params.Set("access_type", "offline")
params.Set("prompt", "consent")
} else {
// OpenAI-specific parameters
params.Set("id_token_add_organizations", "true")
params.Set("codex_cli_simplified_flow", "true")
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
params.Set("originator", "picoclaw")
}
if cfg.Originator != "" {
params.Set("originator", cfg.Originator)
}
}
// Google uses /auth path, OpenAI uses /oauth/authorize
if isGoogle {
return cfg.Issuer + "/auth?" + params.Encode()
}
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
}
@ -314,8 +418,22 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
"client_id": {cfg.ClientID},
"code_verifier": {codeVerifier},
}
if cfg.ClientSecret != "" {
data.Set("client_secret", cfg.ClientSecret)
}
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
tokenURL := cfg.Issuer + "/oauth/token"
if cfg.TokenURL != "" {
tokenURL = cfg.TokenURL
}
// Determine provider name from config
provider := "openai"
if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") {
provider = "google-antigravity"
}
resp, err := http.PostForm(tokenURL, data)
if err != nil {
return nil, fmt.Errorf("exchanging code for tokens: %w", err)
}
@ -326,7 +444,7 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
return nil, fmt.Errorf("token exchange failed: %s", string(body))
}
return parseTokenResponse(body, "openai")
return parseTokenResponse(body, provider)
}
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
@ -357,7 +475,9 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
AuthMethod: "oauth",
}
if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
cred.AccountID = accountID
} else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
cred.AccountID = accountID
} else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
// Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims.
@ -367,12 +487,45 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
return cred, nil
}
func extractAccountID(accessToken string) string {
parts := strings.Split(accessToken, ".")
if len(parts) < 2 {
func extractAccountID(token string) string {
claims, err := parseJWTClaims(token)
if err != nil {
return ""
}
if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" {
return accountID
}
if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" {
return accountID
}
if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]any); ok {
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
return accountID
}
}
if orgs, ok := claims["organizations"].([]any); ok {
for _, org := range orgs {
if orgMap, ok := org.(map[string]any); ok {
if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
return accountID
}
}
}
}
return ""
}
func parseJWTClaims(token string) (map[string]any, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("token is not a JWT")
}
payload := parts[1]
switch len(payload) % 4 {
case 2:
@ -383,21 +536,15 @@ func extractAccountID(accessToken string) string {
decoded, err := base64URLDecode(payload)
if err != nil {
return ""
return nil, err
}
var claims map[string]interface{}
var claims map[string]any
if err := json.Unmarshal(decoded, &claims); err != nil {
return ""
return nil, err
}
if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok {
return accountID
}
}
return ""
return claims, nil
}
func base64URLDecode(s string) ([]byte, error) {

View file

@ -5,10 +5,23 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func makeJWTForClaims(t *testing.T, claims map[string]any) string {
t.Helper()
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
payloadJSON, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
payload := base64.RawURLEncoding.EncodeToString(payloadJSON)
return header + "." + payload + ".sig"
}
func TestBuildAuthorizeURL(t *testing.T) {
cfg := OAuthProviderConfig{
Issuer: "https://auth.example.com",
@ -53,8 +66,30 @@ func TestBuildAuthorizeURL(t *testing.T) {
}
}
func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
cfg := OpenAIOAuthConfig()
pkce := PKCECodes{CodeVerifier: "test-verifier", CodeChallenge: "test-challenge"}
u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("url.Parse() error: %v", err)
}
q := parsed.Query()
if q.Get("id_token_add_organizations") != "true" {
t.Errorf("id_token_add_organizations = %q, want true", q.Get("id_token_add_organizations"))
}
if q.Get("codex_cli_simplified_flow") != "true" {
t.Errorf("codex_cli_simplified_flow = %q, want true", q.Get("codex_cli_simplified_flow"))
}
if q.Get("originator") != "codex_cli_rs" {
t.Errorf("originator = %q, want codex_cli_rs", q.Get("originator"))
}
}
func TestParseTokenResponse(t *testing.T) {
resp := map[string]interface{}{
resp := map[string]any{
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"expires_in": 3600,
@ -84,6 +119,37 @@ func TestParseTokenResponse(t *testing.T) {
}
}
func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
idToken := makeJWTForClaims(t, map[string]any{"chatgpt_account_id": "acc-id-from-id-token"})
resp := map[string]any{
"access_token": "opaque-access-token",
"refresh_token": "test-refresh-token",
"expires_in": 3600,
"id_token": idToken,
}
body, _ := json.Marshal(resp)
cred, err := parseTokenResponse(body, "openai")
if err != nil {
t.Fatalf("parseTokenResponse() error: %v", err)
}
if cred.AccountID != "acc-id-from-id-token" {
t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-id-from-id-token")
}
}
func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
token := makeJWTForClaims(t, map[string]any{
"organizations": []any{
map[string]any{"id": "org_from_orgs"},
},
})
if got := extractAccountID(token); got != "org_from_orgs" {
t.Errorf("extractAccountID() = %q, want %q", got, "org_from_orgs")
}
}
func TestParseTokenResponseNoAccessToken(t *testing.T) {
body := []byte(`{"refresh_token": "test"}`)
_, err := parseTokenResponse(body, "openai")
@ -94,7 +160,7 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) {
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
idToken := makeJWTWithAccountID("acc-from-id")
resp := map[string]interface{}{
resp := map[string]any{
"access_token": "not-a-jwt",
"refresh_token": "test-refresh-token",
"expires_in": 3600,
@ -114,7 +180,9 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
func makeJWTWithAccountID(accountID string) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`))
payload := base64.RawURLEncoding.EncodeToString(
[]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`),
)
return header + "." + payload + ".sig"
}
@ -135,7 +203,7 @@ func TestExchangeCodeForTokens(t *testing.T) {
return
}
resp := map[string]interface{}{
resp := map[string]any{
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_in": 3600,
@ -174,7 +242,7 @@ func TestRefreshAccessToken(t *testing.T) {
return
}
resp := map[string]interface{}{
resp := map[string]any{
"access_token": "refreshed-access-token",
"refresh_token": "refreshed-refresh-token",
"expires_in": 3600,
@ -222,6 +290,37 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
}
}
func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"access_token": "new-access-token-only",
"expires_in": 3600,
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
cfg := OAuthProviderConfig{Issuer: server.URL, ClientID: "test-client"}
cred := &AuthCredential{
AccessToken: "old-access",
RefreshToken: "existing-refresh",
AccountID: "acc_existing",
Provider: "openai",
AuthMethod: "oauth",
}
refreshed, err := RefreshAccessToken(cred, cfg)
if err != nil {
t.Fatalf("RefreshAccessToken() error: %v", err)
}
if refreshed.RefreshToken != "existing-refresh" {
t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "existing-refresh")
}
if refreshed.AccountID != "acc_existing" {
t.Errorf("AccountID = %q, want %q", refreshed.AccountID, "acc_existing")
}
}
func TestOpenAIOAuthConfig(t *testing.T) {
cfg := OpenAIOAuthConfig()
if cfg.Issuer != "https://auth.openai.com" {

View file

@ -14,6 +14,8 @@ type AuthCredential struct {
ExpiresAt time.Time `json:"expires_at,omitempty"`
Provider string `json:"provider"`
AuthMethod string `json:"auth_method"`
Email string `json:"email,omitempty"`
ProjectID string `json:"project_id,omitempty"`
}
type AuthStore struct {
@ -62,7 +64,7 @@ func LoadStore() (*AuthStore, error) {
func SaveStore(store *AuthStore) error {
path := authFilePath()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@ -70,7 +72,7 @@ func SaveStore(store *AuthStore) error {
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
return os.WriteFile(path, data, 0o600)
}
func GetCredential(provider string) (*AuthCredential, error) {

View file

@ -108,7 +108,7 @@ func TestStoreFilePermissions(t *testing.T) {
t.Fatalf("Stat() error: %v", err)
}
perm := info.Mode().Perm()
if perm != 0600 {
if perm != 0o600 {
t.Errorf("file permissions = %o, want 0600", perm)
}
}

View file

@ -9,6 +9,7 @@ type MessageBus struct {
inbound chan InboundMessage
outbound chan OutboundMessage
handlers map[string]MessageHandler
closed bool
mu sync.RWMutex
}
@ -21,6 +22,11 @@ func NewMessageBus() *MessageBus {
}
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
mb.mu.RLock()
defer mb.mu.RUnlock()
if mb.closed {
return
}
mb.inbound <- msg
}
@ -34,6 +40,11 @@ func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool)
}
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
mb.mu.RLock()
defer mb.mu.RUnlock()
if mb.closed {
return
}
mb.outbound <- msg
}
@ -60,6 +71,12 @@ func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
}
func (mb *MessageBus) Close() {
mb.mu.Lock()
defer mb.mu.Unlock()
if mb.closed {
return
}
mb.closed = true
close(mb.inbound)
close(mb.outbound)
}

View file

@ -2,7 +2,6 @@ package channels
import (
"context"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
@ -18,14 +17,14 @@ type Channel interface {
}
type BaseChannel struct {
config interface{}
config any
bus *bus.MessageBus
running bool
name string
allowList []string
}
func NewBaseChannel(name string, config interface{}, bus *bus.MessageBus, allowList []string) *BaseChannel {
func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel {
return &BaseChannel{
config: config,
bus: bus,
@ -87,17 +86,13 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
return
}
// Build session key: channel:chatID
sessionKey := fmt.Sprintf("%s:%s", c.name, chatID)
msg := bus.InboundMessage{
Channel: c.name,
SenderID: senderID,
ChatID: chatID,
Content: content,
Media: media,
SessionKey: sessionKey,
Metadata: metadata,
Channel: c.name,
SenderID: senderID,
ChatID: chatID,
Content: content,
Media: media,
Metadata: metadata,
}
c.bus.PublishInbound(msg)

View file

@ -10,6 +10,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@ -108,7 +109,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID)
}
logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{
logger.DebugCF("dingtalk", "Sending message", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
@ -120,12 +121,15 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
// This is called by the Stream SDK when a new message arrives
// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error)
func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
func (c *DingTalkChannel) onChatBotMessageReceived(
ctx context.Context,
data *chatbot.BotCallbackDataModel,
) ([]byte, error) {
// Extract message content from Text field
content := data.Text.Content
if content == "" {
// Try to extract from Content interface{} if Text is empty
if contentMap, ok := data.Content.(map[string]interface{}); ok {
if contentMap, ok := data.Content.(map[string]any); ok {
if textContent, ok := contentMap["content"].(string); ok {
content = textContent
}
@ -155,7 +159,15 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch
"session_webhook": data.SessionWebhook,
}
logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
if data.ConversationType == "1" {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
} else {
metadata["peer_kind"] = "group"
metadata["peer_id"] = data.ConversationId
}
logger.DebugCF("dingtalk", "Received message", map[string]any{
"sender_nick": senderNick,
"sender_id": senderID,
"preview": utils.Truncate(content, 50),
@ -184,7 +196,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
titleBytes,
contentBytes,
)
if err != nil {
return fmt.Errorf("failed to send reply: %w", err)
}

View file

@ -4,9 +4,12 @@ import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@ -25,6 +28,9 @@ type DiscordChannel struct {
config config.DiscordConfig
transcriber *voice.GroqTranscriber
ctx context.Context
typingMu sync.Mutex
typingStop map[string]chan struct{} // chatID → stop signal
botUserID string // stored for mention checking
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
@ -41,6 +47,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
config: cfg,
transcriber: nil,
ctx: context.Background(),
typingStop: make(map[string]chan struct{}),
}, nil
}
@ -59,6 +66,14 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
logger.InfoC("discord", "Starting Discord bot")
c.ctx = ctx
// Get bot user ID before opening session to avoid race condition
botUser, err := c.session.User("@me")
if err != nil {
return fmt.Errorf("failed to get bot user: %w", err)
}
c.botUserID = botUser.ID
c.session.AddHandler(c.handleMessage)
if err := c.session.Open(); err != nil {
@ -67,10 +82,6 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
c.setRunning(true)
botUser, err := c.session.User("@me")
if err != nil {
return fmt.Errorf("failed to get bot user: %w", err)
}
logger.InfoCF("discord", "Discord bot connected", map[string]any{
"username": botUser.Username,
"user_id": botUser.ID,
@ -83,6 +94,14 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
logger.InfoC("discord", "Stopping Discord bot")
c.setRunning(false)
// Stop all typing goroutines before closing session
c.typingMu.Lock()
for chatID, stop := range c.typingStop {
close(stop)
delete(c.typingStop, chatID)
}
c.typingMu.Unlock()
if err := c.session.Close(); err != nil {
return fmt.Errorf("failed to close discord session: %w", err)
}
@ -91,6 +110,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
}
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
c.stopTyping(msg.ChatID)
if !c.IsRunning() {
return fmt.Errorf("discord bot not running")
}
@ -100,15 +121,30 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("channel ID is empty")
}
message := msg.Content
runes := []rune(msg.Content)
if len(runes) == 0 {
return nil
}
// 使用传入的 ctx 进行超时控制
chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
for _, chunk := range chunks {
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
return err
}
}
return nil
}
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
// Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := c.session.ChannelMessageSend(channelID, message)
_, err := c.session.ChannelMessageSend(channelID, content)
done <- err
}()
@ -123,7 +159,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
}
}
// appendContent 安全地追加内容到现有文本
// appendContent safely appends content to existing text
func appendContent(content, suffix string) string {
if content == "" {
return suffix
@ -140,7 +176,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
// 检查白名单,避免为被拒绝的用户下载附件和转录
// Check allowlist first to avoid downloading attachments and transcribing for rejected users
if !c.IsAllowed(m.Author.ID) {
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
"user_id": m.Author.ID,
@ -148,6 +184,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
// If configured to only respond to mentions, check if bot is mentioned
// Skip this check for DMs (GuildID is empty) - DMs should always be responded to
if c.config.MentionOnly && m.GuildID != "" {
isMentioned := false
for _, mention := range m.Mentions {
if mention.ID == c.botUserID {
isMentioned = true
break
}
}
if !isMentioned {
logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{
"user_id": m.Author.ID,
})
return
}
}
senderID := m.Author.ID
senderName := m.Author.Username
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
@ -155,10 +209,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
}
content := m.Content
content = c.stripBotMention(content)
mediaPaths := make([]string, 0, len(m.Attachments))
localFiles := make([]string, 0, len(m.Attachments))
// 确保临时文件在函数返回时被清理
// Ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
@ -182,7 +237,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
result, err := c.transcriber.Transcribe(ctx, localPath)
cancel() // 立即释放context资源避免在for循环中泄漏
cancel() // Release context resources immediately to avoid leaks in for loop
if err != nil {
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
@ -222,12 +277,22 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
content = "[media only]"
}
// Start typing after all early returns — guaranteed to have a matching Send()
c.startTyping(m.ChannelID)
logger.DebugCF("discord", "Received message", map[string]any{
"sender_name": senderName,
"sender_id": senderID,
"preview": utils.Truncate(content, 50),
})
peerKind := "channel"
peerID := m.ChannelID
if m.GuildID == "" {
peerKind = "direct"
peerID = senderID
}
metadata := map[string]string{
"message_id": m.ID,
"user_id": senderID,
@ -236,13 +301,73 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
"guild_id": m.GuildID,
"channel_id": m.ChannelID,
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
"peer_kind": peerKind,
"peer_id": peerID,
}
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
}
// startTyping starts a continuous typing indicator loop for the given chatID.
// It stops any existing typing loop for that chatID before starting a new one.
func (c *DiscordChannel) startTyping(chatID string) {
c.typingMu.Lock()
// Stop existing loop for this chatID if any
if stop, ok := c.typingStop[chatID]; ok {
close(stop)
}
stop := make(chan struct{})
c.typingStop[chatID] = stop
c.typingMu.Unlock()
go func() {
if err := c.session.ChannelTyping(chatID); err != nil {
logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
}
ticker := time.NewTicker(8 * time.Second)
defer ticker.Stop()
timeout := time.After(5 * time.Minute)
for {
select {
case <-stop:
return
case <-timeout:
return
case <-c.ctx.Done():
return
case <-ticker.C:
if err := c.session.ChannelTyping(chatID); err != nil {
logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
}
}
}
}()
}
// stopTyping stops the typing indicator loop for the given chatID.
func (c *DiscordChannel) stopTyping(chatID string) {
c.typingMu.Lock()
defer c.typingMu.Unlock()
if stop, ok := c.typingStop[chatID]; ok {
close(stop)
delete(c.typingStop, chatID)
}
}
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
return utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "discord",
})
}
// stripBotMention removes the bot mention from the message content.
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
func (c *DiscordChannel) stripBotMention(text string) string {
if c.botUserID == "" {
return text
}
// Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID>
text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "")
text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "")
return strings.TrimSpace(text)
}

View file

@ -17,7 +17,9 @@ type FeishuChannel struct {
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config")
return nil, errors.New(
"feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
)
}
// Start is a stub method to satisfy the Channel interface

View file

@ -65,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
go func() {
if err := wsClient.Start(runCtx); err != nil {
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
"error": err.Error(),
})
}
@ -121,7 +121,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
}
logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
"chat_id": msg.ChatID,
})
@ -165,7 +165,16 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
metadata["tenant_key"] = *sender.TenantKey
}
logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
chatType := stringValue(message.ChatType)
if chatType == "p2p" {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
} else {
metadata["peer_kind"] = "group"
metadata["peer_id"] = chatID
}
logger.InfoCF("feishu", "Feishu message received", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 80),

View file

@ -75,11 +75,11 @@ func (c *LINEChannel) Start(ctx context.Context) error {
// Fetch bot profile to get bot's userId for mention detection
if err := c.fetchBotInfo(); err != nil {
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
"error": err.Error(),
})
} else {
logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
logger.InfoCF("line", "Bot info fetched", map[string]any{
"bot_user_id": c.botUserID,
"basic_id": c.botBasicID,
"display_name": c.botDisplayName,
@ -100,12 +100,12 @@ func (c *LINEChannel) Start(ctx context.Context) error {
}
go func() {
logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
logger.InfoCF("line", "LINE webhook server listening", map[string]any{
"addr": addr,
"path": path,
})
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
logger.ErrorCF("line", "Webhook server error", map[string]any{
"error": err.Error(),
})
}
@ -162,7 +162,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{
logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{
"error": err.Error(),
})
}
@ -182,7 +182,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
@ -200,7 +200,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
Events []lineEvent `json:"events"`
}
if err := json.Unmarshal(body, &payload); err != nil {
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
@ -266,7 +266,7 @@ type lineMentionee struct {
func (c *LINEChannel) processEvent(event lineEvent) {
if event.Type != "message" {
logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
"type": event.Type,
})
return
@ -278,7 +278,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
var msg lineMessage
if err := json.Unmarshal(event.Message, &msg); err != nil {
logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
logger.ErrorCF("line", "Failed to parse message", map[string]any{
"error": err.Error(),
})
return
@ -286,7 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
// In group chats, only respond when the bot is mentioned
if isGroup && !c.isBotMentioned(msg) {
logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{
logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
"chat_id": chatID,
})
return
@ -312,7 +312,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@ -366,7 +366,15 @@ func (c *LINEChannel) processEvent(event lineEvent) {
"message_id": msg.ID,
}
logger.DebugCF("line", "Received message", map[string]interface{}{
if isGroup {
metadata["peer_kind"] = "group"
metadata["peer_id"] = chatID
} else {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
}
logger.DebugCF("line", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"message_type": msg.Type,
@ -497,7 +505,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
tokenEntry := entry.(replyTokenEntry)
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
"chat_id": msg.ChatID,
"quoted": quoteToken != "",
})
@ -525,7 +533,7 @@ func buildTextMessage(content, quoteToken string) map[string]string {
// sendReply sends a message using the LINE Reply API.
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
payload := map[string]interface{}{
payload := map[string]any{
"replyToken": replyToken,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
@ -535,7 +543,7 @@ func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteT
// sendPush sends a message using the LINE Push API.
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
payload := map[string]interface{}{
payload := map[string]any{
"to": to,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
@ -545,19 +553,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri
// sendLoading sends a loading animation indicator to the chat.
func (c *LINEChannel) sendLoading(chatID string) {
payload := map[string]interface{}{
payload := map[string]any{
"chatId": chatID,
"loadingSeconds": 60,
}
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{
logger.DebugCF("line", "Failed to send loading indicator", map[string]any{
"error": err.Error(),
})
}
}
// callAPI makes an authenticated POST request to the LINE API.
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)

View file

@ -18,14 +18,13 @@ type MaixCamChannel struct {
listener net.Listener
clients map[net.Conn]bool
clientsMux sync.RWMutex
running bool
}
type MaixCamMessage struct {
Type string `json:"type"`
Tips string `json:"tips"`
Timestamp float64 `json:"timestamp"`
Data map[string]interface{} `json:"data"`
Type string `json:"type"`
Tips string `json:"tips"`
Timestamp float64 `json:"timestamp"`
Data map[string]any `json:"data"`
}
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC
BaseChannel: base,
config: cfg,
clients: make(map[net.Conn]bool),
running: false,
}, nil
}
@ -51,7 +49,7 @@ func (c *MaixCamChannel) Start(ctx context.Context) error {
c.listener = listener
c.setRunning(true)
logger.InfoCF("maixcam", "MaixCam server listening", map[string]interface{}{
logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
"host": c.config.Host,
"port": c.config.Port,
})
@ -73,14 +71,14 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
conn, err := c.listener.Accept()
if err != nil {
if c.running {
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
"error": err.Error(),
})
}
return
}
logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]interface{}{
logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{
"remote_addr": conn.RemoteAddr().String(),
})
@ -114,7 +112,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
var msg MaixCamMessage
if err := decoder.Decode(&msg); err != nil {
if err.Error() != "EOF" {
logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{
logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{
"error": err.Error(),
})
}
@ -135,14 +133,14 @@ func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) {
case "status":
c.handleStatusUpdate(msg)
default:
logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{
logger.WarnCF("maixcam", "Unknown message type", map[string]any{
"type": msg.Type,
})
}
}
func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
logger.InfoCF("maixcam", "", map[string]interface{}{
logger.InfoCF("maixcam", "", map[string]any{
"timestamp": msg.Timestamp,
"data": msg.Data,
})
@ -172,13 +170,15 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
"y": fmt.Sprintf("%.0f", y),
"w": fmt.Sprintf("%.0f", w),
"h": fmt.Sprintf("%.0f", h),
"peer_kind": "channel",
"peer_id": "default",
}
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
}
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
logger.InfoCF("maixcam", "Status update from MaixCam", map[string]interface{}{
logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{
"status": msg.Data,
})
}
@ -216,7 +216,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("no connected MaixCam devices")
}
response := map[string]interface{}{
response := map[string]any{
"type": "command",
"timestamp": float64(0),
"message": msg.Content,
@ -231,7 +231,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
var sendErr error
for conn := range c.clients {
if _, err := conn.Write(data); err != nil {
logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{
logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
"client": conn.RemoteAddr().String(),
"error": err.Error(),
})

View file

@ -48,9 +48,9 @@ func (m *Manager) initChannels() error {
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
logger.DebugC("channels", "Attempting to initialize Telegram channel")
telegram, err := NewTelegramChannel(m.config.Channels.Telegram, m.bus)
telegram, err := NewTelegramChannel(m.config, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{
"error": err.Error(),
})
} else {
@ -63,7 +63,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{
"error": err.Error(),
})
} else {
@ -76,7 +76,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Feishu channel")
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{
"error": err.Error(),
})
} else {
@ -89,7 +89,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Discord channel")
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{
"error": err.Error(),
})
} else {
@ -102,7 +102,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{
"error": err.Error(),
})
} else {
@ -115,7 +115,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize QQ channel")
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{
"error": err.Error(),
})
} else {
@ -128,7 +128,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{
"error": err.Error(),
})
} else {
@ -141,7 +141,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Slack channel")
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{
"error": err.Error(),
})
} else {
@ -154,7 +154,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize LINE channel")
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{
"error": err.Error(),
})
} else {
@ -167,7 +167,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize OneBot channel")
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{
"error": err.Error(),
})
} else {
@ -176,7 +176,33 @@ func (m *Manager) initChannels() error {
}
}
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
logger.DebugC("channels", "Attempting to initialize WeCom channel")
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["wecom"] = wecom
logger.InfoC("channels", "WeCom channel enabled successfully")
}
}
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["wecom_app"] = wecomApp
logger.InfoC("channels", "WeCom App channel enabled successfully")
}
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
@ -200,11 +226,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
go m.dispatchOutbound(dispatchCtx)
for name, channel := range m.channels {
logger.InfoCF("channels", "Starting channel", map[string]interface{}{
logger.InfoCF("channels", "Starting channel", map[string]any{
"channel": name,
})
if err := channel.Start(ctx); err != nil {
logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{
logger.ErrorCF("channels", "Failed to start channel", map[string]any{
"channel": name,
"error": err.Error(),
})
@ -227,11 +253,11 @@ func (m *Manager) StopAll(ctx context.Context) error {
}
for name, channel := range m.channels {
logger.InfoCF("channels", "Stopping channel", map[string]interface{}{
logger.InfoCF("channels", "Stopping channel", map[string]any{
"channel": name,
})
if err := channel.Stop(ctx); err != nil {
logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{
logger.ErrorCF("channels", "Error stopping channel", map[string]any{
"channel": name,
"error": err.Error(),
})
@ -262,14 +288,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
m.mu.RUnlock()
if !exists {
logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{
logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{
"channel": msg.Channel,
})
continue
}
if err := channel.Send(ctx, msg); err != nil {
logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{
logger.ErrorCF("channels", "Error sending message to channel", map[string]any{
"channel": msg.Channel,
"error": err.Error(),
})
@ -284,13 +310,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
return channel, ok
}
func (m *Manager) GetStatus() map[string]interface{} {
func (m *Manager) GetStatus() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
status := make(map[string]interface{})
status := make(map[string]any)
for name, channel := range m.channels {
status[name] = map[string]interface{}{
status[name] = map[string]any{
"enabled": true,
"running": channel.IsRunning(),
}

File diff suppressed because it is too large Load diff

View file

@ -47,47 +47,47 @@ func (c *QQChannel) Start(ctx context.Context) error {
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// 创建 token source
// create token source
credentials := &token.QQBotCredentials{
AppID: c.config.AppID,
AppSecret: c.config.AppSecret,
}
c.tokenSource = token.NewQQBotTokenSource(credentials)
// 创建子 context
// create child context
c.ctx, c.cancel = context.WithCancel(ctx)
// 启动自动刷新 token 协程
// start auto-refresh token goroutine
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
return fmt.Errorf("failed to start token refresh: %w", err)
}
// 初始化 OpenAPI 客户端
// initialize OpenAPI client
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
// 注册事件处理器
// register event handlers
intent := event.RegisterHandlers(
c.handleC2CMessage(),
c.handleGroupATMessage(),
)
// 获取 WebSocket 接入点
// get WebSocket endpoint
wsInfo, err := c.api.WS(c.ctx, nil, "")
if err != nil {
return fmt.Errorf("failed to get websocket info: %w", err)
}
logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{
logger.InfoCF("qq", "Got WebSocket info", map[string]any{
"shards": wsInfo.Shards,
})
// 创建并保存 sessionManager
// create and save sessionManager
c.sessionManager = botgo.NewSessionManager()
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
// start WebSocket connection in goroutine to avoid blocking
go func() {
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
"error": err.Error(),
})
c.setRunning(false)
@ -116,15 +116,15 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return fmt.Errorf("QQ bot not running")
}
// 构造消息
// construct message
msgToCreate := &dto.MessageToCreate{
Content: msg.Content,
}
// C2C 消息发送
// send C2C message
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
if err != nil {
logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
"error": err.Error(),
})
return err
@ -133,15 +133,15 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return nil
}
// handleC2CMessage 处理 QQ 私聊消息
// handleC2CMessage handles QQ private messages
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
// 去重检查
// deduplication check
if c.isDuplicate(data.ID) {
return nil
}
// 提取用户信息
// extract user info
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@ -150,21 +150,23 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil
}
// 提取消息内容
// extract message content
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty message, ignoring")
return nil
}
logger.InfoCF("qq", "Received C2C message", map[string]interface{}{
logger.InfoCF("qq", "Received C2C message", map[string]any{
"sender": senderID,
"length": len(content),
})
// 转发到消息总线
// forward to message bus
metadata := map[string]string{
"message_id": data.ID,
"peer_kind": "direct",
"peer_id": senderID,
}
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
@ -173,15 +175,15 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
}
}
// handleGroupATMessage 处理群@消息
// handleGroupATMessage handles group @messages
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
// 去重检查
// deduplication check
if c.isDuplicate(data.ID) {
return nil
}
// 提取用户信息
// extract user info
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@ -190,23 +192,25 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
// 提取消息内容(去掉 @ 机器人部分)
// extract message content (remove @bot part)
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
return nil
}
logger.InfoCF("qq", "Received group AT message", map[string]interface{}{
logger.InfoCF("qq", "Received group AT message", map[string]any{
"sender": senderID,
"group": data.GroupID,
"length": len(content),
})
// 转发到消息总线(使用 GroupID 作为 ChatID
// forward to message bus (use GroupID as ChatID)
metadata := map[string]string{
"message_id": data.ID,
"group_id": data.GroupID,
"peer_kind": "group",
"peer_id": data.GroupID,
}
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
@ -215,7 +219,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
}
}
// isDuplicate 检查消息是否重复
// isDuplicate checks if message is duplicate
func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock()
defer c.mu.Unlock()
@ -226,9 +230,9 @@ func (c *QQChannel) isDuplicate(messageID string) bool {
c.processedIDs[messageID] = true
// 简单清理:限制 map 大小
// simple cleanup: limit map size
if len(c.processedIDs) > 10000 {
// 清空一半
// clear half
count := 0
for id := range c.processedIDs {
if count >= 5000 {

View file

@ -25,6 +25,7 @@ type SlackChannel struct {
api *slack.Client
socketClient *socketmode.Client
botUserID string
teamID string
transcriber *voice.GroqTranscriber
ctx context.Context
cancel context.CancelFunc
@ -72,8 +73,9 @@ func (c *SlackChannel) Start(ctx context.Context) error {
return fmt.Errorf("slack auth test failed: %w", err)
}
c.botUserID = authResp.UserID
c.teamID = authResp.TeamID
logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
logger.InfoCF("slack", "Slack bot connected", map[string]any{
"bot_user_id": c.botUserID,
"team": authResp.Team,
})
@ -83,7 +85,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
go func() {
if err := c.socketClient.RunContext(c.ctx); err != nil {
if c.ctx.Err() == nil {
logger.ErrorCF("slack", "Socket Mode connection error", map[string]interface{}{
logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{
"error": err.Error(),
})
}
@ -138,7 +140,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
})
}
logger.DebugCF("slack", "Message sent", map[string]interface{}{
logger.DebugCF("slack", "Message sent", map[string]any{
"channel_id": channelID,
"thread_ts": threadTS,
})
@ -198,9 +200,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
return
}
// 检查白名单,避免为被拒绝的用户下载附件
// check allowlist to avoid downloading attachments for rejected users
if !c.IsAllowed(ev.User) {
logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
"user_id": ev.User,
})
return
@ -230,13 +232,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
content = c.stripBotMention(content)
var mediaPaths []string
localFiles := []string{} // 跟踪需要清理的本地文件
localFiles := []string{} // track local files that need cleanup
// 确保临时文件在函数返回时被清理
// ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@ -259,7 +261,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
result, err := c.transcriber.Transcribe(ctx, localPath)
if err != nil {
logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()})
logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()})
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
} else {
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
@ -274,14 +276,24 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
return
}
peerKind := "channel"
peerID := channelID
if strings.HasPrefix(channelID, "D") {
peerKind = "direct"
peerID = senderID
}
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
"peer_kind": peerKind,
"peer_id": peerID,
"team_id": c.teamID,
}
logger.DebugCF("slack", "Received message", map[string]interface{}{
logger.DebugCF("slack", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 50),
@ -296,6 +308,13 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
if !c.IsAllowed(ev.User) {
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
"user_id": ev.User,
})
return
}
senderID := ev.User
channelID := ev.Channel
threadTS := ev.ThreadTimeStamp
@ -324,12 +343,22 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
mentionPeerKind := "channel"
mentionPeerID := channelID
if strings.HasPrefix(channelID, "D") {
mentionPeerKind = "direct"
mentionPeerID = senderID
}
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
"is_mention": "true",
"peer_kind": mentionPeerKind,
"peer_id": mentionPeerID,
"team_id": c.teamID,
}
c.HandleMessage(senderID, chatID, content, nil, metadata)
@ -345,6 +374,13 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
c.socketClient.Ack(*event.Request)
}
if !c.IsAllowed(cmd.UserID) {
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
"user_id": cmd.UserID,
})
return
}
senderID := cmd.UserID
channelID := cmd.ChannelID
chatID := channelID
@ -359,9 +395,12 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"platform": "slack",
"is_command": "true",
"trigger_id": cmd.TriggerID,
"peer_kind": "channel",
"peer_id": channelID,
"team_id": c.teamID,
}
logger.DebugCF("slack", "Slash command received", map[string]interface{}{
logger.DebugCF("slack", "Slash command received", map[string]any{
"sender_id": senderID,
"command": cmd.Command,
"text": utils.Truncate(content, 50),
@ -376,7 +415,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
downloadURL = file.URLPrivate
}
if downloadURL == "" {
logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID})
logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID})
return ""
}

View file

@ -12,6 +12,8 @@ import (
"time"
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegohandler"
th "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/sipeed/picoclaw/pkg/bus"
@ -24,7 +26,8 @@ import (
type TelegramChannel struct {
*BaseChannel
bot *telego.Bot
config config.TelegramConfig
commands TelegramCommander
config *config.Config
chatIDs map[string]int64
transcriber *voice.GroqTranscriber
placeholders sync.Map // chatID -> messageID
@ -41,30 +44,39 @@ func (c *thinkingCancel) Cancel() {
}
}
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
var opts []telego.BotOption
telegramCfg := cfg.Channels.Telegram
if cfg.Proxy != "" {
proxyURL, parseErr := url.Parse(cfg.Proxy)
if telegramCfg.Proxy != "" {
proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
if parseErr != nil {
return nil, fmt.Errorf("invalid proxy URL %q: %w", cfg.Proxy, parseErr)
return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr)
}
opts = append(opts, telego.WithHTTPClient(&http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
}))
} else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
// Use environment proxy if configured
opts = append(opts, telego.WithHTTPClient(&http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
}))
}
bot, err := telego.NewBot(cfg.Token, opts...)
bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
base := NewBaseChannel("telegram", cfg, bus, cfg.AllowFrom)
base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom)
return &TelegramChannel{
BaseChannel: base,
commands: NewTelegramCommands(bot, cfg),
bot: bot,
config: cfg,
chatIDs: make(map[string]int64),
@ -88,26 +100,41 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start long polling: %w", err)
}
bh, err := telegohandler.NewBotHandler(c.bot, updates)
if err != nil {
return fmt.Errorf("failed to create bot handler: %w", err)
}
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
c.commands.Help(ctx, message)
return nil
}, th.CommandEqual("help"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.commands.Start(ctx, message)
}, th.CommandEqual("start"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.commands.Show(ctx, message)
}, th.CommandEqual("show"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.commands.List(ctx, message)
}, th.CommandEqual("list"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.handleMessage(ctx, &message)
}, th.AnyMessage())
c.setRunning(true)
logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{
logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
"username": c.bot.Username(),
})
go bh.Start()
go func() {
for {
select {
case <-ctx.Done():
return
case update, ok := <-updates:
if !ok {
logger.InfoC("telegram", "Updates channel closed, reconnecting...")
return
}
if update.Message != nil {
c.handleMessage(ctx, update)
}
}
}
<-ctx.Done()
bh.Stop()
}()
return nil
@ -155,7 +182,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
tgMsg.ParseMode = telego.ModeHTML
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
"error": err.Error(),
})
tgMsg.ParseMode = ""
@ -166,30 +193,27 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return nil
}
func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Update) {
message := update.Message
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
if message == nil {
return
return fmt.Errorf("message is nil")
}
user := message.From
if user == nil {
return
return fmt.Errorf("message sender (user) is nil")
}
userID := fmt.Sprintf("%d", user.ID)
senderID := userID
senderID := fmt.Sprintf("%d", user.ID)
if user.Username != "" {
senderID = fmt.Sprintf("%s|%s", userID, user.Username)
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
}
// 检查白名单,避免为被拒绝的用户下载附件
if !c.IsAllowed(userID) && !c.IsAllowed(senderID) {
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
"user_id": userID,
"username": user.Username,
// check allowlist to avoid downloading attachments for rejected users
if !c.IsAllowed(senderID) {
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
"user_id": senderID,
})
return
return nil
}
chatID := message.Chat.ID
@ -197,13 +221,13 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
content := ""
mediaPaths := []string{}
localFiles := []string{} // 跟踪需要清理的本地文件
localFiles := []string{} // track local files that need cleanup
// 确保临时文件在函数返回时被清理
// ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@ -222,7 +246,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
content += message.Caption
}
if message.Photo != nil && len(message.Photo) > 0 {
if len(message.Photo) > 0 {
photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
@ -231,7 +255,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
content += fmt.Sprintf("[image: photo]")
content += "[image: photo]"
}
}
@ -243,24 +267,24 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
transcribedText := ""
if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
result, err := c.transcriber.Transcribe(ctx, voicePath)
result, err := c.transcriber.Transcribe(transcriberCtx, voicePath)
if err != nil {
logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{
logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
"error": err.Error(),
"path": voicePath,
})
transcribedText = fmt.Sprintf("[voice (transcription failed)]")
transcribedText = "[voice (transcription failed)]"
} else {
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{
"text": result.Text,
})
}
} else {
transcribedText = fmt.Sprintf("[voice]")
transcribedText = "[voice]"
}
if content != "" {
@ -278,7 +302,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
content += fmt.Sprintf("[audio]")
content += "[audio]"
}
}
@ -290,7 +314,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
content += fmt.Sprintf("[file]")
content += "[file]"
}
}
@ -298,7 +322,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
content = "[empty message]"
}
logger.DebugCF("telegram", "Received message", map[string]interface{}{
logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": fmt.Sprintf("%d", chatID),
"preview": utils.Truncate(content, 50),
@ -307,7 +331,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
// Thinking indicator
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
if err != nil {
logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{
"error": err.Error(),
})
}
@ -330,21 +354,31 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
c.placeholders.Store(chatIDStr, pID)
}
peerKind := "direct"
peerID := fmt.Sprintf("%d", user.ID)
if message.Chat.Type != "private" {
peerKind = "group"
peerID = fmt.Sprintf("%d", chatID)
}
metadata := map[string]string{
"message_id": fmt.Sprintf("%d", message.MessageID),
"user_id": fmt.Sprintf("%d", user.ID),
"username": user.Username,
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
"peer_kind": peerKind,
"peer_id": peerID,
}
c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
return nil
}
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{
logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
"error": err.Error(),
})
return ""
@ -359,7 +393,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
}
url := c.bot.FileDownloadURL(file.FilePath)
logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url})
logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
// Use FilePath as filename for better identification
filename := file.FilePath + ext
@ -371,7 +405,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{
logger.ErrorCF("telegram", "Failed to get file", map[string]any{
"error": err.Error(),
})
return ""
@ -429,7 +463,11 @@ func markdownToTelegramHTML(text string) string {
for i, code := range codeBlocks.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("<pre><code>%s</code></pre>", escaped))
text = strings.ReplaceAll(
text,
fmt.Sprintf("\x00CB%d\x00", i),
fmt.Sprintf("<pre><code>%s</code></pre>", escaped),
)
}
return text

View file

@ -0,0 +1,156 @@
package channels
import (
"context"
"fmt"
"strings"
"github.com/mymmrac/telego"
"github.com/sipeed/picoclaw/pkg/config"
)
type TelegramCommander interface {
Help(ctx context.Context, message telego.Message) error
Start(ctx context.Context, message telego.Message) error
Show(ctx context.Context, message telego.Message) error
List(ctx context.Context, message telego.Message) error
}
type cmd struct {
bot *telego.Bot
config *config.Config
}
func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander {
return &cmd{
bot: bot,
config: cfg,
}
}
func commandArgs(text string) string {
parts := strings.SplitN(text, " ", 2)
if len(parts) < 2 {
return ""
}
return strings.TrimSpace(parts[1])
}
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
msg := `/start - Start the bot
/help - Show this help message
/show [model|channel] - Show current configuration
/list [models|channels] - List available options
`
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: message.Chat.ID},
Text: msg,
ReplyParameters: &telego.ReplyParameters{
MessageID: message.MessageID,
},
})
return err
}
func (c *cmd) Start(ctx context.Context, message telego.Message) error {
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: message.Chat.ID},
Text: "Hello! I am PicoClaw 🦞",
ReplyParameters: &telego.ReplyParameters{
MessageID: message.MessageID,
},
})
return err
}
func (c *cmd) Show(ctx context.Context, message telego.Message) error {
args := commandArgs(message.Text)
if args == "" {
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: message.Chat.ID},
Text: "Usage: /show [model|channel]",
ReplyParameters: &telego.ReplyParameters{
MessageID: message.MessageID,
},
})
return err
}
var response string
switch args {
case "model":
response = fmt.Sprintf("Current Model: %s (Provider: %s)",
c.config.Agents.Defaults.GetModelName(),
c.config.Agents.Defaults.Provider)
case "channel":
response = "Current Channel: telegram"
default:
response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args)
}
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: message.Chat.ID},
Text: response,
ReplyParameters: &telego.ReplyParameters{
MessageID: message.MessageID,
},
})
return err
}
func (c *cmd) List(ctx context.Context, message telego.Message) error {
args := commandArgs(message.Text)
if args == "" {
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: message.Chat.ID},
Text: "Usage: /list [models|channels]",
ReplyParameters: &telego.ReplyParameters{
MessageID: message.MessageID,
},
})
return err
}
var response string
switch args {
case "models":
provider := c.config.Agents.Defaults.Provider
if provider == "" {
provider = "configured default"
}
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
c.config.Agents.Defaults.GetModelName(), provider)
case "channels":
var enabled []string
if c.config.Channels.Telegram.Enabled {
enabled = append(enabled, "telegram")
}
if c.config.Channels.WhatsApp.Enabled {
enabled = append(enabled, "whatsapp")
}
if c.config.Channels.Feishu.Enabled {
enabled = append(enabled, "feishu")
}
if c.config.Channels.Discord.Enabled {
enabled = append(enabled, "discord")
}
if c.config.Channels.Slack.Enabled {
enabled = append(enabled, "slack")
}
response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))
default:
response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args)
}
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: message.Chat.ID},
Text: response,
ReplyParameters: &telego.ReplyParameters{
MessageID: message.MessageID,
},
})
return err
}

605
pkg/channels/wecom.go Normal file
View file

@ -0,0 +1,605 @@
// PicoClaw - Ultra-lightweight personal AI agent
// WeCom Bot (企业微信智能机器人) channel implementation
// Uses webhook callback mode for receiving messages and webhook API for sending replies
package channels
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
type WeComBotChannel struct {
*BaseChannel
config config.WeComConfig
server *http.Server
ctx context.Context
cancel context.CancelFunc
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
msgMu sync.RWMutex
}
// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
type WeComBotMessage struct {
MsgID string `json:"msgid"`
AIBotID string `json:"aibotid"`
ChatID string `json:"chatid"` // Session ID, only present for group chats
ChatType string `json:"chattype"` // "single" for DM, "group" for group chat
From struct {
UserID string `json:"userid"`
} `json:"from"`
ResponseURL string `json:"response_url"`
MsgType string `json:"msgtype"` // text, image, voice, file, mixed
Text struct {
Content string `json:"content"`
} `json:"text"`
Image struct {
URL string `json:"url"`
} `json:"image"`
Voice struct {
Content string `json:"content"` // Voice to text content
} `json:"voice"`
File struct {
URL string `json:"url"`
} `json:"file"`
Mixed struct {
MsgItem []struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
} `json:"text"`
Image struct {
URL string `json:"url"`
} `json:"image"`
} `json:"msg_item"`
} `json:"mixed"`
Quote struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
} `json:"text"`
} `json:"quote"`
}
// WeComBotReplyMessage represents the reply message structure
type WeComBotReplyMessage struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
} `json:"text,omitempty"`
}
// NewWeComBotChannel creates a new WeCom Bot channel instance
func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) {
if cfg.Token == "" || cfg.WebhookURL == "" {
return nil, fmt.Errorf("wecom token and webhook_url are required")
}
base := NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom)
return &WeComBotChannel{
BaseChannel: base,
config: cfg,
processedMsgs: make(map[string]bool),
}, nil
}
// Name returns the channel name
func (c *WeComBotChannel) Name() string {
return "wecom"
}
// Start initializes the WeCom Bot channel with HTTP webhook server
func (c *WeComBotChannel) Start(ctx context.Context) error {
logger.InfoC("wecom", "Starting WeCom Bot channel...")
c.ctx, c.cancel = context.WithCancel(ctx)
// Setup HTTP server for webhook
mux := http.NewServeMux()
webhookPath := c.config.WebhookPath
if webhookPath == "" {
webhookPath = "/webhook/wecom"
}
mux.HandleFunc(webhookPath, c.handleWebhook)
// Health check endpoint
mux.HandleFunc("/health/wecom", c.handleHealth)
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
c.server = &http.Server{
Addr: addr,
Handler: mux,
}
c.setRunning(true)
logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{
"address": addr,
"path": webhookPath,
})
// Start server in goroutine
go func() {
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("wecom", "HTTP server error", map[string]any{
"error": err.Error(),
})
}
}()
return nil
}
// Stop gracefully stops the WeCom Bot channel
func (c *WeComBotChannel) Stop(ctx context.Context) error {
logger.InfoC("wecom", "Stopping WeCom Bot channel...")
if c.cancel != nil {
c.cancel()
}
if c.server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
c.server.Shutdown(shutdownCtx)
}
c.setRunning(false)
logger.InfoC("wecom", "WeCom Bot channel stopped")
return nil
}
// Send sends a message to WeCom user via webhook API
// Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message
// For delayed responses, we use the webhook URL
func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("wecom channel not running")
}
logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
}
// handleWebhook handles incoming webhook requests from WeCom
func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if r.Method == http.MethodGet {
// Handle verification request
c.handleVerification(ctx, w, r)
return
}
if r.Method == http.MethodPost {
// Handle message callback
c.handleMessageCallback(ctx, w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// handleVerification handles the URL verification request from WeCom
func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
msgSignature := query.Get("msg_signature")
timestamp := query.Get("timestamp")
nonce := query.Get("nonce")
echostr := query.Get("echostr")
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
http.Error(w, "Missing parameters", http.StatusBadRequest)
return
}
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnC("wecom", "Signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
// Decrypt echostr
// For AIBOT (智能机器人), receiveid should be empty string ""
// Reference: https://developer.work.weixin.qq.com/document/path/101033
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
return
}
// Remove BOM and whitespace as per WeCom documentation
// The response must be plain text without quotes, BOM, or newlines
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
w.Write([]byte(decryptedEchoStr))
}
// handleMessageCallback handles incoming messages from WeCom
func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
msgSignature := query.Get("msg_signature")
timestamp := query.Get("timestamp")
nonce := query.Get("nonce")
if msgSignature == "" || timestamp == "" || nonce == "" {
http.Error(w, "Missing parameters", http.StatusBadRequest)
return
}
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Parse XML to get encrypted message
var encryptedMsg struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
Encrypt string `xml:"Encrypt"`
AgentID string `xml:"AgentID"`
}
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid XML", http.StatusBadRequest)
return
}
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
// Decrypt message
// For AIBOT (智能机器人), receiveid should be empty string ""
// Reference: https://developer.work.weixin.qq.com/document/path/101033
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
return
}
// Parse decrypted JSON message (AIBOT uses JSON format)
var msg WeComBotMessage
if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid message format", http.StatusBadRequest)
return
}
// Process the message asynchronously with context
go c.processMessage(ctx, msg)
// Return success response immediately
// WeCom Bot requires response within configured timeout (default 5 seconds)
w.Write([]byte("success"))
}
// processMessage processes the received message
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {
// Skip unsupported message types
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" &&
msg.MsgType != "mixed" {
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{
"msg_type": msg.MsgType,
})
return
}
// Message deduplication: Use msg_id to prevent duplicate processing
msgID := msg.MsgID
c.msgMu.Lock()
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
}
c.processedMsgs[msgID] = true
c.msgMu.Unlock()
// Clean up old messages periodically (keep last 1000)
if len(c.processedMsgs) > 1000 {
c.msgMu.Lock()
c.processedMsgs = make(map[string]bool)
c.msgMu.Unlock()
}
senderID := msg.From.UserID
// Determine if this is a group chat or direct message
// ChatType: "single" for DM, "group" for group chat
isGroupChat := msg.ChatType == "group"
var chatID, peerKind, peerID string
if isGroupChat {
// Group chat: use ChatID as chatID and peer_id
chatID = msg.ChatID
peerKind = "group"
peerID = msg.ChatID
} else {
// Direct message: use senderID as chatID and peer_id
chatID = senderID
peerKind = "direct"
peerID = senderID
}
// Extract content based on message type
var content string
switch msg.MsgType {
case "text":
content = msg.Text.Content
case "voice":
content = msg.Voice.Content // Voice to text content
case "mixed":
// For mixed messages, concatenate text items
for _, item := range msg.Mixed.MsgItem {
if item.MsgType == "text" {
content += item.Text.Content
}
}
case "image", "file":
// For image and file, we don't have text content
content = ""
}
// Build metadata
metadata := map[string]string{
"msg_type": msg.MsgType,
"msg_id": msg.MsgID,
"platform": "wecom",
"peer_kind": peerKind,
"peer_id": peerID,
"response_url": msg.ResponseURL,
}
if isGroupChat {
metadata["chat_id"] = msg.ChatID
metadata["sender_id"] = senderID
}
logger.DebugCF("wecom", "Received message", map[string]any{
"sender_id": senderID,
"msg_type": msg.MsgType,
"peer_kind": peerKind,
"is_group_chat": isGroupChat,
"preview": utils.Truncate(content, 50),
})
// Handle the message through the base channel
c.HandleMessage(senderID, chatID, content, nil, metadata)
}
// sendWebhookReply sends a reply using the webhook URL
func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error {
reply := WeComBotReplyMessage{
MsgType: "text",
}
reply.Text.Content = content
jsonData, err := json.Marshal(reply)
if err != nil {
return fmt.Errorf("failed to marshal reply: %w", err)
}
// Use configurable timeout (default 5 seconds)
timeout := c.config.ReplyTimeout
if timeout <= 0 {
timeout = 5
}
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.config.WebhookURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send webhook reply: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
// Check response
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("webhook API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
}
return nil
}
// handleHealth handles health check requests
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
status := map[string]any{
"status": "ok",
"running": c.IsRunning(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
// WeCom common utilities for both WeCom Bot and WeCom App
// The following functions were moved from wecom_common.go
// WeComVerifySignature verifies the message signature for WeCom
// This is a common function used by both WeCom Bot and WeCom App
func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" {
return true // Skip verification if token is not set
}
// Sort parameters
params := []string{token, timestamp, nonce, msgEncrypt}
sort.Strings(params)
// Concatenate
str := strings.Join(params, "")
// SHA1 hash
hash := sha1.Sum([]byte(str))
expectedSignature := fmt.Sprintf("%x", hash)
return expectedSignature == msgSignature
}
// WeComDecryptMessage decrypts the encrypted message using AES
// This is a common function used by both WeCom Bot and WeCom App
// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
}
// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
if encodingAESKey == "" {
// No encryption, return as is (base64 decode)
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
if err != nil {
return "", err
}
return string(decoded), nil
}
// Decode AES key (base64)
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return "", fmt.Errorf("failed to decode AES key: %w", err)
}
// Decode encrypted message
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
if err != nil {
return "", fmt.Errorf("failed to decode message: %w", err)
}
// AES decrypt
block, err := aes.NewCipher(aesKey)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
if len(cipherText) < aes.BlockSize {
return "", fmt.Errorf("ciphertext too short")
}
// IV is the first 16 bytes of AESKey
iv := aesKey[:aes.BlockSize]
mode := cipher.NewCBCDecrypter(block, iv)
plainText := make([]byte, len(cipherText))
mode.CryptBlocks(plainText, cipherText)
// Remove PKCS7 padding
plainText, err = pkcs7UnpadWeCom(plainText)
if err != nil {
return "", fmt.Errorf("failed to unpad: %w", err)
}
// Parse message structure
// Format: random(16) + msg_len(4) + msg + receiveid
if len(plainText) < 20 {
return "", fmt.Errorf("decrypted message too short")
}
msgLen := binary.BigEndian.Uint32(plainText[16:20])
if int(msgLen) > len(plainText)-20 {
return "", fmt.Errorf("invalid message length")
}
msg := plainText[20 : 20+msgLen]
// Verify receiveid if provided
if receiveid != "" && len(plainText) > 20+int(msgLen) {
actualReceiveID := string(plainText[20+msgLen:])
if actualReceiveID != receiveid {
return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
}
}
return string(msg), nil
}
// pkcs7UnpadWeCom removes PKCS7 padding with validation
// WeCom uses block size of 32 (not standard AES block size of 16)
const wecomBlockSize = 32
func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
if len(data) == 0 {
return data, nil
}
padding := int(data[len(data)-1])
// WeCom uses 32-byte block size for PKCS7 padding
if padding == 0 || padding > wecomBlockSize {
return nil, fmt.Errorf("invalid padding size: %d", padding)
}
if padding > len(data) {
return nil, fmt.Errorf("padding size larger than data")
}
// Verify all padding bytes
for i := 0; i < padding; i++ {
if data[len(data)-1-i] != byte(padding) {
return nil, fmt.Errorf("invalid padding byte at position %d", i)
}
}
return data[:len(data)-padding], nil
}

584
pkg/channels/wecom_app.go Normal file
View file

@ -0,0 +1,584 @@
// PicoClaw - Ultra-lightweight personal AI agent
// WeCom App (企业微信自建应用) channel implementation
// Supports receiving messages via webhook callback and sending messages proactively
package channels
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
const (
wecomAPIBase = "https://qyapi.weixin.qq.com"
)
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
type WeComAppChannel struct {
*BaseChannel
config config.WeComAppConfig
server *http.Server
accessToken string
tokenExpiry time.Time
tokenMu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
msgMu sync.RWMutex
}
// WeComXMLMessage represents the XML message structure from WeCom
type WeComXMLMessage struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Content string `xml:"Content"`
MsgId int64 `xml:"MsgId"`
AgentID int64 `xml:"AgentID"`
PicUrl string `xml:"PicUrl"`
MediaId string `xml:"MediaId"`
Format string `xml:"Format"`
ThumbMediaId string `xml:"ThumbMediaId"`
LocationX float64 `xml:"Location_X"`
LocationY float64 `xml:"Location_Y"`
Scale int `xml:"Scale"`
Label string `xml:"Label"`
Title string `xml:"Title"`
Description string `xml:"Description"`
Url string `xml:"Url"`
Event string `xml:"Event"`
EventKey string `xml:"EventKey"`
}
// WeComTextMessage represents text message for sending
type WeComTextMessage struct {
ToUser string `json:"touser"`
MsgType string `json:"msgtype"`
AgentID int64 `json:"agentid"`
Text struct {
Content string `json:"content"`
} `json:"text"`
Safe int `json:"safe,omitempty"`
}
// WeComMarkdownMessage represents markdown message for sending
type WeComMarkdownMessage struct {
ToUser string `json:"touser"`
MsgType string `json:"msgtype"`
AgentID int64 `json:"agentid"`
Markdown struct {
Content string `json:"content"`
} `json:"markdown"`
}
// WeComImageMessage represents image message for sending
type WeComImageMessage struct {
ToUser string `json:"touser"`
MsgType string `json:"msgtype"`
AgentID int64 `json:"agentid"`
Image struct {
MediaID string `json:"media_id"`
} `json:"image"`
}
// WeComAccessTokenResponse represents the access token API response
type WeComAccessTokenResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
// WeComSendMessageResponse represents the send message API response
type WeComSendMessageResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
InvalidUser string `json:"invaliduser"`
InvalidParty string `json:"invalidparty"`
InvalidTag string `json:"invalidtag"`
}
// PKCS7Padding adds PKCS7 padding
type PKCS7Padding struct{}
// NewWeComAppChannel creates a new WeCom App channel instance
func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) {
if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 {
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
}
base := NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom)
return &WeComAppChannel{
BaseChannel: base,
config: cfg,
processedMsgs: make(map[string]bool),
}, nil
}
// Name returns the channel name
func (c *WeComAppChannel) Name() string {
return "wecom_app"
}
// Start initializes the WeCom App channel with HTTP webhook server
func (c *WeComAppChannel) Start(ctx context.Context) error {
logger.InfoC("wecom_app", "Starting WeCom App channel...")
c.ctx, c.cancel = context.WithCancel(ctx)
// Get initial access token
if err := c.refreshAccessToken(); err != nil {
logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{
"error": err.Error(),
})
}
// Start token refresh goroutine
go c.tokenRefreshLoop()
// Setup HTTP server for webhook
mux := http.NewServeMux()
webhookPath := c.config.WebhookPath
if webhookPath == "" {
webhookPath = "/webhook/wecom-app"
}
mux.HandleFunc(webhookPath, c.handleWebhook)
// Health check endpoint
mux.HandleFunc("/health/wecom-app", c.handleHealth)
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
c.server = &http.Server{
Addr: addr,
Handler: mux,
}
c.setRunning(true)
logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{
"address": addr,
"path": webhookPath,
})
// Start server in goroutine
go func() {
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{
"error": err.Error(),
})
}
}()
return nil
}
// Stop gracefully stops the WeCom App channel
func (c *WeComAppChannel) Stop(ctx context.Context) error {
logger.InfoC("wecom_app", "Stopping WeCom App channel...")
if c.cancel != nil {
c.cancel()
}
if c.server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
c.server.Shutdown(shutdownCtx)
}
c.setRunning(false)
logger.InfoC("wecom_app", "WeCom App channel stopped")
return nil
}
// Send sends a message to WeCom user proactively using access token
func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("wecom_app channel not running")
}
accessToken := c.getAccessToken()
if accessToken == "" {
return fmt.Errorf("no valid access token available")
}
logger.DebugCF("wecom_app", "Sending message", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
}
// handleWebhook handles incoming webhook requests from WeCom
func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Log all incoming requests for debugging
logger.DebugCF("wecom_app", "Received webhook request", map[string]any{
"method": r.Method,
"url": r.URL.String(),
"path": r.URL.Path,
"query": r.URL.RawQuery,
})
if r.Method == http.MethodGet {
// Handle verification request
c.handleVerification(ctx, w, r)
return
}
if r.Method == http.MethodPost {
// Handle message callback
c.handleMessageCallback(ctx, w, r)
return
}
logger.WarnCF("wecom_app", "Method not allowed", map[string]any{
"method": r.Method,
})
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// handleVerification handles the URL verification request from WeCom
func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
msgSignature := query.Get("msg_signature")
timestamp := query.Get("timestamp")
nonce := query.Get("nonce")
echostr := query.Get("echostr")
logger.DebugCF("wecom_app", "Handling verification request", map[string]any{
"msg_signature": msgSignature,
"timestamp": timestamp,
"nonce": nonce,
"echostr": echostr,
"corp_id": c.config.CorpID,
})
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
logger.ErrorC("wecom_app", "Missing parameters in verification request")
http.Error(w, "Missing parameters", http.StatusBadRequest)
return
}
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
"token": c.config.Token,
"msg_signature": msgSignature,
"timestamp": timestamp,
"nonce": nonce,
})
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
logger.DebugC("wecom_app", "Signature verification passed")
// Decrypt echostr with CorpID verification
// For WeCom App (自建应用), receiveid should be corp_id
logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{
"encoding_aes_key": c.config.EncodingAESKey,
"corp_id": c.config.CorpID,
})
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
"encoding_aes_key": c.config.EncodingAESKey,
"corp_id": c.config.CorpID,
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
return
}
logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{
"decrypted": decryptedEchoStr,
})
// Remove BOM and whitespace as per WeCom documentation
// The response must be plain text without quotes, BOM, or newlines
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
w.Write([]byte(decryptedEchoStr))
}
// handleMessageCallback handles incoming messages from WeCom
func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
msgSignature := query.Get("msg_signature")
timestamp := query.Get("timestamp")
nonce := query.Get("nonce")
if msgSignature == "" || timestamp == "" || nonce == "" {
http.Error(w, "Missing parameters", http.StatusBadRequest)
return
}
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Parse XML to get encrypted message
var encryptedMsg struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
Encrypt string `xml:"Encrypt"`
AgentID string `xml:"AgentID"`
}
if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid XML", http.StatusBadRequest)
return
}
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom_app", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
// Decrypt message with CorpID verification
// For WeCom App (自建应用), receiveid should be corp_id
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Decryption failed", http.StatusInternalServerError)
return
}
// Parse decrypted XML message
var msg WeComXMLMessage
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{
"error": err.Error(),
})
http.Error(w, "Invalid message format", http.StatusBadRequest)
return
}
// Process the message with context
go c.processMessage(ctx, msg)
// Return success response immediately
// WeCom App requires response within configured timeout (default 5 seconds)
w.Write([]byte("success"))
}
// processMessage processes the received message
func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) {
// Skip non-text messages for now (can be extended)
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{
"msg_type": msg.MsgType,
})
return
}
// Message deduplication: Use msg_id to prevent duplicate processing
// As per WeCom documentation, use msg_id for deduplication
msgID := fmt.Sprintf("%d", msg.MsgId)
c.msgMu.Lock()
if c.processedMsgs[msgID] {
c.msgMu.Unlock()
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{
"msg_id": msgID,
})
return
}
c.processedMsgs[msgID] = true
c.msgMu.Unlock()
// Clean up old messages periodically (keep last 1000)
if len(c.processedMsgs) > 1000 {
c.msgMu.Lock()
c.processedMsgs = make(map[string]bool)
c.msgMu.Unlock()
}
senderID := msg.FromUserName
chatID := senderID // WeCom App uses user ID as chat ID for direct messages
// Build metadata
// WeCom App only supports direct messages (private chat)
metadata := map[string]string{
"msg_type": msg.MsgType,
"msg_id": fmt.Sprintf("%d", msg.MsgId),
"agent_id": fmt.Sprintf("%d", msg.AgentID),
"platform": "wecom_app",
"media_id": msg.MediaId,
"create_time": fmt.Sprintf("%d", msg.CreateTime),
"peer_kind": "direct",
"peer_id": senderID,
}
content := msg.Content
logger.DebugCF("wecom_app", "Received message", map[string]any{
"sender_id": senderID,
"msg_type": msg.MsgType,
"preview": utils.Truncate(content, 50),
})
// Handle the message through the base channel
c.HandleMessage(senderID, chatID, content, nil, metadata)
}
// tokenRefreshLoop periodically refreshes the access token
func (c *WeComAppChannel) tokenRefreshLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
if err := c.refreshAccessToken(); err != nil {
logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{
"error": err.Error(),
})
}
}
}
}
// refreshAccessToken gets a new access token from WeCom API
func (c *WeComAppChannel) refreshAccessToken() error {
apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret))
resp, err := http.Get(apiURL)
if err != nil {
return fmt.Errorf("failed to request access token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var tokenResp WeComAccessTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if tokenResp.ErrCode != 0 {
return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode)
}
c.tokenMu.Lock()
c.accessToken = tokenResp.AccessToken
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early
c.tokenMu.Unlock()
logger.DebugC("wecom_app", "Access token refreshed successfully")
return nil
}
// getAccessToken returns the current valid access token
func (c *WeComAppChannel) getAccessToken() string {
c.tokenMu.RLock()
defer c.tokenMu.RUnlock()
if time.Now().After(c.tokenExpiry) {
return ""
}
return c.accessToken
}
// sendTextMessage sends a text message to a user
func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error {
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
msg := WeComTextMessage{
ToUser: userID,
MsgType: "text",
AgentID: c.config.AgentID,
}
msg.Text.Content = content
jsonData, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
// Use configurable timeout (default 5 seconds)
timeout := c.config.ReplyTimeout
if timeout <= 0 {
timeout = 5
}
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var sendResp WeComSendMessageResponse
if err := json.Unmarshal(body, &sendResp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if sendResp.ErrCode != 0 {
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
}
return nil
}
// handleHealth handles health check requests
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
status := map[string]any{
"status": "ok",
"running": c.IsRunning(),
"has_token": c.getAccessToken() != "",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}

File diff suppressed because it is too large Load diff

785
pkg/channels/wecom_test.go Normal file
View file

@ -0,0 +1,785 @@
// PicoClaw - Ultra-lightweight personal AI agent
// WeCom Bot (企业微信智能机器人) channel tests
package channels
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
// generateTestAESKey generates a valid test AES key
func generateTestAESKey() string {
// AES key needs to be 32 bytes (256 bits) for AES-256
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
// Return base64 encoded key without padding
return base64.StdEncoding.EncodeToString(key)[:43]
}
// encryptTestMessage encrypts a message for testing (AIBOT JSON format)
func encryptTestMessage(message, aesKey string) (string, error) {
// Decode AES key
key, err := base64.StdEncoding.DecodeString(aesKey + "=")
if err != nil {
return "", err
}
// Prepare message: random(16) + msg_len(4) + msg + receiveid
random := make([]byte, 0, 16)
for i := 0; i < 16; i++ {
random = append(random, byte(i))
}
msgBytes := []byte(message)
receiveID := []byte("test_aibot_id")
msgLen := uint32(len(msgBytes))
lenBytes := make([]byte, 4)
binary.BigEndian.PutUint32(lenBytes, msgLen)
plainText := append(random, lenBytes...)
plainText = append(plainText, msgBytes...)
plainText = append(plainText, receiveID...)
// PKCS7 padding
blockSize := aes.BlockSize
padding := blockSize - len(plainText)%blockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
plainText = append(plainText, padText...)
// Encrypt
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize])
cipherText := make([]byte, len(plainText))
mode.CryptBlocks(cipherText, plainText)
return base64.StdEncoding.EncodeToString(cipherText), nil
}
// generateSignature generates a signature for testing
func generateSignature(token, timestamp, nonce, msgEncrypt string) string {
params := []string{token, timestamp, nonce, msgEncrypt}
sort.Strings(params)
str := strings.Join(params, "")
hash := sha1.Sum([]byte(str))
return fmt.Sprintf("%x", hash)
}
func TestNewWeComBotChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("missing token", func(t *testing.T) {
cfg := config.WeComConfig{
Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
_, err := NewWeComBotChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing token, got nil")
}
})
t.Run("missing webhook_url", func(t *testing.T) {
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "",
}
_, err := NewWeComBotChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing webhook_url, got nil")
}
})
t.Run("valid config", func(t *testing.T) {
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
AllowFrom: []string{"user1", "user2"},
}
ch, err := NewWeComBotChannel(cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch.Name() != "wecom" {
t.Errorf("Name() = %q, want %q", ch.Name(), "wecom")
}
if ch.IsRunning() {
t.Error("new channel should not be running")
}
})
}
func TestWeComBotChannelIsAllowed(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("empty allowlist allows all", func(t *testing.T) {
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
AllowFrom: []string{},
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
if !ch.IsAllowed("any_user") {
t.Error("empty allowlist should allow all users")
}
})
t.Run("allowlist restricts users", func(t *testing.T) {
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
AllowFrom: []string{"allowed_user"},
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
if !ch.IsAllowed("allowed_user") {
t.Error("allowed user should pass allowlist check")
}
if ch.IsAllowed("blocked_user") {
t.Error("non-allowed user should be blocked")
}
})
}
func TestWeComBotVerifySignature(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("valid signature", func(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
msgEncrypt := "test_message"
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification")
}
})
t.Run("invalid signature", func(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
msgEncrypt := "test_message"
if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
t.Run("empty token skips verification", func(t *testing.T) {
// Create a channel manually with empty token to test the behavior
cfgEmpty := config.WeComConfig{
Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
chEmpty := &WeComBotChannel{
config: cfgEmpty,
}
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
}
})
}
func TestWeComBotDecryptMessage(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("decrypt without AES key", func(t *testing.T) {
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
EncodingAESKey: "",
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
// Without AES key, message should be base64 decoded only
plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != plainText {
t.Errorf("decryptMessage() = %q, want %q", result, plainText)
}
})
t.Run("decrypt with AES key", func(t *testing.T) {
aesKey := generateTestAESKey()
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
EncodingAESKey: aesKey,
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
originalMsg := "<xml><Content>Hello</Content></xml>"
encrypted, err := encryptTestMessage(originalMsg, aesKey)
if err != nil {
t.Fatalf("failed to encrypt test message: %v", err)
}
result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != originalMsg {
t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg)
}
})
t.Run("invalid base64", func(t *testing.T) {
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
EncodingAESKey: "",
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
_, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
})
t.Run("invalid AES key", func(t *testing.T) {
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
EncodingAESKey: "invalid_key",
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
_, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid AES key, got nil")
}
})
}
func TestWeComBotPKCS7Unpad(t *testing.T) {
tests := []struct {
name string
input []byte
expected []byte
}{
{
name: "empty input",
input: []byte{},
expected: []byte{},
},
{
name: "valid padding 3 bytes",
input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...),
expected: []byte("hello"),
},
{
name: "valid padding 16 bytes (full block)",
input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...),
expected: []byte("123456789012345"),
},
{
name: "invalid padding larger than data",
input: []byte{20},
expected: nil, // should return error
},
{
name: "invalid padding zero",
input: append([]byte("test"), byte(0)),
expected: nil, // should return error
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := pkcs7UnpadWeCom(tt.input)
if tt.expected == nil {
// This case should return an error
if err == nil {
t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result)
}
return
}
if err != nil {
t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err)
return
}
if !bytes.Equal(result, tt.expected) {
t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected)
}
})
}
}
func TestWeComBotHandleVerification(t *testing.T) {
msgBus := bus.NewMessageBus()
aesKey := generateTestAESKey()
cfg := config.WeComConfig{
Token: "test_token",
EncodingAESKey: aesKey,
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("valid verification request", func(t *testing.T) {
echostr := "test_echostr_123"
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
timestamp := "1234567890"
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
req := httptest.NewRequest(
http.MethodGet,
"/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
nil,
)
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
}
if w.Body.String() != echostr {
t.Errorf("response body = %q, want %q", w.Body.String(), echostr)
}
})
t.Run("missing parameters", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig&timestamp=ts", nil)
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
}
})
t.Run("invalid signature", func(t *testing.T) {
echostr := "test_echostr"
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
timestamp := "1234567890"
nonce := "test_nonce"
req := httptest.NewRequest(
http.MethodGet,
"/webhook/wecom?msg_signature=invalid_sig&timestamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr,
nil,
)
w := httptest.NewRecorder()
ch.handleVerification(context.Background(), w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
}
})
}
func TestWeComBotHandleMessageCallback(t *testing.T) {
msgBus := bus.NewMessageBus()
aesKey := generateTestAESKey()
cfg := config.WeComConfig{
Token: "test_token",
EncodingAESKey: aesKey,
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("valid direct message callback", func(t *testing.T) {
// Create JSON message for direct chat (single)
jsonMsg := `{
"msgid": "test_msg_id_123",
"aibotid": "test_aibot_id",
"chattype": "single",
"from": {"userid": "user123"},
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
"msgtype": "text",
"text": {"content": "Hello World"}
}`
// Encrypt message
encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
// Create encrypted XML wrapper
encryptedWrapper := struct {
XMLName xml.Name `xml:"xml"`
Encrypt string `xml:"Encrypt"`
}{
Encrypt: encrypted,
}
wrapperData, _ := xml.Marshal(encryptedWrapper)
timestamp := "1234567890"
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encrypted)
req := httptest.NewRequest(
http.MethodPost,
"/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce,
bytes.NewReader(wrapperData),
)
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
}
if w.Body.String() != "success" {
t.Errorf("response body = %q, want %q", w.Body.String(), "success")
}
})
t.Run("valid group message callback", func(t *testing.T) {
// Create JSON message for group chat
jsonMsg := `{
"msgid": "test_msg_id_456",
"aibotid": "test_aibot_id",
"chatid": "group_chat_id_123",
"chattype": "group",
"from": {"userid": "user456"},
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
"msgtype": "text",
"text": {"content": "Hello Group"}
}`
// Encrypt message
encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
// Create encrypted XML wrapper
encryptedWrapper := struct {
XMLName xml.Name `xml:"xml"`
Encrypt string `xml:"Encrypt"`
}{
Encrypt: encrypted,
}
wrapperData, _ := xml.Marshal(encryptedWrapper)
timestamp := "1234567890"
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encrypted)
req := httptest.NewRequest(
http.MethodPost,
"/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce,
bytes.NewReader(wrapperData),
)
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
}
if w.Body.String() != "success" {
t.Errorf("response body = %q, want %q", w.Body.String(), "success")
}
})
t.Run("missing parameters", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil)
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
}
})
t.Run("invalid XML", func(t *testing.T) {
timestamp := "1234567890"
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, "")
req := httptest.NewRequest(
http.MethodPost,
"/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce,
strings.NewReader("invalid xml"),
)
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
}
})
t.Run("invalid signature", func(t *testing.T) {
encryptedWrapper := struct {
XMLName xml.Name `xml:"xml"`
Encrypt string `xml:"Encrypt"`
}{
Encrypt: "encrypted_data",
}
wrapperData, _ := xml.Marshal(encryptedWrapper)
timestamp := "1234567890"
nonce := "test_nonce"
req := httptest.NewRequest(
http.MethodPost,
"/webhook/wecom?msg_signature=invalid_sig&timestamp="+timestamp+"&nonce="+nonce,
bytes.NewReader(wrapperData),
)
w := httptest.NewRecorder()
ch.handleMessageCallback(context.Background(), w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
}
})
}
func TestWeComBotProcessMessage(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("process direct text message", func(t *testing.T) {
msg := WeComBotMessage{
MsgID: "test_msg_id_123",
AIBotID: "test_aibot_id",
ChatType: "single",
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
MsgType: "text",
}
msg.From.UserID = "user123"
msg.Text.Content = "Hello World"
// Should not panic
ch.processMessage(context.Background(), msg)
})
t.Run("process group text message", func(t *testing.T) {
msg := WeComBotMessage{
MsgID: "test_msg_id_456",
AIBotID: "test_aibot_id",
ChatID: "group_chat_id_123",
ChatType: "group",
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
MsgType: "text",
}
msg.From.UserID = "user456"
msg.Text.Content = "Hello Group"
// Should not panic
ch.processMessage(context.Background(), msg)
})
t.Run("process voice message", func(t *testing.T) {
msg := WeComBotMessage{
MsgID: "test_msg_id_789",
AIBotID: "test_aibot_id",
ChatType: "single",
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
MsgType: "voice",
}
msg.From.UserID = "user123"
msg.Voice.Content = "Voice message text"
// Should not panic
ch.processMessage(context.Background(), msg)
})
t.Run("skip unsupported message type", func(t *testing.T) {
msg := WeComBotMessage{
MsgID: "test_msg_id_000",
AIBotID: "test_aibot_id",
ChatType: "single",
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
MsgType: "video",
}
msg.From.UserID = "user123"
// Should not panic
ch.processMessage(context.Background(), msg)
})
}
func TestWeComBotHandleWebhook(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("GET request calls verification", func(t *testing.T) {
echostr := "test_echostr"
encoded := base64.StdEncoding.EncodeToString([]byte(echostr))
timestamp := "1234567890"
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encoded)
req := httptest.NewRequest(
http.MethodGet,
"/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded,
nil,
)
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
}
})
t.Run("POST request calls message callback", func(t *testing.T) {
encryptedWrapper := struct {
XMLName xml.Name `xml:"xml"`
Encrypt string `xml:"Encrypt"`
}{
Encrypt: base64.StdEncoding.EncodeToString([]byte("test")),
}
wrapperData, _ := xml.Marshal(encryptedWrapper)
timestamp := "1234567890"
nonce := "test_nonce"
signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
req := httptest.NewRequest(
http.MethodPost,
"/webhook/wecom?msg_signature="+signature+"&timestamp="+timestamp+"&nonce="+nonce,
bytes.NewReader(wrapperData),
)
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
// Should not be method not allowed
if w.Code == http.StatusMethodNotAllowed {
t.Error("POST request should not return Method Not Allowed")
}
})
t.Run("unsupported method", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil)
w := httptest.NewRecorder()
ch.handleWebhook(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
})
}
func TestWeComBotHandleHealth(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.WeComConfig{
Token: "test_token",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil)
w := httptest.NewRecorder()
ch.handleHealth(w, req)
if w.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
}
body := w.Body.String()
if !strings.Contains(body, "status") || !strings.Contains(body, "running") {
t.Errorf("response body should contain status and running fields, got: %s", body)
}
}
func TestWeComBotReplyMessage(t *testing.T) {
msg := WeComBotReplyMessage{
MsgType: "text",
}
msg.Text.Content = "Hello World"
if msg.MsgType != "text" {
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
}
if msg.Text.Content != "Hello World" {
t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
}
}
func TestWeComBotMessageStructure(t *testing.T) {
jsonData := `{
"msgid": "test_msg_id_123",
"aibotid": "test_aibot_id",
"chatid": "group_chat_id_123",
"chattype": "group",
"from": {"userid": "user123"},
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
"msgtype": "text",
"text": {"content": "Hello World"}
}`
var msg WeComBotMessage
err := json.Unmarshal([]byte(jsonData), &msg)
if err != nil {
t.Fatalf("failed to unmarshal JSON: %v", err)
}
if msg.MsgID != "test_msg_id_123" {
t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123")
}
if msg.AIBotID != "test_aibot_id" {
t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id")
}
if msg.ChatID != "group_chat_id_123" {
t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123")
}
if msg.ChatType != "group" {
t.Errorf("ChatType = %q, want %q", msg.ChatType, "group")
}
if msg.From.UserID != "user123" {
t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123")
}
if msg.MsgType != "text" {
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
}
if msg.Text.Content != "Hello World" {
t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
}
}

View file

@ -86,7 +86,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("whatsapp connection not established")
}
payload := map[string]interface{}{
payload := map[string]any{
"type": "message",
"to": msg.ChatID,
"content": msg.Content,
@ -126,7 +126,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
continue
}
var msg map[string]interface{}
var msg map[string]any
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
continue
@ -144,7 +144,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
}
}
func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
senderID, ok := msg["from"].(string)
if !ok {
return
@ -161,7 +161,7 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
}
var mediaPaths []string
if mediaData, ok := msg["media"].([]interface{}); ok {
if mediaData, ok := msg["media"].([]any); ok {
mediaPaths = make([]string, 0, len(mediaData))
for _, m := range mediaData {
if path, ok := m.(string); ok {
@ -178,6 +178,14 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
metadata["user_name"] = userName
}
if chatID == senderID {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
} else {
metadata["peer_kind"] = "group"
metadata["peer_id"] = chatID
}
log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50))
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)

View file

@ -5,11 +5,14 @@ import (
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"github.com/caarlos0/env/v11"
)
// rrCounter is a global counter for round-robin load balancing across models.
var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
type FlexibleStringSlice []string
@ -23,7 +26,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
}
// Try []interface{} to handle mixed types
var raw []interface{}
var raw []any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
@ -45,27 +48,145 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
type Config struct {
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
Session SessionConfig `json:"session,omitempty"`
Channels ChannelsConfig `json:"channels"`
Providers ProvidersConfig `json:"providers"`
Providers ProvidersConfig `json:"providers,omitempty"`
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
mu sync.RWMutex
}
// MarshalJSON implements custom JSON marshaling for Config
// to omit providers section when empty and session when empty
func (c Config) MarshalJSON() ([]byte, error) {
type Alias Config
aux := &struct {
Providers *ProvidersConfig `json:"providers,omitempty"`
Session *SessionConfig `json:"session,omitempty"`
*Alias
}{
Alias: (*Alias)(&c),
}
// Only include providers if not empty
if !c.Providers.IsEmpty() {
aux.Providers = &c.Providers
}
// Only include session if not empty
if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
aux.Session = &c.Session
}
return json.Marshal(aux)
}
type AgentsConfig struct {
Defaults AgentDefaults `json:"defaults"`
List []AgentConfig `json:"list,omitempty"`
}
// AgentModelConfig supports both string and structured model config.
// String format: "gpt-4" (just primary, no fallbacks)
// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
type AgentModelConfig struct {
Primary string `json:"primary,omitempty"`
Fallbacks []string `json:"fallbacks,omitempty"`
}
func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
m.Primary = s
m.Fallbacks = nil
return nil
}
type raw struct {
Primary string `json:"primary"`
Fallbacks []string `json:"fallbacks"`
}
var r raw
if err := json.Unmarshal(data, &r); err != nil {
return err
}
m.Primary = r.Primary
m.Fallbacks = r.Fallbacks
return nil
}
func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
if len(m.Fallbacks) == 0 && m.Primary != "" {
return json.Marshal(m.Primary)
}
type raw struct {
Primary string `json:"primary,omitempty"`
Fallbacks []string `json:"fallbacks,omitempty"`
}
return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})
}
type AgentConfig struct {
ID string `json:"id"`
Default bool `json:"default,omitempty"`
Name string `json:"name,omitempty"`
Workspace string `json:"workspace,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
Skills []string `json:"skills,omitempty"`
Subagents *SubagentsConfig `json:"subagents,omitempty"`
}
type SubagentsConfig struct {
AllowAgents []string `json:"allow_agents,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
}
type PeerMatch struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
type BindingMatch struct {
Channel string `json:"channel"`
AccountID string `json:"account_id,omitempty"`
Peer *PeerMatch `json:"peer,omitempty"`
GuildID string `json:"guild_id,omitempty"`
TeamID string `json:"team_id,omitempty"`
}
type AgentBinding struct {
AgentID string `json:"agent_id"`
Match BindingMatch `json:"match"`
}
type SessionConfig struct {
DMScope string `json:"dm_scope,omitempty"`
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
}
type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
if d.ModelName != "" {
return d.ModelName
}
return d.Model
}
type ChannelsConfig struct {
@ -79,114 +200,223 @@ type ChannelsConfig struct {
Slack SlackConfig `json:"slack"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"`
}
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
}
type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
}
type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
}
type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
}
type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
}
type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
}
type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}
type LINEConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
}
type OneBotConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
}
type WeComConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
}
type WeComAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
}
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
}
type DevicesConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI ProviderConfig `json:"openai"`
OpenRouter ProviderConfig `json:"openrouter"`
Groq ProviderConfig `json:"groq"`
Zhipu ProviderConfig `json:"zhipu"`
VLLM ProviderConfig `json:"vllm"`
Gemini ProviderConfig `json:"gemini"`
Nvidia ProviderConfig `json:"nvidia"`
Moonshot ProviderConfig `json:"moonshot"`
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"`
GitHubCopilot ProviderConfig `json:"github_copilot"`
Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"`
OpenRouter ProviderConfig `json:"openrouter"`
Groq ProviderConfig `json:"groq"`
Zhipu ProviderConfig `json:"zhipu"`
VLLM ProviderConfig `json:"vllm"`
Gemini ProviderConfig `json:"gemini"`
Nvidia ProviderConfig `json:"nvidia"`
Ollama ProviderConfig `json:"ollama"`
Moonshot ProviderConfig `json:"moonshot"`
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"`
Cerebras ProviderConfig `json:"cerebras"`
VolcEngine ProviderConfig `json:"volcengine"`
GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"`
Mistral ProviderConfig `json:"mistral"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
// Note: WebSearch is an optimization option and doesn't count as "non-empty"
func (p ProvidersConfig) IsEmpty() bool {
return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
// to omit the entire section when empty
func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
if p.IsEmpty() {
return []byte("null"), nil
}
type Alias ProvidersConfig
return json.Marshal((*Alias)(&p))
}
type ProviderConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
ProviderConfig
WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
}
// ModelConfig represents a model-centric provider configuration.
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
// The model field uses protocol prefix format: [protocol/]model-identifier
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
// Default protocol is "openai" if no prefix is specified.
type ModelConfig struct {
// Required fields
ModelName string `json:"model_name"` // User-facing alias for the model
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
// HTTP-based providers
APIBase string `json:"api_base,omitempty"` // API endpoint URL
APIKey string `json:"api_key"` // API authentication key
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
// Special providers (CLI-based, OAuth, etc.)
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
// Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
}
// Validate checks if the ModelConfig has all required fields.
func (c *ModelConfig) Validate() error {
if c.ModelName == "" {
return fmt.Errorf("model_name is required")
}
if c.Model == "" {
return fmt.Errorf("model is required")
}
return nil
}
type GatewayConfig struct {
@ -195,142 +425,77 @@ type GatewayConfig struct {
}
type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
type TavilyConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
}
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
type PerplexityConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"`
}
type CronToolsConfig struct {
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
}
type ExecConfig struct {
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
}
type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"`
}
func DefaultConfig() *Config {
return &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true,
Provider: "",
Model: "glm-4.7",
MaxTokens: 8192,
Temperature: 0.7,
MaxToolIterations: 20,
},
},
Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{
Enabled: false,
BridgeURL: "ws://localhost:3001",
AllowFrom: FlexibleStringSlice{},
},
Telegram: TelegramConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
},
Feishu: FeishuConfig{
Enabled: false,
AppID: "",
AppSecret: "",
EncryptKey: "",
VerificationToken: "",
AllowFrom: FlexibleStringSlice{},
},
Discord: DiscordConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
},
MaixCam: MaixCamConfig{
Enabled: false,
Host: "0.0.0.0",
Port: 18790,
AllowFrom: FlexibleStringSlice{},
},
QQ: QQConfig{
Enabled: false,
AppID: "",
AppSecret: "",
AllowFrom: FlexibleStringSlice{},
},
DingTalk: DingTalkConfig{
Enabled: false,
ClientID: "",
ClientSecret: "",
AllowFrom: FlexibleStringSlice{},
},
Slack: SlackConfig{
Enabled: false,
BotToken: "",
AppToken: "",
AllowFrom: FlexibleStringSlice{},
},
LINE: LINEConfig{
Enabled: false,
ChannelSecret: "",
ChannelAccessToken: "",
WebhookHost: "0.0.0.0",
WebhookPort: 18791,
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
},
OneBot: OneBotConfig{
Enabled: false,
WSUrl: "ws://127.0.0.1:3001",
AccessToken: "",
ReconnectInterval: 5,
GroupTriggerPrefix: []string{},
AllowFrom: FlexibleStringSlice{},
},
},
Providers: ProvidersConfig{
Anthropic: ProviderConfig{},
OpenAI: ProviderConfig{},
OpenRouter: ProviderConfig{},
Groq: ProviderConfig{},
Zhipu: ProviderConfig{},
VLLM: ProviderConfig{},
Gemini: ProviderConfig{},
Nvidia: ProviderConfig{},
Moonshot: ProviderConfig{},
ShengSuanYun: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
Port: 18790,
},
Tools: ToolsConfig{
Web: WebToolsConfig{
Brave: BraveConfig{
Enabled: false,
APIKey: "",
MaxResults: 5,
},
DuckDuckGo: DuckDuckGoConfig{
Enabled: true,
MaxResults: 5,
},
},
},
Heartbeat: HeartbeatConfig{
Enabled: true,
Interval: 30, // default 30 minutes
},
Devices: DevicesConfig{
Enabled: false,
MonitorUSB: true,
},
}
type SkillsToolsConfig struct {
Registries SkillsRegistriesConfig `json:"registries"`
MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"`
SearchCache SearchCacheConfig `json:"search_cache"`
}
type SearchCacheConfig struct {
MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"`
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
}
type SkillsRegistriesConfig struct {
ClawHub ClawHubRegistryConfig `json:"clawhub"`
}
type ClawHubRegistryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
AuthToken string `json:"auth_token" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"`
SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"`
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"`
Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"`
MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"`
MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"`
}
func LoadConfig(path string) (*Config, error) {
@ -344,6 +509,20 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
// Pre-scan the JSON to check how many model_list entries the user provided.
// Go's JSON decoder reuses existing slice backing-array elements rather than
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
// would silently inherit values from the DefaultConfig template at the same
// index position. We only reset cfg.ModelList when the user actually provides
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
var tmp Config
if err := json.Unmarshal(data, &tmp); err != nil {
return nil, err
}
if len(tmp.ModelList) > 0 {
cfg.ModelList = nil
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
@ -352,35 +531,38 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
// Auto-migrate: if only legacy providers config exists, convert to model_list
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
cfg.ModelList = ConvertProvidersToModelList(cfg)
}
// Validate model_list for uniqueness and required fields
if err := cfg.ValidateModelList(); err != nil {
return nil, err
}
return cfg, nil
}
func SaveConfig(path string, cfg *Config) error {
cfg.mu.RLock()
defer cfg.mu.RUnlock()
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(path, data, 0644)
return os.WriteFile(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
c.mu.RLock()
defer c.mu.RUnlock()
return expandHome(c.Agents.Defaults.Workspace)
}
func (c *Config) GetAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.Providers.OpenRouter.APIKey != "" {
return c.Providers.OpenRouter.APIKey
}
@ -405,12 +587,13 @@ func (c *Config) GetAPIKey() string {
if c.Providers.ShengSuanYun.APIKey != "" {
return c.Providers.ShengSuanYun.APIKey
}
if c.Providers.Cerebras.APIKey != "" {
return c.Providers.Cerebras.APIKey
}
return ""
}
func (c *Config) GetAPIBase() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.Providers.OpenRouter.APIKey != "" {
if c.Providers.OpenRouter.APIBase != "" {
return c.Providers.OpenRouter.APIBase
@ -439,3 +622,66 @@ func expandHome(path string) string {
}
return path
}
// GetModelConfig returns the ModelConfig for the given model name.
// If multiple configs exist with the same model_name, it uses round-robin
// selection for load balancing. Returns an error if the model is not found.
func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
matches := c.findMatches(modelName)
if len(matches) == 0 {
return nil, fmt.Errorf("model %q not found in model_list or providers", modelName)
}
if len(matches) == 1 {
return &matches[0], nil
}
// Multiple configs - use round-robin for load balancing
idx := rrCounter.Add(1) % uint64(len(matches))
return &matches[idx], nil
}
// findMatches finds all ModelConfig entries with the given model_name.
func (c *Config) findMatches(modelName string) []ModelConfig {
var matches []ModelConfig
for i := range c.ModelList {
if c.ModelList[i].ModelName == modelName {
matches = append(matches, c.ModelList[i])
}
}
return matches
}
// HasProvidersConfig checks if any provider in the old providers config has configuration.
func (c *Config) HasProvidersConfig() bool {
v := c.Providers
return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" ||
v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" ||
v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" ||
v.Groq.APIKey != "" || v.Groq.APIBase != "" ||
v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" ||
v.VLLM.APIKey != "" || v.VLLM.APIBase != "" ||
v.Gemini.APIKey != "" || v.Gemini.APIBase != "" ||
v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" ||
v.Ollama.APIKey != "" || v.Ollama.APIBase != "" ||
v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" ||
v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" ||
v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" ||
v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" ||
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
v.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
}
// ValidateModelList validates all ModelConfig entries in the model_list.
// It checks that each model config is valid.
// Note: Multiple entries with the same model_name are allowed for load balancing.
func (c *Config) ValidateModelList() error {
for i := range c.ModelList {
if err := c.ModelList[i].Validate(); err != nil {
return fmt.Errorf("model_list[%d]: %w", i, err)
}
}
return nil
}

View file

@ -1,9 +1,193 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestAgentModelConfig_UnmarshalString(t *testing.T) {
var m AgentModelConfig
if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil {
t.Fatalf("unmarshal string: %v", err)
}
if m.Primary != "gpt-4" {
t.Errorf("Primary = %q, want 'gpt-4'", m.Primary)
}
if m.Fallbacks != nil {
t.Errorf("Fallbacks = %v, want nil", m.Fallbacks)
}
}
func TestAgentModelConfig_UnmarshalObject(t *testing.T) {
var m AgentModelConfig
data := `{"primary": "claude-opus", "fallbacks": ["gpt-4o-mini", "haiku"]}`
if err := json.Unmarshal([]byte(data), &m); err != nil {
t.Fatalf("unmarshal object: %v", err)
}
if m.Primary != "claude-opus" {
t.Errorf("Primary = %q, want 'claude-opus'", m.Primary)
}
if len(m.Fallbacks) != 2 {
t.Fatalf("Fallbacks len = %d, want 2", len(m.Fallbacks))
}
if m.Fallbacks[0] != "gpt-4o-mini" || m.Fallbacks[1] != "haiku" {
t.Errorf("Fallbacks = %v", m.Fallbacks)
}
}
func TestAgentModelConfig_MarshalString(t *testing.T) {
m := AgentModelConfig{Primary: "gpt-4"}
data, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(data) != `"gpt-4"` {
t.Errorf("marshal = %s, want '\"gpt-4\"'", string(data))
}
}
func TestAgentModelConfig_MarshalObject(t *testing.T) {
m := AgentModelConfig{Primary: "claude-opus", Fallbacks: []string{"haiku"}}
data, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var result map[string]any
json.Unmarshal(data, &result)
if result["primary"] != "claude-opus" {
t.Errorf("primary = %v", result["primary"])
}
}
func TestAgentConfig_FullParse(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"max_tokens": 8192,
"max_tool_iterations": 20
},
"list": [
{
"id": "sales",
"default": true,
"name": "Sales Bot",
"model": "gpt-4"
},
{
"id": "support",
"name": "Support Bot",
"model": {
"primary": "claude-opus",
"fallbacks": ["haiku"]
},
"subagents": {
"allow_agents": ["sales"]
}
}
]
},
"bindings": [
{
"agent_id": "support",
"match": {
"channel": "telegram",
"account_id": "*",
"peer": {"kind": "direct", "id": "user123"}
}
}
],
"session": {
"dm_scope": "per-peer",
"identity_links": {
"john": ["telegram:123", "discord:john#1234"]
}
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Agents.List) != 2 {
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
}
sales := cfg.Agents.List[0]
if sales.ID != "sales" || !sales.Default || sales.Name != "Sales Bot" {
t.Errorf("sales = %+v", sales)
}
if sales.Model == nil || sales.Model.Primary != "gpt-4" {
t.Errorf("sales.Model = %+v", sales.Model)
}
support := cfg.Agents.List[1]
if support.ID != "support" || support.Name != "Support Bot" {
t.Errorf("support = %+v", support)
}
if support.Model == nil || support.Model.Primary != "claude-opus" {
t.Errorf("support.Model = %+v", support.Model)
}
if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" {
t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks)
}
if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 {
t.Errorf("support.Subagents = %+v", support.Subagents)
}
if len(cfg.Bindings) != 1 {
t.Fatalf("bindings len = %d, want 1", len(cfg.Bindings))
}
binding := cfg.Bindings[0]
if binding.AgentID != "support" || binding.Match.Channel != "telegram" {
t.Errorf("binding = %+v", binding)
}
if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" {
t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer)
}
if cfg.Session.DMScope != "per-peer" {
t.Errorf("Session.DMScope = %q", cfg.Session.DMScope)
}
if len(cfg.Session.IdentityLinks) != 1 {
t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks)
}
links := cfg.Session.IdentityLinks["john"]
if len(links) != 2 {
t.Errorf("john links = %v", links)
}
}
func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"max_tokens": 8192,
"max_tool_iterations": 20
}
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Agents.List) != 0 {
t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List))
}
if len(cfg.Bindings) != 0 {
t.Errorf("bindings should be empty, got %d", len(cfg.Bindings))
}
}
// TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default
func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
cfg := DefaultConfig()
@ -17,8 +201,6 @@ func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
func TestDefaultConfig_WorkspacePath(t *testing.T) {
cfg := DefaultConfig()
// Just verify the workspace is set, don't compare exact paths
// since expandHome behavior may differ based on environment
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty")
}
@ -55,8 +237,8 @@ func TestDefaultConfig_MaxToolIterations(t *testing.T) {
func TestDefaultConfig_Temperature(t *testing.T) {
cfg := DefaultConfig()
if cfg.Agents.Defaults.Temperature == 0 {
t.Error("Temperature should not be zero")
if cfg.Agents.Defaults.Temperature != nil {
t.Error("Temperature should be nil when not provided")
}
}
@ -64,7 +246,7 @@ func TestDefaultConfig_Temperature(t *testing.T) {
func TestDefaultConfig_Gateway(t *testing.T) {
cfg := DefaultConfig()
if cfg.Gateway.Host != "0.0.0.0" {
if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
@ -76,7 +258,6 @@ func TestDefaultConfig_Gateway(t *testing.T) {
func TestDefaultConfig_Providers(t *testing.T) {
cfg := DefaultConfig()
// Verify all providers are empty by default
if cfg.Providers.Anthropic.APIKey != "" {
t.Error("Anthropic API key should be empty by default")
}
@ -86,46 +267,18 @@ func TestDefaultConfig_Providers(t *testing.T) {
if cfg.Providers.OpenRouter.APIKey != "" {
t.Error("OpenRouter API key should be empty by default")
}
if cfg.Providers.Groq.APIKey != "" {
t.Error("Groq API key should be empty by default")
}
if cfg.Providers.Zhipu.APIKey != "" {
t.Error("Zhipu API key should be empty by default")
}
if cfg.Providers.VLLM.APIKey != "" {
t.Error("VLLM API key should be empty by default")
}
if cfg.Providers.Gemini.APIKey != "" {
t.Error("Gemini API key should be empty by default")
}
}
// TestDefaultConfig_Channels verifies channels are disabled by default
func TestDefaultConfig_Channels(t *testing.T) {
cfg := DefaultConfig()
// Verify all channels are disabled by default
if cfg.Channels.WhatsApp.Enabled {
t.Error("WhatsApp should be disabled by default")
}
if cfg.Channels.Telegram.Enabled {
t.Error("Telegram should be disabled by default")
}
if cfg.Channels.Feishu.Enabled {
t.Error("Feishu should be disabled by default")
}
if cfg.Channels.Discord.Enabled {
t.Error("Discord should be disabled by default")
}
if cfg.Channels.MaixCam.Enabled {
t.Error("MaixCam should be disabled by default")
}
if cfg.Channels.QQ.Enabled {
t.Error("QQ should be disabled by default")
}
if cfg.Channels.DingTalk.Enabled {
t.Error("DingTalk should be disabled by default")
}
if cfg.Channels.Slack.Enabled {
t.Error("Slack should be disabled by default")
}
@ -147,19 +300,42 @@ func TestDefaultConfig_WebTools(t *testing.T) {
}
}
func TestSaveConfig_FilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file permission bits are not enforced on Windows")
}
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "config.json")
cfg := DefaultConfig()
if err := SaveConfig(path, cfg); err != nil {
t.Fatalf("SaveConfig failed: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
perm := info.Mode().Perm()
if perm != 0o600 {
t.Errorf("config file has permission %04o, want 0600", perm)
}
}
// TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig()
// Verify complete config structure
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty")
}
if cfg.Agents.Defaults.Model == "" {
t.Error("Model should not be empty")
}
if cfg.Agents.Defaults.Temperature == 0 {
t.Error("Temperature should have default value")
if cfg.Agents.Defaults.Temperature != nil {
t.Error("Temperature should be nil when not provided")
}
if cfg.Agents.Defaults.MaxTokens == 0 {
t.Error("MaxTokens should not be zero")
@ -167,7 +343,7 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.MaxToolIterations == 0 {
t.Error("MaxToolIterations should not be zero")
}
if cfg.Gateway.Host != "0.0.0.0" {
if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
@ -177,3 +353,42 @@ func TestConfig_Complete(t *testing.T) {
t.Error("Heartbeat should be enabled by default")
}
}
func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Providers.OpenAI.WebSearch {
t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true")
}
}
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if !cfg.Providers.OpenAI.WebSearch {
t.Fatal("OpenAI codex web search should remain true when unset in config file")
}
}
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Providers.OpenAI.WebSearch {
t.Fatal("OpenAI codex web search should be false when disabled in config file")
}
}

324
pkg/config/defaults.go Normal file
View file

@ -0,0 +1,324 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
// DefaultConfig returns the default configuration for PicoClaw.
func DefaultConfig() *Config {
return &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true,
Provider: "",
Model: "glm-4.7",
MaxTokens: 8192,
Temperature: nil, // nil means use provider default
MaxToolIterations: 20,
},
},
Bindings: []AgentBinding{},
Session: SessionConfig{
DMScope: "main",
},
Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{
Enabled: false,
BridgeURL: "ws://localhost:3001",
AllowFrom: FlexibleStringSlice{},
},
Telegram: TelegramConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
},
Feishu: FeishuConfig{
Enabled: false,
AppID: "",
AppSecret: "",
EncryptKey: "",
VerificationToken: "",
AllowFrom: FlexibleStringSlice{},
},
Discord: DiscordConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
MentionOnly: false,
},
MaixCam: MaixCamConfig{
Enabled: false,
Host: "0.0.0.0",
Port: 18790,
AllowFrom: FlexibleStringSlice{},
},
QQ: QQConfig{
Enabled: false,
AppID: "",
AppSecret: "",
AllowFrom: FlexibleStringSlice{},
},
DingTalk: DingTalkConfig{
Enabled: false,
ClientID: "",
ClientSecret: "",
AllowFrom: FlexibleStringSlice{},
},
Slack: SlackConfig{
Enabled: false,
BotToken: "",
AppToken: "",
AllowFrom: FlexibleStringSlice{},
},
LINE: LINEConfig{
Enabled: false,
ChannelSecret: "",
ChannelAccessToken: "",
WebhookHost: "0.0.0.0",
WebhookPort: 18791,
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
},
OneBot: OneBotConfig{
Enabled: false,
WSUrl: "ws://127.0.0.1:3001",
AccessToken: "",
ReconnectInterval: 5,
GroupTriggerPrefix: []string{},
AllowFrom: FlexibleStringSlice{},
},
WeCom: WeComConfig{
Enabled: false,
Token: "",
EncodingAESKey: "",
WebhookURL: "",
WebhookHost: "0.0.0.0",
WebhookPort: 18793,
WebhookPath: "/webhook/wecom",
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
},
WeComApp: WeComAppConfig{
Enabled: false,
CorpID: "",
CorpSecret: "",
AgentID: 0,
Token: "",
EncodingAESKey: "",
WebhookHost: "0.0.0.0",
WebhookPort: 18792,
WebhookPath: "/webhook/wecom-app",
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
},
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true},
},
ModelList: []ModelConfig{
// ============================================
// Add your API key to the model you want to use
// ============================================
// Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys
{
ModelName: "glm-4.7",
Model: "zhipu/glm-4.7",
APIBase: "https://open.bigmodel.cn/api/paas/v4",
APIKey: "",
},
// OpenAI - https://platform.openai.com/api-keys
{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
APIBase: "https://api.openai.com/v1",
APIKey: "",
},
// Anthropic Claude - https://console.anthropic.com/settings/keys
{
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
APIBase: "https://api.anthropic.com/v1",
APIKey: "",
},
// DeepSeek - https://platform.deepseek.com/
{
ModelName: "deepseek-chat",
Model: "deepseek/deepseek-chat",
APIBase: "https://api.deepseek.com/v1",
APIKey: "",
},
// Google Gemini - https://ai.google.dev/
{
ModelName: "gemini-2.0-flash",
Model: "gemini/gemini-2.0-flash-exp",
APIBase: "https://generativelanguage.googleapis.com/v1beta",
APIKey: "",
},
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
{
ModelName: "qwen-plus",
Model: "qwen/qwen-plus",
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
APIKey: "",
},
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
{
ModelName: "moonshot-v1-8k",
Model: "moonshot/moonshot-v1-8k",
APIBase: "https://api.moonshot.cn/v1",
APIKey: "",
},
// Groq - https://console.groq.com/keys
{
ModelName: "llama-3.3-70b",
Model: "groq/llama-3.3-70b-versatile",
APIBase: "https://api.groq.com/openai/v1",
APIKey: "",
},
// OpenRouter (100+ models) - https://openrouter.ai/keys
{
ModelName: "openrouter-auto",
Model: "openrouter/auto",
APIBase: "https://openrouter.ai/api/v1",
APIKey: "",
},
{
ModelName: "openrouter-gpt-5.2",
Model: "openrouter/openai/gpt-5.2",
APIBase: "https://openrouter.ai/api/v1",
APIKey: "",
},
// NVIDIA - https://build.nvidia.com/
{
ModelName: "nemotron-4-340b",
Model: "nvidia/nemotron-4-340b-instruct",
APIBase: "https://integrate.api.nvidia.com/v1",
APIKey: "",
},
// Cerebras - https://inference.cerebras.ai/
{
ModelName: "cerebras-llama-3.3-70b",
Model: "cerebras/llama-3.3-70b",
APIBase: "https://api.cerebras.ai/v1",
APIKey: "",
},
// Volcengine (火山引擎) - https://console.volcengine.com/ark
{
ModelName: "doubao-pro",
Model: "volcengine/doubao-pro-32k",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
APIKey: "",
},
// ShengsuanYun (神算云)
{
ModelName: "deepseek-v3",
Model: "shengsuanyun/deepseek-v3",
APIBase: "https://api.shengsuanyun.com/v1",
APIKey: "",
},
// Antigravity (Google Cloud Code Assist) - OAuth only
{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: "oauth",
},
// GitHub Copilot - https://github.com/settings/tokens
{
ModelName: "copilot-gpt-5.2",
Model: "github-copilot/gpt-5.2",
APIBase: "http://localhost:4321",
AuthMethod: "oauth",
},
// Ollama (local) - https://ollama.com
{
ModelName: "llama3",
Model: "ollama/llama3",
APIBase: "http://localhost:11434/v1",
APIKey: "ollama",
},
// Mistral AI - https://console.mistral.ai/api-keys
{
ModelName: "mistral-small",
Model: "mistral/mistral-small-latest",
APIBase: "https://api.mistral.ai/v1",
APIKey: "",
},
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
Model: "vllm/custom-model",
APIBase: "http://localhost:8000/v1",
APIKey: "",
},
},
Gateway: GatewayConfig{
Host: "127.0.0.1",
Port: 18790,
},
Tools: ToolsConfig{
Web: WebToolsConfig{
Brave: BraveConfig{
Enabled: false,
APIKey: "",
MaxResults: 5,
},
DuckDuckGo: DuckDuckGoConfig{
Enabled: true,
MaxResults: 5,
},
Perplexity: PerplexityConfig{
Enabled: false,
APIKey: "",
MaxResults: 5,
},
},
Cron: CronToolsConfig{
ExecTimeoutMinutes: 5,
},
Exec: ExecConfig{
EnableDenyPatterns: true,
},
Skills: SkillsToolsConfig{
Registries: SkillsRegistriesConfig{
ClawHub: ClawHubRegistryConfig{
Enabled: true,
BaseURL: "https://clawhub.ai",
},
},
MaxConcurrentSearches: 2,
SearchCache: SearchCacheConfig{
MaxSize: 50,
TTLSeconds: 300,
},
},
},
Heartbeat: HeartbeatConfig{
Enabled: true,
Interval: 30,
},
Devices: DevicesConfig{
Enabled: false,
MonitorUSB: true,
},
}
}

369
pkg/config/migration.go Normal file
View file

@ -0,0 +1,369 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"slices"
"strings"
)
// buildModelWithProtocol constructs a model string with protocol prefix.
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
// Otherwise, the protocol prefix is added.
func buildModelWithProtocol(protocol, model string) string {
if strings.Contains(model, "/") {
// Model already has a protocol prefix, return as-is
return model
}
return protocol + "/" + model
}
// providerMigrationConfig defines how to migrate a provider from old config to new format.
type providerMigrationConfig struct {
// providerNames are the possible names used in agents.defaults.provider
providerNames []string
// protocol is the protocol prefix for the model field
protocol string
// buildConfig creates the ModelConfig from ProviderConfig
buildConfig func(p ProvidersConfig) (ModelConfig, bool)
}
// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.
// This enables backward compatibility with existing configurations.
// It preserves the user's configured model from agents.defaults.model when possible.
func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
if cfg == nil {
return nil
}
// Get user's configured provider and model
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
userModel := cfg.Agents.Defaults.GetModelName()
p := cfg.Providers
var result []ModelConfig
// Track if we've applied the legacy model name fix (only for first provider)
legacyModelNameApplied := false
// Define migration rules for each provider
migrations := []providerMigrationConfig{
{
providerNames: []string{"openai", "gpt"},
protocol: "openai",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "openai",
Model: "openai/gpt-5.2",
APIKey: p.OpenAI.APIKey,
APIBase: p.OpenAI.APIBase,
Proxy: p.OpenAI.Proxy,
AuthMethod: p.OpenAI.AuthMethod,
}, true
},
},
{
providerNames: []string{"anthropic", "claude"},
protocol: "anthropic",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "anthropic",
Model: "anthropic/claude-sonnet-4.6",
APIKey: p.Anthropic.APIKey,
APIBase: p.Anthropic.APIBase,
Proxy: p.Anthropic.Proxy,
AuthMethod: p.Anthropic.AuthMethod,
}, true
},
},
{
providerNames: []string{"openrouter"},
protocol: "openrouter",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "openrouter",
Model: "openrouter/auto",
APIKey: p.OpenRouter.APIKey,
APIBase: p.OpenRouter.APIBase,
Proxy: p.OpenRouter.Proxy,
}, true
},
},
{
providerNames: []string{"groq"},
protocol: "groq",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Groq.APIKey == "" && p.Groq.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "groq",
Model: "groq/llama-3.1-70b-versatile",
APIKey: p.Groq.APIKey,
APIBase: p.Groq.APIBase,
Proxy: p.Groq.Proxy,
}, true
},
},
{
providerNames: []string{"zhipu", "glm"},
protocol: "zhipu",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "zhipu",
Model: "zhipu/glm-4",
APIKey: p.Zhipu.APIKey,
APIBase: p.Zhipu.APIBase,
Proxy: p.Zhipu.Proxy,
}, true
},
},
{
providerNames: []string{"vllm"},
protocol: "vllm",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "vllm",
Model: "vllm/auto",
APIKey: p.VLLM.APIKey,
APIBase: p.VLLM.APIBase,
Proxy: p.VLLM.Proxy,
}, true
},
},
{
providerNames: []string{"gemini", "google"},
protocol: "gemini",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "gemini",
Model: "gemini/gemini-pro",
APIKey: p.Gemini.APIKey,
APIBase: p.Gemini.APIBase,
Proxy: p.Gemini.Proxy,
}, true
},
},
{
providerNames: []string{"nvidia"},
protocol: "nvidia",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "nvidia",
Model: "nvidia/meta/llama-3.1-8b-instruct",
APIKey: p.Nvidia.APIKey,
APIBase: p.Nvidia.APIBase,
Proxy: p.Nvidia.Proxy,
}, true
},
},
{
providerNames: []string{"ollama"},
protocol: "ollama",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "ollama",
Model: "ollama/llama3",
APIKey: p.Ollama.APIKey,
APIBase: p.Ollama.APIBase,
Proxy: p.Ollama.Proxy,
}, true
},
},
{
providerNames: []string{"moonshot", "kimi"},
protocol: "moonshot",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "moonshot",
Model: "moonshot/kimi",
APIKey: p.Moonshot.APIKey,
APIBase: p.Moonshot.APIBase,
Proxy: p.Moonshot.Proxy,
}, true
},
},
{
providerNames: []string{"shengsuanyun"},
protocol: "shengsuanyun",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "shengsuanyun",
Model: "shengsuanyun/auto",
APIKey: p.ShengSuanYun.APIKey,
APIBase: p.ShengSuanYun.APIBase,
Proxy: p.ShengSuanYun.Proxy,
}, true
},
},
{
providerNames: []string{"deepseek"},
protocol: "deepseek",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "deepseek",
Model: "deepseek/deepseek-chat",
APIKey: p.DeepSeek.APIKey,
APIBase: p.DeepSeek.APIBase,
Proxy: p.DeepSeek.Proxy,
}, true
},
},
{
providerNames: []string{"cerebras"},
protocol: "cerebras",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "cerebras",
Model: "cerebras/llama-3.3-70b",
APIKey: p.Cerebras.APIKey,
APIBase: p.Cerebras.APIBase,
Proxy: p.Cerebras.Proxy,
}, true
},
},
{
providerNames: []string{"volcengine", "doubao"},
protocol: "volcengine",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "volcengine",
Model: "volcengine/doubao-pro",
APIKey: p.VolcEngine.APIKey,
APIBase: p.VolcEngine.APIBase,
Proxy: p.VolcEngine.Proxy,
}, true
},
},
{
providerNames: []string{"github_copilot", "copilot"},
protocol: "github-copilot",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "github-copilot",
Model: "github-copilot/gpt-5.2",
APIBase: p.GitHubCopilot.APIBase,
ConnectMode: p.GitHubCopilot.ConnectMode,
}, true
},
},
{
providerNames: []string{"antigravity"},
protocol: "antigravity",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "antigravity",
Model: "antigravity/gemini-2.0-flash",
APIKey: p.Antigravity.APIKey,
AuthMethod: p.Antigravity.AuthMethod,
}, true
},
},
{
providerNames: []string{"qwen", "tongyi"},
protocol: "qwen",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "qwen",
Model: "qwen/qwen-max",
APIKey: p.Qwen.APIKey,
APIBase: p.Qwen.APIBase,
Proxy: p.Qwen.Proxy,
}, true
},
},
{
providerNames: []string{"mistral"},
protocol: "mistral",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "mistral",
Model: "mistral/mistral-small-latest",
APIKey: p.Mistral.APIKey,
APIBase: p.Mistral.APIBase,
Proxy: p.Mistral.Proxy,
}, true
},
},
}
// Process each provider migration
for _, m := range migrations {
mc, ok := m.buildConfig(p)
if !ok {
continue
}
// Check if this is the user's configured provider
if slices.Contains(m.providerNames, userProvider) && userModel != "" {
// Use the user's configured model instead of default
mc.Model = buildModelWithProtocol(m.protocol, userModel)
} else if userProvider == "" && userModel != "" && !legacyModelNameApplied {
// Legacy config: no explicit provider field but model is specified
// Use userModel as ModelName for the FIRST provider so GetModelConfig(model) can find it
// This maintains backward compatibility with old configs that relied on implicit provider selection
mc.ModelName = userModel
mc.Model = buildModelWithProtocol(m.protocol, userModel)
legacyModelNameApplied = true
}
result = append(result, mc)
}
return result
}

View file

@ -0,0 +1,562 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"strings"
"testing"
)
func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{
ProviderConfig: ProviderConfig{
APIKey: "sk-test-key",
APIBase: "https://custom.api.com/v1",
},
},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].ModelName != "openai" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
}
if result[0].Model != "openai/gpt-5.2" {
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2")
}
if result[0].APIKey != "sk-test-key" {
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
}
}
func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
Anthropic: ProviderConfig{
APIKey: "ant-key",
APIBase: "https://custom.anthropic.com",
},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].ModelName != "anthropic" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "anthropic")
}
if result[0].Model != "anthropic/claude-sonnet-4.6" {
t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-sonnet-4.6")
}
}
func TestConvertProvidersToModelList_Multiple(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
Groq: ProviderConfig{APIKey: "groq-key"},
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 3 {
t.Fatalf("len(result) = %d, want 3", len(result))
}
// Check that all providers are present
found := make(map[string]bool)
for _, mc := range result {
found[mc.ModelName] = true
}
for _, name := range []string{"openai", "groq", "zhipu"} {
if !found[name] {
t.Errorf("Missing provider %q in result", name)
}
}
}
func TestConvertProvidersToModelList_Empty(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 0 {
t.Errorf("len(result) = %d, want 0", len(result))
}
}
func TestConvertProvidersToModelList_Nil(t *testing.T) {
result := ConvertProvidersToModelList(nil)
if result != nil {
t.Errorf("result = %v, want nil", result)
}
}
func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
Anthropic: ProviderConfig{APIKey: "key2"},
OpenRouter: ProviderConfig{APIKey: "key3"},
Groq: ProviderConfig{APIKey: "key4"},
Zhipu: ProviderConfig{APIKey: "key5"},
VLLM: ProviderConfig{APIKey: "key6"},
Gemini: ProviderConfig{APIKey: "key7"},
Nvidia: ProviderConfig{APIKey: "key8"},
Ollama: ProviderConfig{APIKey: "key9"},
Moonshot: ProviderConfig{APIKey: "key10"},
ShengSuanYun: ProviderConfig{APIKey: "key11"},
DeepSeek: ProviderConfig{APIKey: "key12"},
Cerebras: ProviderConfig{APIKey: "key13"},
VolcEngine: ProviderConfig{APIKey: "key14"},
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
Antigravity: ProviderConfig{AuthMethod: "oauth"},
Qwen: ProviderConfig{APIKey: "key17"},
Mistral: ProviderConfig{APIKey: "key18"},
},
}
result := ConvertProvidersToModelList(cfg)
// All 18 providers should be converted
if len(result) != 18 {
t.Errorf("len(result) = %d, want 18", len(result))
}
}
func TestConvertProvidersToModelList_Proxy(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{
ProviderConfig: ProviderConfig{
APIKey: "key",
Proxy: "http://proxy:8080",
},
},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].Proxy != "http://proxy:8080" {
t.Errorf("Proxy = %q, want %q", result[0].Proxy, "http://proxy:8080")
}
}
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{
ProviderConfig: ProviderConfig{
AuthMethod: "oauth",
},
},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 0 {
t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result))
}
}
// Tests for preserving user's configured model during migration
func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "deepseek",
Model: "deepseek-reasoner",
},
},
Providers: ProvidersConfig{
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
// Should use user's model, not default
if result[0].Model != "deepseek/deepseek-reasoner" {
t.Errorf("Model = %q, want %q (user's configured model)", result[0].Model, "deepseek/deepseek-reasoner")
}
}
func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "openai",
Model: "gpt-4-turbo",
},
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].Model != "openai/gpt-4-turbo" {
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-4-turbo")
}
}
func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) {
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "claude", // alternative name
Model: "claude-opus-4-20250514",
},
},
Providers: ProvidersConfig{
Anthropic: ProviderConfig{APIKey: "sk-ant"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].Model != "anthropic/claude-opus-4-20250514" {
t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-opus-4-20250514")
}
}
func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "qwen",
Model: "qwen-plus",
},
},
Providers: ProvidersConfig{
Qwen: ProviderConfig{APIKey: "sk-qwen"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].Model != "qwen/qwen-plus" {
t.Errorf("Model = %q, want %q", result[0].Model, "qwen/qwen-plus")
}
}
func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "deepseek",
Model: "", // no model specified
},
},
Providers: ProvidersConfig{
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
// Should use default model
if result[0].Model != "deepseek/deepseek-chat" {
t.Errorf("Model = %q, want %q (default)", result[0].Model, "deepseek/deepseek-chat")
}
}
func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) {
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "deepseek",
Model: "deepseek-reasoner",
},
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result))
}
// Find each provider and verify model
for _, mc := range result {
switch mc.ModelName {
case "openai":
if mc.Model != "openai/gpt-5.2" {
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2")
}
case "deepseek":
if mc.Model != "deepseek/deepseek-reasoner" {
t.Errorf("DeepSeek Model = %q, want %q (user's)", mc.Model, "deepseek/deepseek-reasoner")
}
}
}
}
func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
tests := []struct {
providerAlias string
expectedModel string
provider ProviderConfig
}{
{"gpt", "openai/gpt-4-custom", ProviderConfig{APIKey: "key"}},
{"claude", "anthropic/claude-custom", ProviderConfig{APIKey: "key"}},
{"doubao", "volcengine/doubao-custom", ProviderConfig{APIKey: "key"}},
{"tongyi", "qwen/qwen-custom", ProviderConfig{APIKey: "key"}},
{"kimi", "moonshot/kimi-custom", ProviderConfig{APIKey: "key"}},
}
for _, tt := range tests {
t.Run(tt.providerAlias, func(t *testing.T) {
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: tt.providerAlias,
Model: strings.TrimPrefix(
tt.expectedModel,
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
),
},
},
Providers: ProvidersConfig{},
}
// Set the appropriate provider config
switch tt.providerAlias {
case "gpt":
cfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider}
case "claude":
cfg.Providers.Anthropic = tt.provider
case "doubao":
cfg.Providers.VolcEngine = tt.provider
case "tongyi":
cfg.Providers.Qwen = tt.provider
case "kimi":
cfg.Providers.Moonshot = tt.provider
}
// Need to fix the model name in config
cfg.Agents.Defaults.Model = strings.TrimPrefix(
tt.expectedModel,
tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1],
)
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
// Extract just the model ID part (after the first /)
expectedModelID := tt.expectedModel
if result[0].Model != expectedModelID {
t.Errorf("Model = %q, want %q", result[0].Model, expectedModelID)
}
})
}
}
// Test for backward compatibility: single provider without explicit provider field
// This matches the legacy config pattern where users only set model, not provider
func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T) {
// This matches the user's actual config:
// - No provider field set
// - model = "glm-4.7"
// - Only zhipu has API key configured
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "", // Not set
Model: "glm-4.7",
},
},
Providers: ProvidersConfig{
Zhipu: ProviderConfig{APIKey: "test-zhipu-key"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
// ModelName should be the user's model value for backward compatibility
if result[0].ModelName != "glm-4.7" {
t.Errorf("ModelName = %q, want %q (user's model for backward compatibility)", result[0].ModelName, "glm-4.7")
}
// Model should use the user's model with protocol prefix
if result[0].Model != "zhipu/glm-4.7" {
t.Errorf("Model = %q, want %q", result[0].Model, "zhipu/glm-4.7")
}
}
func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testing.T) {
// When multiple providers are configured but no provider field is set,
// the FIRST provider (in migration order) will use userModel as ModelName
// for backward compatibility with legacy implicit provider selection
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "", // Not set
Model: "some-model",
},
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 2 {
t.Fatalf("len(result) = %d, want 2", len(result))
}
// The first provider (OpenAI in migration order) should use userModel as ModelName
// This ensures GetModelConfig("some-model") will find it
if result[0].ModelName != "some-model" {
t.Errorf("First provider ModelName = %q, want %q", result[0].ModelName, "some-model")
}
// Other providers should use provider name as ModelName
if result[1].ModelName != "zhipu" {
t.Errorf("Second provider ModelName = %q, want %q", result[1].ModelName, "zhipu")
}
}
func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
// Edge case: no provider, no model
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "",
Model: "",
},
},
Providers: ProvidersConfig{
Zhipu: ProviderConfig{APIKey: "zhipu-key"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
// Should use default provider name since no model is specified
if result[0].ModelName != "zhipu" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "zhipu")
}
}
// Tests for buildModelWithProtocol helper function
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
result := buildModelWithProtocol("openai", "gpt-5.2")
if result != "openai/gpt-5.2" {
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
}
}
func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
result := buildModelWithProtocol("openrouter", "openrouter/auto")
if result != "openrouter/auto" {
t.Errorf("buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", result, "openrouter/auto")
}
}
func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
if result != "openrouter/claude-sonnet-4.6" {
t.Errorf(
"buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q",
result,
"openrouter/claude-sonnet-4.6",
)
}
}
// Test for legacy config with protocol prefix in model name
func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
cfg := &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Provider: "", // No explicit provider
Model: "openrouter/auto", // Model already has protocol prefix
},
},
Providers: ProvidersConfig{
OpenRouter: ProviderConfig{APIKey: "sk-or-test"},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) < 1 {
t.Fatalf("len(result) = %d, want at least 1", len(result))
}
// First provider should use userModel as ModelName for backward compatibility
if result[0].ModelName != "openrouter/auto" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openrouter/auto")
}
// Model should NOT have duplicated prefix
if result[0].Model != "openrouter/auto" {
t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto")
}
}

View file

@ -0,0 +1,367 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
import (
"encoding/json"
"strings"
"sync"
"testing"
)
func TestGetModelConfig_Found(t *testing.T) {
cfg := &Config{
ModelList: []ModelConfig{
{ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
{ModelName: "other-model", Model: "anthropic/claude", APIKey: "key2"},
},
}
result, err := cfg.GetModelConfig("test-model")
if err != nil {
t.Fatalf("GetModelConfig() error = %v", err)
}
if result.Model != "openai/gpt-4o" {
t.Errorf("Model = %q, want %q", result.Model, "openai/gpt-4o")
}
}
func TestGetModelConfig_NotFound(t *testing.T) {
cfg := &Config{
ModelList: []ModelConfig{
{ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
},
}
_, err := cfg.GetModelConfig("nonexistent")
if err == nil {
t.Fatal("GetModelConfig() expected error for nonexistent model")
}
}
func TestGetModelConfig_EmptyList(t *testing.T) {
cfg := &Config{
ModelList: []ModelConfig{},
}
_, err := cfg.GetModelConfig("any-model")
if err == nil {
t.Fatal("GetModelConfig() expected error for empty model list")
}
}
func TestGetModelConfig_RoundRobin(t *testing.T) {
cfg := &Config{
ModelList: []ModelConfig{
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
{ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
},
}
// Test round-robin distribution
results := make(map[string]int)
for i := 0; i < 30; i++ {
result, err := cfg.GetModelConfig("lb-model")
if err != nil {
t.Fatalf("GetModelConfig() error = %v", err)
}
results[result.Model]++
}
// Each model should appear roughly 10 times (30 calls / 3 models)
for model, count := range results {
if count < 5 || count > 15 {
t.Errorf("Model %s appeared %d times, expected ~10", model, count)
}
}
}
func TestGetModelConfig_Concurrent(t *testing.T) {
cfg := &Config{
ModelList: []ModelConfig{
{ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
{ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
},
}
const goroutines = 100
const iterations = 10
var wg sync.WaitGroup
errors := make(chan error, goroutines*iterations)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
_, err := cfg.GetModelConfig("concurrent-model")
if err != nil {
errors <- err
}
}
}()
}
wg.Wait()
close(errors)
for err := range errors {
t.Errorf("Concurrent GetModelConfig() error: %v", err)
}
}
func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {
tests := []struct {
name string
defaults AgentDefaults
wantName string
}{
{
name: "new model_name field only",
defaults: AgentDefaults{ModelName: "new-model"},
wantName: "new-model",
},
{
name: "old model field only",
defaults: AgentDefaults{Model: "legacy-model"},
wantName: "legacy-model",
},
{
name: "both fields - model_name takes precedence",
defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
wantName: "new-model",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.defaults.GetModelName(); got != tt.wantName {
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
}
})
}
}
func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
tests := []struct {
name string
json string
wantName string
}{
{
name: "new model_name field",
json: `{"model_name": "gpt4"}`,
wantName: "gpt4",
},
{
name: "old model field",
json: `{"model": "gpt4"}`,
wantName: "gpt4",
},
{
name: "both fields - model_name wins",
json: `{"model_name": "new", "model": "old"}`,
wantName: "new",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var defaults AgentDefaults
if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if got := defaults.GetModelName(); got != tt.wantName {
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
}
})
}
}
func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
// Test complete config with both old and new formats
oldFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
newFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
for name, jsonStr := range map[string]string{
"old format (model)": oldFormat,
"new format (model_name)": newFormat,
} {
t.Run(name, func(t *testing.T) {
cfg := &Config{}
if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
// Check that GetModelName returns correct value
if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
}
// Check that GetModelConfig works
modelCfg, err := cfg.GetModelConfig("gpt4")
if err != nil {
t.Fatalf("GetModelConfig error: %v", err)
}
if modelCfg.Model != "openai/gpt-4o" {
t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
}
})
}
}
func TestModelConfig_Validate(t *testing.T) {
tests := []struct {
name string
config ModelConfig
wantErr bool
}{
{
name: "valid config",
config: ModelConfig{
ModelName: "test",
Model: "openai/gpt-4o",
},
wantErr: false,
},
{
name: "missing model_name",
config: ModelConfig{
Model: "openai/gpt-4o",
},
wantErr: true,
},
{
name: "missing model",
config: ModelConfig{
ModelName: "test",
},
wantErr: true,
},
{
name: "empty config",
config: ModelConfig{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestConfig_ValidateModelList(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string // partial error message to check
}{
{
name: "valid list",
config: &Config{
ModelList: []ModelConfig{
{ModelName: "test1", Model: "openai/gpt-4o"},
{ModelName: "test2", Model: "anthropic/claude"},
},
},
wantErr: false,
},
{
name: "invalid entry",
config: &Config{
ModelList: []ModelConfig{
{ModelName: "test1", Model: "openai/gpt-4o"},
{ModelName: "", Model: "anthropic/claude"}, // missing model_name
},
},
wantErr: true,
errMsg: "model_name is required",
},
{
name: "empty list",
config: &Config{
ModelList: []ModelConfig{},
},
wantErr: false,
},
{
// Load balancing: multiple entries with same model_name are allowed
name: "duplicate model_name for load balancing",
config: &Config{
ModelList: []ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1"},
{ModelName: "gpt-4", Model: "openai/gpt-4-turbo", APIKey: "key2"},
},
},
wantErr: false, // Changed: duplicates are allowed for load balancing
},
{
// Load balancing: non-adjacent entries with same model_name are also allowed
name: "duplicate model_name non-adjacent for load balancing",
config: &Config{
ModelList: []ModelConfig{
{ModelName: "model-a", Model: "openai/gpt-4o"},
{ModelName: "model-b", Model: "anthropic/claude"},
{ModelName: "model-a", Model: "openai/gpt-4-turbo"},
},
},
wantErr: false, // Changed: duplicates are allowed for load balancing
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.ValidateModelList()
if (err != nil) != tt.wantErr {
t.Errorf("ValidateModelList() error = %v, wantErr %v", err, tt.wantErr)
}
if err != nil && tt.errMsg != "" {
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("ValidateModelList() error = %v, want error containing %q", err, tt.errMsg)
}
}
})
}
}

View file

@ -1,15 +1,16 @@
// Package constants provides shared constants across the codebase.
package constants
// InternalChannels defines channels that are used for internal communication
// internalChannels defines channels that are used for internal communication
// and should not be exposed to external users or recorded as last active channel.
var InternalChannels = map[string]bool{
"cli": true,
"system": true,
"subagent": true,
var internalChannels = map[string]struct{}{
"cli": {},
"system": {},
"subagent": {},
}
// IsInternalChannel returns true if the channel is an internal channel.
func IsInternalChannel(channel string) bool {
return InternalChannels[channel]
_, found := internalChannels[channel]
return found
}

View file

@ -331,7 +331,7 @@ func (cs *CronService) loadStore() error {
func (cs *CronService) saveStoreUnsafe() error {
dir := filepath.Dir(cs.storePath)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@ -340,10 +340,16 @@ func (cs *CronService) saveStoreUnsafe() error {
return err
}
return os.WriteFile(cs.storePath, data, 0644)
return os.WriteFile(cs.storePath, data, 0o600)
}
func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
func (cs *CronService) AddJob(
name string,
schedule CronSchedule,
message string,
deliver bool,
channel, to string,
) (*CronJob, error) {
cs.mu.Lock()
defer cs.mu.Unlock()
@ -465,7 +471,7 @@ func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
return enabled
}
func (cs *CronService) Status() map[string]interface{} {
func (cs *CronService) Status() map[string]any {
cs.mu.RLock()
defer cs.mu.RUnlock()
@ -476,7 +482,7 @@ func (cs *CronService) Status() map[string]interface{} {
}
}
return map[string]interface{}{
return map[string]any{
"enabled": cs.running,
"jobs": len(cs.store.Jobs),
"nextWakeAtMS": cs.getNextWakeMS(),

38
pkg/cron/service_test.go Normal file
View file

@ -0,0 +1,38 @@
package cron
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestSaveStore_FilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file permission bits are not enforced on Windows")
}
tmpDir := t.TempDir()
storePath := filepath.Join(tmpDir, "cron", "jobs.json")
cs := NewCronService(storePath, nil)
_, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct")
if err != nil {
t.Fatalf("AddJob failed: %v", err)
}
info, err := os.Stat(storePath)
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
perm := info.Mode().Perm()
if perm != 0o600 {
t.Errorf("cron store has permission %04o, want 0600", perm)
}
}
func int64Ptr(v int64) *int64 {
return &v
}

View file

@ -63,14 +63,14 @@ func (s *Service) Start(ctx context.Context) error {
for _, src := range s.sources {
eventCh, err := src.Start(s.ctx)
if err != nil {
logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{
logger.ErrorCF("devices", "Failed to start source", map[string]any{
"kind": src.Kind(),
"error": err.Error(),
})
continue
}
go s.handleEvents(src.Kind(), eventCh)
logger.InfoCF("devices", "Device source started", map[string]interface{}{
logger.InfoCF("devices", "Device source started", map[string]any{
"kind": src.Kind(),
})
}
@ -115,7 +115,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
lastChannel := s.state.GetLastChannel()
if lastChannel == "" {
logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{
logger.DebugCF("devices", "No last channel, skipping notification", map[string]any{
"event": ev.FormatMessage(),
})
return
@ -133,7 +133,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
Content: msg,
})
logger.InfoCF("devices", "Device notification sent", map[string]interface{}{
logger.InfoCF("devices", "Device notification sent", map[string]any{
"kind": ev.Kind,
"action": ev.Action,
"to": platform,

View file

@ -35,9 +35,8 @@ var usbClassToCapability = map[string]string{
}
type USBMonitor struct {
cmd *exec.Cmd
cancel context.CancelFunc
mu sync.Mutex
cmd *exec.Cmd
mu sync.Mutex
}
func NewUSBMonitor() *USBMonitor {
@ -115,7 +114,7 @@ func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, err
}
if err := scanner.Err(); err != nil {
logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()})
logger.ErrorCF("devices", "udevadm scan error", map[string]any{"error": err.Error()})
}
cmd.Wait()
}()

164
pkg/health/server.go Normal file
View file

@ -0,0 +1,164 @@
package health
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
type Server struct {
server *http.Server
mu sync.RWMutex
ready bool
checks map[string]Check
startTime time.Time
}
type Check struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type StatusResponse struct {
Status string `json:"status"`
Uptime string `json:"uptime"`
Checks map[string]Check `json:"checks,omitempty"`
}
func NewServer(host string, port int) *Server {
mux := http.NewServeMux()
s := &Server{
ready: false,
checks: make(map[string]Check),
startTime: time.Now(),
}
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
addr := fmt.Sprintf("%s:%d", host, port)
s.server = &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
return s
}
func (s *Server) Start() error {
s.mu.Lock()
s.ready = true
s.mu.Unlock()
return s.server.ListenAndServe()
}
func (s *Server) StartContext(ctx context.Context) error {
s.mu.Lock()
s.ready = true
s.mu.Unlock()
errCh := make(chan error, 1)
go func() {
errCh <- s.server.ListenAndServe()
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
return s.server.Shutdown(context.Background())
}
}
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
s.ready = false
s.mu.Unlock()
return s.server.Shutdown(ctx)
}
func (s *Server) SetReady(ready bool) {
s.mu.Lock()
s.ready = ready
s.mu.Unlock()
}
func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) {
s.mu.Lock()
defer s.mu.Unlock()
status, msg := checkFn()
s.checks[name] = Check{
Name: name,
Status: statusString(status),
Message: msg,
Timestamp: time.Now(),
}
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
uptime := time.Since(s.startTime)
resp := StatusResponse{
Status: "ok",
Uptime: uptime.String(),
}
json.NewEncoder(w).Encode(resp)
}
func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
s.mu.RLock()
ready := s.ready
checks := make(map[string]Check)
for k, v := range s.checks {
checks[k] = v
}
s.mu.RUnlock()
if !ready {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{
Status: "not ready",
Checks: checks,
})
return
}
for _, check := range checks {
if check.Status == "fail" {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{
Status: "not ready",
Checks: checks,
})
return
}
}
w.WriteHeader(http.StatusOK)
uptime := time.Since(s.startTime)
json.NewEncoder(w).Encode(StatusResponse{
Status: "ready",
Uptime: uptime.String(),
Checks: checks,
})
}
func statusString(ok bool) string {
if ok {
return "ok"
}
return "fail"
}

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