mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-08-01 01:26:19 +00:00
Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e20ac34f35 | |||
| 13e4028d42 | |||
| e7f15afdd4 | |||
| 8d757fbb6f | |||
| e3f65fc3d6 | |||
| 5c321a90de | |||
| 17685da584 | |||
| 159a954122 | |||
| a371d53438 | |||
| 9d5728ec5b | |||
| 32cb8fdc12 | |||
| 341dbd3007 | |||
| 0cb9387cf8 | |||
| e77b0a6755 | |||
| a5503aea36 | |||
| cd638fff6c | |||
| 0d18210803 | |||
| 1d748fb742 | |||
| c6c82b3c44 | |||
| 811e4f8728 | |||
| 214b201bfa | |||
| 9a3f3611c3 | |||
| 8eb9dcd99a | |||
| 0a88ff0817 | |||
| ddd73cad48 | |||
| 3334595859 | |||
| 25a47b50ef | |||
| 0f506d4202 | |||
| 6ce7659090 | |||
| 6f2e730eba | |||
| 16e5a02953 | |||
| 4b886b6573 | |||
| 0a4bf32e81 | |||
| 9eb1a53fb8 | |||
| 5a6ad37dab | |||
| f6d6221c09 | |||
| 7a9659971d | |||
| ecbe31599e | |||
| 6c392c3387 | |||
| 0aab8d8afc | |||
| 5faa67b77d | |||
| 7140e73d46 | |||
| dbf2739783 | |||
| a9557aa073 | |||
| a24cbd4385 | |||
| 8cff6cf312 | |||
| 60c3d96b5e | |||
| 773ce9bcb6 | |||
| 1cff7d4e37 | |||
| f7421128a0 | |||
| 1cb690df32 | |||
| 59c7aa1628 | |||
| 1fcf3fde0f | |||
| b805ec8bde | |||
| 378045510d | |||
| 2720fa71c7 | |||
| 7fa641a2ed | |||
| 18d3634f1b | |||
| afc3a2cda3 | |||
| ba0ef4b62c | |||
| 7d45101fcd | |||
| 1299b20465 | |||
| da804a0748 | |||
| 11a6f5eb71 | |||
| 7304ab7d33 | |||
| 5872e0f55e | |||
| 6463796fa1 | |||
| ac79a23e0a | |||
| ff92973361 | |||
| d7822e5d52 | |||
| aa7a8b89c3 | |||
| 6083168ab4 | |||
| f294a71bc5 | |||
| 2cb90f2fe6 | |||
| a286100db5 | |||
| 45351a6a79 | |||
| 1aea912fcd | |||
| 55d5e89246 | |||
| bd9c9d7efc | |||
| 15e3c7d08a | |||
| 82a9a80d94 | |||
| 878650c459 | |||
| 7f60392d88 | |||
| bc27707671 | |||
| 1e17bac9f0 | |||
| 14de80d35f | |||
| b5a8effcd6 | |||
| 82fea61551 | |||
| 92490feff0 | |||
| 3c2e467324 | |||
| 9036a511fb | |||
| e0f702fe03 | |||
| ee3e8ccebb | |||
| b484d3fcf3 | |||
| 000e64c55a | |||
| c86e121688 | |||
| e0a766243e | |||
| 1516cb57b4 | |||
| 584a3dcc87 | |||
| c58f8b740f | |||
| 5339389ef7 | |||
| f739c459bf | |||
| 2f5849b39d | |||
| cf5a84aac1 | |||
| 5aa4dd2975 | |||
| 896eae4c56 | |||
| 0d339d9e5a | |||
| c0d1346b5c | |||
| 7fa70b8cdf | |||
| 9ccfea4ed4 | |||
| cef1e39734 | |||
| a6aa833237 | |||
| 8851152cbd | |||
| 132fe7db51 | |||
| 0cce9fc905 | |||
| fc40f291d1 | |||
| 0c4b8b00f4 | |||
| 42e0e588dd | |||
| 68abf6b2ee | |||
| 4dfa133cb8 | |||
| 92b8710f59 | |||
| 8e15c9093b | |||
| 8fbbb67f70 | |||
| aa0ab7294b | |||
| 7fa341c449 | |||
| 5893245b45 | |||
| b59464230a | |||
| 875a16d2d6 | |||
| e353844dfb | |||
| 474f3dbf90 | |||
| e7e086155e | |||
| 53b5be862f | |||
| ab20314882 | |||
| fbe1152e2d | |||
| d83fb6e081 | |||
| d2a372e7cc | |||
| 759f4cfe25 | |||
| 2997ea0b01 | |||
| 1b45195acb | |||
| 04924ed640 | |||
| 53df8d1f3d | |||
| b36c87bd60 | |||
| d165fde011 | |||
| 3eb9d6a409 | |||
| e2862c721d | |||
| 0b7f8c1e82 | |||
| 19fc918e9c | |||
| bba44cf89c | |||
| 19b9f93935 | |||
| 2855764712 | |||
| a8cd784636 | |||
| 7c16afbe62 | |||
| 71a81eb297 | |||
| ae2501d943 | |||
| f6362ce7b1 | |||
| 9c98c11351 | |||
| 930080f603 | |||
| 5b6ad3f7cf | |||
| a9a7a89c07 | |||
| 53c69ae41e | |||
| 1d143fa10a | |||
| 0661d05541 | |||
| 8661d54406 | |||
| 8d155988a9 | |||
| df1e4d0494 | |||
| 5582b6d910 | |||
| be81ba1f30 | |||
| e63f96794f | |||
| 03b02cc7d7 | |||
| 28734c3a2e | |||
| 061b07192d | |||
| a14181543e | |||
| 35fa64cde8 | |||
| 0ac93d4429 | |||
| 88014ecaff | |||
| e7e3f95ebe | |||
| 2989c391e3 | |||
| feba44ecf0 | |||
| b94941da4a | |||
| 4c4c10c915 | |||
| 7bcd8b284f | |||
| 56ac18ab70 | |||
| b573d61a58 | |||
| c6c61b4e9d | |||
| ca781d4b37 | |||
| 8968d58fed | |||
| 2fb2604b0e | |||
| bab78de6a4 | |||
| a7bbda147e | |||
| 13fcbe6c59 | |||
| 481eee672e | |||
| 792639d813 | |||
| d7da39d62b | |||
| 4a7c48112a | |||
| 5c8626f07b | |||
| 4a39658e61 | |||
| 64295955a5 | |||
| fe4962794d | |||
| f4a8ff7571 | |||
| 292a371dca | |||
| 44e33d8b1c | |||
| 167efc55e0 | |||
| 66669d6635 | |||
| 5f1caedec2 | |||
| ddd6fca1be | |||
| fe59662df3 | |||
| 91e8abf804 | |||
| 8ceef6e8a7 | |||
| af3f6596f9 | |||
| ca189588e6 | |||
| cddafb403a | |||
| 83f6e44b02 | |||
| fbad753b2a | |||
| 5eec80c654 | |||
| 3d54ec59e2 | |||
| 5efe8a2020 | |||
| eff0f491e9 | |||
| f12c337965 | |||
| 6ccd9d0a99 | |||
| 4f51441a3b | |||
| 4fbf017991 | |||
| f5b919179d | |||
| 9ec84c6630 | |||
| ae9cfc2f31 | |||
| c704990cec | |||
| af60ce26fc | |||
| 4bc9e2d768 | |||
| 6d4d2bc61e | |||
| 18bdbef815 | |||
| 3e902abb5c | |||
| 833c092998 | |||
| 8c8daf64ef | |||
| 7231d48c26 | |||
| 94935c53ae | |||
| c3f5398538 | |||
| 21d60f63fc | |||
| 10442732b4 | |||
| be2ed5d759 | |||
| 2b3de5cae5 | |||
| c5f6bec44b | |||
| 9936dbce52 | |||
| 24d5e833ad | |||
| 91e7e18e85 | |||
| f3f7ca7851 | |||
| ac945fa6f3 | |||
| 2df60b2fa3 | |||
| 07e624c8da | |||
| f7d6a9ca43 | |||
| 5baae33911 | |||
| b599a022be |
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
build/
|
||||
.picoclaw/
|
||||
config/
|
||||
.env
|
||||
.env.example
|
||||
*.md
|
||||
LICENSE
|
||||
assets/
|
||||
@@ -0,0 +1,19 @@
|
||||
# ── LLM Provider ──────────────────────────
|
||||
# Uncomment and set the API key for your provider
|
||||
# OPENROUTER_API_KEY=sk-or-v1-xxx
|
||||
# ZHIPU_API_KEY=xxx
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# GEMINI_API_KEY=xxx
|
||||
|
||||
# ── Chat Channel ──────────────────────────
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
# DISCORD_BOT_TOKEN=xxx
|
||||
# LINE_CHANNEL_SECRET=xxx
|
||||
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
||||
|
||||
# ── Web Search (optional) ────────────────
|
||||
# BRAVE_SEARCH_API_KEY=BSA...
|
||||
|
||||
# ── Timezone ──────────────────────────────
|
||||
TZ=Asia/Tokyo
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 #
|
||||
@@ -0,0 +1,25 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
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
|
||||
@@ -0,0 +1,76 @@
|
||||
name: 🐳 Build & Push Docker Image
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
GHCR_IMAGE_NAME: ${{ github.repository_owner }}/picoclaw
|
||||
DOCKERHUB_REGISTRY: docker.io
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 🏗️ Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
# ── Checkout ──────────────────────────────
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
# ── Docker Buildx ─────────────────────────
|
||||
- name: 🔧 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# ── Login to GHCR ─────────────────────────
|
||||
- name: 🔑 Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ── Login to Docker Hub ────────────────────
|
||||
- name: 🔑 Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
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: 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,linux/riscv64
|
||||
@@ -0,0 +1,58 @@
|
||||
name: pr-check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
fmt-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: fmt-check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Run go test
|
||||
run: go test ./...
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Create Tag and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (required, e.g. v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Mark as pre-release"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
draft:
|
||||
description: "Create as draft"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
name: Create Git Tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create and push tag
|
||||
shell: bash
|
||||
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 }}"
|
||||
|
||||
release:
|
||||
name: GoReleaser Release
|
||||
needs: create-tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
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 }}
|
||||
|
||||
- name: Apply release flags
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release edit "${{ inputs.tag }}" \
|
||||
--draft=${{ inputs.draft }} \
|
||||
--prerelease=${{ inputs.prerelease }}
|
||||
+33
-2
@@ -1,15 +1,46 @@
|
||||
# Binaries
|
||||
# Go build artifacts
|
||||
bin/
|
||||
build/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
/picoclaw
|
||||
/picoclaw-test
|
||||
cmd/picoclaw/workspace
|
||||
|
||||
# Picoclaw specific
|
||||
|
||||
# PicoClaw
|
||||
.picoclaw/
|
||||
config.json
|
||||
sessions/
|
||||
build/
|
||||
|
||||
# Coverage
|
||||
|
||||
# Secrets & Config (keep templates, ignore actual secrets)
|
||||
.env
|
||||
config/config.json
|
||||
|
||||
# Test
|
||||
coverage.txt
|
||||
coverage.html
|
||||
.DS_Store
|
||||
build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Ralph workspace
|
||||
ralph/
|
||||
.ralph/
|
||||
tasks/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./cmd/picoclaw
|
||||
|
||||
builds:
|
||||
- id: picoclaw
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- s390x
|
||||
- mips64
|
||||
- arm
|
||||
main: ./cmd/picoclaw
|
||||
ignore:
|
||||
- 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`.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
{{- title .Os }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
# use zip for windows archives
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
# upx:
|
||||
# - enabled: true
|
||||
# compress: best
|
||||
# lzma: true
|
||||
|
||||
release:
|
||||
footer: >-
|
||||
|
||||
---
|
||||
|
||||
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN make build
|
||||
|
||||
# ============================================================
|
||||
# Stage 2: Minimal runtime image
|
||||
# ============================================================
|
||||
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
|
||||
RUN /usr/local/bin/picoclaw onboard
|
||||
|
||||
ENTRYPOINT ["picoclaw"]
|
||||
CMD ["gateway"]
|
||||
@@ -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"]
|
||||
@@ -8,8 +8,10 @@ MAIN_GO=$(CMD_DIR)/main.go
|
||||
|
||||
# Version
|
||||
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)
|
||||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME)"
|
||||
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)"
|
||||
|
||||
# Go variables
|
||||
GO?=go
|
||||
@@ -61,22 +63,29 @@ BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
||||
# Default target
|
||||
all: build
|
||||
|
||||
## generate: Run generate
|
||||
generate:
|
||||
@echo "Run generate..."
|
||||
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
|
||||
@$(GO) generate ./...
|
||||
@echo "Run generate complete"
|
||||
|
||||
## build: Build the picoclaw binary for current platform
|
||||
build:
|
||||
build: generate
|
||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BINARY_PATH)"
|
||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
## build-all: Build picoclaw for all platforms
|
||||
build-all:
|
||||
build-all: generate
|
||||
@echo "Building for multiple platforms..."
|
||||
@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=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||
# GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./$(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)
|
||||
@echo "All builds complete"
|
||||
|
||||
@@ -87,35 +96,8 @@ install: build
|
||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||
@echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..."
|
||||
@mkdir -p $(WORKSPACE_SKILLS_DIR)
|
||||
@for skill in $(BUILTIN_SKILLS_DIR)/*/; do \
|
||||
if [ -d "$$skill" ]; then \
|
||||
skill_name=$$(basename "$$skill"); \
|
||||
if [ -f "$$skill/SKILL.md" ]; then \
|
||||
cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \
|
||||
echo " ✓ Installed skill: $$skill_name"; \
|
||||
fi; \
|
||||
fi; \
|
||||
done
|
||||
@echo "Installation complete!"
|
||||
|
||||
## install-skills: Install builtin skills to workspace
|
||||
install-skills:
|
||||
@echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..."
|
||||
@mkdir -p $(WORKSPACE_SKILLS_DIR)
|
||||
@for skill in $(BUILTIN_SKILLS_DIR)/*/; do \
|
||||
if [ -d "$$skill" ]; then \
|
||||
skill_name=$$(basename "$$skill"); \
|
||||
if [ -f "$$skill/SKILL.md" ]; then \
|
||||
mkdir -p $(WORKSPACE_SKILLS_DIR)/$$skill_name; \
|
||||
cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \
|
||||
echo " ✓ Installed skill: $$skill_name"; \
|
||||
fi; \
|
||||
fi; \
|
||||
done
|
||||
@echo "Skills installation complete!"
|
||||
|
||||
## uninstall: Remove picoclaw from system
|
||||
uninstall:
|
||||
@echo "Uninstalling $(BINARY_NAME)..."
|
||||
@@ -137,15 +119,31 @@ clean:
|
||||
@rm -rf $(BUILD_DIR)
|
||||
@echo "Clean complete"
|
||||
|
||||
## vet: Run go vet for static analysis
|
||||
vet:
|
||||
@$(GO) vet ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
test:
|
||||
@$(GO) test ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
fmt:
|
||||
@$(GO) fmt ./...
|
||||
|
||||
## deps: Update dependencies
|
||||
## 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)
|
||||
@@ -162,13 +160,12 @@ help:
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make build # Build for current platform"
|
||||
@echo " make install # Install to /usr/local/bin"
|
||||
@echo " make install-user # Install to ~/.local/bin"
|
||||
@echo " make install # Install to ~/.local/bin"
|
||||
@echo " make uninstall # Remove from /usr/local/bin"
|
||||
@echo " make install-skills # Install skills to workspace"
|
||||
@echo ""
|
||||
@echo "Environment Variables:"
|
||||
@echo " INSTALL_PREFIX # Installation prefix (default: /usr/local)"
|
||||
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
|
||||
@echo " WORKSPACE_DIR # Workspace directory (default: ~/.picoclaw/workspace)"
|
||||
@echo " VERSION # Version string (default: git describe)"
|
||||
@echo ""
|
||||
|
||||
+769
@@ -0,0 +1,769 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||
|
||||
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 皮皮虾,我们走!</h3>
|
||||
<h3></h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
</p>
|
||||
|
||||
**日本語** | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
🦐 PicoClaw は [nanobot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。
|
||||
|
||||
⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 📢 ニュース
|
||||
2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 皮皮虾,我们走!
|
||||
|
||||
## ✨ 特徴
|
||||
|
||||
🪶 **超軽量**: メモリフットプリント 10MB 未満 — Clawdbot のコア機能より 99% 小さい。
|
||||
|
||||
💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。
|
||||
|
||||
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
||||
|
||||
🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go!
|
||||
|
||||
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| --- | --- | --- |--- |
|
||||
| **言語** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB |>100MB| **< 10MB** |
|
||||
| **起動時間**</br>(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
|
||||
| **コスト** | Mac Mini 599$ | 大半の Linux SBC </br>~50$ |**あらゆる Linux ボード**</br>**最安 10$** |
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
## 🦾 デモンストレーション
|
||||
### 🛠️ スタンダードアシスタントワークフロー
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 フルスタックエンジニア</p></th>
|
||||
<th><p align="center">🗂️ ログ&計画管理</p></th>
|
||||
<th><p align="center">🔎 Web 検索&学習</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">開発 · デプロイ · スケール</td>
|
||||
<td align="center">スケジュール · 自動化 · メモリ</td>
|
||||
<td align="center">発見 · インサイト · トレンド</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 革新的な省フットプリントデプロイ
|
||||
PicoClaw はほぼすべての Linux デバイスにデプロイできます!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) または 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://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のファームウェアをダウンロードしてください。
|
||||
|
||||
### ソースからインストール(最新機能、開発向け推奨)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# ビルド(インストール不要)
|
||||
make build
|
||||
|
||||
# 複数プラットフォーム向けビルド
|
||||
make build-all
|
||||
|
||||
# ビルドとインストール
|
||||
make install
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
Docker Compose を使えば、ローカルにインストールせずに PicoClaw を実行できます。
|
||||
|
||||
```bash
|
||||
# 1. リポジトリをクローン
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. API キーを設定
|
||||
cp config/config.example.json config/config.json
|
||||
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
|
||||
|
||||
# 3. ビルドと起動
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
# 4. ログ確認
|
||||
docker compose logs -f picoclaw-gateway
|
||||
|
||||
# 5. 停止
|
||||
docker compose --profile gateway down
|
||||
```
|
||||
|
||||
### Agent モード(ワンショット)
|
||||
|
||||
```bash
|
||||
# 質問を投げる
|
||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
||||
|
||||
# インタラクティブモード
|
||||
docker compose run --rm picoclaw-agent
|
||||
```
|
||||
|
||||
### リビルド
|
||||
|
||||
```bash
|
||||
docker compose --profile gateway build --no-cache
|
||||
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 クエリ無料)
|
||||
|
||||
**1. 初期化**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
**2. 設定** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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 リクエスト)
|
||||
|
||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||
|
||||
**3. チャット**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
```
|
||||
|
||||
これだけです!2 分で AI アシスタントが動きます。
|
||||
|
||||
---
|
||||
|
||||
## 💬 チャットアプリ
|
||||
|
||||
Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
||||
|
||||
| チャネル | セットアップ |
|
||||
|---------|------------|
|
||||
| **Telegram** | 簡単(トークンのみ) |
|
||||
| **Discord** | 簡単(Bot トークン + Intents) |
|
||||
| **QQ** | 簡単(AppID + AppSecret) |
|
||||
| **DingTalk** | 普通(アプリ認証情報) |
|
||||
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b>(推奨)</summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- Telegram を開き、`@BotFather` を検索
|
||||
- `/newbot` を送信、プロンプトに従う
|
||||
- トークンをコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ユーザー ID は Telegram の `@userinfobot` から取得できます。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
- https://discord.com/developers/applications にアクセス
|
||||
- アプリケーションを作成 → Bot → Add Bot
|
||||
- Bot トークンをコピー
|
||||
|
||||
**2. Intents を有効化**
|
||||
- Bot の設定画面で **MESSAGE CONTENT INTENT** を有効化
|
||||
- (任意)**SERVER MEMBERS INTENT** も有効化
|
||||
|
||||
**3. ユーザー ID を取得**
|
||||
- Discord 設定 → 詳細設定 → **開発者モード** を有効化
|
||||
- 自分のアバターを右クリック → **ユーザーIDをコピー**
|
||||
|
||||
**4. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**5. Bot を招待**
|
||||
- OAuth2 → URL Generator
|
||||
- Scopes: `bot`
|
||||
- Bot Permissions: `Send Messages`, `Read Message History`
|
||||
- 生成された招待 URL を開き、サーバーに Bot を追加
|
||||
|
||||
**6. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- [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. Bot を作成**
|
||||
|
||||
- [オープンプラットフォーム](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>
|
||||
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
**1. LINE 公式アカウントを作成**
|
||||
|
||||
- [LINE Developers Console](https://developers.line.biz/) にアクセス
|
||||
- プロバイダーを作成 → Messaging API チャネルを作成
|
||||
- **チャネルシークレット** と **チャネルアクセストークン** をコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Webhook URL を設定**
|
||||
|
||||
LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください:
|
||||
|
||||
```bash
|
||||
# ngrok の例
|
||||
ngrok http 18791
|
||||
```
|
||||
|
||||
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
||||
|
||||
**4. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
||||
|
||||
> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。
|
||||
|
||||
</details>
|
||||
|
||||
## ⚙️ 設定
|
||||
|
||||
設定ファイル: `~/.picoclaw/config.json`
|
||||
|
||||
### ワークスペース構成
|
||||
|
||||
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # 会話セッションと履歴
|
||||
├── memory/ # 長期メモリ(MEMORY.md)
|
||||
├── state/ # 永続状態(最後のチャネルなど)
|
||||
├── cron/ # スケジュールジョブデータベース
|
||||
├── skills/ # カスタムスキル
|
||||
├── AGENTS.md # エージェントの行動ガイド
|
||||
├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
|
||||
├── IDENTITY.md # エージェントのアイデンティティ
|
||||
├── SOUL.md # エージェントのソウル
|
||||
├── TOOLS.md # ツールの説明
|
||||
└── USER.md # ユーザー設定
|
||||
```
|
||||
|
||||
### 🔒 セキュリティサンドボックス
|
||||
|
||||
PicoClaw はデフォルトでサンドボックス環境で実行されます。エージェントは設定されたワークスペース内のファイルにのみアクセスし、コマンドを実行できます。
|
||||
|
||||
#### デフォルト設定
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
|-----------|-----------|------|
|
||||
| `workspace` | `~/.picoclaw/workspace` | エージェントの作業ディレクトリ |
|
||||
| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペースに制限 |
|
||||
|
||||
#### 保護対象ツール
|
||||
|
||||
`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます:
|
||||
|
||||
| ツール | 機能 | 制限 |
|
||||
|-------|------|------|
|
||||
| `read_file` | ファイル読み込み | ワークスペース内のファイルのみ |
|
||||
| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ |
|
||||
| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ |
|
||||
| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ |
|
||||
| `append_file` | ファイル追記 | ワークスペース内のファイルのみ |
|
||||
| `exec` | コマンド実行 | コマンドパスはワークスペース内である必要あり |
|
||||
|
||||
#### exec ツールの追加保護
|
||||
|
||||
`restrict_to_workspace: false` でも、`exec` ツールは以下の危険なコマンドをブロックします:
|
||||
|
||||
- `rm -rf`, `del /f`, `rmdir /s` — 一括削除
|
||||
- `format`, `mkfs`, `diskpart` — ディスクフォーマット
|
||||
- `dd if=` — ディスクイメージング
|
||||
- `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み
|
||||
- `shutdown`, `reboot`, `poweroff` — システムシャットダウン
|
||||
- フォークボム `:(){ :|:& };:`
|
||||
|
||||
#### エラー例
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### 制限の無効化(セキュリティリスク)
|
||||
|
||||
エージェントにワークスペース外のパスへのアクセスが必要な場合:
|
||||
|
||||
**方法1: 設定ファイル**
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**方法2: 環境変数**
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **警告**: この制限を無効にすると、エージェントはシステム上の任意のパスにアクセスできるようになります。制御された環境でのみ慎重に使用してください。
|
||||
|
||||
#### セキュリティ境界の一貫性
|
||||
|
||||
`restrict_to_workspace` 設定は、すべての実行パスで一貫して適用されます:
|
||||
|
||||
| 実行パス | セキュリティ境界 |
|
||||
|---------|-----------------|
|
||||
| メインエージェント | `restrict_to_workspace` ✅ |
|
||||
| サブエージェント / Spawn | 同じ制限を継承 ✅ |
|
||||
| ハートビートタスク | 同じ制限を継承 ✅ |
|
||||
|
||||
すべてのパスで同じワークスペース制限が適用されます — サブエージェントやスケジュールタスクを通じてセキュリティ境界をバイパスする方法はありません。
|
||||
|
||||
### ハートビート(定期タスク)
|
||||
|
||||
PicoClaw は自動的に定期タスクを実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成します:
|
||||
|
||||
```markdown
|
||||
# 定期タスク
|
||||
|
||||
- 重要なメールをチェック
|
||||
- 今後の予定を確認
|
||||
- 天気予報をチェック
|
||||
```
|
||||
|
||||
エージェントは30分ごと(設定可能)にこのファイルを読み込み、利用可能なツールを使ってタスクを実行します。
|
||||
|
||||
#### spawn で非同期タスク実行
|
||||
|
||||
時間のかかるタスク(Web検索、API呼び出し)には `spawn` ツールを使って**サブエージェント**を作成します:
|
||||
|
||||
```markdown
|
||||
# 定期タスク
|
||||
|
||||
## クイックタスク(直接応答)
|
||||
- 現在時刻を報告
|
||||
|
||||
## 長時間タスク(spawn で非同期)
|
||||
- AIニュースを検索して要約
|
||||
- メールをチェックして重要なメッセージを報告
|
||||
```
|
||||
|
||||
**主な特徴:**
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| **spawn** | 非同期サブエージェントを作成、ハートビートをブロックしない |
|
||||
| **独立コンテキスト** | サブエージェントは独自のコンテキストを持ち、セッション履歴なし |
|
||||
| **message ツール** | サブエージェントは message ツールで直接ユーザーと通信 |
|
||||
| **非ブロッキング** | spawn 後、ハートビートは次のタスクへ継続 |
|
||||
|
||||
#### サブエージェントの通信方法
|
||||
|
||||
```
|
||||
ハートビート発動
|
||||
↓
|
||||
エージェントが HEARTBEAT.md を読む
|
||||
↓
|
||||
長いタスク: spawn サブエージェント
|
||||
↓ ↓
|
||||
次のタスクへ継続 サブエージェントが独立して動作
|
||||
↓ ↓
|
||||
全タスク完了 message ツールを使用
|
||||
↓ ↓
|
||||
HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||
```
|
||||
|
||||
サブエージェントはツール(message、web_search など)にアクセスでき、メインエージェントを経由せずにユーザーと通信できます。
|
||||
|
||||
**設定:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
|-----------|-----------|------|
|
||||
| `enabled` | `true` | ハートビートの有効/無効 |
|
||||
| `interval` | `30` | チェック間隔(分)、最小5分 |
|
||||
|
||||
**環境変数:**
|
||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
|
||||
|
||||
### 基本設定
|
||||
|
||||
1. **設定ファイルの作成:**
|
||||
|
||||
```bash
|
||||
cp config.example.json config/config.json
|
||||
```
|
||||
|
||||
2. **設定の編集:**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-..."
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **実行**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "Hello"
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>完全な設定例</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"apiKey": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allowFrom": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "xxx",
|
||||
"encryptKey": "",
|
||||
"verificationToken": "",
|
||||
"allowFrom": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"apiKey": "BSA..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## CLI リファレンス
|
||||
|
||||
| コマンド | 説明 |
|
||||
|---------|------|
|
||||
| `picoclaw onboard` | 設定&ワークスペースの初期化 |
|
||||
| `picoclaw agent -m "..."` | エージェントとチャット |
|
||||
| `picoclaw agent` | インタラクティブチャットモード |
|
||||
| `picoclaw gateway` | ゲートウェイを起動 |
|
||||
| `picoclaw status` | ステータスを表示 |
|
||||
|
||||
## 🤝 コントリビュート&ロードマップ
|
||||
|
||||
PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗
|
||||
|
||||
Discord: https://discord.gg/V4sAZ9XWpN
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
## 🐛 トラブルシューティング
|
||||
|
||||
### Web 検索で「API 配置问题」と表示される
|
||||
|
||||
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
||||
|
||||
Web 検索を有効にするには:
|
||||
1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
||||
2. `~/.picoclaw/config.json` に追加:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### コンテンツフィルタリングエラーが出る
|
||||
|
||||
一部のプロバイダー(Zhipu など)にはコンテンツフィルタリングがあります。クエリを言い換えるか、別のモデルを使用してください。
|
||||
|
||||
### Telegram Bot で「Conflict: terminated by other getUpdates」と表示される
|
||||
|
||||
別のインスタンスが実行中の場合に発生します。`picoclaw gateway` が 1 つだけ実行されていることを確認してください。
|
||||
|
||||
---
|
||||
|
||||
## 📝 API キー比較
|
||||
|
||||
| サービス | 無料枠 | ユースケース |
|
||||
|---------|--------|------------|
|
||||
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||
@@ -1,20 +1,22 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
||||
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
||||
|
||||
<h3>$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!</h3>
|
||||
<h3></h3>
|
||||
<h3>$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
</p>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<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>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | **English**
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [nanobot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
|
||||
@@ -36,8 +38,23 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> [!CAUTION]
|
||||
> **🚨 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 (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
||||
|
||||
|
||||
## 📢 News
|
||||
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 皮皮虾,我们走!
|
||||
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](doc/picoclaw_community_roadmap_260216.md) —we can’t 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 come 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. 🦐 PicoClaw,Let's Go!
|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -51,17 +68,19 @@
|
||||
|
||||
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| --- | --- | --- |--- |
|
||||
| **Language** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB |>100MB| **< 10MB** |
|
||||
| **Startup**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||
| **Cost** | Mac Mini 599$ | Most Linux SBC </br>~50$ |**Any Linux Board**</br>**As low as 10$** |
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||
| **Language** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||
| **Startup**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||
| **Cost** | Mac Mini 599$ | Most Linux SBC </br>~50$ | **Any Linux Board**</br>**As low as 10$** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
## 🦾 Demonstration
|
||||
|
||||
### 🛠️ Standard Assistant Workflows
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 Full-Stack Engineer</p></th>
|
||||
@@ -81,11 +100,14 @@
|
||||
</table>
|
||||
|
||||
### 🐜 Innovative Low-Footprint Deploy
|
||||
1. Minimal 10$ Home Assitant
|
||||
2. NanoKVM Automated Maintenance
|
||||
3. MaixCAM2 Smart Monitoring
|
||||
|
||||
https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
|
||||
PicoClaw can be deployed on almost any Linux device!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
|
||||
|
||||
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||
|
||||
🌟 More Deployment Cases Await!
|
||||
|
||||
@@ -113,12 +135,52 @@ make build-all
|
||||
make install
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
You can also run PicoClaw using Docker Compose without installing anything locally.
|
||||
|
||||
```bash
|
||||
# 1. Clone this repo
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Set your API keys
|
||||
cp config/config.example.json config/config.json
|
||||
vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
|
||||
|
||||
# 3. Build & Start
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
# 4. Check logs
|
||||
docker compose logs -f picoclaw-gateway
|
||||
|
||||
# 5. Stop
|
||||
docker compose --profile gateway down
|
||||
```
|
||||
|
||||
### Agent Mode (One-shot)
|
||||
|
||||
```bash
|
||||
# Ask a question
|
||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
||||
|
||||
# Interactive mode
|
||||
docker compose run --rm picoclaw-agent
|
||||
```
|
||||
|
||||
### Rebuild
|
||||
|
||||
```bash
|
||||
docker compose --profile gateway build --no-cache
|
||||
docker compose --profile gateway up -d
|
||||
```
|
||||
|
||||
### 🚀 Quick Start
|
||||
|
||||
> [!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)
|
||||
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||
|
||||
**1. Initialize**
|
||||
|
||||
@@ -142,14 +204,19 @@ picoclaw onboard
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,16 +225,12 @@ picoclaw onboard
|
||||
|
||||
**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)
|
||||
- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
||||
* **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)
|
||||
|
||||
> **Note**: See `config.example.json` for a complete configuration template.
|
||||
|
||||
- **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)
|
||||
|
||||
|
||||
**3. Chat**
|
||||
**4. Chat**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
@@ -179,21 +242,24 @@ That's it! You have a working AI assistant in 2 minutes.
|
||||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Talk to your picoclaw through Telegram
|
||||
Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
||||
|
||||
| Channel | Setup |
|
||||
|---------|-------|
|
||||
| **Telegram** | Easy (just a token) |
|
||||
| **Discord** | Easy (bot token + intents) |
|
||||
| Channel | Setup |
|
||||
| ------------ | ---------------------------------- |
|
||||
| **Telegram** | Easy (just a token) |
|
||||
| **Discord** | Easy (bot token + intents) |
|
||||
| **QQ** | Easy (AppID + AppSecret) |
|
||||
| **DingTalk** | Medium (app credentials) |
|
||||
| **LINE** | Medium (credentials + webhook URL) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
|
||||
**1. Create a bot**
|
||||
|
||||
- Open Telegram, search `@BotFather`
|
||||
- Send `/newbot`, follow prompts
|
||||
- Copy the token
|
||||
* Open Telegram, search `@BotFather`
|
||||
* Send `/newbot`, follow prompts
|
||||
* Copy the token
|
||||
|
||||
**2. Configure**
|
||||
|
||||
@@ -216,24 +282,27 @@ Talk to your picoclaw through Telegram
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. Create a bot**
|
||||
- Go to https://discord.com/developers/applications
|
||||
- Create an application → Bot → Add Bot
|
||||
- Copy the bot token
|
||||
|
||||
* Go to <https://discord.com/developers/applications>
|
||||
* Create an application → Bot → Add Bot
|
||||
* Copy the bot token
|
||||
|
||||
**2. Enable intents**
|
||||
- In the Bot settings, enable **MESSAGE CONTENT INTENT**
|
||||
- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||
|
||||
* In the Bot settings, enable **MESSAGE CONTENT INTENT**
|
||||
* (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**
|
||||
|
||||
* Discord Settings → Advanced → enable **Developer Mode**
|
||||
* Right-click your avatar → **Copy User ID**
|
||||
|
||||
**4. Configure**
|
||||
|
||||
@@ -250,44 +319,358 @@ picoclaw gateway
|
||||
```
|
||||
|
||||
**5. Invite the bot**
|
||||
- OAuth2 → URL Generator
|
||||
- Scopes: `bot`
|
||||
- Bot Permissions: `Send Messages`, `Read Message History`
|
||||
- Open the generated invite URL and add the bot to your server
|
||||
|
||||
* OAuth2 → URL Generator
|
||||
* Scopes: `bot`
|
||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||
* Open the generated invite URL and add the bot to your server
|
||||
|
||||
**6. Run**
|
||||
|
||||
```bash
|
||||
nanobot gateway
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**1. Create a bot**
|
||||
|
||||
- Go to [QQ Open Platform](https://q.qq.com/#)
|
||||
- Create an application → Get **AppID** and **AppSecret**
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"app_secret": "YOUR_APP_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
**1. Create a bot**
|
||||
|
||||
* Go to [Open Platform](https://open.dingtalk.com/)
|
||||
* Create an internal app
|
||||
* Copy Client ID and Client Secret
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
**1. Create a LINE Official Account**
|
||||
|
||||
- Go to [LINE Developers Console](https://developers.line.biz/)
|
||||
- Create a provider → Create a Messaging API channel
|
||||
- Copy **Channel Secret** and **Channel Access Token**
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Set up Webhook URL**
|
||||
|
||||
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
|
||||
|
||||
```bash
|
||||
# Example with ngrok
|
||||
ngrok http 18791
|
||||
```
|
||||
|
||||
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
|
||||
|
||||
**4. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
|
||||
|
||||
> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port.
|
||||
|
||||
</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.
|
||||
|
||||
**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Config file: `~/.picoclaw/config.json`
|
||||
|
||||
### Workspace Layout
|
||||
|
||||
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # Conversation sessions and history
|
||||
├── memory/ # Long-term memory (MEMORY.md)
|
||||
├── state/ # Persistent state (last channel, etc.)
|
||||
├── cron/ # Scheduled jobs database
|
||||
├── skills/ # Custom skills
|
||||
├── AGENTS.md # Agent behavior guide
|
||||
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
|
||||
├── IDENTITY.md # Agent identity
|
||||
├── SOUL.md # Agent soul
|
||||
├── TOOLS.md # Tool descriptions
|
||||
└── USER.md # User preferences
|
||||
```
|
||||
|
||||
### 🔒 Security Sandbox
|
||||
|
||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||
|
||||
#### Default Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 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 |
|
||||
|
||||
#### Additional Exec Protection
|
||||
|
||||
Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands:
|
||||
|
||||
* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion
|
||||
* `format`, `mkfs`, `diskpart` — Disk formatting
|
||||
* `dd if=` — Disk imaging
|
||||
* Writing to `/dev/sd[a-z]` — Direct disk writes
|
||||
* `shutdown`, `reboot`, `poweroff` — System shutdown
|
||||
* Fork bomb `:(){ :|:& };:`
|
||||
|
||||
#### Error Examples
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### Disabling Restrictions (Security Risk)
|
||||
|
||||
If you need the agent to access paths outside the workspace:
|
||||
|
||||
**Method 1: Config file**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Method 2: Environment variable**
|
||||
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only.
|
||||
|
||||
#### Security Boundary Consistency
|
||||
|
||||
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
||||
|
||||
| Execution Path | Security Boundary |
|
||||
|----------------|-------------------|
|
||||
| Main Agent | `restrict_to_workspace` ✅ |
|
||||
| Subagent / Spawn | 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.
|
||||
|
||||
### Heartbeat (Periodic Tasks)
|
||||
|
||||
PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
```
|
||||
|
||||
The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools.
|
||||
|
||||
#### Async Tasks with Spawn
|
||||
|
||||
For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**:
|
||||
|
||||
```markdown
|
||||
# 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 |
|
||||
|
||||
#### How Subagent Communication Works
|
||||
|
||||
```
|
||||
Heartbeat triggers
|
||||
↓
|
||||
Agent reads HEARTBEAT.md
|
||||
↓
|
||||
For long task: spawn subagent
|
||||
↓ ↓
|
||||
Continue to next task Subagent works independently
|
||||
↓ ↓
|
||||
All tasks done Subagent uses "message" tool
|
||||
↓ ↓
|
||||
Respond HEARTBEAT_OK User receives result directly
|
||||
```
|
||||
|
||||
The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `enabled` | `true` | Enable/disable heartbeat |
|
||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
|
||||
|
||||
### Providers
|
||||
|
||||
> [!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(To be tested)` | 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](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) |
|
||||
|
||||
<details>
|
||||
<summary><b>Zhipu</b></summary>
|
||||
|
||||
**1. Get API key and base URL**
|
||||
- Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. Configure**
|
||||
|
||||
@@ -306,8 +689,8 @@ Config file: `~/.picoclaw/config.json`
|
||||
"zhipu": {
|
||||
"api_key": "Your API Key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -316,6 +699,7 @@ Config file: `~/.picoclaw/config.json`
|
||||
```bash
|
||||
picoclaw agent -m "Hello"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -330,17 +714,17 @@ picoclaw agent -m "Hello"
|
||||
},
|
||||
"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,
|
||||
@@ -352,19 +736,35 @@ picoclaw agent -m "Hello"
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "xxx",
|
||||
"encryptKey": "",
|
||||
"verificationToken": "",
|
||||
"allowFrom": []
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"apiKey": "BSA..."
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "BSA...",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -373,39 +773,39 @@ picoclaw agent -m "Hello"
|
||||
|
||||
## CLI Reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `picoclaw onboard` | Initialize config & workspace |
|
||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||
| `picoclaw agent` | Interactive chat mode |
|
||||
| `picoclaw gateway` | Start the gateway |
|
||||
| `picoclaw status` | Show status |
|
||||
| `picoclaw channels login` | Link WhatsApp (scan QR) |
|
||||
| `picoclaw channels status` | Show channel status |
|
||||
| Command | Description |
|
||||
| ------------------------- | ----------------------------- |
|
||||
| `picoclaw onboard` | Initialize config & workspace |
|
||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||
| `picoclaw agent` | Interactive chat mode |
|
||||
| `picoclaw gateway` | Start the gateway |
|
||||
| `picoclaw status` | Show status |
|
||||
| `picoclaw cron list` | List all scheduled jobs |
|
||||
| `picoclaw cron add ...` | Add a scheduled job |
|
||||
|
||||
<details>
|
||||
<summary><b>Scheduled Tasks (Cron)</b></summary>
|
||||
### Scheduled Tasks / Reminders
|
||||
|
||||
```bash
|
||||
# Add a job
|
||||
picoclaw cron add --name "daily" --message "Good morning!" --cron "0 9 * * *"
|
||||
picoclaw cron add --name "hourly" --message "Check status" --every 3600
|
||||
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
|
||||
|
||||
# List jobs
|
||||
picoclaw cron list
|
||||
* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
|
||||
* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
|
||||
* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
|
||||
|
||||
# Remove a job
|
||||
picoclaw cron remove <job_id>
|
||||
```
|
||||
|
||||
</details>
|
||||
Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
||||
|
||||
## 🤝 Contribute & Roadmap
|
||||
|
||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||
|
||||
**Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/picoclaw/pulls)!
|
||||
Roadmap coming soon...
|
||||
|
||||
Developer group building, Entry Requirement: At least 1 Merged PR.
|
||||
|
||||
User Groups:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
@@ -414,20 +814,29 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
||||
|
||||
To enable web search:
|
||||
1. Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month)
|
||||
2. Add to `~/.picoclaw/config.json`:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
|
||||
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
|
||||
|
||||
Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Getting content filtering errors
|
||||
|
||||
@@ -441,9 +850,9 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||
|
||||
## 📝 API Key Comparison
|
||||
|
||||
| Service | Free Tier | Use Case |
|
||||
|---------|-----------|-----------|
|
||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||
| Service | Free Tier | Use Case |
|
||||
| ---------------- | ------------------- | ------------------------------------- |
|
||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||
|
||||
+721
@@ -0,0 +1,721 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||
|
||||
<h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<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>
|
||||
</p>
|
||||
|
||||
**中文** | [日本語](README.ja.md) | [English](README.md)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。
|
||||
|
||||
⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。
|
||||
|
||||
> [!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正式版发布前,请不要将其部署到生产环境中
|
||||
> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
|
||||
|
||||
|
||||
## 📢 新闻 (News)
|
||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](doc/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
||||
|
||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
||||
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
||||
|
||||
2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走!
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。
|
||||
|
||||
💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。
|
||||
|
||||
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
||||
|
||||
🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
|
||||
|
||||
🤖 **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** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
## 🦾 演示
|
||||
|
||||
### 🛠️ 标准助手工作流
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 全栈工程师模式</p></th>
|
||||
<th><p align="center">🗂️ 日志与规划管理</p></th>
|
||||
<th><p align="center">🔎 网络搜索与学习</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">开发 • 部署 • 扩展</td>
|
||||
<td align="center">日程 • 自动化 • 记忆</td>
|
||||
<td align="center">发现 • 洞察 • 趋势</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 创新的低占用部署
|
||||
|
||||
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),用于智能监控。
|
||||
|
||||
[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)
|
||||
|
||||
🌟 更多部署案例敬请期待!
|
||||
|
||||
## 📦 安装
|
||||
|
||||
### 使用预编译二进制文件安装
|
||||
|
||||
从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。
|
||||
|
||||
### 从源码安装(获取最新特性,开发推荐)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# 构建(无需安装)
|
||||
make build
|
||||
|
||||
# 为多平台构建
|
||||
make build-all
|
||||
|
||||
# 构建并安装
|
||||
make install
|
||||
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. 设置 API Key
|
||||
cp config/config.example.json config/config.json
|
||||
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
|
||||
|
||||
# 3. 构建并启动
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
# 4. 查看日志
|
||||
docker compose logs -f picoclaw-gateway
|
||||
|
||||
# 5. 停止
|
||||
docker compose --profile gateway down
|
||||
|
||||
```
|
||||
|
||||
### Agent 模式 (一次性运行)
|
||||
|
||||
```bash
|
||||
# 提问
|
||||
docker compose run --rm picoclaw-agent -m "2+2 等于几?"
|
||||
|
||||
# 交互模式
|
||||
docker compose run --rm picoclaw-agent
|
||||
|
||||
```
|
||||
|
||||
### 重新构建
|
||||
|
||||
```bash
|
||||
docker compose --profile gateway build --no-cache
|
||||
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 次免费查询)
|
||||
|
||||
**1. 初始化 (Initialize)**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
|
||||
```
|
||||
|
||||
**2. 配置 (Configure)** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "xxx",
|
||||
"api_base": "https://openrouter.ai/api/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**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 请求/月)
|
||||
|
||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
||||
|
||||
**4. 对话 (Chat)**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "2+2 等于几?"
|
||||
|
||||
```
|
||||
|
||||
就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。
|
||||
|
||||
---
|
||||
|
||||
## 💬 聊天应用集成 (Chat Apps)
|
||||
|
||||
通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
|
||||
|
||||
| 渠道 | 设置难度 |
|
||||
| --- | --- |
|
||||
| **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>
|
||||
|
||||
## <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)
|
||||
|
||||
## ⚙️ 配置详解
|
||||
|
||||
配置文件路径: `~/.picoclaw/config.json`
|
||||
|
||||
### 工作区布局 (Workspace Layout)
|
||||
|
||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # 对话会话和历史
|
||||
├── memory/ # 长期记忆 (MEMORY.md)
|
||||
├── state/ # 持久化状态 (最后一次频道等)
|
||||
├── cron/ # 定时任务数据库
|
||||
├── skills/ # 自定义技能
|
||||
├── AGENTS.md # Agent 行为指南
|
||||
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
||||
├── IDENTITY.md # Agent 身份设定
|
||||
├── SOUL.md # Agent 灵魂/性格
|
||||
├── TOOLS.md # 工具描述
|
||||
└── USER.md # 用户偏好
|
||||
|
||||
```
|
||||
|
||||
### 心跳 / 周期性任务 (Heartbeat)
|
||||
|
||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
|
||||
```
|
||||
|
||||
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
||||
|
||||
#### 使用 Spawn 的异步任务
|
||||
|
||||
对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**:
|
||||
|
||||
```markdown
|
||||
# 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 拥有独立上下文,无会话历史 |
|
||||
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
||||
| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
|
||||
|
||||
#### 子 Agent 通信原理
|
||||
|
||||
```
|
||||
心跳触发 (Heartbeat triggers)
|
||||
↓
|
||||
Agent 读取 HEARTBEAT.md
|
||||
↓
|
||||
对于长任务: spawn 子 Agent
|
||||
↓ ↓
|
||||
继续下一个任务 子 Agent 独立工作
|
||||
↓ ↓
|
||||
所有任务完成 子 Agent 使用 "message" 工具
|
||||
↓ ↓
|
||||
响应 HEARTBEAT_OK 用户直接收到结果
|
||||
|
||||
```
|
||||
|
||||
子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。
|
||||
|
||||
**配置:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `true` | 启用/禁用心跳 |
|
||||
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
||||
|
||||
**环境变量:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||
|
||||
### 提供商 (Providers)
|
||||
|
||||
> [!NOTE]
|
||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
||||
|
||||
| 提供商 | 用途 | 获取 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) |
|
||||
|
||||
<details>
|
||||
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
|
||||
|
||||
**1. 获取 API key 和 base URL**
|
||||
|
||||
* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "Your API Key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "你好"
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>完整配置示例</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allow_from": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "BSA..."
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## CLI 命令行参考
|
||||
|
||||
| 命令 | 描述 |
|
||||
| --- | --- |
|
||||
| `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 表达式
|
||||
|
||||
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
||||
|
||||
## 🤝 贡献与路线图 (Roadmap)
|
||||
|
||||
欢迎提交 PR!代码库刻意保持小巧和可读。🤗
|
||||
|
||||
路线图即将发布...
|
||||
|
||||
开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
|
||||
|
||||
用户群组:
|
||||
|
||||
Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
## 🐛 疑难解答 (Troubleshooting)
|
||||
|
||||
### 网络搜索提示 "API 配置问题"
|
||||
|
||||
如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。
|
||||
|
||||
启用网络搜索:
|
||||
|
||||
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
|
||||
2. 添加到 `~/.picoclaw/config.json`:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 遇到内容过滤错误 (Content Filtering Errors)
|
||||
|
||||
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
||||
|
||||
### Telegram bot 提示 "Conflict: terminated by other getUpdates"
|
||||
|
||||
这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。
|
||||
|
||||
---
|
||||
|
||||
## 📝 API Key 对比
|
||||
|
||||
| 服务 | 免费层级 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
+554
-294
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
@@ -12,6 +13,7 @@
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"proxy": "",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
},
|
||||
"discord": {
|
||||
@@ -37,6 +39,35 @@
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"dingtalk": {
|
||||
"enabled": false,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
},
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||
"allow_from": []
|
||||
},
|
||||
"line": {
|
||||
"enabled": false,
|
||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
},
|
||||
"onebot": {
|
||||
"enabled": false,
|
||||
"ws_url": "ws://127.0.0.1:3001",
|
||||
"access_token": "",
|
||||
"reconnect_interval": 5,
|
||||
"group_trigger_prefix": [],
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
@@ -67,6 +98,19 @@
|
||||
"vllm": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"nvidia": {
|
||||
"api_key": "nvapi-xxx",
|
||||
"api_base": "",
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
},
|
||||
"moonshot": {
|
||||
"api_key": "sk-xxx",
|
||||
"api_base": ""
|
||||
},
|
||||
"ollama": {
|
||||
"api_key": "",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -77,6 +121,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
},
|
||||
"devices": {
|
||||
"enabled": false,
|
||||
"monitor_usb": true
|
||||
},
|
||||
"gateway": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 18790
|
||||
@@ -0,0 +1,112 @@
|
||||
## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal
|
||||
|
||||
**Hello, PicoClaw Community!**
|
||||
|
||||
First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs!
|
||||
|
||||
PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum.
|
||||
|
||||
This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**.
|
||||
|
||||
### 🎁 Community Perks
|
||||
|
||||
To show our appreciation, developers who officially join our community operations will receive:
|
||||
|
||||
* **Exclusive AI Hardware:** Our upcoming, unreleased AI device.
|
||||
* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers).
|
||||
|
||||
### 🎥 Calling All Content Creators!
|
||||
|
||||
Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**.
|
||||
|
||||
* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**.
|
||||
* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM.
|
||||
We will be rewarding high-quality content creators with the same perks as our community developers!
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Urgent Volunteer Roles
|
||||
|
||||
We are looking for experts in the following areas:
|
||||
|
||||
1. **Issue/PR Reviewers**
|
||||
* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging.
|
||||
* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles.
|
||||
|
||||
|
||||
2. **Resource Optimization Experts**
|
||||
* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean.
|
||||
* **Focus:** Analyzing resource growth between releases and trimming redundancy.
|
||||
* **Priority:** **RAM usage optimization** > Binary size reduction.
|
||||
|
||||
|
||||
3. **Security Audit & Bug Fixes**
|
||||
* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management.
|
||||
* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes.
|
||||
|
||||
|
||||
4. **Documentation & DX (Developer Experience)**
|
||||
* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow.
|
||||
* **Focus:** Creating clear, user-friendly documentation for both setup and development.
|
||||
|
||||
|
||||
5. **AI-Powered CI/CD Optimization**
|
||||
* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it.
|
||||
* **Focus:** Automating builds with AI and exploring AI-driven issue resolution.
|
||||
|
||||
**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role.
|
||||
Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors!
|
||||
|
||||
---
|
||||
|
||||
## 📍 The Roadmap
|
||||
|
||||
Interested in a specific feature? You can "claim" these tasks and start building:
|
||||
|
||||
###
|
||||
* **Provider:**
|
||||
* **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days)
|
||||
* You can still submit code; Daming will merge it into the new implementation.
|
||||
* **Channels:**
|
||||
* Support for OneBot, additional platforms
|
||||
* attachments (images, audio, video, files).
|
||||
* **Skills:**
|
||||
* Implementing `find_skill` to discover tools via [openclaw/skills](https://github.com/openclaw/skills) and other platforms.
|
||||
* **Operations:** * MCP Support.
|
||||
* Android operations (e.g., botdrop).
|
||||
* Browser automation via CDP or ActionBook.
|
||||
|
||||
|
||||
* **Multi-Agent Ecosystem:**
|
||||
* **Basic Model-Agnet** S
|
||||
* **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
|
||||
* **Swarm Mode.**
|
||||
* **AIEOS Integration.**
|
||||
|
||||
|
||||
* **Branding:**
|
||||
* **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch!
|
||||
|
||||
|
||||
We have officially created these tasks as GitHub Issues, all marked with the roadmap tag.
|
||||
This list will be updated continuously as we progress.
|
||||
If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue!
|
||||
|
||||
---
|
||||
|
||||
## 🤝 How to Join
|
||||
|
||||
**Everything is open to your creativity!** If you have a wild idea, just PR it.
|
||||
|
||||
1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw.
|
||||
2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject:
|
||||
> `[Apply Join PicoClaw Dev Group] + Your GitHub Account`
|
||||
> Include the role you're interested in and any evidence of your development experience.
|
||||
|
||||
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential.
|
||||
|
||||
**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎
|
||||
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
# ─────────────────────────────────────────────
|
||||
# PicoClaw Agent (one-shot query)
|
||||
# docker compose run --rm picoclaw-agent -m "Hello"
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: picoclaw-agent
|
||||
profiles:
|
||||
- agent
|
||||
volumes:
|
||||
- ./config/config.json:/root/.picoclaw/config.json:ro
|
||||
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||
entrypoint: ["picoclaw", "agent"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PicoClaw Gateway (Long-running Bot)
|
||||
# docker compose up picoclaw-gateway
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: picoclaw-gateway
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- gateway
|
||||
volumes:
|
||||
# Configuration file
|
||||
- ./config/config.json:/root/.picoclaw/config.json:ro
|
||||
# Persistent workspace (sessions, memory, logs)
|
||||
- picoclaw-workspace:/root/.picoclaw/workspace
|
||||
command: ["gateway"]
|
||||
|
||||
volumes:
|
||||
picoclaw-workspace:
|
||||
@@ -1,16 +1,55 @@
|
||||
module github.com/sipeed/picoclaw
|
||||
|
||||
go 1.18
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.28.1
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/anthropics/anthropic-sdk-go v1.22.1
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/chzyer/readline v1.5.1
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/mymmrac/telego v1.6.0
|
||||
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 (
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
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
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
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/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
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,26 +1,261 @@
|
||||
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
|
||||
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
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=
|
||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
|
||||
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
|
||||
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
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=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+152
-42
@@ -4,63 +4,138 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
type ContextBuilder struct {
|
||||
workspace string
|
||||
skillsLoader *skills.SkillsLoader
|
||||
memory *MemoryStore
|
||||
tools *tools.ToolRegistry // Direct reference to tool registry
|
||||
}
|
||||
|
||||
func getGlobalConfigDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw")
|
||||
}
|
||||
|
||||
func NewContextBuilder(workspace string) *ContextBuilder {
|
||||
builtinSkillsDir := filepath.Join(filepath.Dir(workspace), "picoclaw", "skills")
|
||||
// builtin skills: skills directory in current project
|
||||
// Use the skills/ directory under the current working directory
|
||||
wd, _ := os.Getwd()
|
||||
builtinSkillsDir := filepath.Join(wd, "skills")
|
||||
globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills")
|
||||
|
||||
return &ContextBuilder{
|
||||
workspace: workspace,
|
||||
skillsLoader: skills.NewSkillsLoader(workspace, builtinSkillsDir),
|
||||
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
||||
memory: NewMemoryStore(workspace),
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
|
||||
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
||||
cb.tools = registry
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) getIdentity() string {
|
||||
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||
runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
||||
|
||||
// Build tools section dynamically
|
||||
toolsSection := cb.buildToolsSection()
|
||||
|
||||
return fmt.Sprintf(`# picoclaw 🦞
|
||||
|
||||
You are picoclaw, a helpful AI assistant. You have access to tools that allow you to:
|
||||
- Read, write, and edit files
|
||||
- Execute shell commands
|
||||
- Search the web and fetch web pages
|
||||
- Send messages to users on chat channels
|
||||
- Spawn subagents for complex background tasks
|
||||
You are picoclaw, a helpful AI assistant.
|
||||
|
||||
## Current Time
|
||||
%s
|
||||
|
||||
## Runtime
|
||||
%s
|
||||
|
||||
## Workspace
|
||||
Your workspace is at: %s
|
||||
- Memory files: %s/memory/MEMORY.md
|
||||
- Daily notes: %s/memory/2006-01-02.md
|
||||
- Custom skills: %s/skills/{skill-name}/SKILL.md
|
||||
- Memory: %s/memory/MEMORY.md
|
||||
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
|
||||
- Skills: %s/skills/{skill-name}/SKILL.md
|
||||
|
||||
## Weather Information
|
||||
When users ask about weather, use the web_fetch tool with wttr.in URLs:
|
||||
- Current weather: https://wttr.in/{city}?format=j1
|
||||
- Beijing: https://wttr.in/Beijing?format=j1
|
||||
- Shanghai: https://wttr.in/Shanghai?format=j1
|
||||
- New York: https://wttr.in/New_York?format=j1
|
||||
- London: https://wttr.in/London?format=j1
|
||||
- Tokyo: https://wttr.in/Tokyo?format=j1
|
||||
%s
|
||||
|
||||
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
|
||||
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
|
||||
For normal conversation, just respond with text - do not call the message tool.
|
||||
## Important Rules
|
||||
|
||||
Always be helpful, accurate, and concise. When using tools, explain what you're doing.
|
||||
When remembering something, write to %s/memory/MEMORY.md`,
|
||||
now, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
|
||||
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
|
||||
|
||||
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`,
|
||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) buildToolsSection() string {
|
||||
if cb.tools == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
summaries := cb.tools.GetSummaries()
|
||||
if len(summaries) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
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("You have access to the following tools:\n\n")
|
||||
for _, s := range summaries {
|
||||
sb.WriteString(s)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||
parts := []string{}
|
||||
|
||||
// Core identity section
|
||||
parts = append(parts, cb.getIdentity())
|
||||
|
||||
// Bootstrap files
|
||||
bootstrapContent := cb.LoadBootstrapFiles()
|
||||
if bootstrapContent != "" {
|
||||
parts = append(parts, bootstrapContent)
|
||||
}
|
||||
|
||||
// Skills - show summary, AI can read full content with read_file tool
|
||||
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
|
||||
if skillsSummary != "" {
|
||||
parts = append(parts, fmt.Sprintf(`# Skills
|
||||
|
||||
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
|
||||
|
||||
%s`, skillsSummary))
|
||||
}
|
||||
|
||||
// Memory context
|
||||
memoryContext := cb.memory.GetMemoryContext()
|
||||
if memoryContext != "" {
|
||||
parts = append(parts, "# Memory\n\n"+memoryContext)
|
||||
}
|
||||
|
||||
// Join with "---" separator
|
||||
return strings.Join(parts, "\n\n---\n\n")
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||
@@ -68,9 +143,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"MEMORY.md",
|
||||
}
|
||||
|
||||
var result string
|
||||
@@ -84,24 +157,48 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||
return result
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) BuildMessages(history []providers.Message, currentMessage string, media []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()
|
||||
bootstrapContent := cb.LoadBootstrapFiles()
|
||||
if bootstrapContent != "" {
|
||||
systemPrompt += "\n\n" + bootstrapContent
|
||||
|
||||
// Add Current Session info if provided
|
||||
if channel != "" && chatID != "" {
|
||||
systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
|
||||
}
|
||||
|
||||
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
|
||||
if skillsSummary != "" {
|
||||
systemPrompt += "\n\n## Available Skills\n\n" + skillsSummary
|
||||
// Log system prompt summary for debugging (debug mode only)
|
||||
logger.DebugCF("agent", "System prompt built",
|
||||
map[string]interface{}{
|
||||
"total_chars": len(systemPrompt),
|
||||
"total_lines": strings.Count(systemPrompt, "\n") + 1,
|
||||
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
|
||||
})
|
||||
|
||||
// Log preview of system prompt (avoid logging huge content)
|
||||
preview := systemPrompt
|
||||
if len(preview) > 500 {
|
||||
preview = preview[:500] + "... (truncated)"
|
||||
}
|
||||
logger.DebugCF("agent", "System prompt preview",
|
||||
map[string]interface{}{
|
||||
"preview": preview,
|
||||
})
|
||||
|
||||
if summary != "" {
|
||||
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
|
||||
}
|
||||
|
||||
skillsContent := cb.loadSkills()
|
||||
if skillsContent != "" {
|
||||
systemPrompt += "\n\n" + skillsContent
|
||||
//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 ---
|
||||
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
@@ -132,14 +229,13 @@ func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, cont
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
// Always add assistant message, whether or not it has tool calls
|
||||
messages = append(messages, msg)
|
||||
return messages
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) loadSkills() string {
|
||||
allSkills := cb.skillsLoader.ListSkills(true)
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
if len(allSkills) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -156,3 +252,17 @@ func (cb *ContextBuilder) loadSkills() string {
|
||||
|
||||
return "# Skill Definitions\n\n" + content
|
||||
}
|
||||
|
||||
// GetSkillsInfo returns information about loaded skills.
|
||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
|
||||
allSkills := cb.skillsLoader.ListSkills()
|
||||
skillNames := make([]string, 0, len(allSkills))
|
||||
for _, s := range allSkills {
|
||||
skillNames = append(skillNames, s.Name)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"total": len(allSkills),
|
||||
"available": len(allSkills),
|
||||
"names": skillNames,
|
||||
}
|
||||
}
|
||||
|
||||
+938
-58
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,626 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"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-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test config
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create agent loop
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Test RecordLastChannel
|
||||
testChannel := "test-channel"
|
||||
err = al.RecordLastChannel(testChannel)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordLastChannel failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify channel was saved
|
||||
lastChannel := al.state.GetLastChannel()
|
||||
if lastChannel != testChannel {
|
||||
t.Errorf("Expected channel '%s', got '%s'", testChannel, lastChannel)
|
||||
}
|
||||
|
||||
// Verify persistence by creating a new agent loop
|
||||
al2 := NewAgentLoop(cfg, msgBus, provider)
|
||||
if al2.state.GetLastChannel() != testChannel {
|
||||
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordLastChatID(t *testing.T) {
|
||||
// Create temp workspace
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test config
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create agent loop
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Test RecordLastChatID
|
||||
testChatID := "test-chat-id-123"
|
||||
err = al.RecordLastChatID(testChatID)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordLastChatID failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify chat ID was saved
|
||||
lastChatID := al.state.GetLastChatID()
|
||||
if lastChatID != testChatID {
|
||||
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, lastChatID)
|
||||
}
|
||||
|
||||
// Verify persistence by creating a new agent loop
|
||||
al2 := NewAgentLoop(cfg, msgBus, provider)
|
||||
if al2.state.GetLastChatID() != testChatID {
|
||||
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentLoop_StateInitialized(t *testing.T) {
|
||||
// Create temp workspace
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test config
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create agent loop
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Verify state manager is initialized
|
||||
if al.state == nil {
|
||||
t.Error("Expected state manager to be initialized")
|
||||
}
|
||||
|
||||
// Verify state directory was created
|
||||
stateDir := filepath.Join(tmpDir, "state")
|
||||
if _, err := os.Stat(stateDir); os.IsNotExist(err) {
|
||||
t.Error("Expected state directory to exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved
|
||||
func TestToolRegistry_ToolRegistration(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()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Register a custom tool
|
||||
customTool := &mockCustomTool{}
|
||||
al.RegisterTool(customTool)
|
||||
|
||||
// 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{})
|
||||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
found := false
|
||||
for _, name := range toolsList {
|
||||
if name == "mock_custom" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Expected custom tool to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolContext_Updates verifies tool context is updated with channel/chatID
|
||||
func TestToolContext_Updates(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()
|
||||
provider := &simpleMockProvider{response: "OK"}
|
||||
_ = NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Verify that ContextualTool interface is defined and can be implemented
|
||||
// This test validates the interface contract exists
|
||||
ctxTool := &mockContextualTool{}
|
||||
|
||||
// Verify the tool implements the interface correctly
|
||||
var _ tools.ContextualTool = ctxTool
|
||||
}
|
||||
|
||||
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
||||
func TestToolRegistry_GetDefinitions(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()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Register a test tool and verify it shows up in startup info
|
||||
testTool := &mockCustomTool{}
|
||||
al.RegisterTool(testTool)
|
||||
|
||||
info := al.GetStartupInfo()
|
||||
toolsInfo := info["tools"].(map[string]interface{})
|
||||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
found := false
|
||||
for _, name := range toolsList {
|
||||
if name == "mock_custom" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Expected custom tool to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentLoop_GetStartupInfo verifies startup info contains tools
|
||||
func TestAgentLoop_GetStartupInfo(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()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
info := al.GetStartupInfo()
|
||||
|
||||
// Verify tools info exists
|
||||
toolsInfo, ok := info["tools"]
|
||||
if !ok {
|
||||
t.Fatal("Expected 'tools' key in startup info")
|
||||
}
|
||||
|
||||
toolsMap, ok := toolsInfo.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected 'tools' to be a map")
|
||||
}
|
||||
|
||||
count, ok := toolsMap["count"]
|
||||
if !ok {
|
||||
t.Fatal("Expected 'count' in tools info")
|
||||
}
|
||||
|
||||
// Should have default tools registered
|
||||
if count.(int) == 0 {
|
||||
t.Error("Expected at least some tools to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentLoop_Stop verifies Stop() sets running to false
|
||||
func TestAgentLoop_Stop(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()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Note: running is only set to true when Run() is called
|
||||
// We can't test that without starting the event loop
|
||||
// Instead, verify the Stop method can be called safely
|
||||
al.Stop()
|
||||
|
||||
// Verify running is false (initial state or after Stop)
|
||||
if al.running.Load() {
|
||||
t.Error("Expected agent to be stopped (or never started)")
|
||||
}
|
||||
}
|
||||
|
||||
// Mock implementations for testing
|
||||
|
||||
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) {
|
||||
return &providers.LLMResponse{
|
||||
Content: m.response,
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *simpleMockProvider) GetDefaultModel() string {
|
||||
return "mock-model"
|
||||
}
|
||||
|
||||
// mockCustomTool is a simple mock tool for registration testing
|
||||
type mockCustomTool struct{}
|
||||
|
||||
func (m *mockCustomTool) Name() string {
|
||||
return "mock_custom"
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Description() string {
|
||||
return "Mock custom tool for testing"
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
|
||||
return tools.SilentResult("Custom tool executed")
|
||||
}
|
||||
|
||||
// mockContextualTool tracks context updates
|
||||
type mockContextualTool struct {
|
||||
lastChannel string
|
||||
lastChatID string
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Name() string {
|
||||
return "mock_contextual"
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Description() string {
|
||||
return "Mock contextual tool"
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
|
||||
return tools.SilentResult("Contextual tool executed")
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) SetContext(channel, chatID string) {
|
||||
m.lastChannel = channel
|
||||
m.lastChatID = chatID
|
||||
}
|
||||
|
||||
// testHelper executes a message and returns the response
|
||||
type testHelper struct {
|
||||
al *AgentLoop
|
||||
}
|
||||
|
||||
func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string {
|
||||
// Use a short timeout to avoid hanging
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout)
|
||||
defer cancel()
|
||||
|
||||
response, err := h.al.processMessage(timeoutCtx, msg)
|
||||
if err != nil {
|
||||
tb.Fatalf("processMessage failed: %v", err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const responseTimeout = 3 * time.Second
|
||||
|
||||
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
||||
func TestToolResult_SilentToolDoesNotSendUserMessage(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()
|
||||
provider := &simpleMockProvider{response: "File operation complete"}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
helper := testHelper{al: al}
|
||||
|
||||
// ReadFileTool returns SilentResult, which should not send user message
|
||||
ctx := context.Background()
|
||||
msg := bus.InboundMessage{
|
||||
Channel: "test",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "read test.txt",
|
||||
SessionKey: "test-session",
|
||||
}
|
||||
|
||||
response := helper.executeAndGetResponse(t, ctx, msg)
|
||||
|
||||
// Silent tool should return the LLM's response directly
|
||||
if response != "File operation complete" {
|
||||
t.Errorf("Expected 'File operation complete', got: %s", response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResult_UserFacingToolDoesSendMessage verifies user-facing tools trigger outbound
|
||||
func TestToolResult_UserFacingToolDoesSendMessage(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()
|
||||
provider := &simpleMockProvider{response: "Command output: hello world"}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
helper := testHelper{al: al}
|
||||
|
||||
// ExecTool returns UserResult, which should send user message
|
||||
ctx := context.Background()
|
||||
msg := bus.InboundMessage{
|
||||
Channel: "test",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "run hello",
|
||||
SessionKey: "test-session",
|
||||
}
|
||||
|
||||
response := helper.executeAndGetResponse(t, ctx, msg)
|
||||
|
||||
// User-facing tool should include the output in final response
|
||||
if response != "Command output: hello world" {
|
||||
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]interface{}) (*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"},
|
||||
}
|
||||
al.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 := al.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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MemoryStore manages persistent memory for the agent.
|
||||
// - Long-term memory: memory/MEMORY.md
|
||||
// - Daily notes: memory/YYYYMM/YYYYMMDD.md
|
||||
type MemoryStore struct {
|
||||
workspace string
|
||||
memoryDir string
|
||||
memoryFile string
|
||||
}
|
||||
|
||||
// NewMemoryStore creates a new MemoryStore with the given workspace path.
|
||||
// It ensures the memory directory exists.
|
||||
func NewMemoryStore(workspace string) *MemoryStore {
|
||||
memoryDir := filepath.Join(workspace, "memory")
|
||||
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||
|
||||
// Ensure memory directory exists
|
||||
os.MkdirAll(memoryDir, 0755)
|
||||
|
||||
return &MemoryStore{
|
||||
workspace: workspace,
|
||||
memoryDir: memoryDir,
|
||||
memoryFile: memoryFile,
|
||||
}
|
||||
}
|
||||
|
||||
// getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md).
|
||||
func (ms *MemoryStore) getTodayFile() string {
|
||||
today := time.Now().Format("20060102") // YYYYMMDD
|
||||
monthDir := today[:6] // YYYYMM
|
||||
filePath := filepath.Join(ms.memoryDir, monthDir, today+".md")
|
||||
return filePath
|
||||
}
|
||||
|
||||
// ReadLongTerm reads the long-term memory (MEMORY.md).
|
||||
// Returns empty string if the file doesn't exist.
|
||||
func (ms *MemoryStore) ReadLongTerm() string {
|
||||
if data, err := os.ReadFile(ms.memoryFile); err == nil {
|
||||
return string(data)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ReadToday reads today's daily note.
|
||||
// Returns empty string if the file doesn't exist.
|
||||
func (ms *MemoryStore) ReadToday() string {
|
||||
todayFile := ms.getTodayFile()
|
||||
if data, err := os.ReadFile(todayFile); err == nil {
|
||||
return string(data)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AppendToday appends content to today's daily note.
|
||||
// If the file doesn't exist, it creates a new file with a date header.
|
||||
func (ms *MemoryStore) AppendToday(content string) error {
|
||||
todayFile := ms.getTodayFile()
|
||||
|
||||
// Ensure month directory exists
|
||||
monthDir := filepath.Dir(todayFile)
|
||||
os.MkdirAll(monthDir, 0755)
|
||||
|
||||
var existingContent string
|
||||
if data, err := os.ReadFile(todayFile); err == nil {
|
||||
existingContent = string(data)
|
||||
}
|
||||
|
||||
var newContent string
|
||||
if existingContent == "" {
|
||||
// Add header for new day
|
||||
header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02"))
|
||||
newContent = header + content
|
||||
} else {
|
||||
// Append to existing content
|
||||
newContent = existingContent + "\n" + content
|
||||
}
|
||||
|
||||
return os.WriteFile(todayFile, []byte(newContent), 0644)
|
||||
}
|
||||
|
||||
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||
// Contents are joined with "---" separator.
|
||||
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
||||
var notes []string
|
||||
|
||||
for i := 0; i < days; i++ {
|
||||
date := time.Now().AddDate(0, 0, -i)
|
||||
dateStr := date.Format("20060102") // YYYYMMDD
|
||||
monthDir := dateStr[:6] // YYYYMM
|
||||
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
|
||||
|
||||
if data, err := os.ReadFile(filePath); err == nil {
|
||||
notes = append(notes, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Join parts with separator
|
||||
var result string
|
||||
for i, part := range parts {
|
||||
if i > 0 {
|
||||
result += "\n\n---\n\n"
|
||||
}
|
||||
result += part
|
||||
}
|
||||
return fmt.Sprintf("# Memory\n\n%s", result)
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OAuthProviderConfig struct {
|
||||
Issuer string
|
||||
ClientID string
|
||||
Scopes string
|
||||
Originator string
|
||||
Port int
|
||||
}
|
||||
|
||||
func OpenAIOAuthConfig() OAuthProviderConfig {
|
||||
return OAuthProviderConfig{
|
||||
Issuer: "https://auth.openai.com",
|
||||
ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
Scopes: "openid profile email offline_access",
|
||||
Originator: "codex_cli_rs",
|
||||
Port: 1455,
|
||||
}
|
||||
}
|
||||
|
||||
func generateState() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
pkce, err := GeneratePKCE()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating PKCE: %w", err)
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating state: %w", err)
|
||||
}
|
||||
|
||||
redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port)
|
||||
|
||||
authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI)
|
||||
|
||||
resultCh := make(chan callbackResult, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("state") != state {
|
||||
resultCh <- callbackResult{err: fmt.Errorf("state mismatch")}
|
||||
http.Error(w, "State mismatch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
errMsg := r.URL.Query().Get("error")
|
||||
resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)}
|
||||
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
|
||||
resultCh <- callbackResult{code: code}
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err)
|
||||
}
|
||||
|
||||
server := &http.Server{Handler: mux}
|
||||
go server.Serve(listener)
|
||||
defer func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
server.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL)
|
||||
|
||||
if err := openBrowser(authURL); err != nil {
|
||||
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...")
|
||||
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
if result.err != nil {
|
||||
return nil, result.err
|
||||
}
|
||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||
case <-time.After(5 * time.Minute):
|
||||
return nil, fmt.Errorf("authentication timed out after 5 minutes")
|
||||
}
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
code string
|
||||
err error
|
||||
}
|
||||
|
||||
type deviceCodeResponse struct {
|
||||
DeviceAuthID string
|
||||
UserCode string
|
||||
Interval int
|
||||
}
|
||||
|
||||
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
|
||||
var raw struct {
|
||||
DeviceAuthID string `json:"device_auth_id"`
|
||||
UserCode string `json:"user_code"`
|
||||
Interval json.RawMessage `json:"interval"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return deviceCodeResponse{}, err
|
||||
}
|
||||
|
||||
interval, err := parseFlexibleInt(raw.Interval)
|
||||
if err != nil {
|
||||
return deviceCodeResponse{}, err
|
||||
}
|
||||
|
||||
return deviceCodeResponse{
|
||||
DeviceAuthID: raw.DeviceAuthID,
|
||||
UserCode: raw.UserCode,
|
||||
Interval: interval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseFlexibleInt(raw json.RawMessage) (int, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var interval int
|
||||
if err := json.Unmarshal(raw, &interval); err == nil {
|
||||
return interval, nil
|
||||
}
|
||||
|
||||
var intervalStr string
|
||||
if err := json.Unmarshal(raw, &intervalStr); err == nil {
|
||||
intervalStr = strings.TrimSpace(intervalStr)
|
||||
if intervalStr == "" {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.Atoi(intervalStr)
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("invalid integer value: %s", string(raw))
|
||||
}
|
||||
|
||||
func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
reqBody, _ := json.Marshal(map[string]string{
|
||||
"client_id": cfg.ClientID,
|
||||
})
|
||||
|
||||
resp, err := http.Post(
|
||||
cfg.Issuer+"/api/accounts/deviceauth/usercode",
|
||||
"application/json",
|
||||
strings.NewReader(string(reqBody)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("requesting device code: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||
}
|
||||
|
||||
deviceResp, err := parseDeviceCodeResponse(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing device code response: %w", err)
|
||||
}
|
||||
|
||||
if deviceResp.Interval < 1 {
|
||||
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)
|
||||
|
||||
deadline := time.After(15 * time.Minute)
|
||||
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
return nil, fmt.Errorf("device code authentication timed out after 15 minutes")
|
||||
case <-ticker.C:
|
||||
cred, err := pollDeviceCode(cfg, deviceResp.DeviceAuthID, deviceResp.UserCode)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cred != nil {
|
||||
return cred, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
|
||||
reqBody, _ := json.Marshal(map[string]string{
|
||||
"device_auth_id": deviceAuthID,
|
||||
"user_code": userCode,
|
||||
})
|
||||
|
||||
resp, err := http.Post(
|
||||
cfg.Issuer+"/api/accounts/deviceauth/token",
|
||||
"application/json",
|
||||
strings.NewReader(string(reqBody)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("pending")
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var tokenResp struct {
|
||||
AuthorizationCode string `json:"authorization_code"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
redirectURI := cfg.Issuer + "/deviceauth/callback"
|
||||
return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI)
|
||||
}
|
||||
|
||||
func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||
if cred.RefreshToken == "" {
|
||||
return nil, fmt.Errorf("no refresh token available")
|
||||
}
|
||||
|
||||
data := url.Values{
|
||||
"client_id": {cfg.ClientID},
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {cred.RefreshToken},
|
||||
"scope": {"openid profile email"},
|
||||
}
|
||||
|
||||
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
|
||||
return buildAuthorizeURL(cfg, pkce, state, redirectURI)
|
||||
}
|
||||
|
||||
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},
|
||||
}
|
||||
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
|
||||
params.Set("originator", "picoclaw")
|
||||
}
|
||||
if cfg.Originator != "" {
|
||||
params.Set("originator", cfg.Originator)
|
||||
}
|
||||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||
}
|
||||
|
||||
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirectURI},
|
||||
"client_id": {cfg.ClientID},
|
||||
"code_verifier": {codeVerifier},
|
||||
}
|
||||
|
||||
resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exchanging code for tokens: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
||||
}
|
||||
|
||||
return parseTokenResponse(body, "openai")
|
||||
}
|
||||
|
||||
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("parsing token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
return nil, fmt.Errorf("no access token in response")
|
||||
}
|
||||
|
||||
var expiresAt time.Time
|
||||
if tokenResp.ExpiresIn > 0 {
|
||||
expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
}
|
||||
|
||||
cred := &AuthCredential{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
Provider: provider,
|
||||
AuthMethod: "oauth",
|
||||
}
|
||||
|
||||
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.
|
||||
cred.AccountID = accountID
|
||||
}
|
||||
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
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]interface{}); ok {
|
||||
if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
|
||||
return accountID
|
||||
}
|
||||
}
|
||||
|
||||
if orgs, ok := claims["organizations"].([]interface{}); ok {
|
||||
for _, org := range orgs {
|
||||
if orgMap, ok := org.(map[string]interface{}); ok {
|
||||
if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
|
||||
return accountID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseJWTClaims(token string) (map[string]interface{}, 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:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
decoded, err := base64URLDecode(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func base64URLDecode(s string) ([]byte, error) {
|
||||
s = strings.NewReplacer("-", "+", "_", "/").Replace(s)
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", url).Start()
|
||||
case "windows":
|
||||
return exec.Command("cmd", "/c", "start", url).Start()
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeJWTForClaims(t *testing.T, claims map[string]interface{}) 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",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: "openid profile",
|
||||
Originator: "codex_cli_rs",
|
||||
Port: 1455,
|
||||
}
|
||||
pkce := PKCECodes{
|
||||
CodeVerifier: "test-verifier",
|
||||
CodeChallenge: "test-challenge",
|
||||
}
|
||||
|
||||
u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
|
||||
|
||||
if !strings.HasPrefix(u, "https://auth.example.com/oauth/authorize?") {
|
||||
t.Errorf("URL does not start with expected prefix: %s", u)
|
||||
}
|
||||
if !strings.Contains(u, "client_id=test-client-id") {
|
||||
t.Error("URL missing client_id")
|
||||
}
|
||||
if !strings.Contains(u, "code_challenge=test-challenge") {
|
||||
t.Error("URL missing code_challenge")
|
||||
}
|
||||
if !strings.Contains(u, "code_challenge_method=S256") {
|
||||
t.Error("URL missing code_challenge_method")
|
||||
}
|
||||
if !strings.Contains(u, "state=test-state") {
|
||||
t.Error("URL missing state")
|
||||
}
|
||||
if !strings.Contains(u, "response_type=code") {
|
||||
t.Error("URL missing response_type")
|
||||
}
|
||||
if !strings.Contains(u, "id_token_add_organizations=true") {
|
||||
t.Error("URL missing id_token_add_organizations")
|
||||
}
|
||||
if !strings.Contains(u, "codex_cli_simplified_flow=true") {
|
||||
t.Error("URL missing codex_cli_simplified_flow")
|
||||
}
|
||||
if !strings.Contains(u, "originator=codex_cli_rs") {
|
||||
t.Error("URL missing originator")
|
||||
}
|
||||
}
|
||||
|
||||
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{}{
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"expires_in": 3600,
|
||||
"id_token": "test-id-token",
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
|
||||
cred, err := parseTokenResponse(body, "openai")
|
||||
if err != nil {
|
||||
t.Fatalf("parseTokenResponse() error: %v", err)
|
||||
}
|
||||
|
||||
if cred.AccessToken != "test-access-token" {
|
||||
t.Errorf("AccessToken = %q, want %q", cred.AccessToken, "test-access-token")
|
||||
}
|
||||
if cred.RefreshToken != "test-refresh-token" {
|
||||
t.Errorf("RefreshToken = %q, want %q", cred.RefreshToken, "test-refresh-token")
|
||||
}
|
||||
if cred.Provider != "openai" {
|
||||
t.Errorf("Provider = %q, want %q", cred.Provider, "openai")
|
||||
}
|
||||
if cred.AuthMethod != "oauth" {
|
||||
t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth")
|
||||
}
|
||||
if cred.ExpiresAt.IsZero() {
|
||||
t.Error("ExpiresAt should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
|
||||
idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"})
|
||||
resp := map[string]interface{}{
|
||||
"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]interface{}{
|
||||
"organizations": []interface{}{
|
||||
map[string]interface{}{"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")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing access_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
||||
idToken := makeJWTWithAccountID("acc-from-id")
|
||||
resp := map[string]interface{}{
|
||||
"access_token": "not-a-jwt",
|
||||
"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-from-id" {
|
||||
t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-from-id")
|
||||
}
|
||||
}
|
||||
|
||||
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 + `"}}`))
|
||||
return header + "." + payload + ".sig"
|
||||
}
|
||||
|
||||
func TestExchangeCodeForTokens(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth/token" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
if r.FormValue("grant_type") != "authorization_code" {
|
||||
http.Error(w, "invalid grant_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"access_token": "mock-access-token",
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := OAuthProviderConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "test-client",
|
||||
Scopes: "openid",
|
||||
Port: 1455,
|
||||
}
|
||||
|
||||
cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback")
|
||||
if err != nil {
|
||||
t.Fatalf("exchangeCodeForTokens() error: %v", err)
|
||||
}
|
||||
|
||||
if cred.AccessToken != "mock-access-token" {
|
||||
t.Errorf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAccessToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth/token" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
if r.FormValue("grant_type") != "refresh_token" {
|
||||
http.Error(w, "invalid grant_type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"access_token": "refreshed-access-token",
|
||||
"refresh_token": "refreshed-refresh-token",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := OAuthProviderConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "test-client",
|
||||
}
|
||||
|
||||
cred := &AuthCredential{
|
||||
AccessToken: "old-token",
|
||||
RefreshToken: "old-refresh-token",
|
||||
Provider: "openai",
|
||||
AuthMethod: "oauth",
|
||||
}
|
||||
|
||||
refreshed, err := RefreshAccessToken(cred, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshAccessToken() error: %v", err)
|
||||
}
|
||||
|
||||
if refreshed.AccessToken != "refreshed-access-token" {
|
||||
t.Errorf("AccessToken = %q, want %q", refreshed.AccessToken, "refreshed-access-token")
|
||||
}
|
||||
if refreshed.RefreshToken != "refreshed-refresh-token" {
|
||||
t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "refreshed-refresh-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
|
||||
cfg := OpenAIOAuthConfig()
|
||||
cred := &AuthCredential{
|
||||
AccessToken: "old-token",
|
||||
Provider: "openai",
|
||||
AuthMethod: "oauth",
|
||||
}
|
||||
|
||||
_, err := RefreshAccessToken(cred, cfg)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
"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" {
|
||||
t.Errorf("Issuer = %q, want %q", cfg.Issuer, "https://auth.openai.com")
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
t.Error("ClientID is empty")
|
||||
}
|
||||
if cfg.Port != 1455 {
|
||||
t.Errorf("Port = %d, want 1455", cfg.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeviceCodeResponseIntervalAsNumber(t *testing.T) {
|
||||
body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":5}`)
|
||||
|
||||
resp, err := parseDeviceCodeResponse(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDeviceCodeResponse() error: %v", err)
|
||||
}
|
||||
|
||||
if resp.DeviceAuthID != "abc" {
|
||||
t.Errorf("DeviceAuthID = %q, want %q", resp.DeviceAuthID, "abc")
|
||||
}
|
||||
if resp.UserCode != "DEF-1234" {
|
||||
t.Errorf("UserCode = %q, want %q", resp.UserCode, "DEF-1234")
|
||||
}
|
||||
if resp.Interval != 5 {
|
||||
t.Errorf("Interval = %d, want %d", resp.Interval, 5)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeviceCodeResponseIntervalAsString(t *testing.T) {
|
||||
body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":"5"}`)
|
||||
|
||||
resp, err := parseDeviceCodeResponse(body)
|
||||
if err != nil {
|
||||
t.Fatalf("parseDeviceCodeResponse() error: %v", err)
|
||||
}
|
||||
|
||||
if resp.Interval != 5 {
|
||||
t.Errorf("Interval = %d, want %d", resp.Interval, 5)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) {
|
||||
body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":"abc"}`)
|
||||
|
||||
if _, err := parseDeviceCodeResponse(body); err == nil {
|
||||
t.Fatal("expected error for invalid interval")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
type PKCECodes struct {
|
||||
CodeVerifier string
|
||||
CodeChallenge string
|
||||
}
|
||||
|
||||
func GeneratePKCE() (PKCECodes, error) {
|
||||
buf := make([]byte, 64)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return PKCECodes{}, err
|
||||
}
|
||||
|
||||
verifier := base64.RawURLEncoding.EncodeToString(buf)
|
||||
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
|
||||
return PKCECodes{
|
||||
CodeVerifier: verifier,
|
||||
CodeChallenge: challenge,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratePKCE(t *testing.T) {
|
||||
codes, err := GeneratePKCE()
|
||||
if err != nil {
|
||||
t.Fatalf("GeneratePKCE() error: %v", err)
|
||||
}
|
||||
|
||||
if codes.CodeVerifier == "" {
|
||||
t.Fatal("CodeVerifier is empty")
|
||||
}
|
||||
if codes.CodeChallenge == "" {
|
||||
t.Fatal("CodeChallenge is empty")
|
||||
}
|
||||
|
||||
verifierBytes, err := base64.RawURLEncoding.DecodeString(codes.CodeVerifier)
|
||||
if err != nil {
|
||||
t.Fatalf("CodeVerifier is not valid base64url: %v", err)
|
||||
}
|
||||
if len(verifierBytes) != 64 {
|
||||
t.Errorf("CodeVerifier decoded length = %d, want 64", len(verifierBytes))
|
||||
}
|
||||
|
||||
hash := sha256.Sum256([]byte(codes.CodeVerifier))
|
||||
expectedChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
if codes.CodeChallenge != expectedChallenge {
|
||||
t.Errorf("CodeChallenge = %q, want SHA256 of verifier = %q", codes.CodeChallenge, expectedChallenge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePKCEUniqueness(t *testing.T) {
|
||||
codes1, err := GeneratePKCE()
|
||||
if err != nil {
|
||||
t.Fatalf("GeneratePKCE() error: %v", err)
|
||||
}
|
||||
|
||||
codes2, err := GeneratePKCE()
|
||||
if err != nil {
|
||||
t.Fatalf("GeneratePKCE() error: %v", err)
|
||||
}
|
||||
|
||||
if codes1.CodeVerifier == codes2.CodeVerifier {
|
||||
t.Error("two GeneratePKCE() calls produced identical verifiers")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuthCredential struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
}
|
||||
|
||||
type AuthStore struct {
|
||||
Credentials map[string]*AuthCredential `json:"credentials"`
|
||||
}
|
||||
|
||||
func (c *AuthCredential) IsExpired() bool {
|
||||
if c.ExpiresAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return time.Now().After(c.ExpiresAt)
|
||||
}
|
||||
|
||||
func (c *AuthCredential) NeedsRefresh() bool {
|
||||
if c.ExpiresAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return time.Now().Add(5 * time.Minute).After(c.ExpiresAt)
|
||||
}
|
||||
|
||||
func authFilePath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw", "auth.json")
|
||||
}
|
||||
|
||||
func LoadStore() (*AuthStore, error) {
|
||||
path := authFilePath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &AuthStore{Credentials: make(map[string]*AuthCredential)}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var store AuthStore
|
||||
if err := json.Unmarshal(data, &store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store.Credentials == nil {
|
||||
store.Credentials = make(map[string]*AuthCredential)
|
||||
}
|
||||
return &store, nil
|
||||
}
|
||||
|
||||
func SaveStore(store *AuthStore) error {
|
||||
path := authFilePath()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func GetCredential(provider string) (*AuthCredential, error) {
|
||||
store, err := LoadStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cred, ok := store.Credentials[provider]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return cred, nil
|
||||
}
|
||||
|
||||
func SetCredential(provider string, cred *AuthCredential) error {
|
||||
store, err := LoadStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.Credentials[provider] = cred
|
||||
return SaveStore(store)
|
||||
}
|
||||
|
||||
func DeleteCredential(provider string) error {
|
||||
store, err := LoadStore()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(store.Credentials, provider)
|
||||
return SaveStore(store)
|
||||
}
|
||||
|
||||
func DeleteAllCredentials() error {
|
||||
path := authFilePath()
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthCredentialIsExpired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"zero time", time.Time{}, false},
|
||||
{"future", time.Now().Add(time.Hour), false},
|
||||
{"past", time.Now().Add(-time.Hour), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &AuthCredential{ExpiresAt: tt.expiresAt}
|
||||
if got := c.IsExpired(); got != tt.want {
|
||||
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCredentialNeedsRefresh(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{"zero time", time.Time{}, false},
|
||||
{"far future", time.Now().Add(time.Hour), false},
|
||||
{"within 5 min", time.Now().Add(3 * time.Minute), true},
|
||||
{"already expired", time.Now().Add(-time.Minute), true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &AuthCredential{ExpiresAt: tt.expiresAt}
|
||||
if got := c.NeedsRefresh(); got != tt.want {
|
||||
t.Errorf("NeedsRefresh() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRoundtrip(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
cred := &AuthCredential{
|
||||
AccessToken: "test-access-token",
|
||||
RefreshToken: "test-refresh-token",
|
||||
AccountID: "acct-123",
|
||||
ExpiresAt: time.Now().Add(time.Hour).Truncate(time.Second),
|
||||
Provider: "openai",
|
||||
AuthMethod: "oauth",
|
||||
}
|
||||
|
||||
if err := SetCredential("openai", cred); err != nil {
|
||||
t.Fatalf("SetCredential() error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := GetCredential("openai")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCredential() error: %v", err)
|
||||
}
|
||||
if loaded == nil {
|
||||
t.Fatal("GetCredential() returned nil")
|
||||
}
|
||||
if loaded.AccessToken != cred.AccessToken {
|
||||
t.Errorf("AccessToken = %q, want %q", loaded.AccessToken, cred.AccessToken)
|
||||
}
|
||||
if loaded.RefreshToken != cred.RefreshToken {
|
||||
t.Errorf("RefreshToken = %q, want %q", loaded.RefreshToken, cred.RefreshToken)
|
||||
}
|
||||
if loaded.Provider != cred.Provider {
|
||||
t.Errorf("Provider = %q, want %q", loaded.Provider, cred.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreFilePermissions(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
cred := &AuthCredential{
|
||||
AccessToken: "secret-token",
|
||||
Provider: "openai",
|
||||
AuthMethod: "oauth",
|
||||
}
|
||||
if err := SetCredential("openai", cred); err != nil {
|
||||
t.Fatalf("SetCredential() error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(tmpDir, ".picoclaw", "auth.json")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error: %v", err)
|
||||
}
|
||||
perm := info.Mode().Perm()
|
||||
if perm != 0600 {
|
||||
t.Errorf("file permissions = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMultiProvider(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"}
|
||||
anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"}
|
||||
|
||||
if err := SetCredential("openai", openaiCred); err != nil {
|
||||
t.Fatalf("SetCredential(openai) error: %v", err)
|
||||
}
|
||||
if err := SetCredential("anthropic", anthropicCred); err != nil {
|
||||
t.Fatalf("SetCredential(anthropic) error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := GetCredential("openai")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCredential(openai) error: %v", err)
|
||||
}
|
||||
if loaded.AccessToken != "openai-token" {
|
||||
t.Errorf("openai token = %q, want %q", loaded.AccessToken, "openai-token")
|
||||
}
|
||||
|
||||
loaded, err = GetCredential("anthropic")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCredential(anthropic) error: %v", err)
|
||||
}
|
||||
if loaded.AccessToken != "anthropic-token" {
|
||||
t.Errorf("anthropic token = %q, want %q", loaded.AccessToken, "anthropic-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCredential(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"}
|
||||
if err := SetCredential("openai", cred); err != nil {
|
||||
t.Fatalf("SetCredential() error: %v", err)
|
||||
}
|
||||
|
||||
if err := DeleteCredential("openai"); err != nil {
|
||||
t.Fatalf("DeleteCredential() error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := GetCredential("openai")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCredential() error: %v", err)
|
||||
}
|
||||
if loaded != nil {
|
||||
t.Error("expected nil after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStoreEmpty(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
store, err := LoadStore()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadStore() error: %v", err)
|
||||
}
|
||||
if store == nil {
|
||||
t.Fatal("LoadStore() returned nil")
|
||||
}
|
||||
if len(store.Credentials) != 0 {
|
||||
t.Errorf("expected empty credentials, got %d", len(store.Credentials))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
|
||||
fmt.Printf("Paste your API key or session token from %s:\n", providerDisplayName(provider))
|
||||
fmt.Print("> ")
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("reading token: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("no input received")
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(scanner.Text())
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
|
||||
return &AuthCredential{
|
||||
AccessToken: token,
|
||||
Provider: provider,
|
||||
AuthMethod: "token",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func providerDisplayName(provider string) string {
|
||||
switch provider {
|
||||
case "anthropic":
|
||||
return "console.anthropic.com"
|
||||
case "openai":
|
||||
return "platform.openai.com"
|
||||
default:
|
||||
return provider
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+38
-7
@@ -2,6 +2,8 @@ package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
)
|
||||
@@ -46,8 +48,33 @@ func (c *BaseChannel) IsAllowed(senderID string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Extract parts from compound senderID like "123456|username"
|
||||
idPart := senderID
|
||||
userPart := ""
|
||||
if idx := strings.Index(senderID, "|"); idx > 0 {
|
||||
idPart = senderID[:idx]
|
||||
userPart = senderID[idx+1:]
|
||||
}
|
||||
|
||||
for _, allowed := range c.allowList {
|
||||
if senderID == allowed {
|
||||
// Strip leading "@" from allowed value for username matching
|
||||
trimmed := strings.TrimPrefix(allowed, "@")
|
||||
allowedID := trimmed
|
||||
allowedUser := ""
|
||||
if idx := strings.Index(trimmed, "|"); idx > 0 {
|
||||
allowedID = trimmed[:idx]
|
||||
allowedUser = trimmed[idx+1:]
|
||||
}
|
||||
|
||||
// Support either side using "id|username" compound form.
|
||||
// This keeps backward compatibility with legacy Telegram allowlist entries.
|
||||
if senderID == allowed ||
|
||||
idPart == allowed ||
|
||||
senderID == trimmed ||
|
||||
idPart == trimmed ||
|
||||
idPart == allowedID ||
|
||||
(allowedUser != "" && senderID == allowedUser) ||
|
||||
(userPart != "" && (userPart == allowed || userPart == trimmed || userPart == allowedUser)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -60,13 +87,17 @@ 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,
|
||||
Metadata: metadata,
|
||||
Channel: c.name,
|
||||
SenderID: senderID,
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
Media: media,
|
||||
SessionKey: sessionKey,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
c.bus.PublishInbound(msg)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package channels
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBaseChannelIsAllowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowList []string
|
||||
senderID string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty allowlist allows all",
|
||||
allowList: nil,
|
||||
senderID: "anyone",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compound sender matches numeric allowlist",
|
||||
allowList: []string{"123456"},
|
||||
senderID: "123456|alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compound sender matches username allowlist",
|
||||
allowList: []string{"@alice"},
|
||||
senderID: "123456|alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "numeric sender matches legacy compound allowlist",
|
||||
allowList: []string{"123456|alice"},
|
||||
senderID: "123456",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non matching sender is denied",
|
||||
allowList: []string{"123456"},
|
||||
senderID: "654321|bob",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ch := NewBaseChannel("test", nil, nil, tt.allowList)
|
||||
if got := ch.IsAllowed(tt.senderID); got != tt.want {
|
||||
t.Fatalf("IsAllowed(%q) = %v, want %v", tt.senderID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// DingTalk channel implementation using Stream Mode
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"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"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// DingTalkChannel implements the Channel interface for DingTalk (钉钉)
|
||||
// It uses WebSocket for receiving messages via stream mode and API for sending
|
||||
type DingTalkChannel struct {
|
||||
*BaseChannel
|
||||
config config.DingTalkConfig
|
||||
clientID string
|
||||
clientSecret string
|
||||
streamClient *client.StreamClient
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
// Map to store session webhooks for each chat
|
||||
sessionWebhooks sync.Map // chatID -> sessionWebhook
|
||||
}
|
||||
|
||||
// NewDingTalkChannel creates a new DingTalk channel instance
|
||||
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
|
||||
if cfg.ClientID == "" || cfg.ClientSecret == "" {
|
||||
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
||||
}
|
||||
|
||||
base := NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom)
|
||||
|
||||
return &DingTalkChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
clientID: cfg.ClientID,
|
||||
clientSecret: cfg.ClientSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start initializes the DingTalk channel with Stream Mode
|
||||
func (c *DingTalkChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("dingtalk", "Starting DingTalk channel (Stream Mode)...")
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// Create credential config
|
||||
cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret)
|
||||
|
||||
// Create the stream client with options
|
||||
c.streamClient = client.NewStreamClient(
|
||||
client.WithAppCredential(cred),
|
||||
client.WithAutoReconnect(true),
|
||||
)
|
||||
|
||||
// Register chatbot callback handler (IChatBotMessageHandler is a function type)
|
||||
c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived)
|
||||
|
||||
// Start the stream client
|
||||
if err := c.streamClient.Start(c.ctx); err != nil {
|
||||
return fmt.Errorf("failed to start stream client: %w", err)
|
||||
}
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the DingTalk channel
|
||||
func (c *DingTalkChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("dingtalk", "Stopping DingTalk channel...")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
if c.streamClient != nil {
|
||||
c.streamClient.Close()
|
||||
}
|
||||
|
||||
c.setRunning(false)
|
||||
logger.InfoC("dingtalk", "DingTalk channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends a message to DingTalk via the chatbot reply API
|
||||
func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("dingtalk channel not running")
|
||||
}
|
||||
|
||||
// Get session webhook from storage
|
||||
sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID)
|
||||
if !ok {
|
||||
return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID)
|
||||
}
|
||||
|
||||
sessionWebhook, ok := sessionWebhookRaw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID)
|
||||
}
|
||||
|
||||
logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{
|
||||
"chat_id": msg.ChatID,
|
||||
"preview": utils.Truncate(msg.Content, 100),
|
||||
})
|
||||
|
||||
// Use the session webhook to send the reply
|
||||
return c.SendDirectReply(ctx, sessionWebhook, msg.Content)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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 textContent, ok := contentMap["content"].(string); ok {
|
||||
content = textContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
return nil, nil // Ignore empty messages
|
||||
}
|
||||
|
||||
senderID := data.SenderStaffId
|
||||
senderNick := data.SenderNick
|
||||
chatID := senderID
|
||||
if data.ConversationType != "1" {
|
||||
// For group chats
|
||||
chatID = data.ConversationId
|
||||
}
|
||||
|
||||
// Store the session webhook for this chat so we can reply later
|
||||
c.sessionWebhooks.Store(chatID, data.SessionWebhook)
|
||||
|
||||
metadata := map[string]string{
|
||||
"sender_name": senderNick,
|
||||
"conversation_id": data.ConversationId,
|
||||
"conversation_type": data.ConversationType,
|
||||
"platform": "dingtalk",
|
||||
"session_webhook": data.SessionWebhook,
|
||||
}
|
||||
|
||||
logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
|
||||
"sender_nick": senderNick,
|
||||
"sender_id": senderID,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Handle the message through the base channel
|
||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||
|
||||
// Return nil to indicate we've handled the message asynchronously
|
||||
// The response will be sent through the message bus
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SendDirectReply sends a direct reply using the session webhook
|
||||
func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, content string) error {
|
||||
replier := chatbot.NewChatbotReplier()
|
||||
|
||||
// Convert string content to []byte for the API
|
||||
contentBytes := []byte(content)
|
||||
titleBytes := []byte("PicoClaw")
|
||||
|
||||
// Send markdown formatted reply
|
||||
err := replier.SimpleReplyMarkdown(
|
||||
ctx,
|
||||
sessionWebhook,
|
||||
titleBytes,
|
||||
contentBytes,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send reply: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+271
-13
@@ -3,17 +3,29 @@ package channels
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
const (
|
||||
transcriptionTimeout = 30 * time.Second
|
||||
sendTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type DiscordChannel struct {
|
||||
*BaseChannel
|
||||
session *discordgo.Session
|
||||
config config.DiscordConfig
|
||||
session *discordgo.Session
|
||||
config config.DiscordConfig
|
||||
transcriber *voice.GroqTranscriber
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
||||
@@ -28,12 +40,26 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
|
||||
BaseChannel: base,
|
||||
session: session,
|
||||
config: cfg,
|
||||
transcriber: nil,
|
||||
ctx: context.Background(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||
c.transcriber = transcriber
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) getContext() context.Context {
|
||||
if c.ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return c.ctx
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("discord", "Starting Discord bot")
|
||||
|
||||
c.ctx = ctx
|
||||
c.session.AddHandler(c.handleMessage)
|
||||
|
||||
if err := c.session.Open(); err != nil {
|
||||
@@ -46,7 +72,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get bot user: %w", err)
|
||||
}
|
||||
logger.InfoCF("discord", "Discord bot connected", map[string]interface{}{
|
||||
logger.InfoCF("discord", "Discord bot connected", map[string]any{
|
||||
"username": botUser.Username,
|
||||
"user_id": botUser.ID,
|
||||
})
|
||||
@@ -75,15 +101,178 @@ 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
|
||||
}
|
||||
|
||||
if _, err := c.session.ChannelMessageSend(channelID, message); err != nil {
|
||||
return fmt.Errorf("failed to send discord message: %w", err)
|
||||
chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks
|
||||
|
||||
for _, chunk := range chunks {
|
||||
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitMessage splits long messages into chunks, preserving code block integrity
|
||||
// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks
|
||||
func splitMessage(content string, limit int) []string {
|
||||
var messages []string
|
||||
|
||||
for len(content) > 0 {
|
||||
if len(content) <= limit {
|
||||
messages = append(messages, content)
|
||||
break
|
||||
}
|
||||
|
||||
msgEnd := limit
|
||||
|
||||
// Find natural split point within the limit
|
||||
msgEnd = findLastNewline(content[:limit], 200)
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = findLastSpace(content[:limit], 100)
|
||||
}
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = limit
|
||||
}
|
||||
|
||||
// Check if this would end with an incomplete code block
|
||||
candidate := content[:msgEnd]
|
||||
unclosedIdx := findLastUnclosedCodeBlock(candidate)
|
||||
|
||||
if unclosedIdx >= 0 {
|
||||
// Message would end with incomplete code block
|
||||
// Try to extend to include the closing ``` (with some buffer)
|
||||
extendedLimit := limit + 500 // Allow 500 char buffer for code blocks
|
||||
if len(content) > extendedLimit {
|
||||
closingIdx := findNextClosingCodeBlock(content, msgEnd)
|
||||
if closingIdx > 0 && closingIdx <= extendedLimit {
|
||||
// Extend to include the closing ```
|
||||
msgEnd = closingIdx
|
||||
} else {
|
||||
// Can't find closing, split before the code block
|
||||
msgEnd = findLastNewline(content[:unclosedIdx], 200)
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = findLastSpace(content[:unclosedIdx], 100)
|
||||
}
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = unclosedIdx
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remaining content fits within extended limit
|
||||
msgEnd = len(content)
|
||||
}
|
||||
}
|
||||
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = limit
|
||||
}
|
||||
|
||||
messages = append(messages, content[:msgEnd])
|
||||
content = strings.TrimSpace(content[msgEnd:])
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
|
||||
// Returns the position of the opening ``` or -1 if all code blocks are complete
|
||||
func findLastUnclosedCodeBlock(text string) int {
|
||||
count := 0
|
||||
lastOpenIdx := -1
|
||||
|
||||
for i := 0; i < len(text); i++ {
|
||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
||||
if count == 0 {
|
||||
lastOpenIdx = i
|
||||
}
|
||||
count++
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
|
||||
// If odd number of ``` markers, last one is unclosed
|
||||
if count%2 == 1 {
|
||||
return lastOpenIdx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// findNextClosingCodeBlock finds the next closing ``` starting from a position
|
||||
// Returns the position after the closing ``` or -1 if not found
|
||||
func findNextClosingCodeBlock(text string, startIdx int) int {
|
||||
for i := startIdx; i < len(text); i++ {
|
||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
||||
return i + 3
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// findLastNewline finds the last newline character within the last N characters
|
||||
// Returns the position of the newline or -1 if not found
|
||||
func findLastNewline(s string, searchWindow int) int {
|
||||
searchStart := len(s) - searchWindow
|
||||
if searchStart < 0 {
|
||||
searchStart = 0
|
||||
}
|
||||
for i := len(s) - 1; i >= searchStart; i-- {
|
||||
if s[i] == '\n' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// findLastSpace finds the last space character within the last N characters
|
||||
// Returns the position of the space or -1 if not found
|
||||
func findLastSpace(s string, searchWindow int) int {
|
||||
searchStart := len(s) - searchWindow
|
||||
if searchStart < 0 {
|
||||
searchStart = 0
|
||||
}
|
||||
for i := len(s) - 1; i >= searchStart; i-- {
|
||||
if s[i] == ' ' || s[i] == '\t' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
|
||||
// 使用传入的 ctx 进行超时控制
|
||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := c.session.ChannelMessageSend(channelID, content)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send discord message: %w", err)
|
||||
}
|
||||
return nil
|
||||
case <-sendCtx.Done():
|
||||
return fmt.Errorf("send message timeout: %w", sendCtx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// appendContent 安全地追加内容到现有文本
|
||||
func appendContent(content, suffix string) string {
|
||||
if content == "" {
|
||||
return suffix
|
||||
}
|
||||
return content + "\n" + suffix
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||
if m == nil || m.Author == nil {
|
||||
return
|
||||
@@ -93,6 +282,20 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.session.ChannelTyping(m.ChannelID); err != nil {
|
||||
logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件和转录
|
||||
if !c.IsAllowed(m.Author.ID) {
|
||||
logger.DebugCF("discord", "Message rejected by allowlist", 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" {
|
||||
@@ -100,14 +303,63 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
}
|
||||
|
||||
content := m.Content
|
||||
mediaPaths := []string{}
|
||||
mediaPaths := make([]string, 0, len(m.Attachments))
|
||||
localFiles := make([]string, 0, len(m.Attachments))
|
||||
|
||||
// 确保临时文件在函数返回时被清理
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for _, attachment := range m.Attachments {
|
||||
mediaPaths = append(mediaPaths, attachment.URL)
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
|
||||
|
||||
if isAudio {
|
||||
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
||||
if localPath != "" {
|
||||
localFiles = append(localFiles, localPath)
|
||||
|
||||
transcribedText := ""
|
||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
|
||||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||
cancel() // 立即释放context资源,避免在for循环中泄漏
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename)
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text)
|
||||
logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{
|
||||
"text": result.Text,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename)
|
||||
}
|
||||
|
||||
content = appendContent(content, transcribedText)
|
||||
} else {
|
||||
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
|
||||
"url": attachment.URL,
|
||||
"filename": attachment.Filename,
|
||||
})
|
||||
mediaPaths = append(mediaPaths, attachment.URL)
|
||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
||||
}
|
||||
} else {
|
||||
mediaPaths = append(mediaPaths, attachment.URL)
|
||||
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
||||
}
|
||||
content += fmt.Sprintf("[attachment: %s]", attachment.URL)
|
||||
}
|
||||
|
||||
if content == "" && len(mediaPaths) == 0 {
|
||||
@@ -118,10 +370,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
content = "[media only]"
|
||||
}
|
||||
|
||||
logger.DebugCF("discord", "Received message", map[string]interface{}{
|
||||
logger.DebugCF("discord", "Received message", map[string]any{
|
||||
"sender_name": senderName,
|
||||
"sender_id": senderID,
|
||||
"preview": truncateString(content, 50),
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
metadata := map[string]string{
|
||||
@@ -136,3 +388,9 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||
|
||||
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
|
||||
}
|
||||
|
||||
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "discord",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
type FeishuChannel struct {
|
||||
*BaseChannel
|
||||
config config.FeishuConfig
|
||||
}
|
||||
|
||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||
base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom)
|
||||
|
||||
return &FeishuChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||
log.Println("Feishu channel started")
|
||||
c.setRunning(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||
log.Println("Feishu channel stopped")
|
||||
c.setRunning(false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("feishu channel not running")
|
||||
}
|
||||
|
||||
htmlContent := markdownToFeishuCard(msg.Content)
|
||||
|
||||
log.Printf("Feishu send to %s: %s", msg.ChatID, truncateString(htmlContent, 100))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) handleIncomingMessage(data map[string]interface{}) {
|
||||
senderID, _ := data["sender_id"].(string)
|
||||
chatID, _ := data["chat_id"].(string)
|
||||
content, _ := data["content"].(string)
|
||||
|
||||
log.Printf("Feishu message from %s: %s...", senderID, truncateString(content, 50))
|
||||
|
||||
metadata := make(map[string]string)
|
||||
if messageID, ok := data["message_id"].(string); ok {
|
||||
metadata["message_id"] = messageID
|
||||
}
|
||||
if userName, ok := data["sender_name"].(string); ok {
|
||||
metadata["sender_name"] = userName
|
||||
}
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||
}
|
||||
|
||||
func markdownToFeishuCard(markdown string) string {
|
||||
return markdown
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// FeishuChannel is a stub implementation for 32-bit architectures
|
||||
type FeishuChannel struct {
|
||||
*BaseChannel
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Start is a stub method to satisfy the Channel interface
|
||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop is a stub method to satisfy the Channel interface
|
||||
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send is a stub method to satisfy the Channel interface
|
||||
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
return errors.New("feishu channel is not supported on 32-bit architectures")
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
type FeishuChannel struct {
|
||||
*BaseChannel
|
||||
config config.FeishuConfig
|
||||
client *lark.Client
|
||||
wsClient *larkws.Client
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||
base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom)
|
||||
|
||||
return &FeishuChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||
if c.config.AppID == "" || c.config.AppSecret == "" {
|
||||
return fmt.Errorf("feishu app_id or app_secret is empty")
|
||||
}
|
||||
|
||||
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
|
||||
OnP2MessageReceiveV1(c.handleMessageReceive)
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
c.mu.Lock()
|
||||
c.cancel = cancel
|
||||
c.wsClient = larkws.NewClient(
|
||||
c.config.AppID,
|
||||
c.config.AppSecret,
|
||||
larkws.WithEventHandler(dispatcher),
|
||||
)
|
||||
wsClient := c.wsClient
|
||||
c.mu.Unlock()
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
|
||||
|
||||
go func() {
|
||||
if err := wsClient.Start(runCtx); err != nil {
|
||||
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
c.cancel = nil
|
||||
}
|
||||
c.wsClient = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
c.setRunning(false)
|
||||
logger.InfoC("feishu", "Feishu channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("feishu channel not running")
|
||||
}
|
||||
|
||||
if msg.ChatID == "" {
|
||||
return fmt.Errorf("chat ID is empty")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]string{"text": msg.Content})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal feishu content: %w", err)
|
||||
}
|
||||
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(msg.ChatID).
|
||||
MsgType(larkim.MsgTypeText).
|
||||
Content(string(payload)).
|
||||
Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())).
|
||||
Build()).
|
||||
Build()
|
||||
|
||||
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send feishu message: %w", err)
|
||||
}
|
||||
|
||||
if !resp.Success() {
|
||||
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
|
||||
}
|
||||
|
||||
logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{
|
||||
"chat_id": msg.ChatID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||
if event == nil || event.Event == nil || event.Event.Message == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
message := event.Event.Message
|
||||
sender := event.Event.Sender
|
||||
|
||||
chatID := stringValue(message.ChatId)
|
||||
if chatID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
senderID := extractFeishuSenderID(sender)
|
||||
if senderID == "" {
|
||||
senderID = "unknown"
|
||||
}
|
||||
|
||||
content := extractFeishuMessageContent(message)
|
||||
if content == "" {
|
||||
content = "[empty message]"
|
||||
}
|
||||
|
||||
metadata := map[string]string{}
|
||||
if messageID := stringValue(message.MessageId); messageID != "" {
|
||||
metadata["message_id"] = messageID
|
||||
}
|
||||
if messageType := stringValue(message.MessageType); messageType != "" {
|
||||
metadata["message_type"] = messageType
|
||||
}
|
||||
if chatType := stringValue(message.ChatType); chatType != "" {
|
||||
metadata["chat_type"] = chatType
|
||||
}
|
||||
if sender != nil && sender.TenantKey != nil {
|
||||
metadata["tenant_key"] = *sender.TenantKey
|
||||
}
|
||||
|
||||
logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"preview": utils.Truncate(content, 80),
|
||||
})
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractFeishuSenderID(sender *larkim.EventSender) string {
|
||||
if sender == nil || sender.SenderId == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if sender.SenderId.UserId != nil && *sender.SenderId.UserId != "" {
|
||||
return *sender.SenderId.UserId
|
||||
}
|
||||
if sender.SenderId.OpenId != nil && *sender.SenderId.OpenId != "" {
|
||||
return *sender.SenderId.OpenId
|
||||
}
|
||||
if sender.SenderId.UnionId != nil && *sender.SenderId.UnionId != "" {
|
||||
return *sender.SenderId.UnionId
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractFeishuMessageContent(message *larkim.EventMessage) string {
|
||||
if message == nil || message.Content == nil || *message.Content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if message.MessageType != nil && *message.MessageType == larkim.MsgTypeText {
|
||||
var textPayload struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*message.Content), &textPayload); err == nil {
|
||||
return textPayload.Text
|
||||
}
|
||||
}
|
||||
|
||||
return *message.Content
|
||||
}
|
||||
|
||||
func stringValue(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"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 (
|
||||
lineAPIBase = "https://api.line.me/v2/bot"
|
||||
lineDataAPIBase = "https://api-data.line.me/v2/bot"
|
||||
lineReplyEndpoint = lineAPIBase + "/message/reply"
|
||||
linePushEndpoint = lineAPIBase + "/message/push"
|
||||
lineContentEndpoint = lineDataAPIBase + "/message/%s/content"
|
||||
lineBotInfoEndpoint = lineAPIBase + "/info"
|
||||
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
||||
lineReplyTokenMaxAge = 25 * time.Second
|
||||
)
|
||||
|
||||
type replyTokenEntry struct {
|
||||
token string
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
// LINEChannel implements the Channel interface for LINE Official Account
|
||||
// using the LINE Messaging API with HTTP webhook for receiving messages
|
||||
// and REST API for sending messages.
|
||||
type LINEChannel struct {
|
||||
*BaseChannel
|
||||
config config.LINEConfig
|
||||
httpServer *http.Server
|
||||
botUserID string // Bot's user ID
|
||||
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
||||
botDisplayName string // Bot's display name for text-based mention detection
|
||||
replyTokens sync.Map // chatID -> replyTokenEntry
|
||||
quoteTokens sync.Map // chatID -> quoteToken (string)
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewLINEChannel creates a new LINE channel instance.
|
||||
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
||||
if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
|
||||
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
||||
}
|
||||
|
||||
base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom)
|
||||
|
||||
return &LINEChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start launches the HTTP webhook server.
|
||||
func (c *LINEChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// 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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
|
||||
"bot_user_id": c.botUserID,
|
||||
"basic_id": c.botBasicID,
|
||||
"display_name": c.botDisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
path := c.config.WebhookPath
|
||||
if path == "" {
|
||||
path = "/webhook/line"
|
||||
}
|
||||
mux.HandleFunc(path, c.webhookHandler)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
||||
c.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
|
||||
"addr": addr,
|
||||
"path": path,
|
||||
})
|
||||
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoC("line", "LINE channel started (Webhook Mode)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API.
|
||||
func (c *LINEChannel) fetchBotInfo() error {
|
||||
req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("bot info API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info struct {
|
||||
UserID string `json:"userId"`
|
||||
BasicID string `json:"basicId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.botUserID = info.UserID
|
||||
c.botBasicID = info.BasicID
|
||||
c.botDisplayName = info.DisplayName
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the HTTP server.
|
||||
func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("line", "Stopping LINE channel")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
if c.httpServer != nil {
|
||||
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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.setRunning(false)
|
||||
logger.InfoC("line", "LINE channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// webhookHandler handles incoming LINE webhook requests.
|
||||
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
signature := r.Header.Get("X-Line-Signature")
|
||||
if !c.verifySignature(body, signature) {
|
||||
logger.WarnC("line", "Invalid webhook signature")
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Events []lineEvent `json:"events"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Return 200 immediately, process events asynchronously
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
for _, event := range payload.Events {
|
||||
go c.processEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// verifySignature validates the X-Line-Signature using HMAC-SHA256.
|
||||
func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
|
||||
if signature == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret))
|
||||
mac.Write(body)
|
||||
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
// LINE webhook event types
|
||||
type lineEvent struct {
|
||||
Type string `json:"type"`
|
||||
ReplyToken string `json:"replyToken"`
|
||||
Source lineSource `json:"source"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type lineSource struct {
|
||||
Type string `json:"type"` // "user", "group", "room"
|
||||
UserID string `json:"userId"`
|
||||
GroupID string `json:"groupId"`
|
||||
RoomID string `json:"roomId"`
|
||||
}
|
||||
|
||||
type lineMessage struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker"
|
||||
Text string `json:"text"`
|
||||
QuoteToken string `json:"quoteToken"`
|
||||
Mention *struct {
|
||||
Mentionees []lineMentionee `json:"mentionees"`
|
||||
} `json:"mention"`
|
||||
ContentProvider struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"contentProvider"`
|
||||
}
|
||||
|
||||
type lineMentionee struct {
|
||||
Index int `json:"index"`
|
||||
Length int `json:"length"`
|
||||
Type string `json:"type"` // "user", "all"
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
if event.Type != "message" {
|
||||
logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
|
||||
"type": event.Type,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := event.Source.UserID
|
||||
chatID := c.resolveChatID(event.Source)
|
||||
isGroup := event.Source.Type == "group" || event.Source.Type == "room"
|
||||
|
||||
var msg lineMessage
|
||||
if err := json.Unmarshal(event.Message, &msg); err != nil {
|
||||
logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 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{}{
|
||||
"chat_id": chatID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Store reply token for later use
|
||||
if event.ReplyToken != "" {
|
||||
c.replyTokens.Store(chatID, replyTokenEntry{
|
||||
token: event.ReplyToken,
|
||||
timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Store quote token for quoting the original message in reply
|
||||
if msg.QuoteToken != "" {
|
||||
c.quoteTokens.Store(chatID, msg.QuoteToken)
|
||||
}
|
||||
|
||||
var content string
|
||||
var mediaPaths []string
|
||||
localFiles := []string{}
|
||||
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
switch msg.Type {
|
||||
case "text":
|
||||
content = msg.Text
|
||||
// Strip bot mention from text in group chats
|
||||
if isGroup {
|
||||
content = c.stripBotMention(content, msg)
|
||||
}
|
||||
case "image":
|
||||
localPath := c.downloadContent(msg.ID, "image.jpg")
|
||||
if localPath != "" {
|
||||
localFiles = append(localFiles, localPath)
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
content = "[image]"
|
||||
}
|
||||
case "audio":
|
||||
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
||||
if localPath != "" {
|
||||
localFiles = append(localFiles, localPath)
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
content = "[audio]"
|
||||
}
|
||||
case "video":
|
||||
localPath := c.downloadContent(msg.ID, "video.mp4")
|
||||
if localPath != "" {
|
||||
localFiles = append(localFiles, localPath)
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
content = "[video]"
|
||||
}
|
||||
case "file":
|
||||
content = "[file]"
|
||||
case "sticker":
|
||||
content = "[sticker]"
|
||||
default:
|
||||
content = fmt.Sprintf("[%s]", msg.Type)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"platform": "line",
|
||||
"source_type": event.Source.Type,
|
||||
"message_id": msg.ID,
|
||||
}
|
||||
|
||||
logger.DebugCF("line", "Received message", map[string]interface{}{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"message_type": msg.Type,
|
||||
"is_group": isGroup,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Show typing/loading indicator (requires user ID, not group ID)
|
||||
c.sendLoading(senderID)
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
||||
}
|
||||
|
||||
// isBotMentioned checks if the bot is mentioned in the message.
|
||||
// It first checks the mention metadata (userId match), then falls back
|
||||
// to text-based detection using the bot's display name, since LINE may
|
||||
// not include userId in mentionees for Official Accounts.
|
||||
func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
|
||||
// Check mention metadata
|
||||
if msg.Mention != nil {
|
||||
for _, m := range msg.Mention.Mentionees {
|
||||
if m.Type == "all" {
|
||||
return true
|
||||
}
|
||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Mention metadata exists with mentionees but bot not matched by userId.
|
||||
// The bot IS likely mentioned (LINE includes mention struct when bot is @-ed),
|
||||
// so check if any mentionee overlaps with bot display name in text.
|
||||
if c.botDisplayName != "" {
|
||||
for _, m := range msg.Mention.Mentionees {
|
||||
if m.Index >= 0 && m.Length > 0 {
|
||||
runes := []rune(msg.Text)
|
||||
end := m.Index + m.Length
|
||||
if end <= len(runes) {
|
||||
mentionText := string(runes[m.Index:end])
|
||||
if strings.Contains(mentionText, c.botDisplayName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: text-based detection with display name
|
||||
if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// stripBotMention removes the @BotName mention text from the message.
|
||||
func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
|
||||
stripped := false
|
||||
|
||||
// Try to strip using mention metadata indices
|
||||
if msg.Mention != nil {
|
||||
runes := []rune(text)
|
||||
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
|
||||
m := msg.Mention.Mentionees[i]
|
||||
// Strip if userId matches OR if the mention text contains the bot display name
|
||||
shouldStrip := false
|
||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
||||
shouldStrip = true
|
||||
} else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 {
|
||||
end := m.Index + m.Length
|
||||
if end <= len(runes) {
|
||||
mentionText := string(runes[m.Index:end])
|
||||
if strings.Contains(mentionText, c.botDisplayName) {
|
||||
shouldStrip = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if shouldStrip {
|
||||
start := m.Index
|
||||
end := m.Index + m.Length
|
||||
if start >= 0 && end <= len(runes) {
|
||||
runes = append(runes[:start], runes[end:]...)
|
||||
stripped = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if stripped {
|
||||
return strings.TrimSpace(string(runes))
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: strip @DisplayName from text
|
||||
if c.botDisplayName != "" {
|
||||
text = strings.ReplaceAll(text, "@"+c.botDisplayName, "")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// resolveChatID determines the chat ID from the event source.
|
||||
// For group/room messages, use the group/room ID; for 1:1, use the user ID.
|
||||
func (c *LINEChannel) resolveChatID(source lineSource) string {
|
||||
switch source.Type {
|
||||
case "group":
|
||||
return source.GroupID
|
||||
case "room":
|
||||
return source.RoomID
|
||||
default:
|
||||
return source.UserID
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends a message to LINE. It first tries the Reply API (free)
|
||||
// using a cached reply token, then falls back to the Push API.
|
||||
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("line channel not running")
|
||||
}
|
||||
|
||||
// Load and consume quote token for this chat
|
||||
var quoteToken string
|
||||
if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {
|
||||
quoteToken = qt.(string)
|
||||
}
|
||||
|
||||
// Try reply token first (free, valid for ~25 seconds)
|
||||
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
||||
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{}{
|
||||
"chat_id": msg.ChatID,
|
||||
"quoted": quoteToken != "",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
logger.DebugC("line", "Reply API failed, falling back to Push API")
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Push API
|
||||
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
|
||||
}
|
||||
|
||||
// buildTextMessage creates a text message object, optionally with quoteToken.
|
||||
func buildTextMessage(content, quoteToken string) map[string]string {
|
||||
msg := map[string]string{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
}
|
||||
if quoteToken != "" {
|
||||
msg["quoteToken"] = quoteToken
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// 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{}{
|
||||
"replyToken": replyToken,
|
||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||
}
|
||||
|
||||
return c.callAPI(ctx, lineReplyEndpoint, payload)
|
||||
}
|
||||
|
||||
// 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{}{
|
||||
"to": to,
|
||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||
}
|
||||
|
||||
return c.callAPI(ctx, linePushEndpoint, payload)
|
||||
}
|
||||
|
||||
// sendLoading sends a loading animation indicator to the chat.
|
||||
func (c *LINEChannel) sendLoading(chatID string) {
|
||||
payload := map[string]interface{}{
|
||||
"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{}{
|
||||
"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 {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadContent downloads media content from the LINE API.
|
||||
func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
||||
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "line",
|
||||
ExtraHeaders: map[string]string{
|
||||
"Authorization": "Bearer " + c.config.ChannelAccessToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
+85
-1
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -47,7 +48,7 @@ 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{}{
|
||||
"error": err.Error(),
|
||||
@@ -71,6 +72,19 @@ func (m *Manager) initChannels() error {
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.Feishu.Enabled {
|
||||
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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["feishu"] = feishu
|
||||
logger.InfoC("channels", "Feishu channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
|
||||
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
||||
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
|
||||
@@ -97,6 +111,71 @@ func (m *Manager) initChannels() error {
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.QQ.Enabled {
|
||||
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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["qq"] = qq
|
||||
logger.InfoC("channels", "QQ channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
|
||||
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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["dingtalk"] = dingtalk
|
||||
logger.InfoC("channels", "DingTalk channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
|
||||
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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["slack"] = slackCh
|
||||
logger.InfoC("channels", "Slack channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
|
||||
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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["line"] = line
|
||||
logger.InfoC("channels", "LINE channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
|
||||
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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["onebot"] = onebot
|
||||
logger.InfoC("channels", "OneBot channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||
"enabled_channels": len(m.channels),
|
||||
})
|
||||
@@ -177,6 +256,11 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Silently skip internal channels
|
||||
if constants.IsInternalChannel(msg.Channel) {
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
channel, exists := m.channels[msg.Channel]
|
||||
m.mu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
type OneBotChannel struct {
|
||||
*BaseChannel
|
||||
config config.OneBotConfig
|
||||
conn *websocket.Conn
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
dedup map[string]struct{}
|
||||
dedupRing []string
|
||||
dedupIdx int
|
||||
mu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
echoCounter int64
|
||||
}
|
||||
|
||||
type oneBotRawEvent struct {
|
||||
PostType string `json:"post_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
SubType string `json:"sub_type"`
|
||||
MessageID json.RawMessage `json:"message_id"`
|
||||
UserID json.RawMessage `json:"user_id"`
|
||||
GroupID json.RawMessage `json:"group_id"`
|
||||
RawMessage string `json:"raw_message"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
Sender json.RawMessage `json:"sender"`
|
||||
SelfID json.RawMessage `json:"self_id"`
|
||||
Time json.RawMessage `json:"time"`
|
||||
MetaEventType string `json:"meta_event_type"`
|
||||
Echo string `json:"echo"`
|
||||
RetCode json.RawMessage `json:"retcode"`
|
||||
Status BotStatus `json:"status"`
|
||||
}
|
||||
|
||||
type BotStatus struct {
|
||||
Online bool `json:"online"`
|
||||
Good bool `json:"good"`
|
||||
}
|
||||
|
||||
type oneBotSender struct {
|
||||
UserID json.RawMessage `json:"user_id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Card string `json:"card"`
|
||||
}
|
||||
|
||||
type oneBotEvent struct {
|
||||
PostType string
|
||||
MessageType string
|
||||
SubType string
|
||||
MessageID string
|
||||
UserID int64
|
||||
GroupID int64
|
||||
Content string
|
||||
RawContent string
|
||||
IsBotMentioned bool
|
||||
Sender oneBotSender
|
||||
SelfID int64
|
||||
Time int64
|
||||
MetaEventType string
|
||||
}
|
||||
|
||||
type oneBotAPIRequest struct {
|
||||
Action string `json:"action"`
|
||||
Params interface{} `json:"params"`
|
||||
Echo string `json:"echo,omitempty"`
|
||||
}
|
||||
|
||||
type oneBotSendPrivateMsgParams struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type oneBotSendGroupMsgParams struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
||||
base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
|
||||
|
||||
const dedupSize = 1024
|
||||
return &OneBotChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
dedup: make(map[string]struct{}, dedupSize),
|
||||
dedupRing: make([]string, dedupSize),
|
||||
dedupIdx: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) Start(ctx context.Context) error {
|
||||
if c.config.WSUrl == "" {
|
||||
return fmt.Errorf("OneBot ws_url not configured")
|
||||
}
|
||||
|
||||
logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{
|
||||
"ws_url": c.config.WSUrl,
|
||||
})
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
if err := c.connect(); err != nil {
|
||||
logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
go c.listen()
|
||||
}
|
||||
|
||||
if c.config.ReconnectInterval > 0 {
|
||||
go c.reconnectLoop()
|
||||
} else {
|
||||
// If reconnect is disabled but initial connection failed, we cannot recover
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("failed to connect to OneBot and reconnect is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoC("onebot", "OneBot channel started successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) connect() error {
|
||||
dialer := websocket.DefaultDialer
|
||||
dialer.HandshakeTimeout = 10 * time.Second
|
||||
|
||||
header := make(map[string][]string)
|
||||
if c.config.AccessToken != "" {
|
||||
header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
|
||||
}
|
||||
|
||||
conn, _, err := dialer.Dial(c.config.WSUrl, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
|
||||
logger.InfoC("onebot", "WebSocket connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) reconnectLoop() {
|
||||
interval := time.Duration(c.config.ReconnectInterval) * time.Second
|
||||
if interval < 5*time.Second {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-time.After(interval):
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
logger.InfoC("onebot", "Attempting to reconnect...")
|
||||
if err := c.connect(); err != nil {
|
||||
logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
go c.listen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("onebot", "Stopping OneBot channel")
|
||||
c.setRunning(false)
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("OneBot channel not running")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return fmt.Errorf("OneBot WebSocket not connected")
|
||||
}
|
||||
|
||||
action, params, err := c.buildSendRequest(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
c.echoCounter++
|
||||
echo := fmt.Sprintf("send_%d", c.echoCounter)
|
||||
c.writeMu.Unlock()
|
||||
|
||||
req := oneBotAPIRequest{
|
||||
Action: action,
|
||||
Params: params,
|
||||
Echo: echo,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal OneBot request: %w", err)
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
err = conn.WriteMessage(websocket.TextMessage, data)
|
||||
c.writeMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
|
||||
chatID := msg.ChatID
|
||||
|
||||
if len(chatID) > 6 && chatID[:6] == "group:" {
|
||||
groupID, err := strconv.ParseInt(chatID[6:], 10, 64)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID)
|
||||
}
|
||||
return "send_group_msg", oneBotSendGroupMsgParams{
|
||||
GroupID: groupID,
|
||||
Message: msg.Content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(chatID) > 8 && chatID[:8] == "private:" {
|
||||
userID, err := strconv.ParseInt(chatID[8:], 10, 64)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID)
|
||||
}
|
||||
return "send_private_msg", oneBotSendPrivateMsgParams{
|
||||
UserID: userID,
|
||||
Message: msg.Content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseInt(chatID, 10, 64)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID)
|
||||
}
|
||||
|
||||
return "send_private_msg", oneBotSendPrivateMsgParams{
|
||||
UserID: userID,
|
||||
Message: msg.Content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) listen() {
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
|
||||
return
|
||||
}
|
||||
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{
|
||||
"length": len(message),
|
||||
"payload": string(message),
|
||||
})
|
||||
|
||||
var raw oneBotRawEvent
|
||||
if err := json.Unmarshal(message, &raw); err != nil {
|
||||
logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"payload": string(message),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if raw.Echo != "" || raw.Status.Online || raw.Status.Good {
|
||||
logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{
|
||||
"echo": raw.Echo,
|
||||
"status": raw.Status,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{
|
||||
"post_type": raw.PostType,
|
||||
"message_type": raw.MessageType,
|
||||
"sub_type": raw.SubType,
|
||||
"meta_event_type": raw.MetaEventType,
|
||||
})
|
||||
|
||||
c.handleRawEvent(&raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONInt64(raw json.RawMessage) (int64, error) {
|
||||
if len(raw) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var n int64
|
||||
if err := json.Unmarshal(raw, &n); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
return 0, fmt.Errorf("cannot parse as int64: %s", string(raw))
|
||||
}
|
||||
|
||||
func parseJSONString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
type parseMessageResult struct {
|
||||
Text string
|
||||
IsBotMentioned bool
|
||||
}
|
||||
|
||||
func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult {
|
||||
if len(raw) == 0 {
|
||||
return parseMessageResult{}
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
mentioned := false
|
||||
if selfID > 0 {
|
||||
cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
|
||||
if strings.Contains(s, cqAt) {
|
||||
mentioned = true
|
||||
s = strings.ReplaceAll(s, cqAt, "")
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
|
||||
}
|
||||
|
||||
var segments []map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &segments); err == nil {
|
||||
var text string
|
||||
mentioned := false
|
||||
selfIDStr := strconv.FormatInt(selfID, 10)
|
||||
for _, seg := range segments {
|
||||
segType, _ := seg["type"].(string)
|
||||
data, _ := seg["data"].(map[string]interface{})
|
||||
switch segType {
|
||||
case "text":
|
||||
if data != nil {
|
||||
if t, ok := data["text"].(string); ok {
|
||||
text += t
|
||||
}
|
||||
}
|
||||
case "at":
|
||||
if data != nil && selfID > 0 {
|
||||
qqVal := fmt.Sprintf("%v", data["qq"])
|
||||
if qqVal == selfIDStr || qqVal == "all" {
|
||||
mentioned = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned}
|
||||
}
|
||||
return parseMessageResult{}
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||
switch raw.PostType {
|
||||
case "message":
|
||||
evt, err := c.normalizeMessageEvent(raw)
|
||||
if err != nil {
|
||||
logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.handleMessage(evt)
|
||||
case "meta_event":
|
||||
c.handleMetaEvent(raw)
|
||||
case "notice":
|
||||
logger.DebugCF("onebot", "Notice event received", map[string]interface{}{
|
||||
"sub_type": raw.SubType,
|
||||
})
|
||||
case "request":
|
||||
logger.DebugCF("onebot", "Request event received", map[string]interface{}{
|
||||
"sub_type": raw.SubType,
|
||||
})
|
||||
case "":
|
||||
logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
|
||||
"echo": raw.Echo,
|
||||
"status": raw.Status,
|
||||
})
|
||||
default:
|
||||
logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
|
||||
"post_type": raw.PostType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) {
|
||||
userID, err := parseJSONInt64(raw.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID))
|
||||
}
|
||||
|
||||
groupID, _ := parseJSONInt64(raw.GroupID)
|
||||
selfID, _ := parseJSONInt64(raw.SelfID)
|
||||
ts, _ := parseJSONInt64(raw.Time)
|
||||
messageID := parseJSONString(raw.MessageID)
|
||||
|
||||
parsed := parseMessageContentEx(raw.Message, selfID)
|
||||
isBotMentioned := parsed.IsBotMentioned
|
||||
|
||||
content := raw.RawMessage
|
||||
if content == "" {
|
||||
content = parsed.Text
|
||||
} else if selfID > 0 {
|
||||
cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
|
||||
if strings.Contains(content, cqAt) {
|
||||
isBotMentioned = true
|
||||
content = strings.ReplaceAll(content, cqAt, "")
|
||||
content = strings.TrimSpace(content)
|
||||
}
|
||||
}
|
||||
|
||||
var sender oneBotSender
|
||||
if len(raw.Sender) > 0 {
|
||||
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
|
||||
logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"sender": string(raw.Sender),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{
|
||||
"message_type": raw.MessageType,
|
||||
"user_id": userID,
|
||||
"group_id": groupID,
|
||||
"message_id": messageID,
|
||||
"content_len": len(content),
|
||||
"nickname": sender.Nickname,
|
||||
})
|
||||
|
||||
return &oneBotEvent{
|
||||
PostType: raw.PostType,
|
||||
MessageType: raw.MessageType,
|
||||
SubType: raw.SubType,
|
||||
MessageID: messageID,
|
||||
UserID: userID,
|
||||
GroupID: groupID,
|
||||
Content: content,
|
||||
RawContent: raw.RawMessage,
|
||||
IsBotMentioned: isBotMentioned,
|
||||
Sender: sender,
|
||||
SelfID: selfID,
|
||||
Time: ts,
|
||||
MetaEventType: raw.MetaEventType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
|
||||
switch raw.MetaEventType {
|
||||
case "lifecycle":
|
||||
logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{
|
||||
"sub_type": raw.SubType,
|
||||
})
|
||||
case "heartbeat":
|
||||
logger.DebugC("onebot", "Heartbeat received")
|
||||
default:
|
||||
logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{
|
||||
"meta_event_type": raw.MetaEventType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
|
||||
if c.isDuplicate(evt.MessageID) {
|
||||
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
|
||||
"message_id": evt.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
content := evt.Content
|
||||
if content == "" {
|
||||
logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
|
||||
"message_id": evt.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := strconv.FormatInt(evt.UserID, 10)
|
||||
var chatID string
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_id": evt.MessageID,
|
||||
}
|
||||
|
||||
switch evt.MessageType {
|
||||
case "private":
|
||||
chatID = "private:" + senderID
|
||||
logger.InfoCF("onebot", "Received private message", map[string]interface{}{
|
||||
"sender": senderID,
|
||||
"message_id": evt.MessageID,
|
||||
"length": len(content),
|
||||
"content": truncate(content, 100),
|
||||
})
|
||||
|
||||
case "group":
|
||||
groupIDStr := strconv.FormatInt(evt.GroupID, 10)
|
||||
chatID = "group:" + groupIDStr
|
||||
metadata["group_id"] = groupIDStr
|
||||
|
||||
senderUserID, _ := parseJSONInt64(evt.Sender.UserID)
|
||||
if senderUserID > 0 {
|
||||
metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10)
|
||||
}
|
||||
|
||||
if evt.Sender.Card != "" {
|
||||
metadata["sender_name"] = evt.Sender.Card
|
||||
} else if evt.Sender.Nickname != "" {
|
||||
metadata["sender_name"] = evt.Sender.Nickname
|
||||
}
|
||||
|
||||
triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned)
|
||||
if !triggered {
|
||||
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
|
||||
"sender": senderID,
|
||||
"group": groupIDStr,
|
||||
"is_mentioned": evt.IsBotMentioned,
|
||||
"content": truncate(content, 100),
|
||||
})
|
||||
return
|
||||
}
|
||||
content = strippedContent
|
||||
|
||||
logger.InfoCF("onebot", "Received group message", map[string]interface{}{
|
||||
"sender": senderID,
|
||||
"group": groupIDStr,
|
||||
"message_id": evt.MessageID,
|
||||
"is_mentioned": evt.IsBotMentioned,
|
||||
"length": len(content),
|
||||
"content": truncate(content, 100),
|
||||
})
|
||||
|
||||
default:
|
||||
logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
|
||||
"type": evt.MessageType,
|
||||
"message_id": evt.MessageID,
|
||||
"user_id": evt.UserID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if evt.Sender.Nickname != "" {
|
||||
metadata["nickname"] = evt.Sender.Nickname
|
||||
}
|
||||
|
||||
logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"content": truncate(content, 100),
|
||||
})
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) isDuplicate(messageID string) bool {
|
||||
if messageID == "" || messageID == "0" {
|
||||
return false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if _, exists := c.dedup[messageID]; exists {
|
||||
return true
|
||||
}
|
||||
|
||||
if old := c.dedupRing[c.dedupIdx]; old != "" {
|
||||
delete(c.dedup, old)
|
||||
}
|
||||
c.dedupRing[c.dedupIdx] = messageID
|
||||
c.dedup[messageID] = struct{}{}
|
||||
c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "..."
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) {
|
||||
if isBotMentioned {
|
||||
return true, strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
for _, prefix := range c.config.GroupTriggerPrefix {
|
||||
if prefix == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(content, prefix) {
|
||||
return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
|
||||
}
|
||||
}
|
||||
|
||||
return false, content
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tencent-connect/botgo"
|
||||
"github.com/tencent-connect/botgo/dto"
|
||||
"github.com/tencent-connect/botgo/event"
|
||||
"github.com/tencent-connect/botgo/openapi"
|
||||
"github.com/tencent-connect/botgo/token"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
type QQChannel struct {
|
||||
*BaseChannel
|
||||
config config.QQConfig
|
||||
api openapi.OpenAPI
|
||||
tokenSource oauth2.TokenSource
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
sessionManager botgo.SessionManager
|
||||
processedIDs map[string]bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
|
||||
base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom)
|
||||
|
||||
return &QQChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
processedIDs: make(map[string]bool),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *QQChannel) Start(ctx context.Context) error {
|
||||
if c.config.AppID == "" || c.config.AppSecret == "" {
|
||||
return fmt.Errorf("QQ app_id and app_secret not configured")
|
||||
}
|
||||
|
||||
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
|
||||
|
||||
// 创建 token source
|
||||
credentials := &token.QQBotCredentials{
|
||||
AppID: c.config.AppID,
|
||||
AppSecret: c.config.AppSecret,
|
||||
}
|
||||
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
||||
|
||||
// 创建子 context
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// 启动自动刷新 token 协程
|
||||
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
|
||||
return fmt.Errorf("failed to start token refresh: %w", err)
|
||||
}
|
||||
|
||||
// 初始化 OpenAPI 客户端
|
||||
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
|
||||
|
||||
// 注册事件处理器
|
||||
intent := event.RegisterHandlers(
|
||||
c.handleC2CMessage(),
|
||||
c.handleGroupATMessage(),
|
||||
)
|
||||
|
||||
// 获取 WebSocket 接入点
|
||||
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{}{
|
||||
"shards": wsInfo.Shards,
|
||||
})
|
||||
|
||||
// 创建并保存 sessionManager
|
||||
c.sessionManager = botgo.NewSessionManager()
|
||||
|
||||
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
|
||||
go func() {
|
||||
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
|
||||
logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
c.setRunning(false)
|
||||
}
|
||||
}()
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoC("qq", "QQ bot started successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *QQChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("qq", "Stopping QQ bot")
|
||||
c.setRunning(false)
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("QQ bot not running")
|
||||
}
|
||||
|
||||
// 构造消息
|
||||
msgToCreate := &dto.MessageToCreate{
|
||||
Content: msg.Content,
|
||||
}
|
||||
|
||||
// C2C 消息发送
|
||||
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
||||
if err != nil {
|
||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleC2CMessage 处理 QQ 私聊消息
|
||||
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||
// 去重检查
|
||||
if c.isDuplicate(data.ID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 提取用户信息
|
||||
var senderID string
|
||||
if data.Author != nil && data.Author.ID != "" {
|
||||
senderID = data.Author.ID
|
||||
} else {
|
||||
logger.WarnC("qq", "Received message with no sender ID")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 提取消息内容
|
||||
content := data.Content
|
||||
if content == "" {
|
||||
logger.DebugC("qq", "Received empty message, ignoring")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.InfoCF("qq", "Received C2C message", map[string]interface{}{
|
||||
"sender": senderID,
|
||||
"length": len(content),
|
||||
})
|
||||
|
||||
// 转发到消息总线
|
||||
metadata := map[string]string{
|
||||
"message_id": data.ID,
|
||||
}
|
||||
|
||||
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleGroupATMessage 处理群@消息
|
||||
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||
// 去重检查
|
||||
if c.isDuplicate(data.ID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 提取用户信息
|
||||
var senderID string
|
||||
if data.Author != nil && data.Author.ID != "" {
|
||||
senderID = data.Author.ID
|
||||
} else {
|
||||
logger.WarnC("qq", "Received group message with no sender ID")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 提取消息内容(去掉 @ 机器人部分)
|
||||
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{}{
|
||||
"sender": senderID,
|
||||
"group": data.GroupID,
|
||||
"length": len(content),
|
||||
})
|
||||
|
||||
// 转发到消息总线(使用 GroupID 作为 ChatID)
|
||||
metadata := map[string]string{
|
||||
"message_id": data.ID,
|
||||
"group_id": data.GroupID,
|
||||
}
|
||||
|
||||
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// isDuplicate 检查消息是否重复
|
||||
func (c *QQChannel) isDuplicate(messageID string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.processedIDs[messageID] {
|
||||
return true
|
||||
}
|
||||
|
||||
c.processedIDs[messageID] = true
|
||||
|
||||
// 简单清理:限制 map 大小
|
||||
if len(c.processedIDs) > 10000 {
|
||||
// 清空一半
|
||||
count := 0
|
||||
for id := range c.processedIDs {
|
||||
if count >= 5000 {
|
||||
break
|
||||
}
|
||||
delete(c.processedIDs, id)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
type SlackChannel struct {
|
||||
*BaseChannel
|
||||
config config.SlackConfig
|
||||
api *slack.Client
|
||||
socketClient *socketmode.Client
|
||||
botUserID string
|
||||
transcriber *voice.GroqTranscriber
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
pendingAcks sync.Map
|
||||
}
|
||||
|
||||
type slackMessageRef struct {
|
||||
ChannelID string
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
|
||||
if cfg.BotToken == "" || cfg.AppToken == "" {
|
||||
return nil, fmt.Errorf("slack bot_token and app_token are required")
|
||||
}
|
||||
|
||||
api := slack.New(
|
||||
cfg.BotToken,
|
||||
slack.OptionAppLevelToken(cfg.AppToken),
|
||||
)
|
||||
|
||||
socketClient := socketmode.New(api)
|
||||
|
||||
base := NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom)
|
||||
|
||||
return &SlackChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
api: api,
|
||||
socketClient: socketClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||
c.transcriber = transcriber
|
||||
}
|
||||
|
||||
func (c *SlackChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("slack", "Starting Slack channel (Socket Mode)")
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
authResp, err := c.api.AuthTest()
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack auth test failed: %w", err)
|
||||
}
|
||||
c.botUserID = authResp.UserID
|
||||
|
||||
logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
|
||||
"bot_user_id": c.botUserID,
|
||||
"team": authResp.Team,
|
||||
})
|
||||
|
||||
go c.eventLoop()
|
||||
|
||||
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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoC("slack", "Slack channel started (Socket Mode)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SlackChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("slack", "Stopping Slack channel")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
c.setRunning(false)
|
||||
logger.InfoC("slack", "Slack channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("slack channel not running")
|
||||
}
|
||||
|
||||
channelID, threadTS := parseSlackChatID(msg.ChatID)
|
||||
if channelID == "" {
|
||||
return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
||||
}
|
||||
|
||||
opts := []slack.MsgOption{
|
||||
slack.MsgOptionText(msg.Content, false),
|
||||
}
|
||||
|
||||
if threadTS != "" {
|
||||
opts = append(opts, slack.MsgOptionTS(threadTS))
|
||||
}
|
||||
|
||||
_, _, err := c.api.PostMessageContext(ctx, channelID, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send slack message: %w", err)
|
||||
}
|
||||
|
||||
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
|
||||
msgRef := ref.(slackMessageRef)
|
||||
c.api.AddReaction("white_check_mark", slack.ItemRef{
|
||||
Channel: msgRef.ChannelID,
|
||||
Timestamp: msgRef.Timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Message sent", map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"thread_ts": threadTS,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *SlackChannel) eventLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case event, ok := <-c.socketClient.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch event.Type {
|
||||
case socketmode.EventTypeEventsAPI:
|
||||
c.handleEventsAPI(event)
|
||||
case socketmode.EventTypeSlashCommand:
|
||||
c.handleSlashCommand(event)
|
||||
case socketmode.EventTypeInteractive:
|
||||
if event.Request != nil {
|
||||
c.socketClient.Ack(*event.Request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SlackChannel) handleEventsAPI(event socketmode.Event) {
|
||||
if event.Request != nil {
|
||||
c.socketClient.Ack(*event.Request)
|
||||
}
|
||||
|
||||
eventsAPIEvent, ok := event.Data.(slackevents.EventsAPIEvent)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch ev := eventsAPIEvent.InnerEvent.Data.(type) {
|
||||
case *slackevents.MessageEvent:
|
||||
c.handleMessageEvent(ev)
|
||||
case *slackevents.AppMentionEvent:
|
||||
c.handleAppMention(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
|
||||
if ev.User == c.botUserID || ev.User == "" {
|
||||
return
|
||||
}
|
||||
if ev.BotID != "" {
|
||||
return
|
||||
}
|
||||
if ev.SubType != "" && ev.SubType != "file_share" {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(ev.User) {
|
||||
logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{
|
||||
"user_id": ev.User,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := ev.User
|
||||
channelID := ev.Channel
|
||||
threadTS := ev.ThreadTimeStamp
|
||||
messageTS := ev.TimeStamp
|
||||
|
||||
chatID := channelID
|
||||
if threadTS != "" {
|
||||
chatID = channelID + "/" + threadTS
|
||||
}
|
||||
|
||||
c.api.AddReaction("eyes", slack.ItemRef{
|
||||
Channel: channelID,
|
||||
Timestamp: messageTS,
|
||||
})
|
||||
|
||||
c.pendingAcks.Store(chatID, slackMessageRef{
|
||||
ChannelID: channelID,
|
||||
Timestamp: messageTS,
|
||||
})
|
||||
|
||||
content := ev.Text
|
||||
content = c.stripBotMention(content)
|
||||
|
||||
var mediaPaths []string
|
||||
localFiles := []string{} // 跟踪需要清理的本地文件
|
||||
|
||||
// 确保临时文件在函数返回时被清理
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if ev.Message != nil && len(ev.Message.Files) > 0 {
|
||||
for _, file := range ev.Message.Files {
|
||||
localPath := c.downloadSlackFile(file)
|
||||
if localPath == "" {
|
||||
continue
|
||||
}
|
||||
localFiles = append(localFiles, localPath)
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
|
||||
if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
result, err := c.transcriber.Transcribe(ctx, localPath)
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()})
|
||||
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
|
||||
} else {
|
||||
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
|
||||
}
|
||||
} else {
|
||||
content += fmt.Sprintf("\n[file: %s]", file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_ts": messageTS,
|
||||
"channel_id": channelID,
|
||||
"thread_ts": threadTS,
|
||||
"platform": "slack",
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Received message", map[string]interface{}{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
"has_thread": threadTS != "",
|
||||
})
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
||||
}
|
||||
|
||||
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
|
||||
if ev.User == c.botUserID {
|
||||
return
|
||||
}
|
||||
|
||||
if !c.IsAllowed(ev.User) {
|
||||
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{
|
||||
"user_id": ev.User,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := ev.User
|
||||
channelID := ev.Channel
|
||||
threadTS := ev.ThreadTimeStamp
|
||||
messageTS := ev.TimeStamp
|
||||
|
||||
var chatID string
|
||||
if threadTS != "" {
|
||||
chatID = channelID + "/" + threadTS
|
||||
} else {
|
||||
chatID = channelID + "/" + messageTS
|
||||
}
|
||||
|
||||
c.api.AddReaction("eyes", slack.ItemRef{
|
||||
Channel: channelID,
|
||||
Timestamp: messageTS,
|
||||
})
|
||||
|
||||
c.pendingAcks.Store(chatID, slackMessageRef{
|
||||
ChannelID: channelID,
|
||||
Timestamp: messageTS,
|
||||
})
|
||||
|
||||
content := c.stripBotMention(ev.Text)
|
||||
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_ts": messageTS,
|
||||
"channel_id": channelID,
|
||||
"thread_ts": threadTS,
|
||||
"platform": "slack",
|
||||
"is_mention": "true",
|
||||
}
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||
}
|
||||
|
||||
func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
|
||||
cmd, ok := event.Data.(slack.SlashCommand)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if event.Request != nil {
|
||||
c.socketClient.Ack(*event.Request)
|
||||
}
|
||||
|
||||
if !c.IsAllowed(cmd.UserID) {
|
||||
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{
|
||||
"user_id": cmd.UserID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := cmd.UserID
|
||||
channelID := cmd.ChannelID
|
||||
chatID := channelID
|
||||
content := cmd.Text
|
||||
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = "help"
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"channel_id": channelID,
|
||||
"platform": "slack",
|
||||
"is_command": "true",
|
||||
"trigger_id": cmd.TriggerID,
|
||||
}
|
||||
|
||||
logger.DebugCF("slack", "Slash command received", map[string]interface{}{
|
||||
"sender_id": senderID,
|
||||
"command": cmd.Command,
|
||||
"text": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||
}
|
||||
|
||||
func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
||||
downloadURL := file.URLPrivateDownload
|
||||
if downloadURL == "" {
|
||||
downloadURL = file.URLPrivate
|
||||
}
|
||||
if downloadURL == "" {
|
||||
logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID})
|
||||
return ""
|
||||
}
|
||||
|
||||
return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
|
||||
LoggerPrefix: "slack",
|
||||
ExtraHeaders: map[string]string{
|
||||
"Authorization": "Bearer " + c.config.BotToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *SlackChannel) stripBotMention(text string) string {
|
||||
mention := fmt.Sprintf("<@%s>", c.botUserID)
|
||||
text = strings.ReplaceAll(text, mention, "")
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func parseSlackChatID(chatID string) (channelID, threadTS string) {
|
||||
parts := strings.SplitN(chatID, "/", 2)
|
||||
channelID = parts[0]
|
||||
if len(parts) > 1 {
|
||||
threadTS = parts[1]
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestParseSlackChatID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
chatID string
|
||||
wantChanID string
|
||||
wantThread string
|
||||
}{
|
||||
{
|
||||
name: "channel only",
|
||||
chatID: "C123456",
|
||||
wantChanID: "C123456",
|
||||
wantThread: "",
|
||||
},
|
||||
{
|
||||
name: "channel with thread",
|
||||
chatID: "C123456/1234567890.123456",
|
||||
wantChanID: "C123456",
|
||||
wantThread: "1234567890.123456",
|
||||
},
|
||||
{
|
||||
name: "DM channel",
|
||||
chatID: "D987654",
|
||||
wantChanID: "D987654",
|
||||
wantThread: "",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
chatID: "",
|
||||
wantChanID: "",
|
||||
wantThread: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
chanID, threadTS := parseSlackChatID(tt.chatID)
|
||||
if chanID != tt.wantChanID {
|
||||
t.Errorf("parseSlackChatID(%q) channelID = %q, want %q", tt.chatID, chanID, tt.wantChanID)
|
||||
}
|
||||
if threadTS != tt.wantThread {
|
||||
t.Errorf("parseSlackChatID(%q) threadTS = %q, want %q", tt.chatID, threadTS, tt.wantThread)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripBotMention(t *testing.T) {
|
||||
ch := &SlackChannel{botUserID: "U12345BOT"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "mention at start",
|
||||
input: "<@U12345BOT> hello there",
|
||||
want: "hello there",
|
||||
},
|
||||
{
|
||||
name: "mention in middle",
|
||||
input: "hey <@U12345BOT> can you help",
|
||||
want: "hey can you help",
|
||||
},
|
||||
{
|
||||
name: "no mention",
|
||||
input: "hello world",
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "only mention",
|
||||
input: "<@U12345BOT>",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ch.stripBotMention(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stripBotMention(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSlackChannel(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("missing bot token", func(t *testing.T) {
|
||||
cfg := config.SlackConfig{
|
||||
BotToken: "",
|
||||
AppToken: "xapp-test",
|
||||
}
|
||||
_, err := NewSlackChannel(cfg, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing bot_token, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing app token", func(t *testing.T) {
|
||||
cfg := config.SlackConfig{
|
||||
BotToken: "xoxb-test",
|
||||
AppToken: "",
|
||||
}
|
||||
_, err := NewSlackChannel(cfg, msgBus)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing app_token, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
cfg := config.SlackConfig{
|
||||
BotToken: "xoxb-test",
|
||||
AppToken: "xapp-test",
|
||||
AllowFrom: []string{"U123"},
|
||||
}
|
||||
ch, err := NewSlackChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ch.Name() != "slack" {
|
||||
t.Errorf("Name() = %q, want %q", ch.Name(), "slack")
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Error("new channel should not be running")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlackChannelIsAllowed(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("empty allowlist allows all", func(t *testing.T) {
|
||||
cfg := config.SlackConfig{
|
||||
BotToken: "xoxb-test",
|
||||
AppToken: "xapp-test",
|
||||
AllowFrom: []string{},
|
||||
}
|
||||
ch, _ := NewSlackChannel(cfg, msgBus)
|
||||
if !ch.IsAllowed("U_ANYONE") {
|
||||
t.Error("empty allowlist should allow all users")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlist restricts users", func(t *testing.T) {
|
||||
cfg := config.SlackConfig{
|
||||
BotToken: "xoxb-test",
|
||||
AppToken: "xapp-test",
|
||||
AllowFrom: []string{"U_ALLOWED"},
|
||||
}
|
||||
ch, _ := NewSlackChannel(cfg, msgBus)
|
||||
if !ch.IsAllowed("U_ALLOWED") {
|
||||
t.Error("allowed user should pass allowlist check")
|
||||
}
|
||||
if ch.IsAllowed("U_BLOCKED") {
|
||||
t.Error("non-allowed user should be blocked")
|
||||
}
|
||||
})
|
||||
}
|
||||
+229
-114
@@ -3,41 +3,80 @@ package channels
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/mymmrac/telego/telegohandler"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
type TelegramChannel struct {
|
||||
*BaseChannel
|
||||
bot *tgbotapi.BotAPI
|
||||
config config.TelegramConfig
|
||||
chatIDs map[string]int64
|
||||
updates tgbotapi.UpdatesChannel
|
||||
transcriber *voice.GroqTranscriber
|
||||
bot *telego.Bot
|
||||
commands TelegramCommander
|
||||
config *config.Config
|
||||
chatIDs map[string]int64
|
||||
transcriber *voice.GroqTranscriber
|
||||
placeholders sync.Map // chatID -> messageID
|
||||
stopThinking sync.Map // chatID -> thinkingCancel
|
||||
}
|
||||
|
||||
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||
bot, err := tgbotapi.NewBotAPI(cfg.Token)
|
||||
type thinkingCancel struct {
|
||||
fn context.CancelFunc
|
||||
}
|
||||
|
||||
func (c *thinkingCancel) Cancel() {
|
||||
if c != nil && c.fn != nil {
|
||||
c.fn()
|
||||
}
|
||||
}
|
||||
|
||||
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||
var opts []telego.BotOption
|
||||
telegramCfg := cfg.Channels.Telegram
|
||||
|
||||
if telegramCfg.Proxy != "" {
|
||||
proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
|
||||
if parseErr != nil {
|
||||
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),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
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,
|
||||
bot: bot,
|
||||
config: cfg,
|
||||
chatIDs: make(map[string]int64),
|
||||
transcriber: nil,
|
||||
BaseChannel: base,
|
||||
commands: NewTelegramCommands(bot, cfg),
|
||||
bot: bot,
|
||||
config: cfg,
|
||||
chatIDs: make(map[string]int64),
|
||||
transcriber: nil,
|
||||
placeholders: sync.Map{},
|
||||
stopThinking: sync.Map{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -46,51 +85,57 @@ func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||
log.Printf("Starting Telegram bot (polling mode)...")
|
||||
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 30
|
||||
updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{
|
||||
Timeout: 30,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start long polling: %w", err)
|
||||
}
|
||||
|
||||
updates := c.bot.GetUpdatesChan(u)
|
||||
c.updates = updates
|
||||
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{}{
|
||||
"username": c.bot.Username(),
|
||||
})
|
||||
|
||||
botInfo, err := c.bot.GetMe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get bot info: %w", err)
|
||||
}
|
||||
log.Printf("Telegram bot @%s connected", botInfo.UserName)
|
||||
go bh.Start()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case update, ok := <-updates:
|
||||
if !ok {
|
||||
log.Printf("Updates channel closed, reconnecting...")
|
||||
return
|
||||
}
|
||||
if update.Message != nil {
|
||||
c.handleMessage(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
<-ctx.Done()
|
||||
bh.Stop()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||
log.Println("Stopping Telegram bot...")
|
||||
logger.InfoC("telegram", "Stopping Telegram bot...")
|
||||
c.setRunning(false)
|
||||
|
||||
if c.updates != nil {
|
||||
c.bot.StopReceivingUpdates()
|
||||
c.updates = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,36 +149,64 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||
return fmt.Errorf("invalid chat ID: %w", err)
|
||||
}
|
||||
|
||||
// Stop thinking animation
|
||||
if stop, ok := c.stopThinking.Load(msg.ChatID); ok {
|
||||
if cf, ok := stop.(*thinkingCancel); ok && cf != nil {
|
||||
cf.Cancel()
|
||||
}
|
||||
c.stopThinking.Delete(msg.ChatID)
|
||||
}
|
||||
|
||||
htmlContent := markdownToTelegramHTML(msg.Content)
|
||||
|
||||
tgMsg := tgbotapi.NewMessage(chatID, htmlContent)
|
||||
tgMsg.ParseMode = tgbotapi.ModeHTML
|
||||
// Try to edit placeholder
|
||||
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
|
||||
c.placeholders.Delete(msg.ChatID)
|
||||
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
|
||||
editMsg.ParseMode = telego.ModeHTML
|
||||
|
||||
if _, err := c.bot.Send(tgMsg); err != nil {
|
||||
log.Printf("HTML parse failed, falling back to plain text: %v", err)
|
||||
tgMsg = tgbotapi.NewMessage(chatID, msg.Content)
|
||||
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
|
||||
return nil
|
||||
}
|
||||
// Fallback to new message if edit fails
|
||||
}
|
||||
|
||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||
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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
tgMsg.ParseMode = ""
|
||||
_, err = c.bot.Send(tgMsg)
|
||||
_, err = c.bot.SendMessage(ctx, tgMsg)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleMessage(update tgbotapi.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")
|
||||
}
|
||||
|
||||
senderID := fmt.Sprintf("%d", user.ID)
|
||||
if user.UserName != "" {
|
||||
senderID = fmt.Sprintf("%d|%s", user.ID, user.UserName)
|
||||
if user.Username != "" {
|
||||
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件
|
||||
if !c.IsAllowed(senderID) {
|
||||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
|
||||
"user_id": senderID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
chatID := message.Chat.ID
|
||||
@@ -141,6 +214,19 @@ func (c *TelegramChannel) handleMessage(update tgbotapi.Update) {
|
||||
|
||||
content := ""
|
||||
mediaPaths := []string{}
|
||||
localFiles := []string{} // 跟踪需要清理的本地文件
|
||||
|
||||
// 确保临时文件在函数返回时被清理
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if message.Text != "" {
|
||||
content += message.Text
|
||||
@@ -153,38 +239,45 @@ func (c *TelegramChannel) handleMessage(update tgbotapi.Update) {
|
||||
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(photo.FileID)
|
||||
photoPath := c.downloadPhoto(ctx, photo.FileID)
|
||||
if photoPath != "" {
|
||||
localFiles = append(localFiles, photoPath)
|
||||
mediaPaths = append(mediaPaths, photoPath)
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += fmt.Sprintf("[image: %s]", photoPath)
|
||||
content += "[image: photo]"
|
||||
}
|
||||
}
|
||||
|
||||
if message.Voice != nil {
|
||||
voicePath := c.downloadFile(message.Voice.FileID, ".ogg")
|
||||
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
|
||||
if voicePath != "" {
|
||||
localFiles = append(localFiles, voicePath)
|
||||
mediaPaths = append(mediaPaths, voicePath)
|
||||
|
||||
transcribedText := ""
|
||||
if c.transcriber != nil && c.transcriber.IsAvailable() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := c.transcriber.Transcribe(ctx, voicePath)
|
||||
if err != nil {
|
||||
log.Printf("Voice transcription failed: %v", err)
|
||||
transcribedText = fmt.Sprintf("[voice: %s (transcription failed)]", voicePath)
|
||||
logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"path": voicePath,
|
||||
})
|
||||
transcribedText = "[voice (transcription failed)]"
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
|
||||
log.Printf("Voice transcribed successfully: %s", result.Text)
|
||||
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
|
||||
"text": result.Text,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[voice: %s]", voicePath)
|
||||
transcribedText = "[voice]"
|
||||
}
|
||||
|
||||
if content != "" {
|
||||
@@ -195,24 +288,26 @@ func (c *TelegramChannel) handleMessage(update tgbotapi.Update) {
|
||||
}
|
||||
|
||||
if message.Audio != nil {
|
||||
audioPath := c.downloadFile(message.Audio.FileID, ".mp3")
|
||||
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
|
||||
if audioPath != "" {
|
||||
localFiles = append(localFiles, audioPath)
|
||||
mediaPaths = append(mediaPaths, audioPath)
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += fmt.Sprintf("[audio: %s]", audioPath)
|
||||
content += "[audio]"
|
||||
}
|
||||
}
|
||||
|
||||
if message.Document != nil {
|
||||
docPath := c.downloadFile(message.Document.FileID, "")
|
||||
docPath := c.downloadFile(ctx, message.Document.FileID, "")
|
||||
if docPath != "" {
|
||||
localFiles = append(localFiles, docPath)
|
||||
mediaPaths = append(mediaPaths, docPath)
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += fmt.Sprintf("[file: %s]", docPath)
|
||||
content += "[file]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,66 +315,87 @@ func (c *TelegramChannel) handleMessage(update tgbotapi.Update) {
|
||||
content = "[empty message]"
|
||||
}
|
||||
|
||||
log.Printf("Telegram message from %s: %s...", senderID, truncateString(content, 50))
|
||||
logger.DebugCF("telegram", "Received message", map[string]interface{}{
|
||||
"sender_id": senderID,
|
||||
"chat_id": fmt.Sprintf("%d", chatID),
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// 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{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Stop any previous thinking animation
|
||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
|
||||
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
|
||||
cf.Cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Create cancel function for thinking state
|
||||
_, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
|
||||
|
||||
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
|
||||
if err == nil {
|
||||
pID := pMsg.MessageID
|
||||
c.placeholders.Store(chatIDStr, pID)
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_id": fmt.Sprintf("%d", message.MessageID),
|
||||
"user_id": fmt.Sprintf("%d", user.ID),
|
||||
"username": user.UserName,
|
||||
"username": user.Username,
|
||||
"first_name": user.FirstName,
|
||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||
}
|
||||
|
||||
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(fileID string) string {
|
||||
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
||||
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
|
||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get photo file: %v", err)
|
||||
logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
return c.downloadFileWithInfo(&file, ".jpg")
|
||||
return c.downloadFileWithInfo(file, ".jpg")
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) downloadFileWithInfo(file *tgbotapi.File, ext string) string {
|
||||
func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string {
|
||||
if file.FilePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
url := file.Link(c.bot.Token)
|
||||
log.Printf("File URL: %s", url)
|
||||
url := c.bot.FileDownloadURL(file.FilePath)
|
||||
logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url})
|
||||
|
||||
mediaDir := "/tmp/picoclaw_media"
|
||||
|
||||
return fmt.Sprintf("%s/%s%s", mediaDir, file.FilePath[:min(16, len(file.FilePath))], ext)
|
||||
// Use FilePath as filename for better identification
|
||||
filename := file.FilePath + ext
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "telegram",
|
||||
})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) downloadFile(fileID, ext string) string {
|
||||
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
|
||||
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
|
||||
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get file: %v", err)
|
||||
logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ""
|
||||
}
|
||||
|
||||
if file.FilePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
url := file.Link(c.bot.Token)
|
||||
log.Printf("File URL: %s", url)
|
||||
|
||||
mediaDir := "/tmp/picoclaw_media"
|
||||
|
||||
return fmt.Sprintf("%s/%s%s", mediaDir, fileID[:16], ext)
|
||||
return c.downloadFileWithInfo(file, ext)
|
||||
}
|
||||
|
||||
func parseChatID(chatIDStr string) (int64, error) {
|
||||
@@ -288,13 +404,6 @@ func parseChatID(chatIDStr string) (int64, error) {
|
||||
return id, err
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen]
|
||||
}
|
||||
|
||||
func markdownToTelegramHTML(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
@@ -358,8 +467,11 @@ func extractCodeBlocks(text string) codeBlockMatch {
|
||||
codes = append(codes, match[1])
|
||||
}
|
||||
|
||||
i := 0
|
||||
text = re.ReplaceAllStringFunc(text, func(m string) string {
|
||||
return fmt.Sprintf("\x00CB%d\x00", len(codes)-1)
|
||||
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
|
||||
i++
|
||||
return placeholder
|
||||
})
|
||||
|
||||
return codeBlockMatch{text: text, codes: codes}
|
||||
@@ -379,8 +491,11 @@ func extractInlineCodes(text string) inlineCodeMatch {
|
||||
codes = append(codes, match[1])
|
||||
}
|
||||
|
||||
i := 0
|
||||
text = re.ReplaceAllStringFunc(text, func(m string) string {
|
||||
return fmt.Sprintf("\x00IC%d\x00", len(codes)-1)
|
||||
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
|
||||
i++
|
||||
return placeholder
|
||||
})
|
||||
|
||||
return inlineCodeMatch{text: text, codes: codes}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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.Model,
|
||||
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.Model, 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
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
type WhatsAppChannel struct {
|
||||
@@ -177,7 +178,7 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
|
||||
metadata["user_name"] = userName
|
||||
}
|
||||
|
||||
log.Printf("WhatsApp message from %s: %s...", senderID, truncateString(content, 50))
|
||||
log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50))
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
||||
}
|
||||
|
||||
+238
-57
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -9,12 +10,47 @@ import (
|
||||
"github.com/caarlos0/env/v11"
|
||||
)
|
||||
|
||||
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
||||
// so allow_from can contain both "123" and 123.
|
||||
type FlexibleStringSlice []string
|
||||
|
||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||
// Try []string first
|
||||
var ss []string
|
||||
if err := json.Unmarshal(data, &ss); err == nil {
|
||||
*f = ss
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try []interface{} to handle mixed types
|
||||
var raw []interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
result = append(result, val)
|
||||
case float64:
|
||||
result = append(result, fmt.Sprintf("%.0f", val))
|
||||
default:
|
||||
result = append(result, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
*f = result
|
||||
return nil
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Agents AgentsConfig `json:"agents"`
|
||||
Channels ChannelsConfig `json:"channels"`
|
||||
Providers ProvidersConfig `json:"providers"`
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||
Devices DevicesConfig `json:"devices"`
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -23,11 +59,13 @@ type AgentsConfig struct {
|
||||
}
|
||||
|
||||
type AgentDefaults struct {
|
||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type ChannelsConfig struct {
|
||||
@@ -36,55 +74,120 @@ type ChannelsConfig struct {
|
||||
Feishu FeishuConfig `json:"feishu"`
|
||||
Discord DiscordConfig `json:"discord"`
|
||||
MaixCam MaixCamConfig `json:"maixcam"`
|
||||
QQ QQConfig `json:"qq"`
|
||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||
Slack SlackConfig `json:"slack"`
|
||||
LINE LINEConfig `json:"line"`
|
||||
OneBot OneBotConfig `json:"onebot"`
|
||||
}
|
||||
|
||||
type WhatsAppConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
||||
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||
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"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||
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"`
|
||||
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||
AllowFrom []string `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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
|
||||
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"`
|
||||
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"`
|
||||
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
Ollama ProviderConfig `json:"ollama"`
|
||||
Moonshot ProviderConfig `json:"moonshot"`
|
||||
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
||||
DeepSeek ProviderConfig `json:"deepseek"`
|
||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||
}
|
||||
|
||||
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"`
|
||||
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 GatewayConfig struct {
|
||||
@@ -92,40 +195,58 @@ type GatewayConfig struct {
|
||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||
}
|
||||
|
||||
type WebSearchConfig struct {
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"`
|
||||
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"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type DuckDuckGoConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type WebToolsConfig struct {
|
||||
Search WebSearchConfig `json:"search"`
|
||||
Brave BraveConfig `json:"brave"`
|
||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||
}
|
||||
|
||||
type BrowserConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_BROWSER_ENABLED"`
|
||||
Session string `json:"session" env:"PICOCLAW_TOOLS_BROWSER_SESSION"`
|
||||
Headless bool `json:"headless" env:"PICOCLAW_TOOLS_BROWSER_HEADLESS"`
|
||||
Timeout int `json:"timeout" env:"PICOCLAW_TOOLS_BROWSER_TIMEOUT"`
|
||||
CDPPort int `json:"cdp_port" env:"PICOCLAW_TOOLS_BROWSER_CDP_PORT"`
|
||||
}
|
||||
|
||||
type ToolsConfig struct {
|
||||
Web WebToolsConfig `json:"web"`
|
||||
Web WebToolsConfig `json:"web"`
|
||||
Browser BrowserConfig `json:"browser"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Workspace: "~/.picoclaw/workspace",
|
||||
Model: "glm-4.7",
|
||||
MaxTokens: 8192,
|
||||
Temperature: 0.7,
|
||||
MaxToolIterations: 20,
|
||||
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: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Telegram: TelegramConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Feishu: FeishuConfig{
|
||||
Enabled: false,
|
||||
@@ -133,28 +254,66 @@ func DefaultConfig() *Config {
|
||||
AppSecret: "",
|
||||
EncryptKey: "",
|
||||
VerificationToken: "",
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Discord: DiscordConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
MaixCam: MaixCamConfig{
|
||||
Enabled: false,
|
||||
Host: "0.0.0.0",
|
||||
Port: 18790,
|
||||
AllowFrom: []string{},
|
||||
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{},
|
||||
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",
|
||||
@@ -162,11 +321,30 @@ func DefaultConfig() *Config {
|
||||
},
|
||||
Tools: ToolsConfig{
|
||||
Web: WebToolsConfig{
|
||||
Search: WebSearchConfig{
|
||||
Brave: BraveConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
MaxResults: 5,
|
||||
},
|
||||
DuckDuckGo: DuckDuckGoConfig{
|
||||
Enabled: true,
|
||||
MaxResults: 5,
|
||||
},
|
||||
},
|
||||
Browser: BrowserConfig{
|
||||
Enabled: false,
|
||||
Headless: true,
|
||||
Timeout: 30,
|
||||
CDPPort: 9222,
|
||||
},
|
||||
},
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Enabled: true,
|
||||
Interval: 30, // default 30 minutes
|
||||
},
|
||||
Devices: DevicesConfig{
|
||||
Enabled: false,
|
||||
MonitorUSB: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -207,7 +385,7 @@ func SaveConfig(path string, cfg *Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func (c *Config) WorkspacePath() string {
|
||||
@@ -240,6 +418,9 @@ func (c *Config) GetAPIKey() string {
|
||||
if c.Providers.VLLM.APIKey != "" {
|
||||
return c.Providers.VLLM.APIKey
|
||||
}
|
||||
if c.Providers.ShengSuanYun.APIKey != "" {
|
||||
return c.Providers.ShengSuanYun.APIKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default
|
||||
func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if !cfg.Heartbeat.Enabled {
|
||||
t.Error("Heartbeat should be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_WorkspacePath verifies workspace path is correctly set
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Model verifies model is set
|
||||
func TestDefaultConfig_Model(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.Model == "" {
|
||||
t.Error("Model should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_MaxTokens verifies max tokens has default value
|
||||
func TestDefaultConfig_MaxTokens(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.MaxTokens == 0 {
|
||||
t.Error("MaxTokens should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_MaxToolIterations verifies max tool iterations has default value
|
||||
func TestDefaultConfig_MaxToolIterations(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
||||
t.Error("MaxToolIterations should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Temperature verifies temperature has default value
|
||||
func TestDefaultConfig_Temperature(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.Temperature == 0 {
|
||||
t.Error("Temperature should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Gateway verifies gateway defaults
|
||||
func TestDefaultConfig_Gateway(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Gateway.Host != "0.0.0.0" {
|
||||
t.Error("Gateway host should have default value")
|
||||
}
|
||||
if cfg.Gateway.Port == 0 {
|
||||
t.Error("Gateway port should have default value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Providers verifies provider structure
|
||||
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")
|
||||
}
|
||||
if cfg.Providers.OpenAI.APIKey != "" {
|
||||
t.Error("OpenAI API key should be empty by default")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_WebTools verifies web tools config
|
||||
func TestDefaultConfig_WebTools(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Verify web tools defaults
|
||||
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
||||
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
||||
}
|
||||
if cfg.Tools.Web.Brave.APIKey != "" {
|
||||
t.Error("Brave API key should be empty by default")
|
||||
}
|
||||
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
||||
t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults)
|
||||
}
|
||||
}
|
||||
|
||||
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 != 0600 {
|
||||
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.MaxTokens == 0 {
|
||||
t.Error("MaxTokens should not be zero")
|
||||
}
|
||||
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
||||
t.Error("MaxToolIterations should not be zero")
|
||||
}
|
||||
if cfg.Gateway.Host != "0.0.0.0" {
|
||||
t.Error("Gateway host should have default value")
|
||||
}
|
||||
if cfg.Gateway.Port == 0 {
|
||||
t.Error("Gateway port should have default value")
|
||||
}
|
||||
if !cfg.Heartbeat.Enabled {
|
||||
t.Error("Heartbeat should be enabled by default")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Package constants provides shared constants across the codebase.
|
||||
package constants
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
// IsInternalChannel returns true if the channel is an internal channel.
|
||||
func IsInternalChannel(channel string) bool {
|
||||
return InternalChannels[channel]
|
||||
}
|
||||
+141
-28
@@ -1,12 +1,17 @@
|
||||
package cron
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/adhocore/gronx"
|
||||
)
|
||||
|
||||
type CronSchedule struct {
|
||||
@@ -20,6 +25,7 @@ type CronSchedule struct {
|
||||
type CronPayload struct {
|
||||
Kind string `json:"kind"`
|
||||
Message string `json:"message"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Deliver bool `json:"deliver"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
@@ -58,14 +64,16 @@ type CronService struct {
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
stopChan chan struct{}
|
||||
gronx *gronx.Gronx
|
||||
}
|
||||
|
||||
func NewCronService(storePath string, onJob JobHandler) *CronService {
|
||||
cs := &CronService{
|
||||
storePath: storePath,
|
||||
onJob: onJob,
|
||||
stopChan: make(chan struct{}),
|
||||
gronx: gronx.New(),
|
||||
}
|
||||
// Initialize and load store on creation
|
||||
cs.loadStore()
|
||||
return cs
|
||||
}
|
||||
@@ -83,12 +91,13 @@ func (cs *CronService) Start() error {
|
||||
}
|
||||
|
||||
cs.recomputeNextRuns()
|
||||
if err := cs.saveStore(); err != nil {
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
return fmt.Errorf("failed to save store: %w", err)
|
||||
}
|
||||
|
||||
cs.stopChan = make(chan struct{})
|
||||
cs.running = true
|
||||
go cs.runLoop()
|
||||
go cs.runLoop(cs.stopChan)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -102,16 +111,19 @@ func (cs *CronService) Stop() {
|
||||
}
|
||||
|
||||
cs.running = false
|
||||
close(cs.stopChan)
|
||||
if cs.stopChan != nil {
|
||||
close(cs.stopChan)
|
||||
cs.stopChan = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CronService) runLoop() {
|
||||
func (cs *CronService) runLoop(stopChan chan struct{}) {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cs.stopChan:
|
||||
case <-stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
cs.checkJobs()
|
||||
@@ -120,43 +132,87 @@ func (cs *CronService) runLoop() {
|
||||
}
|
||||
|
||||
func (cs *CronService) checkJobs() {
|
||||
cs.mu.RLock()
|
||||
cs.mu.Lock()
|
||||
|
||||
if !cs.running {
|
||||
cs.mu.RUnlock()
|
||||
cs.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
var dueJobs []*CronJob
|
||||
var dueJobIDs []string
|
||||
|
||||
// Collect jobs that are due (we need to copy them to execute outside lock)
|
||||
for i := range cs.store.Jobs {
|
||||
job := &cs.store.Jobs[i]
|
||||
if job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now {
|
||||
dueJobs = append(dueJobs, job)
|
||||
dueJobIDs = append(dueJobIDs, job.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset next run for due jobs before unlocking to avoid duplicate execution.
|
||||
dueMap := make(map[string]bool, len(dueJobIDs))
|
||||
for _, jobID := range dueJobIDs {
|
||||
dueMap[jobID] = true
|
||||
}
|
||||
for i := range cs.store.Jobs {
|
||||
if dueMap[cs.store.Jobs[i].ID] {
|
||||
cs.store.Jobs[i].State.NextRunAtMS = nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
log.Printf("[cron] failed to save store: %v", err)
|
||||
}
|
||||
|
||||
cs.mu.Unlock()
|
||||
|
||||
// Execute jobs outside lock.
|
||||
for _, jobID := range dueJobIDs {
|
||||
cs.executeJobByID(jobID)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CronService) executeJobByID(jobID string) {
|
||||
startTime := time.Now().UnixMilli()
|
||||
|
||||
cs.mu.RLock()
|
||||
var callbackJob *CronJob
|
||||
for i := range cs.store.Jobs {
|
||||
job := &cs.store.Jobs[i]
|
||||
if job.ID == jobID {
|
||||
jobCopy := *job
|
||||
callbackJob = &jobCopy
|
||||
break
|
||||
}
|
||||
}
|
||||
cs.mu.RUnlock()
|
||||
|
||||
for _, job := range dueJobs {
|
||||
cs.executeJob(job)
|
||||
if callbackJob == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.saveStore()
|
||||
}
|
||||
|
||||
func (cs *CronService) executeJob(job *CronJob) {
|
||||
startTime := time.Now().UnixMilli()
|
||||
|
||||
var err error
|
||||
if cs.onJob != nil {
|
||||
_, err = cs.onJob(job)
|
||||
_, err = cs.onJob(callbackJob)
|
||||
}
|
||||
|
||||
// Now acquire lock to update state
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
var job *CronJob
|
||||
for i := range cs.store.Jobs {
|
||||
if cs.store.Jobs[i].ID == jobID {
|
||||
job = &cs.store.Jobs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if job == nil {
|
||||
log.Printf("[cron] job %s disappeared before state update", jobID)
|
||||
return
|
||||
}
|
||||
|
||||
job.State.LastRunAtMS = &startTime
|
||||
job.UpdatedAtMS = time.Now().UnixMilli()
|
||||
|
||||
@@ -168,6 +224,7 @@ func (cs *CronService) executeJob(job *CronJob) {
|
||||
job.State.LastError = ""
|
||||
}
|
||||
|
||||
// Compute next run time
|
||||
if job.Schedule.Kind == "at" {
|
||||
if job.DeleteAfterRun {
|
||||
cs.removeJobUnsafe(job.ID)
|
||||
@@ -179,6 +236,10 @@ func (cs *CronService) executeJob(job *CronJob) {
|
||||
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
|
||||
job.State.NextRunAtMS = nextRun
|
||||
}
|
||||
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
log.Printf("[cron] failed to save store: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 {
|
||||
@@ -197,6 +258,23 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6
|
||||
return &next
|
||||
}
|
||||
|
||||
if schedule.Kind == "cron" {
|
||||
if schedule.Expr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use gronx to calculate next run time
|
||||
now := time.UnixMilli(nowMS)
|
||||
nextTime, err := gronx.NextTickAfter(schedule.Expr, now, false)
|
||||
if err != nil {
|
||||
log.Printf("[cron] failed to compute next run for expr '%s': %v", schedule.Expr, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
nextMS := nextTime.UnixMilli()
|
||||
return &nextMS
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -223,9 +301,17 @@ func (cs *CronService) getNextWakeMS() *int64 {
|
||||
}
|
||||
|
||||
func (cs *CronService) Load() error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return cs.loadStore()
|
||||
}
|
||||
|
||||
func (cs *CronService) SetOnJob(handler JobHandler) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.onJob = handler
|
||||
}
|
||||
|
||||
func (cs *CronService) loadStore() error {
|
||||
cs.store = &CronStore{
|
||||
Version: 1,
|
||||
@@ -243,7 +329,7 @@ func (cs *CronService) loadStore() error {
|
||||
return json.Unmarshal(data, cs.store)
|
||||
}
|
||||
|
||||
func (cs *CronService) saveStore() error {
|
||||
func (cs *CronService) saveStoreUnsafe() error {
|
||||
dir := filepath.Dir(cs.storePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -254,7 +340,7 @@ func (cs *CronService) saveStore() error {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(cs.storePath, data, 0644)
|
||||
return os.WriteFile(cs.storePath, data, 0600)
|
||||
}
|
||||
|
||||
func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
|
||||
@@ -263,6 +349,9 @@ func (cs *CronService) AddJob(name string, schedule CronSchedule, message string
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// One-time tasks (at) should be deleted after execution
|
||||
deleteAfterRun := (schedule.Kind == "at")
|
||||
|
||||
job := CronJob{
|
||||
ID: generateID(),
|
||||
Name: name,
|
||||
@@ -280,17 +369,31 @@ func (cs *CronService) AddJob(name string, schedule CronSchedule, message string
|
||||
},
|
||||
CreatedAtMS: now,
|
||||
UpdatedAtMS: now,
|
||||
DeleteAfterRun: false,
|
||||
DeleteAfterRun: deleteAfterRun,
|
||||
}
|
||||
|
||||
cs.store.Jobs = append(cs.store.Jobs, job)
|
||||
if err := cs.saveStore(); err != nil {
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
func (cs *CronService) UpdateJob(job *CronJob) error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
for i := range cs.store.Jobs {
|
||||
if cs.store.Jobs[i].ID == job.ID {
|
||||
cs.store.Jobs[i] = *job
|
||||
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
|
||||
return cs.saveStoreUnsafe()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("job not found")
|
||||
}
|
||||
|
||||
func (cs *CronService) RemoveJob(jobID string) bool {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
@@ -310,7 +413,9 @@ func (cs *CronService) removeJobUnsafe(jobID string) bool {
|
||||
removed := len(cs.store.Jobs) < before
|
||||
|
||||
if removed {
|
||||
cs.saveStore()
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
log.Printf("[cron] failed to save store after remove: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return removed
|
||||
@@ -332,7 +437,9 @@ func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {
|
||||
job.State.NextRunAtMS = nil
|
||||
}
|
||||
|
||||
cs.saveStore()
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
log.Printf("[cron] failed to save store after enable: %v", err)
|
||||
}
|
||||
return job
|
||||
}
|
||||
}
|
||||
@@ -377,5 +484,11 @@ func (cs *CronService) Status() map[string]interface{} {
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
// Use crypto/rand for better uniqueness under concurrent access
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback to time-based if crypto/rand fails
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
@@ -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 != 0600 {
|
||||
t.Errorf("cron store has permission %04o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package events
|
||||
|
||||
import "context"
|
||||
|
||||
type EventSource interface {
|
||||
Kind() Kind
|
||||
Start(ctx context.Context) (<-chan *DeviceEvent, error)
|
||||
Stop() error
|
||||
}
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionAdd Action = "add"
|
||||
ActionRemove Action = "remove"
|
||||
ActionChange Action = "change"
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindUSB Kind = "usb"
|
||||
KindBluetooth Kind = "bluetooth"
|
||||
KindPCI Kind = "pci"
|
||||
KindGeneric Kind = "generic"
|
||||
)
|
||||
|
||||
type DeviceEvent struct {
|
||||
Action Action
|
||||
Kind Kind
|
||||
DeviceID string // e.g. "1-2" for USB bus 1 dev 2
|
||||
Vendor string // Vendor name or ID
|
||||
Product string // Product name or ID
|
||||
Serial string // Serial number if available
|
||||
Capabilities string // Human-readable capability description
|
||||
Raw map[string]string // Raw properties for extensibility
|
||||
}
|
||||
|
||||
func (e *DeviceEvent) FormatMessage() string {
|
||||
actionEmoji := "🔌"
|
||||
actionText := "Connected"
|
||||
if e.Action == ActionRemove {
|
||||
actionEmoji = "🔌"
|
||||
actionText = "Disconnected"
|
||||
}
|
||||
|
||||
msg := actionEmoji + " Device " + actionText + "\n\n"
|
||||
msg += "Type: " + string(e.Kind) + "\n"
|
||||
msg += "Device: " + e.Vendor + " " + e.Product + "\n"
|
||||
if e.Capabilities != "" {
|
||||
msg += "Capabilities: " + e.Capabilities + "\n"
|
||||
}
|
||||
if e.Serial != "" {
|
||||
msg += "Serial: " + e.Serial + "\n"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
"github.com/sipeed/picoclaw/pkg/devices/events"
|
||||
"github.com/sipeed/picoclaw/pkg/devices/sources"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
bus *bus.MessageBus
|
||||
state *state.Manager
|
||||
sources []events.EventSource
|
||||
enabled bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
MonitorUSB bool // When true, monitor USB hotplug (Linux only)
|
||||
// Future: MonitorBluetooth, MonitorPCI, etc.
|
||||
}
|
||||
|
||||
func NewService(cfg Config, stateMgr *state.Manager) *Service {
|
||||
s := &Service{
|
||||
state: stateMgr,
|
||||
enabled: cfg.Enabled,
|
||||
sources: make([]EventSource, 0),
|
||||
}
|
||||
|
||||
if cfg.Enabled && cfg.MonitorUSB {
|
||||
s.sources = append(s.sources, sources.NewUSBMonitor())
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) SetBus(msgBus *bus.MessageBus) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.bus = msgBus
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.enabled || len(s.sources) == 0 {
|
||||
logger.InfoC("devices", "Device event service disabled or no sources")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||
|
||||
for _, src := range s.sources {
|
||||
eventCh, err := src.Start(s.ctx)
|
||||
if err != nil {
|
||||
logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{
|
||||
"kind": src.Kind(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
go s.handleEvents(src.Kind(), eventCh)
|
||||
logger.InfoCF("devices", "Device source started", map[string]interface{}{
|
||||
"kind": src.Kind(),
|
||||
})
|
||||
}
|
||||
|
||||
logger.InfoC("devices", "Device event service started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
s.cancel = nil
|
||||
}
|
||||
|
||||
for _, src := range s.sources {
|
||||
src.Stop()
|
||||
}
|
||||
|
||||
logger.InfoC("devices", "Device event service stopped")
|
||||
}
|
||||
|
||||
func (s *Service) handleEvents(kind events.Kind, eventCh <-chan *events.DeviceEvent) {
|
||||
for ev := range eventCh {
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
s.sendNotification(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) sendNotification(ev *events.DeviceEvent) {
|
||||
s.mu.RLock()
|
||||
msgBus := s.bus
|
||||
s.mu.RUnlock()
|
||||
|
||||
if msgBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lastChannel := s.state.GetLastChannel()
|
||||
if lastChannel == "" {
|
||||
logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{
|
||||
"event": ev.FormatMessage(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
platform, userID := parseLastChannel(lastChannel)
|
||||
if platform == "" || userID == "" || constants.IsInternalChannel(platform) {
|
||||
return
|
||||
}
|
||||
|
||||
msg := ev.FormatMessage()
|
||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: platform,
|
||||
ChatID: userID,
|
||||
Content: msg,
|
||||
})
|
||||
|
||||
logger.InfoCF("devices", "Device notification sent", map[string]interface{}{
|
||||
"kind": ev.Kind,
|
||||
"action": ev.Action,
|
||||
"to": platform,
|
||||
})
|
||||
}
|
||||
|
||||
func parseLastChannel(lastChannel string) (platform, userID string) {
|
||||
if lastChannel == "" {
|
||||
return "", ""
|
||||
}
|
||||
parts := strings.SplitN(lastChannel, ":", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", ""
|
||||
}
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package devices
|
||||
|
||||
import "github.com/sipeed/picoclaw/pkg/devices/events"
|
||||
|
||||
type EventSource = events.EventSource
|
||||
@@ -0,0 +1,198 @@
|
||||
//go:build linux
|
||||
|
||||
package sources
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/devices/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
var usbClassToCapability = map[string]string{
|
||||
"00": "Interface Definition (by interface)",
|
||||
"01": "Audio",
|
||||
"02": "CDC Communication (Network Card/Modem)",
|
||||
"03": "HID (Keyboard/Mouse/Gamepad)",
|
||||
"05": "Physical Interface",
|
||||
"06": "Image (Scanner/Camera)",
|
||||
"07": "Printer",
|
||||
"08": "Mass Storage (USB Flash Drive/Hard Disk)",
|
||||
"09": "USB Hub",
|
||||
"0a": "CDC Data",
|
||||
"0b": "Smart Card",
|
||||
"0e": "Video (Camera)",
|
||||
"dc": "Diagnostic Device",
|
||||
"e0": "Wireless Controller (Bluetooth)",
|
||||
"ef": "Miscellaneous",
|
||||
"fe": "Application Specific",
|
||||
"ff": "Vendor Specific",
|
||||
}
|
||||
|
||||
type USBMonitor struct {
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewUSBMonitor() *USBMonitor {
|
||||
return &USBMonitor{}
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Kind() events.Kind {
|
||||
return events.KindUSB
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// udevadm monitor outputs: UDEV/KERNEL [timestamp] action devpath (subsystem)
|
||||
// Followed by KEY=value lines, empty line separates events
|
||||
// Use -s/--subsystem-match (eudev) or --udev-subsystem-match (systemd udev)
|
||||
cmd := exec.CommandContext(ctx, "udevadm", "monitor", "--property", "--subsystem-match=usb")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("udevadm stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("udevadm start: %w (is udevadm installed?)", err)
|
||||
}
|
||||
|
||||
m.cmd = cmd
|
||||
eventCh := make(chan *events.DeviceEvent, 16)
|
||||
|
||||
go func() {
|
||||
defer close(eventCh)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
var props map[string]string
|
||||
var action string
|
||||
isUdev := false // Only UDEV events have complete info (ID_VENDOR, ID_MODEL); KERNEL events come first with less info
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
// End of event block - only process UDEV events (skip KERNEL to avoid duplicate/incomplete notifications)
|
||||
if isUdev && props != nil && (action == "add" || action == "remove") {
|
||||
if ev := parseUSBEvent(action, props); ev != nil {
|
||||
select {
|
||||
case eventCh <- ev:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
props = nil
|
||||
action = ""
|
||||
isUdev = false
|
||||
continue
|
||||
}
|
||||
|
||||
idx := strings.Index(line, "=")
|
||||
// First line of block: "UDEV [ts] action devpath" or "KERNEL[ts] action devpath" - no KEY=value
|
||||
if idx <= 0 {
|
||||
isUdev = strings.HasPrefix(strings.TrimSpace(line), "UDEV")
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse KEY=value
|
||||
key := line[:idx]
|
||||
val := line[idx+1:]
|
||||
if props == nil {
|
||||
props = make(map[string]string)
|
||||
}
|
||||
props[key] = val
|
||||
|
||||
if key == "ACTION" {
|
||||
action = val
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
cmd.Wait()
|
||||
}()
|
||||
|
||||
return eventCh, nil
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Stop() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
m.cmd.Process.Kill()
|
||||
m.cmd = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseUSBEvent(action string, props map[string]string) *events.DeviceEvent {
|
||||
// Only care about add/remove for physical devices (not interfaces)
|
||||
subsystem := props["SUBSYSTEM"]
|
||||
if subsystem != "usb" {
|
||||
return nil
|
||||
}
|
||||
// Skip interface events - we want device-level only to avoid duplicates
|
||||
devType := props["DEVTYPE"]
|
||||
if devType == "usb_interface" {
|
||||
return nil
|
||||
}
|
||||
// Prefer usb_device, but accept if DEVTYPE not set (varies by udev version)
|
||||
if devType != "" && devType != "usb_device" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ev := &events.DeviceEvent{
|
||||
Raw: props,
|
||||
}
|
||||
switch action {
|
||||
case "add":
|
||||
ev.Action = events.ActionAdd
|
||||
case "remove":
|
||||
ev.Action = events.ActionRemove
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
ev.Kind = events.KindUSB
|
||||
|
||||
ev.Vendor = props["ID_VENDOR"]
|
||||
if ev.Vendor == "" {
|
||||
ev.Vendor = props["ID_VENDOR_ID"]
|
||||
}
|
||||
if ev.Vendor == "" {
|
||||
ev.Vendor = "Unknown Vendor"
|
||||
}
|
||||
|
||||
ev.Product = props["ID_MODEL"]
|
||||
if ev.Product == "" {
|
||||
ev.Product = props["ID_MODEL_ID"]
|
||||
}
|
||||
if ev.Product == "" {
|
||||
ev.Product = "Unknown Device"
|
||||
}
|
||||
|
||||
ev.Serial = props["ID_SERIAL_SHORT"]
|
||||
ev.DeviceID = props["DEVPATH"]
|
||||
if bus := props["BUSNUM"]; bus != "" {
|
||||
if dev := props["DEVNUM"]; dev != "" {
|
||||
ev.DeviceID = bus + ":" + dev
|
||||
}
|
||||
}
|
||||
|
||||
// Map USB class to capability
|
||||
if class := props["ID_USB_CLASS"]; class != "" {
|
||||
ev.Capabilities = usbClassToCapability[strings.ToLower(class)]
|
||||
}
|
||||
if ev.Capabilities == "" {
|
||||
ev.Capabilities = "USB Device"
|
||||
}
|
||||
|
||||
return ev
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build !linux
|
||||
|
||||
package sources
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/devices/events"
|
||||
)
|
||||
|
||||
type USBMonitor struct{}
|
||||
|
||||
func NewUSBMonitor() *USBMonitor {
|
||||
return &USBMonitor{}
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Kind() events.Kind {
|
||||
return events.KindUSB
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) {
|
||||
ch := make(chan *events.DeviceEvent)
|
||||
close(ch) // Immediately close, no events
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Stop() error {
|
||||
return nil
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
+282
-51
@@ -1,128 +1,359 @@
|
||||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
minIntervalMinutes = 5
|
||||
defaultIntervalMinutes = 30
|
||||
)
|
||||
|
||||
// HeartbeatHandler is the function type for handling heartbeat.
|
||||
// It returns a ToolResult that can indicate async operations.
|
||||
// channel and chatID are derived from the last active user channel.
|
||||
type HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult
|
||||
|
||||
// HeartbeatService manages periodic heartbeat checks
|
||||
type HeartbeatService struct {
|
||||
workspace string
|
||||
onHeartbeat func(string) (string, error)
|
||||
interval time.Duration
|
||||
enabled bool
|
||||
mu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
workspace string
|
||||
bus *bus.MessageBus
|
||||
state *state.Manager
|
||||
handler HeartbeatHandler
|
||||
interval time.Duration
|
||||
enabled bool
|
||||
mu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
func NewHeartbeatService(workspace string, onHeartbeat func(string) (string, error), intervalS int, enabled bool) *HeartbeatService {
|
||||
// NewHeartbeatService creates a new heartbeat service
|
||||
func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *HeartbeatService {
|
||||
// Apply minimum interval
|
||||
if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {
|
||||
intervalMinutes = minIntervalMinutes
|
||||
}
|
||||
|
||||
if intervalMinutes == 0 {
|
||||
intervalMinutes = defaultIntervalMinutes
|
||||
}
|
||||
|
||||
return &HeartbeatService{
|
||||
workspace: workspace,
|
||||
onHeartbeat: onHeartbeat,
|
||||
interval: time.Duration(intervalS) * time.Second,
|
||||
enabled: enabled,
|
||||
stopChan: make(chan struct{}),
|
||||
workspace: workspace,
|
||||
interval: time.Duration(intervalMinutes) * time.Minute,
|
||||
enabled: enabled,
|
||||
state: state.NewManager(workspace),
|
||||
}
|
||||
}
|
||||
|
||||
// SetBus sets the message bus for delivering heartbeat results.
|
||||
func (hs *HeartbeatService) SetBus(msgBus *bus.MessageBus) {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
hs.bus = msgBus
|
||||
}
|
||||
|
||||
// SetHandler sets the heartbeat handler.
|
||||
func (hs *HeartbeatService) SetHandler(handler HeartbeatHandler) {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
hs.handler = handler
|
||||
}
|
||||
|
||||
// Start begins the heartbeat service
|
||||
func (hs *HeartbeatService) Start() error {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
if hs.running() {
|
||||
if hs.stopChan != nil {
|
||||
logger.InfoC("heartbeat", "Heartbeat service already running")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !hs.enabled {
|
||||
return fmt.Errorf("heartbeat service is disabled")
|
||||
logger.InfoC("heartbeat", "Heartbeat service disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
go hs.runLoop()
|
||||
hs.stopChan = make(chan struct{})
|
||||
go hs.runLoop(hs.stopChan)
|
||||
|
||||
logger.InfoCF("heartbeat", "Heartbeat service started", map[string]any{
|
||||
"interval_minutes": hs.interval.Minutes(),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the heartbeat service
|
||||
func (hs *HeartbeatService) Stop() {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
if !hs.running() {
|
||||
if hs.stopChan == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoC("heartbeat", "Stopping heartbeat service")
|
||||
close(hs.stopChan)
|
||||
hs.stopChan = nil
|
||||
}
|
||||
|
||||
func (hs *HeartbeatService) running() bool {
|
||||
select {
|
||||
case <-hs.stopChan:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
// IsRunning returns whether the service is running
|
||||
func (hs *HeartbeatService) IsRunning() bool {
|
||||
hs.mu.RLock()
|
||||
defer hs.mu.RUnlock()
|
||||
return hs.stopChan != nil
|
||||
}
|
||||
|
||||
func (hs *HeartbeatService) runLoop() {
|
||||
// runLoop runs the heartbeat ticker
|
||||
func (hs *HeartbeatService) runLoop(stopChan chan struct{}) {
|
||||
ticker := time.NewTicker(hs.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run first heartbeat after initial delay
|
||||
time.AfterFunc(time.Second, func() {
|
||||
hs.executeHeartbeat()
|
||||
})
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-hs.stopChan:
|
||||
case <-stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
hs.checkHeartbeat()
|
||||
hs.executeHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (hs *HeartbeatService) checkHeartbeat() {
|
||||
// executeHeartbeat performs a single heartbeat check
|
||||
func (hs *HeartbeatService) executeHeartbeat() {
|
||||
hs.mu.RLock()
|
||||
if !hs.enabled || !hs.running() {
|
||||
enabled := hs.enabled
|
||||
handler := hs.handler
|
||||
if !hs.enabled || hs.stopChan == nil {
|
||||
hs.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
hs.mu.RUnlock()
|
||||
|
||||
prompt := hs.buildPrompt()
|
||||
|
||||
if hs.onHeartbeat != nil {
|
||||
_, err := hs.onHeartbeat(prompt)
|
||||
if err != nil {
|
||||
hs.log(fmt.Sprintf("Heartbeat error: %v", err))
|
||||
}
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
logger.DebugC("heartbeat", "Executing heartbeat")
|
||||
|
||||
prompt := hs.buildPrompt()
|
||||
if prompt == "" {
|
||||
logger.InfoC("heartbeat", "No heartbeat prompt (HEARTBEAT.md empty or missing)")
|
||||
return
|
||||
}
|
||||
|
||||
if handler == nil {
|
||||
hs.logError("Heartbeat handler not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Get last channel info for context
|
||||
lastChannel := hs.state.GetLastChannel()
|
||||
channel, chatID := hs.parseLastChannel(lastChannel)
|
||||
|
||||
// Debug log for channel resolution
|
||||
hs.logInfo("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel)
|
||||
|
||||
result := handler(prompt, channel, chatID)
|
||||
|
||||
if result == nil {
|
||||
hs.logInfo("Heartbeat handler returned nil result")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different result types
|
||||
if result.IsError {
|
||||
hs.logError("Heartbeat error: %s", result.ForLLM)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Async {
|
||||
hs.logInfo("Async task started: %s", result.ForLLM)
|
||||
logger.InfoCF("heartbeat", "Async heartbeat task started",
|
||||
map[string]interface{}{
|
||||
"message": result.ForLLM,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if silent
|
||||
if result.Silent {
|
||||
hs.logInfo("Heartbeat OK - silent")
|
||||
return
|
||||
}
|
||||
|
||||
// Send result to user
|
||||
if result.ForUser != "" {
|
||||
hs.sendResponse(result.ForUser)
|
||||
} else if result.ForLLM != "" {
|
||||
hs.sendResponse(result.ForLLM)
|
||||
}
|
||||
|
||||
hs.logInfo("Heartbeat completed: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md
|
||||
func (hs *HeartbeatService) buildPrompt() string {
|
||||
notesDir := filepath.Join(hs.workspace, "memory")
|
||||
notesFile := filepath.Join(notesDir, "HEARTBEAT.md")
|
||||
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
|
||||
|
||||
var notes string
|
||||
if data, err := os.ReadFile(notesFile); err == nil {
|
||||
notes = string(data)
|
||||
data, err := os.ReadFile(heartbeatPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
hs.createDefaultHeartbeatTemplate()
|
||||
return ""
|
||||
}
|
||||
hs.logError("Error reading HEARTBEAT.md: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04")
|
||||
content := string(data)
|
||||
if len(content) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`# Heartbeat Check
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
return fmt.Sprintf(`# Heartbeat Check
|
||||
|
||||
Current time: %s
|
||||
|
||||
Check if there are any tasks I should be aware of or actions I should take.
|
||||
Review the memory file for any important updates or changes.
|
||||
Be proactive in identifying potential issues or improvements.
|
||||
You are a proactive AI assistant. This is a scheduled heartbeat check.
|
||||
Review the following tasks and execute any necessary actions using available skills.
|
||||
If there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK
|
||||
|
||||
%s
|
||||
`, now, notes)
|
||||
|
||||
return prompt
|
||||
`, now, content)
|
||||
}
|
||||
|
||||
func (hs *HeartbeatService) log(message string) {
|
||||
logFile := filepath.Join(hs.workspace, "memory", "heartbeat.log")
|
||||
// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file
|
||||
func (hs *HeartbeatService) createDefaultHeartbeatTemplate() {
|
||||
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
|
||||
|
||||
defaultContent := `# Heartbeat Check List
|
||||
|
||||
This file contains tasks for the heartbeat service to check periodically.
|
||||
|
||||
## Examples
|
||||
|
||||
- Check for unread messages
|
||||
- Review upcoming calendar events
|
||||
- Check device status (e.g., MaixCam)
|
||||
|
||||
## Instructions
|
||||
|
||||
- Execute ALL tasks listed below. Do NOT skip any task.
|
||||
- For simple tasks (e.g., report current time), respond directly.
|
||||
- For complex tasks that may take time, use the spawn tool to create a subagent.
|
||||
- The spawn tool is async - subagent results will be sent to the user automatically.
|
||||
- After spawning a subagent, CONTINUE to process remaining tasks.
|
||||
- Only respond with HEARTBEAT_OK when ALL tasks are done AND nothing needs attention.
|
||||
|
||||
---
|
||||
|
||||
Add your heartbeat tasks below this line:
|
||||
`
|
||||
|
||||
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0644); err != nil {
|
||||
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
|
||||
} else {
|
||||
hs.logInfo("Created default HEARTBEAT.md template")
|
||||
}
|
||||
}
|
||||
|
||||
// sendResponse sends the heartbeat response to the last channel
|
||||
func (hs *HeartbeatService) sendResponse(response string) {
|
||||
hs.mu.RLock()
|
||||
msgBus := hs.bus
|
||||
hs.mu.RUnlock()
|
||||
|
||||
if msgBus == nil {
|
||||
hs.logInfo("No message bus configured, heartbeat result not sent")
|
||||
return
|
||||
}
|
||||
|
||||
// Get last channel from state
|
||||
lastChannel := hs.state.GetLastChannel()
|
||||
if lastChannel == "" {
|
||||
hs.logInfo("No last channel recorded, heartbeat result not sent")
|
||||
return
|
||||
}
|
||||
|
||||
platform, userID := hs.parseLastChannel(lastChannel)
|
||||
|
||||
// Skip internal channels that can't receive messages
|
||||
if platform == "" || userID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: platform,
|
||||
ChatID: userID,
|
||||
Content: response,
|
||||
})
|
||||
|
||||
hs.logInfo("Heartbeat result sent to %s", platform)
|
||||
}
|
||||
|
||||
// parseLastChannel parses the last channel string into platform and userID.
|
||||
// Returns empty strings for invalid or internal channels.
|
||||
func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, userID string) {
|
||||
if lastChannel == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Parse channel format: "platform:user_id" (e.g., "telegram:123456")
|
||||
parts := strings.SplitN(lastChannel, ":", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
hs.logError("Invalid last channel format: %s", lastChannel)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
platform, userID = parts[0], parts[1]
|
||||
|
||||
// Skip internal channels
|
||||
if constants.IsInternalChannel(platform) {
|
||||
hs.logInfo("Skipping internal channel: %s", platform)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return platform, userID
|
||||
}
|
||||
|
||||
// logInfo logs an informational message to the heartbeat log
|
||||
func (hs *HeartbeatService) logInfo(format string, args ...any) {
|
||||
hs.log("INFO", format, args...)
|
||||
}
|
||||
|
||||
// logError logs an error message to the heartbeat log
|
||||
func (hs *HeartbeatService) logError(format string, args ...any) {
|
||||
hs.log("ERROR", format, args...)
|
||||
}
|
||||
|
||||
// log writes a message to the heartbeat log file
|
||||
func (hs *HeartbeatService) log(level, format string, args ...any) {
|
||||
logFile := filepath.Join(hs.workspace, "heartbeat.log")
|
||||
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -130,5 +361,5 @@ func (hs *HeartbeatService) log(message string) {
|
||||
defer f.Close()
|
||||
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
f.WriteString(fmt.Sprintf("[%s] %s\n", timestamp, message))
|
||||
fmt.Fprintf(f, "[%s] [%s] %s\n", timestamp, level, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
func TestExecuteHeartbeat_Async(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
asyncCalled := false
|
||||
asyncResult := &tools.ToolResult{
|
||||
ForLLM: "Background task started",
|
||||
ForUser: "Task started in background",
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
Async: true,
|
||||
}
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
asyncCalled = true
|
||||
if prompt == "" {
|
||||
t.Error("Expected non-empty prompt")
|
||||
}
|
||||
return asyncResult
|
||||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
|
||||
// Execute heartbeat directly (internal method for testing)
|
||||
hs.executeHeartbeat()
|
||||
|
||||
if !asyncCalled {
|
||||
t.Error("Expected handler to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHeartbeat_Error(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
return &tools.ToolResult{
|
||||
ForLLM: "Heartbeat failed: connection error",
|
||||
ForUser: "",
|
||||
Silent: false,
|
||||
IsError: true,
|
||||
Async: false,
|
||||
}
|
||||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
|
||||
hs.executeHeartbeat()
|
||||
|
||||
// Check log file for error message
|
||||
logFile := filepath.Join(tmpDir, "heartbeat.log")
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
logContent := string(data)
|
||||
if logContent == "" {
|
||||
t.Error("Expected log file to contain error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHeartbeat_Silent(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
return &tools.ToolResult{
|
||||
ForLLM: "Heartbeat completed successfully",
|
||||
ForUser: "",
|
||||
Silent: true,
|
||||
IsError: false,
|
||||
Async: false,
|
||||
}
|
||||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
|
||||
hs.executeHeartbeat()
|
||||
|
||||
// Check log file for completion message
|
||||
logFile := filepath.Join(tmpDir, "heartbeat.log")
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
logContent := string(data)
|
||||
if logContent == "" {
|
||||
t.Error("Expected log file to contain completion message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatService_StartStop(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 1, true)
|
||||
|
||||
err = hs.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start heartbeat service: %v", err)
|
||||
}
|
||||
|
||||
hs.Stop()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestHeartbeatService_Disabled(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 1, false)
|
||||
|
||||
if hs.enabled != false {
|
||||
t.Error("Expected service to be disabled")
|
||||
}
|
||||
|
||||
err = hs.Start()
|
||||
_ = err // Disabled service returns nil
|
||||
}
|
||||
|
||||
func TestExecuteHeartbeat_NilResult(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
|
||||
// Should not panic with nil result
|
||||
hs.executeHeartbeat()
|
||||
}
|
||||
|
||||
// TestLogPath verifies heartbeat log is written to workspace directory
|
||||
func TestLogPath(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
|
||||
// Write a log entry
|
||||
hs.log("INFO", "Test log entry")
|
||||
|
||||
// Verify log file exists at workspace root
|
||||
expectedLogPath := filepath.Join(tmpDir, "heartbeat.log")
|
||||
if _, err := os.Stat(expectedLogPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected log file at %s, but it doesn't exist", expectedLogPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeatFilePath verifies HEARTBEAT.md is at workspace root
|
||||
func TestHeartbeatFilePath(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
|
||||
// Trigger default template creation
|
||||
hs.buildPrompt()
|
||||
|
||||
// Verify HEARTBEAT.md exists at workspace root
|
||||
expectedPath := filepath.Join(tmpDir, "HEARTBEAT.md")
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
var supportedProviders = map[string]bool{
|
||||
"anthropic": true,
|
||||
"openai": true,
|
||||
"openrouter": true,
|
||||
"groq": true,
|
||||
"zhipu": true,
|
||||
"vllm": true,
|
||||
"gemini": true,
|
||||
}
|
||||
|
||||
var supportedChannels = map[string]bool{
|
||||
"telegram": true,
|
||||
"discord": true,
|
||||
"whatsapp": true,
|
||||
"feishu": true,
|
||||
"qq": true,
|
||||
"dingtalk": true,
|
||||
"maixcam": true,
|
||||
}
|
||||
|
||||
func findOpenClawConfig(openclawHome string) (string, error) {
|
||||
candidates := []string{
|
||||
filepath.Join(openclawHome, "openclaw.json"),
|
||||
filepath.Join(openclawHome, "config.json"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome)
|
||||
}
|
||||
|
||||
func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) {
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading OpenClaw config: %w", err)
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
|
||||
}
|
||||
|
||||
converted := convertKeysToSnake(raw)
|
||||
result, ok := converted.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected config format")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error) {
|
||||
cfg := config.DefaultConfig()
|
||||
var warnings []string
|
||||
|
||||
if agents, ok := getMap(data, "agents"); ok {
|
||||
if defaults, ok := getMap(agents, "defaults"); ok {
|
||||
if v, ok := getString(defaults, "model"); ok {
|
||||
cfg.Agents.Defaults.Model = v
|
||||
}
|
||||
if v, ok := getFloat(defaults, "max_tokens"); ok {
|
||||
cfg.Agents.Defaults.MaxTokens = int(v)
|
||||
}
|
||||
if v, ok := getFloat(defaults, "temperature"); ok {
|
||||
cfg.Agents.Defaults.Temperature = v
|
||||
}
|
||||
if v, ok := getFloat(defaults, "max_tool_iterations"); ok {
|
||||
cfg.Agents.Defaults.MaxToolIterations = int(v)
|
||||
}
|
||||
if v, ok := getString(defaults, "workspace"); ok {
|
||||
cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if providers, ok := getMap(data, "providers"); ok {
|
||||
for name, val := range providers {
|
||||
pMap, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
apiKey, _ := getString(pMap, "api_key")
|
||||
apiBase, _ := getString(pMap, "api_base")
|
||||
|
||||
if !supportedProviders[name] {
|
||||
if apiKey != "" || apiBase != "" {
|
||||
warnings = append(warnings, fmt.Sprintf("Provider '%s' not supported in PicoClaw, skipping", name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase}
|
||||
switch name {
|
||||
case "anthropic":
|
||||
cfg.Providers.Anthropic = pc
|
||||
case "openai":
|
||||
cfg.Providers.OpenAI = pc
|
||||
case "openrouter":
|
||||
cfg.Providers.OpenRouter = pc
|
||||
case "groq":
|
||||
cfg.Providers.Groq = pc
|
||||
case "zhipu":
|
||||
cfg.Providers.Zhipu = pc
|
||||
case "vllm":
|
||||
cfg.Providers.VLLM = pc
|
||||
case "gemini":
|
||||
cfg.Providers.Gemini = pc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if channels, ok := getMap(data, "channels"); ok {
|
||||
for name, val := range channels {
|
||||
cMap, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !supportedChannels[name] {
|
||||
warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in PicoClaw, skipping", name))
|
||||
continue
|
||||
}
|
||||
enabled, _ := getBool(cMap, "enabled")
|
||||
allowFrom := getStringSlice(cMap, "allow_from")
|
||||
|
||||
switch name {
|
||||
case "telegram":
|
||||
cfg.Channels.Telegram.Enabled = enabled
|
||||
cfg.Channels.Telegram.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "token"); ok {
|
||||
cfg.Channels.Telegram.Token = v
|
||||
}
|
||||
case "discord":
|
||||
cfg.Channels.Discord.Enabled = enabled
|
||||
cfg.Channels.Discord.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "token"); ok {
|
||||
cfg.Channels.Discord.Token = v
|
||||
}
|
||||
case "whatsapp":
|
||||
cfg.Channels.WhatsApp.Enabled = enabled
|
||||
cfg.Channels.WhatsApp.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "bridge_url"); ok {
|
||||
cfg.Channels.WhatsApp.BridgeURL = v
|
||||
}
|
||||
case "feishu":
|
||||
cfg.Channels.Feishu.Enabled = enabled
|
||||
cfg.Channels.Feishu.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "app_id"); ok {
|
||||
cfg.Channels.Feishu.AppID = v
|
||||
}
|
||||
if v, ok := getString(cMap, "app_secret"); ok {
|
||||
cfg.Channels.Feishu.AppSecret = v
|
||||
}
|
||||
if v, ok := getString(cMap, "encrypt_key"); ok {
|
||||
cfg.Channels.Feishu.EncryptKey = v
|
||||
}
|
||||
if v, ok := getString(cMap, "verification_token"); ok {
|
||||
cfg.Channels.Feishu.VerificationToken = v
|
||||
}
|
||||
case "qq":
|
||||
cfg.Channels.QQ.Enabled = enabled
|
||||
cfg.Channels.QQ.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "app_id"); ok {
|
||||
cfg.Channels.QQ.AppID = v
|
||||
}
|
||||
if v, ok := getString(cMap, "app_secret"); ok {
|
||||
cfg.Channels.QQ.AppSecret = v
|
||||
}
|
||||
case "dingtalk":
|
||||
cfg.Channels.DingTalk.Enabled = enabled
|
||||
cfg.Channels.DingTalk.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "client_id"); ok {
|
||||
cfg.Channels.DingTalk.ClientID = v
|
||||
}
|
||||
if v, ok := getString(cMap, "client_secret"); ok {
|
||||
cfg.Channels.DingTalk.ClientSecret = v
|
||||
}
|
||||
case "maixcam":
|
||||
cfg.Channels.MaixCam.Enabled = enabled
|
||||
cfg.Channels.MaixCam.AllowFrom = allowFrom
|
||||
if v, ok := getString(cMap, "host"); ok {
|
||||
cfg.Channels.MaixCam.Host = v
|
||||
}
|
||||
if v, ok := getFloat(cMap, "port"); ok {
|
||||
cfg.Channels.MaixCam.Port = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gateway, ok := getMap(data, "gateway"); ok {
|
||||
if v, ok := getString(gateway, "host"); ok {
|
||||
cfg.Gateway.Host = v
|
||||
}
|
||||
if v, ok := getFloat(gateway, "port"); ok {
|
||||
cfg.Gateway.Port = int(v)
|
||||
}
|
||||
}
|
||||
|
||||
if tools, ok := getMap(data, "tools"); ok {
|
||||
if web, ok := getMap(tools, "web"); ok {
|
||||
// Migrate old "search" config to "brave" if api_key is present
|
||||
if search, ok := getMap(web, "search"); ok {
|
||||
if v, ok := getString(search, "api_key"); ok {
|
||||
cfg.Tools.Web.Brave.APIKey = v
|
||||
if v != "" {
|
||||
cfg.Tools.Web.Brave.Enabled = true
|
||||
}
|
||||
}
|
||||
if v, ok := getFloat(search, "max_results"); ok {
|
||||
cfg.Tools.Web.Brave.MaxResults = int(v)
|
||||
cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, warnings, nil
|
||||
}
|
||||
|
||||
func MergeConfig(existing, incoming *config.Config) *config.Config {
|
||||
if existing.Providers.Anthropic.APIKey == "" {
|
||||
existing.Providers.Anthropic = incoming.Providers.Anthropic
|
||||
}
|
||||
if existing.Providers.OpenAI.APIKey == "" {
|
||||
existing.Providers.OpenAI = incoming.Providers.OpenAI
|
||||
}
|
||||
if existing.Providers.OpenRouter.APIKey == "" {
|
||||
existing.Providers.OpenRouter = incoming.Providers.OpenRouter
|
||||
}
|
||||
if existing.Providers.Groq.APIKey == "" {
|
||||
existing.Providers.Groq = incoming.Providers.Groq
|
||||
}
|
||||
if existing.Providers.Zhipu.APIKey == "" {
|
||||
existing.Providers.Zhipu = incoming.Providers.Zhipu
|
||||
}
|
||||
if existing.Providers.VLLM.APIKey == "" && existing.Providers.VLLM.APIBase == "" {
|
||||
existing.Providers.VLLM = incoming.Providers.VLLM
|
||||
}
|
||||
if existing.Providers.Gemini.APIKey == "" {
|
||||
existing.Providers.Gemini = incoming.Providers.Gemini
|
||||
}
|
||||
|
||||
if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled {
|
||||
existing.Channels.Telegram = incoming.Channels.Telegram
|
||||
}
|
||||
if !existing.Channels.Discord.Enabled && incoming.Channels.Discord.Enabled {
|
||||
existing.Channels.Discord = incoming.Channels.Discord
|
||||
}
|
||||
if !existing.Channels.WhatsApp.Enabled && incoming.Channels.WhatsApp.Enabled {
|
||||
existing.Channels.WhatsApp = incoming.Channels.WhatsApp
|
||||
}
|
||||
if !existing.Channels.Feishu.Enabled && incoming.Channels.Feishu.Enabled {
|
||||
existing.Channels.Feishu = incoming.Channels.Feishu
|
||||
}
|
||||
if !existing.Channels.QQ.Enabled && incoming.Channels.QQ.Enabled {
|
||||
existing.Channels.QQ = incoming.Channels.QQ
|
||||
}
|
||||
if !existing.Channels.DingTalk.Enabled && incoming.Channels.DingTalk.Enabled {
|
||||
existing.Channels.DingTalk = incoming.Channels.DingTalk
|
||||
}
|
||||
if !existing.Channels.MaixCam.Enabled && incoming.Channels.MaixCam.Enabled {
|
||||
existing.Channels.MaixCam = incoming.Channels.MaixCam
|
||||
}
|
||||
|
||||
if existing.Tools.Web.Brave.APIKey == "" {
|
||||
existing.Tools.Web.Brave = incoming.Tools.Web.Brave
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
func camelToSnake(s string) string {
|
||||
var result strings.Builder
|
||||
for i, r := range s {
|
||||
if unicode.IsUpper(r) {
|
||||
if i > 0 {
|
||||
prev := rune(s[i-1])
|
||||
if unicode.IsLower(prev) || unicode.IsDigit(prev) {
|
||||
result.WriteRune('_')
|
||||
} else if unicode.IsUpper(prev) && i+1 < len(s) && unicode.IsLower(rune(s[i+1])) {
|
||||
result.WriteRune('_')
|
||||
}
|
||||
}
|
||||
result.WriteRune(unicode.ToLower(r))
|
||||
} else {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func convertKeysToSnake(data interface{}) interface{} {
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]interface{}, len(v))
|
||||
for key, val := range v {
|
||||
result[camelToSnake(key)] = convertKeysToSnake(val)
|
||||
}
|
||||
return result
|
||||
case []interface{}:
|
||||
result := make([]interface{}, len(v))
|
||||
for i, val := range v {
|
||||
result[i] = convertKeysToSnake(val)
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteWorkspacePath(path string) string {
|
||||
path = strings.Replace(path, ".openclaw", ".picoclaw", 1)
|
||||
return path
|
||||
}
|
||||
|
||||
func getMap(data map[string]interface{}, key string) (map[string]interface{}, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func getString(data map[string]interface{}, key string) (string, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func getFloat(data map[string]interface{}, key string) (float64, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
return f, ok
|
||||
}
|
||||
|
||||
func getBool(data map[string]interface{}, key string) (bool, bool) {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
return b, ok
|
||||
}
|
||||
|
||||
func getStringSlice(data map[string]interface{}, key string) []string {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
type ActionType int
|
||||
|
||||
const (
|
||||
ActionCopy ActionType = iota
|
||||
ActionSkip
|
||||
ActionBackup
|
||||
ActionConvertConfig
|
||||
ActionCreateDir
|
||||
ActionMergeConfig
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
ConfigOnly bool
|
||||
WorkspaceOnly bool
|
||||
Force bool
|
||||
Refresh bool
|
||||
OpenClawHome string
|
||||
PicoClawHome string
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Type ActionType
|
||||
Source string
|
||||
Destination string
|
||||
Description string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
FilesCopied int
|
||||
FilesSkipped int
|
||||
BackupsCreated int
|
||||
ConfigMigrated bool
|
||||
DirsCreated int
|
||||
Warnings []string
|
||||
Errors []error
|
||||
}
|
||||
|
||||
func Run(opts Options) (*Result, error) {
|
||||
if opts.ConfigOnly && opts.WorkspaceOnly {
|
||||
return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive")
|
||||
}
|
||||
|
||||
if opts.Refresh {
|
||||
opts.WorkspaceOnly = true
|
||||
}
|
||||
|
||||
openclawHome, err := resolveOpenClawHome(opts.OpenClawHome)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
picoClawHome, err := resolvePicoClawHome(opts.PicoClawHome)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(openclawHome); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
|
||||
}
|
||||
|
||||
actions, warnings, err := Plan(opts, openclawHome, picoClawHome)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Println("Migrating from OpenClaw to PicoClaw")
|
||||
fmt.Printf(" Source: %s\n", openclawHome)
|
||||
fmt.Printf(" Destination: %s\n", picoClawHome)
|
||||
fmt.Println()
|
||||
|
||||
if opts.DryRun {
|
||||
PrintPlan(actions, warnings)
|
||||
return &Result{Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
if !opts.Force {
|
||||
PrintPlan(actions, warnings)
|
||||
if !Confirm() {
|
||||
fmt.Println("Aborted.")
|
||||
return &Result{Warnings: warnings}, nil
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
result := Execute(actions, openclawHome, picoClawHome)
|
||||
result.Warnings = warnings
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string, error) {
|
||||
var actions []Action
|
||||
var warnings []string
|
||||
|
||||
force := opts.Force || opts.Refresh
|
||||
|
||||
if !opts.WorkspaceOnly {
|
||||
configPath, err := findOpenClawConfig(openclawHome)
|
||||
if err != nil {
|
||||
if opts.ConfigOnly {
|
||||
return nil, nil, err
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("Config migration skipped: %v", err))
|
||||
} else {
|
||||
actions = append(actions, Action{
|
||||
Type: ActionConvertConfig,
|
||||
Source: configPath,
|
||||
Destination: filepath.Join(picoClawHome, "config.json"),
|
||||
Description: "convert OpenClaw config to PicoClaw format",
|
||||
})
|
||||
|
||||
data, err := LoadOpenClawConfig(configPath)
|
||||
if err == nil {
|
||||
_, configWarnings, _ := ConvertConfig(data)
|
||||
warnings = append(warnings, configWarnings...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.ConfigOnly {
|
||||
srcWorkspace := resolveWorkspace(openclawHome)
|
||||
dstWorkspace := resolveWorkspace(picoClawHome)
|
||||
|
||||
if _, err := os.Stat(srcWorkspace); err == nil {
|
||||
wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("planning workspace migration: %w", err)
|
||||
}
|
||||
actions = append(actions, wsActions...)
|
||||
} else {
|
||||
warnings = append(warnings, "OpenClaw workspace directory not found, skipping workspace migration")
|
||||
}
|
||||
}
|
||||
|
||||
return actions, warnings, nil
|
||||
}
|
||||
|
||||
func Execute(actions []Action, openclawHome, picoClawHome string) *Result {
|
||||
result := &Result{}
|
||||
|
||||
for _, action := range actions {
|
||||
switch action.Type {
|
||||
case ActionConvertConfig:
|
||||
if err := executeConfigMigration(action.Source, action.Destination, picoClawHome); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err))
|
||||
fmt.Printf(" ✗ Config migration failed: %v\n", err)
|
||||
} else {
|
||||
result.ConfigMigrated = true
|
||||
fmt.Printf(" ✓ Converted config: %s\n", action.Destination)
|
||||
}
|
||||
case ActionCreateDir:
|
||||
if err := os.MkdirAll(action.Destination, 0755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
} else {
|
||||
result.DirsCreated++
|
||||
}
|
||||
case ActionBackup:
|
||||
bakPath := action.Destination + ".bak"
|
||||
if err := copyFile(action.Destination, bakPath); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err))
|
||||
fmt.Printf(" ✗ Backup failed: %s\n", action.Destination)
|
||||
continue
|
||||
}
|
||||
result.BackupsCreated++
|
||||
fmt.Printf(" ✓ Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
continue
|
||||
}
|
||||
if err := copyFile(action.Source, action.Destination); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
||||
} else {
|
||||
result.FilesCopied++
|
||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
||||
}
|
||||
case ActionCopy:
|
||||
if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil {
|
||||
result.Errors = append(result.Errors, err)
|
||||
continue
|
||||
}
|
||||
if err := copyFile(action.Source, action.Destination); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err))
|
||||
fmt.Printf(" ✗ Copy failed: %s\n", action.Source)
|
||||
} else {
|
||||
result.FilesCopied++
|
||||
fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome))
|
||||
}
|
||||
case ActionSkip:
|
||||
result.FilesSkipped++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) error {
|
||||
data, err := LoadOpenClawConfig(srcConfigPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
incoming, _, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dstConfigPath); err == nil {
|
||||
existing, err := config.LoadConfig(dstConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading existing PicoClaw config: %w", err)
|
||||
}
|
||||
incoming = MergeConfig(existing, incoming)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return config.SaveConfig(dstConfigPath, incoming)
|
||||
}
|
||||
|
||||
func Confirm() bool {
|
||||
fmt.Print("Proceed with migration? (y/n): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
return strings.ToLower(strings.TrimSpace(response)) == "y"
|
||||
}
|
||||
|
||||
func PrintPlan(actions []Action, warnings []string) {
|
||||
fmt.Println("Planned actions:")
|
||||
copies := 0
|
||||
skips := 0
|
||||
backups := 0
|
||||
configCount := 0
|
||||
|
||||
for _, action := range actions {
|
||||
switch action.Type {
|
||||
case ActionConvertConfig:
|
||||
fmt.Printf(" [config] %s -> %s\n", action.Source, action.Destination)
|
||||
configCount++
|
||||
case ActionCopy:
|
||||
fmt.Printf(" [copy] %s\n", filepath.Base(action.Source))
|
||||
copies++
|
||||
case ActionBackup:
|
||||
fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Destination))
|
||||
backups++
|
||||
copies++
|
||||
case ActionSkip:
|
||||
if action.Description != "" {
|
||||
fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description)
|
||||
}
|
||||
skips++
|
||||
case ActionCreateDir:
|
||||
fmt.Printf(" [mkdir] %s\n", action.Destination)
|
||||
}
|
||||
}
|
||||
|
||||
if len(warnings) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("Warnings:")
|
||||
for _, w := range warnings {
|
||||
fmt.Printf(" - %s\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n",
|
||||
copies, configCount, backups, skips)
|
||||
}
|
||||
|
||||
func PrintSummary(result *Result) {
|
||||
fmt.Println()
|
||||
parts := []string{}
|
||||
if result.FilesCopied > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d files copied", result.FilesCopied))
|
||||
}
|
||||
if result.ConfigMigrated {
|
||||
parts = append(parts, "1 config converted")
|
||||
}
|
||||
if result.BackupsCreated > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d backups created", result.BackupsCreated))
|
||||
}
|
||||
if result.FilesSkipped > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d files skipped", result.FilesSkipped))
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
fmt.Printf("Migration complete! %s.\n", strings.Join(parts, ", "))
|
||||
} else {
|
||||
fmt.Println("Migration complete! No actions taken.")
|
||||
}
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Printf("%d errors occurred:\n", len(result.Errors))
|
||||
for _, e := range result.Errors {
|
||||
fmt.Printf(" - %v\n", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOpenClawHome(override string) (string, error) {
|
||||
if override != "" {
|
||||
return expandHome(override), nil
|
||||
}
|
||||
if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" {
|
||||
return expandHome(envHome), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".openclaw"), nil
|
||||
}
|
||||
|
||||
func resolvePicoClawHome(override string) (string, error) {
|
||||
if override != "" {
|
||||
return expandHome(override), nil
|
||||
}
|
||||
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
|
||||
return expandHome(envHome), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw"), nil
|
||||
}
|
||||
|
||||
func resolveWorkspace(homeDir string) string {
|
||||
return filepath.Join(homeDir, "workspace")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func backupFile(path string) error {
|
||||
bakPath := path + ".bak"
|
||||
return copyFile(path, bakPath)
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
info, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
}
|
||||
|
||||
func relPath(path, base string) string {
|
||||
rel, err := filepath.Rel(base, path)
|
||||
if err != nil {
|
||||
return filepath.Base(path)
|
||||
}
|
||||
return rel
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestCamelToSnake(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"simple", "apiKey", "api_key"},
|
||||
{"two words", "apiBase", "api_base"},
|
||||
{"three words", "maxToolIterations", "max_tool_iterations"},
|
||||
{"already snake", "api_key", "api_key"},
|
||||
{"single word", "enabled", "enabled"},
|
||||
{"all lower", "model", "model"},
|
||||
{"consecutive caps", "apiURL", "api_url"},
|
||||
{"starts upper", "Model", "model"},
|
||||
{"bridge url", "bridgeUrl", "bridge_url"},
|
||||
{"client id", "clientId", "client_id"},
|
||||
{"app secret", "appSecret", "app_secret"},
|
||||
{"verification token", "verificationToken", "verification_token"},
|
||||
{"allow from", "allowFrom", "allow_from"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := camelToSnake(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("camelToSnake(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKeysToSnake(t *testing.T) {
|
||||
input := map[string]interface{}{
|
||||
"apiKey": "test-key",
|
||||
"apiBase": "https://example.com",
|
||||
"nested": map[string]interface{}{
|
||||
"maxTokens": float64(8192),
|
||||
"allowFrom": []interface{}{"user1", "user2"},
|
||||
"deeperLevel": map[string]interface{}{
|
||||
"clientId": "abc",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := convertKeysToSnake(input)
|
||||
m, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected map[string]interface{}")
|
||||
}
|
||||
|
||||
if _, ok := m["api_key"]; !ok {
|
||||
t.Error("expected key 'api_key' after conversion")
|
||||
}
|
||||
if _, ok := m["api_base"]; !ok {
|
||||
t.Error("expected key 'api_base' after conversion")
|
||||
}
|
||||
|
||||
nested, ok := m["nested"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected nested map")
|
||||
}
|
||||
if _, ok := nested["max_tokens"]; !ok {
|
||||
t.Error("expected key 'max_tokens' in nested map")
|
||||
}
|
||||
if _, ok := nested["allow_from"]; !ok {
|
||||
t.Error("expected key 'allow_from' in nested map")
|
||||
}
|
||||
|
||||
deeper, ok := nested["deeper_level"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected deeper_level map")
|
||||
}
|
||||
if _, ok := deeper["client_id"]; !ok {
|
||||
t.Error("expected key 'client_id' in deeper level")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenClawConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
|
||||
openclawConfig := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
"apiKey": "sk-ant-test123",
|
||||
"apiBase": "https://api.anthropic.com",
|
||||
},
|
||||
},
|
||||
"agents": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"maxTokens": float64(4096),
|
||||
"model": "claude-3-opus",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(openclawConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := LoadOpenClawConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOpenClawConfig: %v", err)
|
||||
}
|
||||
|
||||
providers, ok := result["providers"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected providers map")
|
||||
}
|
||||
anthropic, ok := providers["anthropic"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected anthropic map")
|
||||
}
|
||||
if anthropic["api_key"] != "sk-ant-test123" {
|
||||
t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"])
|
||||
}
|
||||
|
||||
agents, ok := result["agents"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected agents map")
|
||||
}
|
||||
defaults, ok := agents["defaults"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected defaults map")
|
||||
}
|
||||
if defaults["max_tokens"] != float64(4096) {
|
||||
t.Errorf("max_tokens = %v, want 4096", defaults["max_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertConfig(t *testing.T) {
|
||||
t.Run("providers mapping", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
"api_key": "sk-ant-test",
|
||||
"api_base": "https://api.anthropic.com",
|
||||
},
|
||||
"openrouter": map[string]interface{}{
|
||||
"api_key": "sk-or-test",
|
||||
},
|
||||
"groq": map[string]interface{}{
|
||||
"api_key": "gsk-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, warnings, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertConfig: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Errorf("expected no warnings, got %v", warnings)
|
||||
}
|
||||
if cfg.Providers.Anthropic.APIKey != "sk-ant-test" {
|
||||
t.Errorf("Anthropic.APIKey = %q, want %q", cfg.Providers.Anthropic.APIKey, "sk-ant-test")
|
||||
}
|
||||
if cfg.Providers.OpenRouter.APIKey != "sk-or-test" {
|
||||
t.Errorf("OpenRouter.APIKey = %q, want %q", cfg.Providers.OpenRouter.APIKey, "sk-or-test")
|
||||
}
|
||||
if cfg.Providers.Groq.APIKey != "gsk-test" {
|
||||
t.Errorf("Groq.APIKey = %q, want %q", cfg.Providers.Groq.APIKey, "gsk-test")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported provider warning", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"deepseek": map[string]interface{}{
|
||||
"api_key": "sk-deep-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, warnings, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertConfig: %v", err)
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("expected 1 warning, got %d", len(warnings))
|
||||
}
|
||||
if warnings[0] != "Provider 'deepseek' not supported in PicoClaw, skipping" {
|
||||
t.Errorf("unexpected warning: %s", warnings[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("channels mapping", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"channels": map[string]interface{}{
|
||||
"telegram": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"token": "tg-token-123",
|
||||
"allow_from": []interface{}{"user1"},
|
||||
},
|
||||
"discord": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"token": "disc-token-456",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, _, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertConfig: %v", err)
|
||||
}
|
||||
if !cfg.Channels.Telegram.Enabled {
|
||||
t.Error("Telegram should be enabled")
|
||||
}
|
||||
if cfg.Channels.Telegram.Token != "tg-token-123" {
|
||||
t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token, "tg-token-123")
|
||||
}
|
||||
if len(cfg.Channels.Telegram.AllowFrom) != 1 || cfg.Channels.Telegram.AllowFrom[0] != "user1" {
|
||||
t.Errorf("Telegram.AllowFrom = %v, want [user1]", cfg.Channels.Telegram.AllowFrom)
|
||||
}
|
||||
if !cfg.Channels.Discord.Enabled {
|
||||
t.Error("Discord should be enabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported channel warning", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"channels": map[string]interface{}{
|
||||
"email": map[string]interface{}{
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, warnings, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertConfig: %v", err)
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("expected 1 warning, got %d", len(warnings))
|
||||
}
|
||||
if warnings[0] != "Channel 'email' not supported in PicoClaw, skipping" {
|
||||
t.Errorf("unexpected warning: %s", warnings[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent defaults", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"agents": map[string]interface{}{
|
||||
"defaults": map[string]interface{}{
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": float64(4096),
|
||||
"temperature": 0.5,
|
||||
"max_tool_iterations": float64(10),
|
||||
"workspace": "~/.openclaw/workspace",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, _, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertConfig: %v", err)
|
||||
}
|
||||
if cfg.Agents.Defaults.Model != "claude-3-opus" {
|
||||
t.Errorf("Model = %q, want %q", cfg.Agents.Defaults.Model, "claude-3-opus")
|
||||
}
|
||||
if cfg.Agents.Defaults.MaxTokens != 4096 {
|
||||
t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096)
|
||||
}
|
||||
if cfg.Agents.Defaults.Temperature != 0.5 {
|
||||
t.Errorf("Temperature = %f, want %f", cfg.Agents.Defaults.Temperature, 0.5)
|
||||
}
|
||||
if cfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
|
||||
t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.picoclaw/workspace")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty config", func(t *testing.T) {
|
||||
data := map[string]interface{}{}
|
||||
|
||||
cfg, warnings, err := ConvertConfig(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertConfig: %v", err)
|
||||
}
|
||||
if len(warnings) != 0 {
|
||||
t.Errorf("expected no warnings, got %v", warnings)
|
||||
}
|
||||
if cfg.Agents.Defaults.Model != "glm-4.7" {
|
||||
t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeConfig(t *testing.T) {
|
||||
t.Run("fills empty fields", func(t *testing.T) {
|
||||
existing := config.DefaultConfig()
|
||||
incoming := config.DefaultConfig()
|
||||
incoming.Providers.Anthropic.APIKey = "sk-ant-incoming"
|
||||
incoming.Providers.OpenRouter.APIKey = "sk-or-incoming"
|
||||
|
||||
result := MergeConfig(existing, incoming)
|
||||
if result.Providers.Anthropic.APIKey != "sk-ant-incoming" {
|
||||
t.Errorf("Anthropic.APIKey = %q, want %q", result.Providers.Anthropic.APIKey, "sk-ant-incoming")
|
||||
}
|
||||
if result.Providers.OpenRouter.APIKey != "sk-or-incoming" {
|
||||
t.Errorf("OpenRouter.APIKey = %q, want %q", result.Providers.OpenRouter.APIKey, "sk-or-incoming")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves existing non-empty fields", func(t *testing.T) {
|
||||
existing := config.DefaultConfig()
|
||||
existing.Providers.Anthropic.APIKey = "sk-ant-existing"
|
||||
|
||||
incoming := config.DefaultConfig()
|
||||
incoming.Providers.Anthropic.APIKey = "sk-ant-incoming"
|
||||
incoming.Providers.OpenAI.APIKey = "sk-oai-incoming"
|
||||
|
||||
result := MergeConfig(existing, incoming)
|
||||
if result.Providers.Anthropic.APIKey != "sk-ant-existing" {
|
||||
t.Errorf("Anthropic.APIKey should be preserved, got %q", result.Providers.Anthropic.APIKey)
|
||||
}
|
||||
if result.Providers.OpenAI.APIKey != "sk-oai-incoming" {
|
||||
t.Errorf("OpenAI.APIKey should be filled, got %q", result.Providers.OpenAI.APIKey)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("merges enabled channels", func(t *testing.T) {
|
||||
existing := config.DefaultConfig()
|
||||
incoming := config.DefaultConfig()
|
||||
incoming.Channels.Telegram.Enabled = true
|
||||
incoming.Channels.Telegram.Token = "tg-token"
|
||||
|
||||
result := MergeConfig(existing, incoming)
|
||||
if !result.Channels.Telegram.Enabled {
|
||||
t.Error("Telegram should be enabled after merge")
|
||||
}
|
||||
if result.Channels.Telegram.Token != "tg-token" {
|
||||
t.Errorf("Telegram.Token = %q, want %q", result.Channels.Telegram.Token, "tg-token")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves existing enabled channels", func(t *testing.T) {
|
||||
existing := config.DefaultConfig()
|
||||
existing.Channels.Telegram.Enabled = true
|
||||
existing.Channels.Telegram.Token = "existing-token"
|
||||
|
||||
incoming := config.DefaultConfig()
|
||||
incoming.Channels.Telegram.Enabled = true
|
||||
incoming.Channels.Telegram.Token = "incoming-token"
|
||||
|
||||
result := MergeConfig(existing, incoming)
|
||||
if result.Channels.Telegram.Token != "existing-token" {
|
||||
t.Errorf("Telegram.Token should be preserved, got %q", result.Channels.Telegram.Token)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlanWorkspaceMigration(t *testing.T) {
|
||||
t.Run("copies available files", func(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
||||
}
|
||||
|
||||
copyCount := 0
|
||||
skipCount := 0
|
||||
for _, a := range actions {
|
||||
if a.Type == ActionCopy {
|
||||
copyCount++
|
||||
}
|
||||
if a.Type == ActionSkip {
|
||||
skipCount++
|
||||
}
|
||||
}
|
||||
if copyCount != 3 {
|
||||
t.Errorf("expected 3 copies, got %d", copyCount)
|
||||
}
|
||||
if skipCount != 2 {
|
||||
t.Errorf("expected 2 skips (TOOLS.md, HEARTBEAT.md), got %d", skipCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plans backup for existing destination files", func(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
||||
}
|
||||
|
||||
backupCount := 0
|
||||
for _, a := range actions {
|
||||
if a.Type == ActionBackup && filepath.Base(a.Destination) == "AGENTS.md" {
|
||||
backupCount++
|
||||
}
|
||||
}
|
||||
if backupCount != 1 {
|
||||
t.Errorf("expected 1 backup action for AGENTS.md, got %d", backupCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("force skips backup", func(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
||||
os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, true)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
||||
}
|
||||
|
||||
for _, a := range actions {
|
||||
if a.Type == ActionBackup {
|
||||
t.Error("expected no backup actions with force=true")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles memory directory", func(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
memDir := filepath.Join(srcDir, "memory")
|
||||
os.MkdirAll(memDir, 0755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
||||
}
|
||||
|
||||
hasCopy := false
|
||||
hasDir := false
|
||||
for _, a := range actions {
|
||||
if a.Type == ActionCopy && filepath.Base(a.Source) == "MEMORY.md" {
|
||||
hasCopy = true
|
||||
}
|
||||
if a.Type == ActionCreateDir {
|
||||
hasDir = true
|
||||
}
|
||||
}
|
||||
if !hasCopy {
|
||||
t.Error("expected copy action for memory/MEMORY.md")
|
||||
}
|
||||
if !hasDir {
|
||||
t.Error("expected create dir action for memory/")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles skills directory", func(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
dstDir := t.TempDir()
|
||||
|
||||
skillDir := filepath.Join(srcDir, "skills", "weather")
|
||||
os.MkdirAll(skillDir, 0755)
|
||||
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644)
|
||||
|
||||
actions, err := PlanWorkspaceMigration(srcDir, dstDir, false)
|
||||
if err != nil {
|
||||
t.Fatalf("PlanWorkspaceMigration: %v", err)
|
||||
}
|
||||
|
||||
hasCopy := false
|
||||
for _, a := range actions {
|
||||
if a.Type == ActionCopy && filepath.Base(a.Source) == "SKILL.md" {
|
||||
hasCopy = true
|
||||
}
|
||||
}
|
||||
if !hasCopy {
|
||||
t.Error("expected copy action for skills/weather/SKILL.md")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindOpenClawConfig(t *testing.T) {
|
||||
t.Run("finds openclaw.json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
||||
|
||||
found, err := findOpenClawConfig(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("findOpenClawConfig: %v", err)
|
||||
}
|
||||
if found != configPath {
|
||||
t.Errorf("found %q, want %q", found, configPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to config.json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
os.WriteFile(configPath, []byte("{}"), 0644)
|
||||
|
||||
found, err := findOpenClawConfig(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("findOpenClawConfig: %v", err)
|
||||
}
|
||||
if found != configPath {
|
||||
t.Errorf("found %q, want %q", found, configPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefers openclaw.json over config.json", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
openclawPath := filepath.Join(tmpDir, "openclaw.json")
|
||||
os.WriteFile(openclawPath, []byte("{}"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644)
|
||||
|
||||
found, err := findOpenClawConfig(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("findOpenClawConfig: %v", err)
|
||||
}
|
||||
if found != openclawPath {
|
||||
t.Errorf("should prefer openclaw.json, got %q", found)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error when no config found", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
_, err := findOpenClawConfig(tmpDir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no config found")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRewriteWorkspacePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"default path", "~/.openclaw/workspace", "~/.picoclaw/workspace"},
|
||||
{"custom path", "/custom/path", "/custom/path"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := rewriteWorkspacePath(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("rewriteWorkspacePath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRun(t *testing.T) {
|
||||
openclawHome := t.TempDir()
|
||||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
"apiKey": "test-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
|
||||
opts := Options{
|
||||
DryRun: true,
|
||||
OpenClawHome: openclawHome,
|
||||
PicoClawHome: picoClawHome,
|
||||
}
|
||||
|
||||
result, err := Run(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
picoWs := filepath.Join(picoClawHome, "workspace")
|
||||
if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) {
|
||||
t.Error("dry run should not create files")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(picoClawHome, "config.json")); !os.IsNotExist(err) {
|
||||
t.Error("dry run should not create config")
|
||||
}
|
||||
|
||||
_ = result
|
||||
}
|
||||
|
||||
func TestRunFullMigration(t *testing.T) {
|
||||
openclawHome := t.TempDir()
|
||||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644)
|
||||
os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644)
|
||||
os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644)
|
||||
|
||||
memDir := filepath.Join(wsDir, "memory")
|
||||
os.MkdirAll(memDir, 0755)
|
||||
os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
"apiKey": "sk-ant-migrate-test",
|
||||
},
|
||||
"openrouter": map[string]interface{}{
|
||||
"apiKey": "sk-or-migrate-test",
|
||||
},
|
||||
},
|
||||
"channels": map[string]interface{}{
|
||||
"telegram": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"token": "tg-migrate-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
|
||||
opts := Options{
|
||||
Force: true,
|
||||
OpenClawHome: openclawHome,
|
||||
PicoClawHome: picoClawHome,
|
||||
}
|
||||
|
||||
result, err := Run(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
picoWs := filepath.Join(picoClawHome, "workspace")
|
||||
|
||||
soulData, err := os.ReadFile(filepath.Join(picoWs, "SOUL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading SOUL.md: %v", err)
|
||||
}
|
||||
if string(soulData) != "# Soul from OpenClaw" {
|
||||
t.Errorf("SOUL.md content = %q, want %q", string(soulData), "# Soul from OpenClaw")
|
||||
}
|
||||
|
||||
agentsData, err := os.ReadFile(filepath.Join(picoWs, "AGENTS.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading AGENTS.md: %v", err)
|
||||
}
|
||||
if string(agentsData) != "# Agents from OpenClaw" {
|
||||
t.Errorf("AGENTS.md content = %q", string(agentsData))
|
||||
}
|
||||
|
||||
memData, err := os.ReadFile(filepath.Join(picoWs, "memory", "MEMORY.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading memory/MEMORY.md: %v", err)
|
||||
}
|
||||
if string(memData) != "# Memory notes" {
|
||||
t.Errorf("MEMORY.md content = %q", string(memData))
|
||||
}
|
||||
|
||||
picoConfig, err := config.LoadConfig(filepath.Join(picoClawHome, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("loading PicoClaw config: %v", err)
|
||||
}
|
||||
if picoConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" {
|
||||
t.Errorf("Anthropic.APIKey = %q, want %q", picoConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test")
|
||||
}
|
||||
if picoConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" {
|
||||
t.Errorf("OpenRouter.APIKey = %q, want %q", picoConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test")
|
||||
}
|
||||
if !picoConfig.Channels.Telegram.Enabled {
|
||||
t.Error("Telegram should be enabled")
|
||||
}
|
||||
if picoConfig.Channels.Telegram.Token != "tg-migrate-test" {
|
||||
t.Errorf("Telegram.Token = %q, want %q", picoConfig.Channels.Telegram.Token, "tg-migrate-test")
|
||||
}
|
||||
|
||||
if result.FilesCopied < 3 {
|
||||
t.Errorf("expected at least 3 files copied, got %d", result.FilesCopied)
|
||||
}
|
||||
if !result.ConfigMigrated {
|
||||
t.Error("config should have been migrated")
|
||||
}
|
||||
if len(result.Errors) > 0 {
|
||||
t.Errorf("expected no errors, got %v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOpenClawNotFound(t *testing.T) {
|
||||
opts := Options{
|
||||
OpenClawHome: "/nonexistent/path/to/openclaw",
|
||||
PicoClawHome: t.TempDir(),
|
||||
}
|
||||
|
||||
_, err := Run(opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when OpenClaw not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMutuallyExclusiveFlags(t *testing.T) {
|
||||
opts := Options{
|
||||
ConfigOnly: true,
|
||||
WorkspaceOnly: true,
|
||||
}
|
||||
|
||||
_, err := Run(opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mutually exclusive flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "test.md")
|
||||
os.WriteFile(filePath, []byte("original content"), 0644)
|
||||
|
||||
if err := backupFile(filePath); err != nil {
|
||||
t.Fatalf("backupFile: %v", err)
|
||||
}
|
||||
|
||||
bakPath := filePath + ".bak"
|
||||
bakData, err := os.ReadFile(bakPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading backup: %v", err)
|
||||
}
|
||||
if string(bakData) != "original content" {
|
||||
t.Errorf("backup content = %q, want %q", string(bakData), "original content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srcPath := filepath.Join(tmpDir, "src.md")
|
||||
dstPath := filepath.Join(tmpDir, "dst.md")
|
||||
|
||||
os.WriteFile(srcPath, []byte("file content"), 0644)
|
||||
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
t.Fatalf("copyFile: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(dstPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading copy: %v", err)
|
||||
}
|
||||
if string(data) != "file content" {
|
||||
t.Errorf("copy content = %q, want %q", string(data), "file content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfigOnly(t *testing.T) {
|
||||
openclawHome := t.TempDir()
|
||||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
"apiKey": "sk-config-only",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
|
||||
opts := Options{
|
||||
Force: true,
|
||||
ConfigOnly: true,
|
||||
OpenClawHome: openclawHome,
|
||||
PicoClawHome: picoClawHome,
|
||||
}
|
||||
|
||||
result, err := Run(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if !result.ConfigMigrated {
|
||||
t.Error("config should have been migrated")
|
||||
}
|
||||
|
||||
picoWs := filepath.Join(picoClawHome, "workspace")
|
||||
if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) {
|
||||
t.Error("config-only should not copy workspace files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceOnly(t *testing.T) {
|
||||
openclawHome := t.TempDir()
|
||||
picoClawHome := t.TempDir()
|
||||
|
||||
wsDir := filepath.Join(openclawHome, "workspace")
|
||||
os.MkdirAll(wsDir, 0755)
|
||||
os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644)
|
||||
|
||||
configData := map[string]interface{}{
|
||||
"providers": map[string]interface{}{
|
||||
"anthropic": map[string]interface{}{
|
||||
"apiKey": "sk-ws-only",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(configData)
|
||||
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
|
||||
|
||||
opts := Options{
|
||||
Force: true,
|
||||
WorkspaceOnly: true,
|
||||
OpenClawHome: openclawHome,
|
||||
PicoClawHome: picoClawHome,
|
||||
}
|
||||
|
||||
result, err := Run(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
if result.ConfigMigrated {
|
||||
t.Error("workspace-only should not migrate config")
|
||||
}
|
||||
|
||||
picoWs := filepath.Join(picoClawHome, "workspace")
|
||||
soulData, err := os.ReadFile(filepath.Join(picoWs, "SOUL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading SOUL.md: %v", err)
|
||||
}
|
||||
if string(soulData) != "# Soul" {
|
||||
t.Errorf("SOUL.md content = %q", string(soulData))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var migrateableFiles = []string{
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"USER.md",
|
||||
"TOOLS.md",
|
||||
"HEARTBEAT.md",
|
||||
}
|
||||
|
||||
var migrateableDirs = []string{
|
||||
"memory",
|
||||
"skills",
|
||||
}
|
||||
|
||||
func PlanWorkspaceMigration(srcWorkspace, dstWorkspace string, force bool) ([]Action, error) {
|
||||
var actions []Action
|
||||
|
||||
for _, filename := range migrateableFiles {
|
||||
src := filepath.Join(srcWorkspace, filename)
|
||||
dst := filepath.Join(dstWorkspace, filename)
|
||||
action := planFileCopy(src, dst, force)
|
||||
if action.Type != ActionSkip || action.Description != "" {
|
||||
actions = append(actions, action)
|
||||
}
|
||||
}
|
||||
|
||||
for _, dirname := range migrateableDirs {
|
||||
srcDir := filepath.Join(srcWorkspace, dirname)
|
||||
if _, err := os.Stat(srcDir); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
dirActions, err := planDirCopy(srcDir, filepath.Join(dstWorkspace, dirname), force)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, dirActions...)
|
||||
}
|
||||
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func planFileCopy(src, dst string, force bool) Action {
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return Action{
|
||||
Type: ActionSkip,
|
||||
Source: src,
|
||||
Destination: dst,
|
||||
Description: "source file not found",
|
||||
}
|
||||
}
|
||||
|
||||
_, dstExists := os.Stat(dst)
|
||||
if dstExists == nil && !force {
|
||||
return Action{
|
||||
Type: ActionBackup,
|
||||
Source: src,
|
||||
Destination: dst,
|
||||
Description: "destination exists, will backup and overwrite",
|
||||
}
|
||||
}
|
||||
|
||||
return Action{
|
||||
Type: ActionCopy,
|
||||
Source: src,
|
||||
Destination: dst,
|
||||
Description: "copy file",
|
||||
}
|
||||
}
|
||||
|
||||
func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
|
||||
var actions []Action
|
||||
|
||||
err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dst := filepath.Join(dstDir, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
actions = append(actions, Action{
|
||||
Type: ActionCreateDir,
|
||||
Destination: dst,
|
||||
Description: "create directory",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
action := planFileCopy(path, dst, force)
|
||||
actions = append(actions, action)
|
||||
return nil
|
||||
})
|
||||
|
||||
return actions, err
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
|
||||
type ClaudeCliProvider struct {
|
||||
command string
|
||||
workspace string
|
||||
}
|
||||
|
||||
// NewClaudeCliProvider creates a new Claude CLI provider.
|
||||
func NewClaudeCliProvider(workspace string) *ClaudeCliProvider {
|
||||
return &ClaudeCliProvider{
|
||||
command: "claude",
|
||||
workspace: workspace,
|
||||
}
|
||||
}
|
||||
|
||||
// Chat implements LLMProvider.Chat by executing the claude CLI.
|
||||
func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
systemPrompt := p.buildSystemPrompt(messages, tools)
|
||||
prompt := p.messagesToPrompt(messages)
|
||||
|
||||
args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"}
|
||||
if systemPrompt != "" {
|
||||
args = append(args, "--system-prompt", systemPrompt)
|
||||
}
|
||||
if model != "" && model != "claude-code" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
args = append(args, "-") // read from stdin
|
||||
|
||||
cmd := exec.CommandContext(ctx, p.command, args...)
|
||||
if p.workspace != "" {
|
||||
cmd.Dir = p.workspace
|
||||
}
|
||||
cmd.Stdin = bytes.NewReader([]byte(prompt))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if stderrStr := stderr.String(); stderrStr != "" {
|
||||
return nil, fmt.Errorf("claude cli error: %s", stderrStr)
|
||||
}
|
||||
return nil, fmt.Errorf("claude cli error: %w", err)
|
||||
}
|
||||
|
||||
return p.parseClaudeCliResponse(stdout.String())
|
||||
}
|
||||
|
||||
// GetDefaultModel returns the default model identifier.
|
||||
func (p *ClaudeCliProvider) GetDefaultModel() string {
|
||||
return "claude-code"
|
||||
}
|
||||
|
||||
// messagesToPrompt converts messages to a CLI-compatible prompt string.
|
||||
func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string {
|
||||
var parts []string
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
// handled via --system-prompt flag
|
||||
case "user":
|
||||
parts = append(parts, "User: "+msg.Content)
|
||||
case "assistant":
|
||||
parts = append(parts, "Assistant: "+msg.Content)
|
||||
case "tool":
|
||||
parts = append(parts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content))
|
||||
}
|
||||
}
|
||||
|
||||
// Simplify single user message
|
||||
if len(parts) == 1 && strings.HasPrefix(parts[0], "User: ") {
|
||||
return strings.TrimPrefix(parts[0], "User: ")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// buildSystemPrompt combines system messages and tool definitions.
|
||||
func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDefinition) string {
|
||||
var parts []string
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "system" {
|
||||
parts = append(parts, msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
parts = append(parts, p.buildToolsPrompt(tools))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// buildToolsPrompt creates the tool definitions section for the system prompt.
|
||||
func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Available Tools\n\n")
|
||||
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
|
||||
sb.WriteString("\n```\n\n")
|
||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
||||
sb.WriteString("### Tool Definitions:\n\n")
|
||||
|
||||
for _, tool := range tools {
|
||||
if tool.Type != "function" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
|
||||
if tool.Function.Description != "" {
|
||||
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
|
||||
}
|
||||
if len(tool.Function.Parameters) > 0 {
|
||||
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
|
||||
sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseClaudeCliResponse parses the JSON output from the claude CLI.
|
||||
func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) {
|
||||
var resp claudeCliJSONResponse
|
||||
if err := json.Unmarshal([]byte(output), &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse claude cli response: %w", err)
|
||||
}
|
||||
|
||||
if resp.IsError {
|
||||
return nil, fmt.Errorf("claude cli returned error: %s", resp.Result)
|
||||
}
|
||||
|
||||
toolCalls := p.extractToolCalls(resp.Result)
|
||||
|
||||
finishReason := "stop"
|
||||
content := resp.Result
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
content = p.stripToolCallsJSON(resp.Result)
|
||||
}
|
||||
|
||||
var usage *UsageInfo
|
||||
if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 {
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens,
|
||||
CompletionTokens: resp.Usage.OutputTokens,
|
||||
TotalTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: strings.TrimSpace(content),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractToolCalls delegates to the shared extractToolCallsFromText function.
|
||||
func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall {
|
||||
return extractToolCallsFromText(text)
|
||||
}
|
||||
|
||||
// stripToolCallsJSON delegates to the shared stripToolCallsFromText function.
|
||||
func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string {
|
||||
return stripToolCallsFromText(text)
|
||||
}
|
||||
|
||||
// findMatchingBrace finds the index after the closing brace matching the opening brace at pos.
|
||||
func findMatchingBrace(text string, pos int) int {
|
||||
depth := 0
|
||||
for i := pos; i < len(text); i++ {
|
||||
if text[i] == '{' {
|
||||
depth++
|
||||
} else if text[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
// claudeCliJSONResponse represents the JSON output from the claude CLI.
|
||||
// Matches the real claude CLI v2.x output format.
|
||||
type claudeCliJSONResponse struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
IsError bool `json:"is_error"`
|
||||
Result string `json:"result"`
|
||||
SessionID string `json:"session_id"`
|
||||
TotalCostUSD float64 `json:"total_cost_usd"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
DurationAPI int `json:"duration_api_ms"`
|
||||
NumTurns int `json:"num_turns"`
|
||||
Usage claudeCliUsageInfo `json:"usage"`
|
||||
}
|
||||
|
||||
// claudeCliUsageInfo represents token usage from the claude CLI response.
|
||||
type claudeCliUsageInfo struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//go:build integration
|
||||
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
exec "os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIntegration_RealClaudeCLI tests the ClaudeCliProvider with a real claude CLI.
|
||||
// Run with: go test -tags=integration ./pkg/providers/...
|
||||
func TestIntegration_RealClaudeCLI(t *testing.T) {
|
||||
// Check if claude CLI is available
|
||||
path, err := exec.LookPath("claude")
|
||||
if err != nil {
|
||||
t.Skip("claude CLI not found in PATH, skipping integration test")
|
||||
}
|
||||
t.Logf("Using claude CLI at: %s", path)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := p.Chat(ctx, []Message{
|
||||
{Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with real CLI error = %v", err)
|
||||
}
|
||||
|
||||
// Verify response structure
|
||||
if resp.Content == "" {
|
||||
t.Error("Content is empty")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Error("Usage should not be nil from real CLI")
|
||||
} else {
|
||||
if resp.Usage.PromptTokens == 0 {
|
||||
t.Error("PromptTokens should be > 0")
|
||||
}
|
||||
if resp.Usage.CompletionTokens == 0 {
|
||||
t.Error("CompletionTokens should be > 0")
|
||||
}
|
||||
t.Logf("Usage: prompt=%d, completion=%d, total=%d",
|
||||
resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens)
|
||||
}
|
||||
|
||||
t.Logf("Response content: %q", resp.Content)
|
||||
|
||||
// Loose check - should contain "pong" somewhere (model might capitalize or add punctuation)
|
||||
if !strings.Contains(strings.ToLower(resp.Content), "pong") {
|
||||
t.Errorf("Content = %q, expected to contain 'pong'", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) {
|
||||
if _, err := exec.LookPath("claude"); err != nil {
|
||||
t.Skip("claude CLI not found in PATH")
|
||||
}
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := p.Chat(ctx, []Message{
|
||||
{Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
|
||||
{Role: "user", Content: "What is 2+2?"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Response: %q", resp.Content)
|
||||
|
||||
if !strings.Contains(resp.Content, "4") {
|
||||
t.Errorf("Content = %q, expected to contain '4'", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_RealClaudeCLI_ParsesRealJSON(t *testing.T) {
|
||||
if _, err := exec.LookPath("claude"); err != nil {
|
||||
t.Skip("claude CLI not found in PATH")
|
||||
}
|
||||
|
||||
// Run claude directly and verify our parser handles real output
|
||||
cmd := exec.Command("claude", "-p", "--output-format", "json",
|
||||
"--dangerously-skip-permissions", "--no-chrome", "--no-session-persistence", "-")
|
||||
cmd.Stdin = strings.NewReader("Say hi")
|
||||
cmd.Dir = t.TempDir()
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("claude CLI failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Raw CLI output: %s", string(output))
|
||||
|
||||
// Verify our parser can handle real output
|
||||
p := NewClaudeCliProvider("")
|
||||
resp, err := p.parseClaudeCliResponse(string(output))
|
||||
if err != nil {
|
||||
t.Fatalf("parseClaudeCliResponse() failed on real CLI output: %v", err)
|
||||
}
|
||||
|
||||
if resp.Content == "" {
|
||||
t.Error("parsed Content is empty")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want stop", resp.FinishReason)
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Error("Usage should not be nil")
|
||||
}
|
||||
|
||||
t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage)
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// --- Compile-time interface check ---
|
||||
|
||||
var _ LLMProvider = (*ClaudeCliProvider)(nil)
|
||||
|
||||
// --- Helper: create mock CLI scripts ---
|
||||
|
||||
// createMockCLI creates a temporary script that simulates the claude CLI.
|
||||
// Uses files for stdout/stderr to avoid shell quoting issues with JSON.
|
||||
func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock CLI scripts not supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
if stdout != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stderr != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("#!/bin/sh\n")
|
||||
if stderr != "" {
|
||||
sb.WriteString(fmt.Sprintf("cat '%s/stderr.txt' >&2\n", dir))
|
||||
}
|
||||
if stdout != "" {
|
||||
sb.WriteString(fmt.Sprintf("cat '%s/stdout.txt'\n", dir))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
|
||||
|
||||
script := filepath.Join(dir, "claude")
|
||||
if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// createSlowMockCLI creates a script that sleeps before responding (for context cancellation tests).
|
||||
func createSlowMockCLI(t *testing.T, sleepSeconds int) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock CLI scripts not supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "claude")
|
||||
content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds)
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// createArgCaptureCLI creates a script that captures CLI args to a file, then outputs JSON.
|
||||
func createArgCaptureCLI(t *testing.T, argsFile string) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock CLI scripts not supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "claude")
|
||||
content := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" > '%s'
|
||||
cat <<'EOFMOCK'
|
||||
{"type":"result","result":"ok","session_id":"test"}
|
||||
EOFMOCK
|
||||
`, argsFile)
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// --- Constructor tests ---
|
||||
|
||||
func TestNewClaudeCliProvider(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/test/workspace")
|
||||
if p == nil {
|
||||
t.Fatal("NewClaudeCliProvider returned nil")
|
||||
}
|
||||
if p.workspace != "/test/workspace" {
|
||||
t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace")
|
||||
}
|
||||
if p.command != "claude" {
|
||||
t.Errorf("command = %q, want %q", p.command, "claude")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClaudeCliProvider_EmptyWorkspace(t *testing.T) {
|
||||
p := NewClaudeCliProvider("")
|
||||
if p.workspace != "" {
|
||||
t.Errorf("workspace = %q, want empty", p.workspace)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetDefaultModel tests ---
|
||||
|
||||
func TestClaudeCliProvider_GetDefaultModel(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
if got := p.GetDefaultModel(); got != "claude-code" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-code")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chat() tests ---
|
||||
|
||||
func TestChat_Success(t *testing.T) {
|
||||
mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","session_id":"sess_123","total_cost_usd":0.005,"duration_ms":200,"duration_api_ms":150,"num_turns":1,"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":100,"cache_read_input_tokens":0}}`
|
||||
script := createMockCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if resp.Content != "Hello from mock!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello from mock!")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if len(resp.ToolCalls) != 0 {
|
||||
t.Errorf("ToolCalls len = %d, want 0", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Fatal("Usage should not be nil")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 110 { // 10 + 100 + 0
|
||||
t.Errorf("PromptTokens = %d, want 110", resp.Usage.PromptTokens)
|
||||
}
|
||||
if resp.Usage.CompletionTokens != 5 {
|
||||
t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens)
|
||||
}
|
||||
if resp.Usage.TotalTokens != 115 { // 110 + 5
|
||||
t.Errorf("TotalTokens = %d, want 115", resp.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_IsErrorResponse(t *testing.T) {
|
||||
mockJSON := `{"type":"result","subtype":"error","is_error":true,"result":"Rate limit exceeded","session_id":"s1","total_cost_usd":0}`
|
||||
script := createMockCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error when is_error=true")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Rate limit exceeded") {
|
||||
t.Errorf("error = %q, want to contain 'Rate limit exceeded'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_WithToolCallsInResponse(t *testing.T) {
|
||||
mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Checking weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"NYC\\\"}\"}}]}","session_id":"s1","total_cost_usd":0.01,"usage":{"input_tokens":5,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`
|
||||
script := createMockCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.ToolCalls[0].Name != "get_weather" {
|
||||
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather")
|
||||
}
|
||||
if resp.ToolCalls[0].Arguments["location"] != "NYC" {
|
||||
t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", resp.ToolCalls[0].Arguments["location"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_StderrError(t *testing.T) {
|
||||
script := createMockCLI(t, "", "Error: rate limited", 1)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limited") {
|
||||
t.Errorf("error = %q, want to contain 'rate limited'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_NonZeroExitNoStderr(t *testing.T) {
|
||||
script := createMockCLI(t, "", "", 1)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for non-zero exit")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "claude cli error") {
|
||||
t.Errorf("error = %q, want to contain 'claude cli error'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_CommandNotFound(t *testing.T) {
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = "/nonexistent/claude-binary-that-does-not-exist"
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for missing command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_InvalidResponseJSON(t *testing.T) {
|
||||
script := createMockCLI(t, "not valid json at all", "", 0)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for invalid JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse claude cli response") {
|
||||
t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_ContextCancellation(t *testing.T) {
|
||||
script := createSlowMockCLI(t, 2) // sleep 2s
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
_, err := p.Chat(ctx, []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error on context cancellation")
|
||||
}
|
||||
// Should fail well before the full 2s sleep completes
|
||||
if elapsed > 3*time.Second {
|
||||
t.Errorf("Chat() took %v, expected to fail faster via context cancellation", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_PassesSystemPromptFlag(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureCLI(t, argsFile)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "system", Content: "Be helpful."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, err := os.ReadFile(argsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read args file: %v", err)
|
||||
}
|
||||
args := string(argsBytes)
|
||||
if !strings.Contains(args, "--system-prompt") {
|
||||
t.Errorf("CLI args missing --system-prompt, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_PassesModelFlag(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureCLI(t, argsFile)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "claude-sonnet-4-5-20250929", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, _ := os.ReadFile(argsFile)
|
||||
args := string(argsBytes)
|
||||
if !strings.Contains(args, "--model") {
|
||||
t.Errorf("CLI args missing --model, got: %s", args)
|
||||
}
|
||||
if !strings.Contains(args, "claude-sonnet-4-5-20250929") {
|
||||
t.Errorf("CLI args missing model name, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_SkipsModelFlagForClaudeCode(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureCLI(t, argsFile)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "claude-code", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, _ := os.ReadFile(argsFile)
|
||||
args := string(argsBytes)
|
||||
if strings.Contains(args, "--model") {
|
||||
t.Errorf("CLI args should NOT contain --model for claude-code, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_SkipsModelFlagForEmptyModel(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureCLI(t, argsFile)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, _ := os.ReadFile(argsFile)
|
||||
args := string(argsBytes)
|
||||
if strings.Contains(args, "--model") {
|
||||
t.Errorf("CLI args should NOT contain --model for empty model, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
||||
mockJSON := `{"type":"result","result":"ok","session_id":"s"}`
|
||||
script := createMockCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewClaudeCliProvider("")
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with empty workspace error = %v", err)
|
||||
}
|
||||
if resp.Content != "ok" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateProvider factory tests ---
|
||||
|
||||
func TestCreateProvider_ClaudeCli(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
||||
cfg.Agents.Defaults.Workspace = "/test/ws"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider(claude-cli) error = %v", err)
|
||||
}
|
||||
|
||||
cliProvider, ok := provider.(*ClaudeCliProvider)
|
||||
if !ok {
|
||||
t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider)
|
||||
}
|
||||
if cliProvider.workspace != "/test/ws" {
|
||||
t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProvider_ClaudeCode(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claude-code"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider(claude-code) error = %v", err)
|
||||
}
|
||||
if _, ok := provider.(*ClaudeCliProvider); !ok {
|
||||
t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claudecode"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider(claudecode) error = %v", err)
|
||||
}
|
||||
if _, ok := provider.(*ClaudeCliProvider); !ok {
|
||||
t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
||||
cfg.Agents.Defaults.Workspace = ""
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider error = %v", err)
|
||||
}
|
||||
|
||||
cliProvider, ok := provider.(*ClaudeCliProvider)
|
||||
if !ok {
|
||||
t.Fatalf("returned %T, want *ClaudeCliProvider", provider)
|
||||
}
|
||||
if cliProvider.workspace != "." {
|
||||
t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".")
|
||||
}
|
||||
}
|
||||
|
||||
// --- messagesToPrompt tests ---
|
||||
|
||||
func TestMessagesToPrompt_SingleUser(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
want := "Hello"
|
||||
if got != want {
|
||||
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_Conversation(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
{Role: "assistant", Content: "Hello!"},
|
||||
{Role: "user", Content: "How are you?"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
want := "User: Hi\nAssistant: Hello!\nUser: How are you?"
|
||||
if got != want {
|
||||
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_WithSystemMessage(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
want := "Hello"
|
||||
if got != want {
|
||||
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_WithToolResults(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
if !strings.Contains(got, "[Tool Result for call_123]") {
|
||||
t.Errorf("messagesToPrompt() missing tool result marker, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `{"temp": 72}`) {
|
||||
t.Errorf("messagesToPrompt() missing tool result content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_EmptyMessages(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
got := p.messagesToPrompt(nil)
|
||||
if got != "" {
|
||||
t.Errorf("messagesToPrompt(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_OnlySystemMessages(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "System 1"},
|
||||
{Role: "system", Content: "System 2"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
if got != "" {
|
||||
t.Errorf("messagesToPrompt() with only system msgs = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildSystemPrompt tests ---
|
||||
|
||||
func TestBuildSystemPrompt_NoSystemNoTools(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
got := p.buildSystemPrompt(messages, nil)
|
||||
if got != "" {
|
||||
t.Errorf("buildSystemPrompt() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_SystemOnly(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
got := p.buildSystemPrompt(messages, nil)
|
||||
if got != "You are helpful." {
|
||||
t.Errorf("buildSystemPrompt() = %q, want %q", got, "You are helpful.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_MultipleSystemMessages(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "system", Content: "Be concise."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
got := p.buildSystemPrompt(messages, nil)
|
||||
if !strings.Contains(got, "You are helpful.") {
|
||||
t.Error("missing first system message")
|
||||
}
|
||||
if !strings.Contains(got, "Be concise.") {
|
||||
t.Error("missing second system message")
|
||||
}
|
||||
// Should be joined with double newline
|
||||
want := "You are helpful.\n\nBe concise."
|
||||
if got != want {
|
||||
t.Errorf("buildSystemPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_WithTools(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
}
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a location",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"location": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := p.buildSystemPrompt(messages, tools)
|
||||
if !strings.Contains(got, "You are helpful.") {
|
||||
t.Error("buildSystemPrompt() missing system message")
|
||||
}
|
||||
if !strings.Contains(got, "get_weather") {
|
||||
t.Error("buildSystemPrompt() missing tool definition")
|
||||
}
|
||||
if !strings.Contains(got, "Available Tools") {
|
||||
t.Error("buildSystemPrompt() missing tools header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "test_tool",
|
||||
Description: "A test tool",
|
||||
},
|
||||
},
|
||||
}
|
||||
got := p.buildSystemPrompt(nil, tools)
|
||||
if !strings.Contains(got, "test_tool") {
|
||||
t.Error("should include tool definitions even without system messages")
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildToolsPrompt tests ---
|
||||
|
||||
func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
tools := []ToolDefinition{
|
||||
{Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}},
|
||||
{Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}},
|
||||
}
|
||||
got := p.buildToolsPrompt(tools)
|
||||
if strings.Contains(got, "skip_me") {
|
||||
t.Error("buildToolsPrompt() should skip non-function tools")
|
||||
}
|
||||
if !strings.Contains(got, "include_me") {
|
||||
t.Error("buildToolsPrompt() should include function tools")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolsPrompt_NoDescription(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
tools := []ToolDefinition{
|
||||
{Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}},
|
||||
}
|
||||
got := p.buildToolsPrompt(tools)
|
||||
if !strings.Contains(got, "bare_tool") {
|
||||
t.Error("should include tool name")
|
||||
}
|
||||
if strings.Contains(got, "Description:") {
|
||||
t.Error("should not include Description: line when empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolsPrompt_NoParameters(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
tools := []ToolDefinition{
|
||||
{Type: "function", Function: ToolFunctionDefinition{
|
||||
Name: "no_params_tool",
|
||||
Description: "A tool with no parameters",
|
||||
}},
|
||||
}
|
||||
got := p.buildToolsPrompt(tools)
|
||||
if strings.Contains(got, "Parameters:") {
|
||||
t.Error("should not include Parameters: section when nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseClaudeCliResponse tests ---
|
||||
|
||||
func TestParseClaudeCliResponse_TextOnly(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","session_id":"abc123","total_cost_usd":0.01,"duration_ms":500,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parseClaudeCliResponse() error = %v", err)
|
||||
}
|
||||
if resp.Content != "Hello, world!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello, world!")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if len(resp.ToolCalls) != 0 {
|
||||
t.Errorf("ToolCalls = %d, want 0", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Fatal("Usage should not be nil")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 10 {
|
||||
t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens)
|
||||
}
|
||||
if resp.Usage.CompletionTokens != 20 {
|
||||
t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_EmptyResult(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"abc"}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if resp.Content != "" {
|
||||
t.Errorf("Content = %q, want empty", resp.Content)
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_IsError(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong","session_id":"abc"}`
|
||||
|
||||
_, err := p.parseClaudeCliResponse(output)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when is_error=true")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Something went wrong") {
|
||||
t.Errorf("error = %q, want to contain 'Something went wrong'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_NoUsage(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s"}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if resp.Usage != nil {
|
||||
t.Errorf("Usage should be nil when no tokens, got %+v", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_InvalidJSON(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
_, err := p.parseClaudeCliResponse("not json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse claude cli response") {
|
||||
t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_WithToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"Let me check.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"Tokyo\\\"}\"}}]}","session_id":"abc123","total_cost_usd":0.01}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("ToolCalls = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
tc := resp.ToolCalls[0]
|
||||
if tc.Name != "get_weather" {
|
||||
t.Errorf("Name = %q, want %q", tc.Name, "get_weather")
|
||||
}
|
||||
if tc.Function == nil {
|
||||
t.Fatal("Function is nil")
|
||||
}
|
||||
if tc.Function.Name != "get_weather" {
|
||||
t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather")
|
||||
}
|
||||
if tc.Arguments["location"] != "Tokyo" {
|
||||
t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"])
|
||||
}
|
||||
if strings.Contains(resp.Content, "tool_calls") {
|
||||
t.Errorf("Content should not contain tool_calls JSON, got %q", resp.Content)
|
||||
}
|
||||
if resp.Content != "Let me check." {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Let me check.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":" hello \n ","session_id":"s"}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if resp.Content != "hello" {
|
||||
t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
// --- extractToolCalls tests ---
|
||||
|
||||
func TestExtractToolCalls_NoToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
got := p.extractToolCalls("Just a regular response.")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("extractToolCalls() = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_WithToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `Here's the result:
|
||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}`
|
||||
|
||||
got := p.extractToolCalls(text)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("extractToolCalls() = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].ID != "call_1" {
|
||||
t.Errorf("ID = %q, want %q", got[0].ID, "call_1")
|
||||
}
|
||||
if got[0].Name != "test" {
|
||||
t.Errorf("Name = %q, want %q", got[0].Name, "test")
|
||||
}
|
||||
if got[0].Type != "function" {
|
||||
t.Errorf("Type = %q, want %q", got[0].Type, "function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_InvalidJSON(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
got := p.extractToolCalls(`{"tool_calls":invalid}`)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_MultipleToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}`
|
||||
|
||||
got := p.extractToolCalls(text)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("extractToolCalls() = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "read_file" {
|
||||
t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file")
|
||||
}
|
||||
if got[1].Name != "write_file" {
|
||||
t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file")
|
||||
}
|
||||
// Verify arguments were parsed
|
||||
if got[0].Arguments["path"] != "/tmp/test" {
|
||||
t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"])
|
||||
}
|
||||
if got[1].Arguments["content"] != "hello" {
|
||||
t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_UnmatchedBrace(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}`
|
||||
|
||||
got := p.extractToolCalls(text)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
// Verify different argument types
|
||||
if got[0].Arguments["num"] != float64(42) {
|
||||
t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"])
|
||||
}
|
||||
if got[0].Arguments["flag"] != true {
|
||||
t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"])
|
||||
}
|
||||
if got[0].Arguments["name"] != "test" {
|
||||
t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"])
|
||||
}
|
||||
// Verify raw arguments string is preserved in FunctionCall
|
||||
if got[0].Function.Arguments == "" {
|
||||
t.Error("Function.Arguments should contain raw JSON string")
|
||||
}
|
||||
}
|
||||
|
||||
// --- stripToolCallsJSON tests ---
|
||||
|
||||
func TestStripToolCallsJSON(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `Let me check the weather.
|
||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}
|
||||
Done.`
|
||||
|
||||
got := p.stripToolCallsJSON(text)
|
||||
if strings.Contains(got, "tool_calls") {
|
||||
t.Errorf("should remove tool_calls JSON, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Let me check the weather.") {
|
||||
t.Errorf("should keep text before, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Done.") {
|
||||
t.Errorf("should keep text after, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripToolCallsJSON_NoToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := "Just regular text."
|
||||
got := p.stripToolCallsJSON(text)
|
||||
if got != text {
|
||||
t.Errorf("stripToolCallsJSON() = %q, want %q", got, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}`
|
||||
got := p.stripToolCallsJSON(text)
|
||||
if got != "" {
|
||||
t.Errorf("stripToolCallsJSON() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- findMatchingBrace tests ---
|
||||
|
||||
func TestFindMatchingBrace(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
pos int
|
||||
want int
|
||||
}{
|
||||
{`{"a":1}`, 0, 7},
|
||||
{`{"a":{"b":2}}`, 0, 13},
|
||||
{`text {"a":1} more`, 5, 12},
|
||||
{`{unclosed`, 0, 0}, // no match returns pos
|
||||
{`{}`, 0, 2}, // empty object
|
||||
{`{{{}}}`, 0, 6}, // deeply nested
|
||||
{`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := findMatchingBrace(tt.text, tt.pos)
|
||||
if got != tt.want {
|
||||
t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
)
|
||||
|
||||
type ClaudeProvider struct {
|
||||
client *anthropic.Client
|
||||
tokenSource func() (string, error)
|
||||
}
|
||||
|
||||
func NewClaudeProvider(token string) *ClaudeProvider {
|
||||
client := anthropic.NewClient(
|
||||
option.WithAuthToken(token),
|
||||
option.WithBaseURL("https://api.anthropic.com"),
|
||||
)
|
||||
return &ClaudeProvider{client: &client}
|
||||
}
|
||||
|
||||
func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider {
|
||||
p := NewClaudeProvider(token)
|
||||
p.tokenSource = tokenSource
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
var opts []option.RequestOption
|
||||
if p.tokenSource != nil {
|
||||
tok, err := p.tokenSource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||
}
|
||||
opts = append(opts, option.WithAuthToken(tok))
|
||||
}
|
||||
|
||||
params, err := buildClaudeParams(messages, tools, model, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.client.Messages.New(ctx, params, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claude API call: %w", err)
|
||||
}
|
||||
|
||||
return parseClaudeResponse(resp), nil
|
||||
}
|
||||
|
||||
func (p *ClaudeProvider) GetDefaultModel() string {
|
||||
return "claude-sonnet-4-5-20250929"
|
||||
}
|
||||
|
||||
func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
|
||||
var system []anthropic.TextBlockParam
|
||||
var anthropicMessages []anthropic.MessageParam
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
system = append(system, anthropic.TextBlockParam{Text: msg.Content})
|
||||
case "user":
|
||||
if msg.ToolCallID != "" {
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
||||
)
|
||||
} else {
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
|
||||
)
|
||||
}
|
||||
case "assistant":
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
var blocks []anthropic.ContentBlockParamUnion
|
||||
if msg.Content != "" {
|
||||
blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
|
||||
}
|
||||
anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
|
||||
} else {
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
|
||||
)
|
||||
}
|
||||
case "tool":
|
||||
anthropicMessages = append(anthropicMessages,
|
||||
anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
maxTokens := int64(4096)
|
||||
if mt, ok := options["max_tokens"].(int); ok {
|
||||
maxTokens = int64(mt)
|
||||
}
|
||||
|
||||
params := anthropic.MessageNewParams{
|
||||
Model: anthropic.Model(model),
|
||||
Messages: anthropicMessages,
|
||||
MaxTokens: maxTokens,
|
||||
}
|
||||
|
||||
if len(system) > 0 {
|
||||
params.System = system
|
||||
}
|
||||
|
||||
if temp, ok := options["temperature"].(float64); ok {
|
||||
params.Temperature = anthropic.Float(temp)
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
params.Tools = translateToolsForClaude(tools)
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func translateToolsForClaude(tools []ToolDefinition) []anthropic.ToolUnionParam {
|
||||
result := make([]anthropic.ToolUnionParam, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
tool := anthropic.ToolParam{
|
||||
Name: t.Function.Name,
|
||||
InputSchema: anthropic.ToolInputSchemaParam{
|
||||
Properties: t.Function.Parameters["properties"],
|
||||
},
|
||||
}
|
||||
if desc := t.Function.Description; desc != "" {
|
||||
tool.Description = anthropic.String(desc)
|
||||
}
|
||||
if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
|
||||
required := make([]string, 0, len(req))
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
required = append(required, s)
|
||||
}
|
||||
}
|
||||
tool.InputSchema.Required = required
|
||||
}
|
||||
result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseClaudeResponse(resp *anthropic.Message) *LLMResponse {
|
||||
var content string
|
||||
var toolCalls []ToolCall
|
||||
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
tb := block.AsText()
|
||||
content += tb.Text
|
||||
case "tool_use":
|
||||
tu := block.AsToolUse()
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal(tu.Input, &args); err != nil {
|
||||
args = map[string]interface{}{"raw": string(tu.Input)}
|
||||
}
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: tu.ID,
|
||||
Name: tu.Name,
|
||||
Arguments: args,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
switch resp.StopReason {
|
||||
case anthropic.StopReasonToolUse:
|
||||
finishReason = "tool_calls"
|
||||
case anthropic.StopReasonMaxTokens:
|
||||
finishReason = "length"
|
||||
case anthropic.StopReasonEndTurn:
|
||||
finishReason = "stop"
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: content,
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: &UsageInfo{
|
||||
PromptTokens: int(resp.Usage.InputTokens),
|
||||
CompletionTokens: int(resp.Usage.OutputTokens),
|
||||
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createClaudeTokenSource() func() (string, error) {
|
||||
return func() (string, error) {
|
||||
cred, err := auth.GetCredential("anthropic")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("loading auth credentials: %w", err)
|
||||
}
|
||||
if cred == nil {
|
||||
return "", fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
||||
}
|
||||
return cred.AccessToken, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
||||
)
|
||||
|
||||
func TestBuildClaudeParams_BasicMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
|
||||
"max_tokens": 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
||||
}
|
||||
if string(params.Model) != "claude-sonnet-4-5-20250929" {
|
||||
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929")
|
||||
}
|
||||
if params.MaxTokens != 1024 {
|
||||
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
||||
}
|
||||
if len(params.Messages) != 1 {
|
||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeParams_SystemMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful"},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
||||
}
|
||||
if len(params.System) != 1 {
|
||||
t.Fatalf("len(System) = %d, want 1", len(params.System))
|
||||
}
|
||||
if params.System[0].Text != "You are helpful" {
|
||||
t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
|
||||
}
|
||||
if len(params.Messages) != 1 {
|
||||
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeParams_ToolCallMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: []ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Name: "get_weather",
|
||||
Arguments: map[string]interface{}{"city": "SF"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
||||
}
|
||||
params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
||||
}
|
||||
if len(params.Messages) != 3 {
|
||||
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeParams_WithTools(t *testing.T) {
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a city",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"city": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []interface{}{"city"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
params, err := buildClaudeParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildClaudeParams() error: %v", err)
|
||||
}
|
||||
if len(params.Tools) != 1 {
|
||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeResponse_TextOnly(t *testing.T) {
|
||||
resp := &anthropic.Message{
|
||||
Content: []anthropic.ContentBlockUnion{},
|
||||
Usage: anthropic.Usage{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 20,
|
||||
},
|
||||
}
|
||||
result := parseClaudeResponse(resp)
|
||||
if result.Usage.PromptTokens != 10 {
|
||||
t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
|
||||
}
|
||||
if result.Usage.CompletionTokens != 20 {
|
||||
t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
|
||||
}
|
||||
if result.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeResponse_StopReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
stopReason anthropic.StopReason
|
||||
want string
|
||||
}{
|
||||
{anthropic.StopReasonEndTurn, "stop"},
|
||||
{anthropic.StopReasonMaxTokens, "length"},
|
||||
{anthropic.StopReasonToolUse, "tool_calls"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
resp := &anthropic.Message{
|
||||
StopReason: tt.stopReason,
|
||||
}
|
||||
result := parseClaudeResponse(resp)
|
||||
if result.FinishReason != tt.want {
|
||||
t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/messages" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": reqBody["model"],
|
||||
"stop_reason": "end_turn",
|
||||
"content": []map[string]interface{}{
|
||||
{"type": "text", "text": "Hello! How can I help you?"},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": 15,
|
||||
"output_tokens": 8,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewClaudeProvider("test-token")
|
||||
provider.client = createAnthropicTestClient(server.URL, "test-token")
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if resp.Content != "Hello! How can I help you?" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 15 {
|
||||
t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeProvider_GetDefaultModel(t *testing.T) {
|
||||
p := NewClaudeProvider("test-token")
|
||||
if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929")
|
||||
}
|
||||
}
|
||||
|
||||
func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
|
||||
c := anthropic.NewClient(
|
||||
anthropicoption.WithAuthToken(token),
|
||||
anthropicoption.WithBaseURL(baseURL),
|
||||
)
|
||||
return &c
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CodexCliAuth represents the ~/.codex/auth.json file structure.
|
||||
type CodexCliAuth struct {
|
||||
Tokens struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccountID string `json:"account_id"`
|
||||
} `json:"tokens"`
|
||||
}
|
||||
|
||||
// ReadCodexCliCredentials reads OAuth tokens from the Codex CLI's auth.json file.
|
||||
// Expiry is estimated as file modification time + 1 hour (same approach as moltbot).
|
||||
func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) {
|
||||
authPath, err := resolveCodexAuthPath()
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(authPath)
|
||||
if err != nil {
|
||||
return "", "", time.Time{}, fmt.Errorf("reading %s: %w", authPath, err)
|
||||
}
|
||||
|
||||
var auth CodexCliAuth
|
||||
if err := json.Unmarshal(data, &auth); err != nil {
|
||||
return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err)
|
||||
}
|
||||
|
||||
if auth.Tokens.AccessToken == "" {
|
||||
return "", "", time.Time{}, fmt.Errorf("no access_token in %s", authPath)
|
||||
}
|
||||
|
||||
stat, err := os.Stat(authPath)
|
||||
if err != nil {
|
||||
expiresAt = time.Now().Add(time.Hour)
|
||||
} else {
|
||||
expiresAt = stat.ModTime().Add(time.Hour)
|
||||
}
|
||||
|
||||
return auth.Tokens.AccessToken, auth.Tokens.AccountID, expiresAt, nil
|
||||
}
|
||||
|
||||
// CreateCodexCliTokenSource creates a token source that reads from ~/.codex/auth.json.
|
||||
// This allows the existing CodexProvider to reuse Codex CLI credentials.
|
||||
func CreateCodexCliTokenSource() func() (string, string, error) {
|
||||
return func() (string, string, error) {
|
||||
token, accountID, expiresAt, err := ReadCodexCliCredentials()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("reading codex cli credentials: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login")
|
||||
}
|
||||
|
||||
return token, accountID, nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveCodexAuthPath() (string, error) {
|
||||
codexHome := os.Getenv("CODEX_HOME")
|
||||
if codexHome == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting home dir: %w", err)
|
||||
}
|
||||
codexHome = filepath.Join(home, ".codex")
|
||||
}
|
||||
return filepath.Join(codexHome, "auth.json"), nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReadCodexCliCredentials_Valid(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{
|
||||
"tokens": {
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
"account_id": "org-test123"
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
token, accountID, expiresAt, err := ReadCodexCliCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadCodexCliCredentials() error: %v", err)
|
||||
}
|
||||
if token != "test-access-token" {
|
||||
t.Errorf("token = %q, want %q", token, "test-access-token")
|
||||
}
|
||||
if accountID != "org-test123" {
|
||||
t.Errorf("accountID = %q, want %q", accountID, "org-test123")
|
||||
}
|
||||
// Expiry should be within ~1 hour from now (file was just written)
|
||||
if expiresAt.Before(time.Now()) {
|
||||
t.Errorf("expiresAt = %v, should be in the future", expiresAt)
|
||||
}
|
||||
if expiresAt.After(time.Now().Add(2 * time.Hour)) {
|
||||
t.Errorf("expiresAt = %v, should be within ~1 hour", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_MissingFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
_, _, _, err := ReadCodexCliCredentials()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing auth.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
_, _, _, err := ReadCodexCliCredentials()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty access_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
_, _, _, err := ReadCodexCliCredentials()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
token, accountID, _, err := ReadCodexCliCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "tok123" {
|
||||
t.Errorf("token = %q, want %q", token, "tok123")
|
||||
}
|
||||
if accountID != "" {
|
||||
t.Errorf("accountID = %q, want empty", accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
customDir := filepath.Join(tmpDir, "custom-codex")
|
||||
if err := os.MkdirAll(customDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}`
|
||||
if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", customDir)
|
||||
|
||||
token, _, _, err := ReadCodexCliCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "custom-token" {
|
||||
t.Errorf("token = %q, want %q", token, "custom-token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCodexCliTokenSource_Valid(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
source := CreateCodexCliTokenSource()
|
||||
token, accountID, err := source()
|
||||
if err != nil {
|
||||
t.Fatalf("token source error: %v", err)
|
||||
}
|
||||
if token != "fresh-token" {
|
||||
t.Errorf("token = %q, want %q", token, "fresh-token")
|
||||
}
|
||||
if accountID != "acc" {
|
||||
t.Errorf("accountID = %q, want %q", accountID, "acc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCodexCliTokenSource_Expired(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
authPath := filepath.Join(tmpDir, "auth.json")
|
||||
|
||||
authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}`
|
||||
if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set file modification time to 2 hours ago
|
||||
oldTime := time.Now().Add(-2 * time.Hour)
|
||||
if err := os.Chtimes(authPath, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_HOME", tmpDir)
|
||||
|
||||
source := CreateCodexCliTokenSource()
|
||||
_, _, err := source()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired credentials")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
|
||||
type CodexCliProvider struct {
|
||||
command string
|
||||
workspace string
|
||||
}
|
||||
|
||||
// NewCodexCliProvider creates a new Codex CLI provider.
|
||||
func NewCodexCliProvider(workspace string) *CodexCliProvider {
|
||||
return &CodexCliProvider{
|
||||
command: "codex",
|
||||
workspace: workspace,
|
||||
}
|
||||
}
|
||||
|
||||
// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
|
||||
func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
if p.command == "" {
|
||||
return nil, fmt.Errorf("codex command not configured")
|
||||
}
|
||||
|
||||
prompt := p.buildPrompt(messages, tools)
|
||||
|
||||
args := []string{
|
||||
"exec",
|
||||
"--json",
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--skip-git-repo-check",
|
||||
"--color", "never",
|
||||
}
|
||||
if model != "" && model != "codex-cli" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
if p.workspace != "" {
|
||||
args = append(args, "-C", p.workspace)
|
||||
}
|
||||
args = append(args, "-") // read prompt from stdin
|
||||
|
||||
cmd := exec.CommandContext(ctx, p.command, args...)
|
||||
cmd.Stdin = bytes.NewReader([]byte(prompt))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
// Parse JSONL from stdout even if exit code is non-zero,
|
||||
// because codex writes diagnostic noise to stderr (e.g. rollout errors)
|
||||
// but still produces valid JSONL output.
|
||||
if stdoutStr := stdout.String(); stdoutStr != "" {
|
||||
resp, parseErr := p.parseJSONLEvents(stdoutStr)
|
||||
if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() == context.Canceled {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if stderrStr := stderr.String(); stderrStr != "" {
|
||||
return nil, fmt.Errorf("codex cli error: %s", stderrStr)
|
||||
}
|
||||
return nil, fmt.Errorf("codex cli error: %w", err)
|
||||
}
|
||||
|
||||
return p.parseJSONLEvents(stdout.String())
|
||||
}
|
||||
|
||||
// GetDefaultModel returns the default model identifier.
|
||||
func (p *CodexCliProvider) GetDefaultModel() string {
|
||||
return "codex-cli"
|
||||
}
|
||||
|
||||
// buildPrompt converts messages to a prompt string for the Codex CLI.
|
||||
// System messages are prepended as instructions since Codex CLI has no --system-prompt flag.
|
||||
func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string {
|
||||
var systemParts []string
|
||||
var conversationParts []string
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
systemParts = append(systemParts, msg.Content)
|
||||
case "user":
|
||||
conversationParts = append(conversationParts, msg.Content)
|
||||
case "assistant":
|
||||
conversationParts = append(conversationParts, "Assistant: "+msg.Content)
|
||||
case "tool":
|
||||
conversationParts = append(conversationParts,
|
||||
fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content))
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
if len(systemParts) > 0 {
|
||||
sb.WriteString("## System Instructions\n\n")
|
||||
sb.WriteString(strings.Join(systemParts, "\n\n"))
|
||||
sb.WriteString("\n\n## Task\n\n")
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
sb.WriteString(p.buildToolsPrompt(tools))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Simplify single user message (no prefix)
|
||||
if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 {
|
||||
return conversationParts[0]
|
||||
}
|
||||
|
||||
sb.WriteString(strings.Join(conversationParts, "\n"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildToolsPrompt creates a tool definitions section for the prompt.
|
||||
func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Available Tools\n\n")
|
||||
sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
|
||||
sb.WriteString("\n```\n\n")
|
||||
sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
|
||||
sb.WriteString("### Tool Definitions:\n\n")
|
||||
|
||||
for _, tool := range tools {
|
||||
if tool.Type != "function" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
|
||||
if tool.Function.Description != "" {
|
||||
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
|
||||
}
|
||||
if len(tool.Function.Parameters) > 0 {
|
||||
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
|
||||
sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// codexEvent represents a single JSONL event from `codex exec --json`.
|
||||
type codexEvent struct {
|
||||
Type string `json:"type"`
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Item *codexEventItem `json:"item,omitempty"`
|
||||
Usage *codexUsage `json:"usage,omitempty"`
|
||||
Error *codexEventErr `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type codexEventItem struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ExitCode *int `json:"exit_code,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
type codexUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CachedInputTokens int `json:"cached_input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
type codexEventErr struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// parseJSONLEvents processes the JSONL output from codex exec --json.
|
||||
func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) {
|
||||
var contentParts []string
|
||||
var usage *UsageInfo
|
||||
var lastError string
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event codexEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
continue // skip malformed lines
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "item.completed":
|
||||
if event.Item != nil && event.Item.Type == "agent_message" && event.Item.Text != "" {
|
||||
contentParts = append(contentParts, event.Item.Text)
|
||||
}
|
||||
case "turn.completed":
|
||||
if event.Usage != nil {
|
||||
promptTokens := event.Usage.InputTokens + event.Usage.CachedInputTokens
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: promptTokens,
|
||||
CompletionTokens: event.Usage.OutputTokens,
|
||||
TotalTokens: promptTokens + event.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
case "error":
|
||||
lastError = event.Message
|
||||
case "turn.failed":
|
||||
if event.Error != nil {
|
||||
lastError = event.Error.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastError != "" && len(contentParts) == 0 {
|
||||
return nil, fmt.Errorf("codex cli: %s", lastError)
|
||||
}
|
||||
|
||||
content := strings.Join(contentParts, "\n")
|
||||
|
||||
// Extract tool calls from response text (same pattern as ClaudeCliProvider)
|
||||
toolCalls := extractToolCallsFromText(content)
|
||||
|
||||
finishReason := "stop"
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
content = stripToolCallsFromText(content)
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: strings.TrimSpace(content),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- JSONL Event Parsing Tests ---
|
||||
|
||||
func TestParseJSONLEvents_AgentMessage(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
events := `{"type":"thread.started","thread_id":"abc-123"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello from Codex!"}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":50,"output_tokens":20}}`
|
||||
|
||||
resp, err := p.parseJSONLEvents(events)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
||||
}
|
||||
if resp.Content != "Hello from Codex!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello from Codex!")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Fatal("Usage should not be nil")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 150 {
|
||||
t.Errorf("PromptTokens = %d, want 150", resp.Usage.PromptTokens)
|
||||
}
|
||||
if resp.Usage.CompletionTokens != 20 {
|
||||
t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens)
|
||||
}
|
||||
if resp.Usage.TotalTokens != 170 {
|
||||
t.Errorf("TotalTokens = %d, want 170", resp.Usage.TotalTokens)
|
||||
}
|
||||
if len(resp.ToolCalls) != 0 {
|
||||
t.Errorf("ToolCalls should be empty, got %d", len(resp.ToolCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
toolCallText := `Let me read that file.
|
||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test.txt\"}"}}]}`
|
||||
// Build valid JSONL by marshaling the event
|
||||
item := codexEvent{
|
||||
Type: "item.completed",
|
||||
Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText},
|
||||
}
|
||||
itemJSON, _ := json.Marshal(item)
|
||||
usageEvt := `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":0,"output_tokens":20}}`
|
||||
events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + usageEvt
|
||||
|
||||
resp, err := p.parseJSONLEvents(events)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("ToolCalls count = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.ToolCalls[0].Name != "read_file" {
|
||||
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file")
|
||||
}
|
||||
if resp.ToolCalls[0].ID != "call_1" {
|
||||
t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
|
||||
}
|
||||
if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` {
|
||||
t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments)
|
||||
}
|
||||
// Content should have the tool call JSON stripped
|
||||
if strings.Contains(resp.Content, "tool_calls") {
|
||||
t.Errorf("Content should not contain tool_calls JSON, got: %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_MultipleToolCalls(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
toolCallText := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"b.txt\",\"content\":\"hello\"}"}}]}`
|
||||
item := codexEvent{
|
||||
Type: "item.completed",
|
||||
Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText},
|
||||
}
|
||||
itemJSON, _ := json.Marshal(item)
|
||||
events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + `{"type":"turn.completed"}`
|
||||
|
||||
resp, err := p.parseJSONLEvents(events)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 2 {
|
||||
t.Fatalf("ToolCalls count = %d, want 2", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.ToolCalls[0].Name != "read_file" {
|
||||
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file")
|
||||
}
|
||||
if resp.ToolCalls[1].Name != "write_file" {
|
||||
t.Errorf("ToolCalls[1].Name = %q, want %q", resp.ToolCalls[1].Name, "write_file")
|
||||
}
|
||||
if resp.FinishReason != "tool_calls" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_MultipleMessages(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
events := `{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"First part."}}
|
||||
{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"ls","status":"completed"}}
|
||||
{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Second part."}}
|
||||
{"type":"turn.completed"}`
|
||||
|
||||
resp, err := p.parseJSONLEvents(events)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
||||
}
|
||||
if resp.Content != "First part.\nSecond part." {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "First part.\nSecond part.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_ErrorEvent(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
events := `{"type":"thread.started","thread_id":"abc"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"error","message":"token expired"}
|
||||
{"type":"turn.failed","error":{"message":"token expired"}}`
|
||||
|
||||
_, err := p.parseJSONLEvents(events)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token expired") {
|
||||
t.Errorf("error = %q, want to contain 'token expired'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_TurnFailed(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
events := `{"type":"turn.started"}
|
||||
{"type":"turn.failed","error":{"message":"rate limit exceeded"}}`
|
||||
|
||||
_, err := p.parseJSONLEvents(events)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limit exceeded") {
|
||||
t.Errorf("error = %q, want to contain 'rate limit exceeded'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_ErrorWithContent(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
// If there's an error but also content, return the content (partial success)
|
||||
events := `{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Partial result."}}
|
||||
{"type":"error","message":"connection reset"}
|
||||
{"type":"turn.failed","error":{"message":"connection reset"}}`
|
||||
|
||||
resp, err := p.parseJSONLEvents(events)
|
||||
if err != nil {
|
||||
t.Fatalf("should not error when content exists: %v", err)
|
||||
}
|
||||
if resp.Content != "Partial result." {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Partial result.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_EmptyOutput(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
resp, err := p.parseJSONLEvents("")
|
||||
if err != nil {
|
||||
t.Fatalf("empty output should not error: %v", err)
|
||||
}
|
||||
if resp.Content != "" {
|
||||
t.Errorf("Content = %q, want empty", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_MalformedLines(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
events := `not json at all
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Good line."}}
|
||||
another bad line
|
||||
{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}`
|
||||
|
||||
resp, err := p.parseJSONLEvents(events)
|
||||
if err != nil {
|
||||
t.Fatalf("should skip malformed lines: %v", err)
|
||||
}
|
||||
if resp.Content != "Good line." {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Good line.")
|
||||
}
|
||||
if resp.Usage == nil || resp.Usage.TotalTokens != 15 {
|
||||
t.Errorf("Usage.TotalTokens = %v, want 15", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_CommandExecution(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
events := `{"type":"turn.started"}
|
||||
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"completed","exit_code":0,"output":"file1.go\nfile2.go"}}
|
||||
{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"Found 2 files."}}
|
||||
{"type":"turn.completed"}`
|
||||
|
||||
resp, err := p.parseJSONLEvents(events)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
||||
}
|
||||
// command_execution items should be skipped; only agent_message text is returned
|
||||
if resp.Content != "Found 2 files." {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Found 2 files.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONLEvents_NoUsage(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
events := `{"type":"turn.started"}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"No usage info."}}
|
||||
{"type":"turn.completed"}`
|
||||
|
||||
resp, err := p.parseJSONLEvents(events)
|
||||
if err != nil {
|
||||
t.Fatalf("parseJSONLEvents() error: %v", err)
|
||||
}
|
||||
if resp.Usage != nil {
|
||||
t.Errorf("Usage should be nil when turn.completed has no usage, got %+v", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prompt Building Tests ---
|
||||
|
||||
func TestBuildPrompt_SystemAsInstructions(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hi there"},
|
||||
}
|
||||
|
||||
prompt := p.buildPrompt(messages, nil)
|
||||
|
||||
if !strings.Contains(prompt, "## System Instructions") {
|
||||
t.Error("prompt should contain '## System Instructions'")
|
||||
}
|
||||
if !strings.Contains(prompt, "You are helpful.") {
|
||||
t.Error("prompt should contain system content")
|
||||
}
|
||||
if !strings.Contains(prompt, "## Task") {
|
||||
t.Error("prompt should contain '## Task'")
|
||||
}
|
||||
if !strings.Contains(prompt, "Hi there") {
|
||||
t.Error("prompt should contain user message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_NoSystem(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Just a question"},
|
||||
}
|
||||
|
||||
prompt := p.buildPrompt(messages, nil)
|
||||
|
||||
if strings.Contains(prompt, "## System Instructions") {
|
||||
t.Error("prompt should not contain system instructions header")
|
||||
}
|
||||
if prompt != "Just a question" {
|
||||
t.Errorf("prompt = %q, want %q", prompt, "Just a question")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_WithTools(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Get weather"},
|
||||
}
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get current weather",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"city": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
prompt := p.buildPrompt(messages, tools)
|
||||
|
||||
if !strings.Contains(prompt, "## Available Tools") {
|
||||
t.Error("prompt should contain tools section")
|
||||
}
|
||||
if !strings.Contains(prompt, "get_weather") {
|
||||
t.Error("prompt should contain tool name")
|
||||
}
|
||||
if !strings.Contains(prompt, "Get current weather") {
|
||||
t.Error("prompt should contain tool description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_MultipleMessages(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
{Role: "assistant", Content: "Hi! How can I help?"},
|
||||
{Role: "user", Content: "Tell me about Go"},
|
||||
}
|
||||
|
||||
prompt := p.buildPrompt(messages, nil)
|
||||
|
||||
if !strings.Contains(prompt, "Hello") {
|
||||
t.Error("prompt should contain first user message")
|
||||
}
|
||||
if !strings.Contains(prompt, "Assistant: Hi! How can I help?") {
|
||||
t.Error("prompt should contain assistant message with prefix")
|
||||
}
|
||||
if !strings.Contains(prompt, "Tell me about Go") {
|
||||
t.Error("prompt should contain second user message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_ToolResults(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Weather?"},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
||||
}
|
||||
|
||||
prompt := p.buildPrompt(messages, nil)
|
||||
|
||||
if !strings.Contains(prompt, "[Tool Result for call_1]") {
|
||||
t.Error("prompt should contain tool result")
|
||||
}
|
||||
if !strings.Contains(prompt, `{"temp": 72}`) {
|
||||
t.Error("prompt should contain tool result content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_SystemAndTools(t *testing.T) {
|
||||
p := &CodexCliProvider{}
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "Be concise."},
|
||||
{Role: "user", Content: "Do something"},
|
||||
}
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "my_tool",
|
||||
Description: "A tool",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
prompt := p.buildPrompt(messages, tools)
|
||||
|
||||
// System instructions should come first
|
||||
sysIdx := strings.Index(prompt, "## System Instructions")
|
||||
toolIdx := strings.Index(prompt, "## Available Tools")
|
||||
taskIdx := strings.Index(prompt, "## Task")
|
||||
|
||||
if sysIdx == -1 || toolIdx == -1 || taskIdx == -1 {
|
||||
t.Fatal("prompt should contain all sections")
|
||||
}
|
||||
if sysIdx >= taskIdx {
|
||||
t.Error("system instructions should come before task")
|
||||
}
|
||||
if taskIdx >= toolIdx {
|
||||
t.Error("task section should come before tools in the output")
|
||||
}
|
||||
}
|
||||
|
||||
// --- CLI Argument Tests ---
|
||||
|
||||
func TestCodexCliProvider_GetDefaultModel(t *testing.T) {
|
||||
p := NewCodexCliProvider("")
|
||||
if got := p.GetDefaultModel(); got != "codex-cli" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "codex-cli")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mock CLI Integration Test ---
|
||||
|
||||
func createMockCodexCLI(t *testing.T, events []string) string {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "codex")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("#!/bin/bash\n")
|
||||
for _, event := range events {
|
||||
sb.WriteString(fmt.Sprintf("echo '%s'\n", event))
|
||||
}
|
||||
|
||||
if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
func TestCodexCliProvider_MockCLI_Success(t *testing.T) {
|
||||
scriptPath := createMockCodexCLI(t, []string{
|
||||
`{"type":"thread.started","thread_id":"test-123"}`,
|
||||
`{"type":"turn.started"}`,
|
||||
`{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Mock response from Codex CLI"}}`,
|
||||
`{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":10,"output_tokens":15}}`,
|
||||
})
|
||||
|
||||
p := &CodexCliProvider{
|
||||
command: scriptPath,
|
||||
workspace: "",
|
||||
}
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := p.Chat(context.Background(), messages, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if resp.Content != "Mock response from Codex CLI" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Mock response from Codex CLI")
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Fatal("Usage should not be nil")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 60 {
|
||||
t.Errorf("PromptTokens = %d, want 60", resp.Usage.PromptTokens)
|
||||
}
|
||||
if resp.Usage.CompletionTokens != 15 {
|
||||
t.Errorf("CompletionTokens = %d, want 15", resp.Usage.CompletionTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexCliProvider_MockCLI_Error(t *testing.T) {
|
||||
scriptPath := createMockCodexCLI(t, []string{
|
||||
`{"type":"thread.started","thread_id":"test-err"}`,
|
||||
`{"type":"turn.started"}`,
|
||||
`{"type":"error","message":"auth token expired"}`,
|
||||
`{"type":"turn.failed","error":{"message":"auth token expired"}}`,
|
||||
})
|
||||
|
||||
p := &CodexCliProvider{
|
||||
command: scriptPath,
|
||||
workspace: "",
|
||||
}
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
_, err := p.Chat(context.Background(), messages, nil, "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "auth token expired") {
|
||||
t.Errorf("error = %q, want to contain 'auth token expired'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) {
|
||||
// Mock script that captures args to verify model flag is passed
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "codex")
|
||||
script := `#!/bin/bash
|
||||
# Write args to a file for verification
|
||||
echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `"
|
||||
echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
|
||||
echo '{"type":"turn.completed"}'`
|
||||
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &CodexCliProvider{
|
||||
command: scriptPath,
|
||||
workspace: "/tmp/test-workspace",
|
||||
}
|
||||
|
||||
messages := []Message{{Role: "user", Content: "test"}}
|
||||
_, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the args
|
||||
argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading args: %v", err)
|
||||
}
|
||||
args := string(argsData)
|
||||
|
||||
if !strings.Contains(args, "-m gpt-5.2-codex") {
|
||||
t.Errorf("args should contain model flag, got: %s", args)
|
||||
}
|
||||
if !strings.Contains(args, "-C /tmp/test-workspace") {
|
||||
t.Errorf("args should contain workspace flag, got: %s", args)
|
||||
}
|
||||
if !strings.Contains(args, "--json") {
|
||||
t.Errorf("args should contain --json, got: %s", args)
|
||||
}
|
||||
if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") {
|
||||
t.Errorf("args should contain bypass flag, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
|
||||
// Script that sleeps forever
|
||||
tmpDir := t.TempDir()
|
||||
scriptPath := filepath.Join(tmpDir, "codex")
|
||||
script := "#!/bin/bash\nsleep 60"
|
||||
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &CodexCliProvider{
|
||||
command: scriptPath,
|
||||
workspace: "",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
|
||||
messages := []Message{{Role: "user", Content: "test"}}
|
||||
_, err := p.Chat(ctx, messages, nil, "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on canceled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexCliProvider_EmptyCommand(t *testing.T) {
|
||||
p := &CodexCliProvider{command: ""}
|
||||
|
||||
messages := []Message{{Role: "user", Content: "test"}}
|
||||
_, err := p.Chat(context.Background(), messages, nil, "", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty command")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Integration Test (requires real codex CLI with valid auth) ---
|
||||
|
||||
func TestCodexCliProvider_Integration(t *testing.T) {
|
||||
if os.Getenv("PICOCLAW_INTEGRATION_TESTS") == "" {
|
||||
t.Skip("skipping integration test (set PICOCLAW_INTEGRATION_TESTS=1 to enable)")
|
||||
}
|
||||
|
||||
// Verify codex is available
|
||||
codexPath, err := exec.LookPath("codex")
|
||||
if err != nil {
|
||||
t.Skip("codex CLI not found in PATH")
|
||||
}
|
||||
|
||||
p := &CodexCliProvider{
|
||||
command: codexPath,
|
||||
workspace: "",
|
||||
}
|
||||
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Respond with just the word 'hello' and nothing else."},
|
||||
}
|
||||
|
||||
resp, err := p.Chat(context.Background(), messages, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(strings.TrimSpace(resp.Content))
|
||||
if !strings.Contains(lower, "hello") {
|
||||
t.Errorf("Content = %q, expected to contain 'hello'", resp.Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/option"
|
||||
"github.com/openai/openai-go/v3/responses"
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const codexDefaultModel = "gpt-5.2"
|
||||
const codexDefaultInstructions = "You are Codex, a coding assistant."
|
||||
|
||||
type CodexProvider struct {
|
||||
client *openai.Client
|
||||
accountID string
|
||||
tokenSource func() (string, string, error)
|
||||
}
|
||||
|
||||
const defaultCodexInstructions = "You are Codex, a coding assistant."
|
||||
|
||||
func NewCodexProvider(token, accountID string) *CodexProvider {
|
||||
opts := []option.RequestOption{
|
||||
option.WithBaseURL("https://chatgpt.com/backend-api/codex"),
|
||||
option.WithAPIKey(token),
|
||||
option.WithHeader("originator", "codex_cli_rs"),
|
||||
option.WithHeader("OpenAI-Beta", "responses=experimental"),
|
||||
}
|
||||
if accountID != "" {
|
||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
||||
}
|
||||
client := openai.NewClient(opts...)
|
||||
return &CodexProvider{
|
||||
client: &client,
|
||||
accountID: accountID,
|
||||
}
|
||||
}
|
||||
|
||||
func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func() (string, string, error)) *CodexProvider {
|
||||
p := NewCodexProvider(token, accountID)
|
||||
p.tokenSource = tokenSource
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
var opts []option.RequestOption
|
||||
accountID := p.accountID
|
||||
resolvedModel, fallbackReason := resolveCodexModel(model)
|
||||
if fallbackReason != "" {
|
||||
logger.WarnCF("provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]interface{}{
|
||||
"requested_model": model,
|
||||
"resolved_model": resolvedModel,
|
||||
"reason": fallbackReason,
|
||||
})
|
||||
}
|
||||
if p.tokenSource != nil {
|
||||
tok, accID, err := p.tokenSource()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refreshing token: %w", err)
|
||||
}
|
||||
opts = append(opts, option.WithAPIKey(tok))
|
||||
if accID != "" {
|
||||
accountID = accID
|
||||
}
|
||||
}
|
||||
if accountID != "" {
|
||||
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
|
||||
} else {
|
||||
logger.WarnCF("provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]interface{}{
|
||||
"requested_model": model,
|
||||
"resolved_model": resolvedModel,
|
||||
})
|
||||
}
|
||||
|
||||
params := buildCodexParams(messages, tools, resolvedModel, options)
|
||||
|
||||
stream := p.client.Responses.NewStreaming(ctx, params, opts...)
|
||||
defer stream.Close()
|
||||
|
||||
var resp *responses.Response
|
||||
for stream.Next() {
|
||||
evt := stream.Current()
|
||||
if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
|
||||
evtResp := evt.Response
|
||||
if evtResp.ID != "" {
|
||||
copy := evtResp
|
||||
resp = ©
|
||||
}
|
||||
}
|
||||
}
|
||||
err := stream.Err()
|
||||
if err != nil {
|
||||
fields := map[string]interface{}{
|
||||
"requested_model": model,
|
||||
"resolved_model": resolvedModel,
|
||||
"messages_count": len(messages),
|
||||
"tools_count": len(tools),
|
||||
"account_id_present": accountID != "",
|
||||
"error": err.Error(),
|
||||
}
|
||||
var apiErr *openai.Error
|
||||
if errors.As(err, &apiErr) {
|
||||
fields["status_code"] = apiErr.StatusCode
|
||||
fields["api_type"] = apiErr.Type
|
||||
fields["api_code"] = apiErr.Code
|
||||
fields["api_param"] = apiErr.Param
|
||||
fields["api_message"] = apiErr.Message
|
||||
if apiErr.StatusCode == 400 {
|
||||
fields["hint"] = "verify account id header and model compatibility for codex backend"
|
||||
}
|
||||
if apiErr.Response != nil {
|
||||
fields["request_id"] = apiErr.Response.Header.Get("x-request-id")
|
||||
}
|
||||
}
|
||||
logger.ErrorCF("provider.codex", "Codex API call failed", fields)
|
||||
return nil, fmt.Errorf("codex API call: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
fields := map[string]interface{}{
|
||||
"requested_model": model,
|
||||
"resolved_model": resolvedModel,
|
||||
"messages_count": len(messages),
|
||||
"tools_count": len(tools),
|
||||
"account_id_present": accountID != "",
|
||||
}
|
||||
logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields)
|
||||
return nil, fmt.Errorf("codex API call: stream ended without completed response")
|
||||
}
|
||||
|
||||
return parseCodexResponse(resp), nil
|
||||
}
|
||||
|
||||
func (p *CodexProvider) GetDefaultModel() string {
|
||||
return codexDefaultModel
|
||||
}
|
||||
|
||||
func resolveCodexModel(model string) (string, string) {
|
||||
m := strings.ToLower(strings.TrimSpace(model))
|
||||
if m == "" {
|
||||
return codexDefaultModel, "empty model"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(m, "openai/") {
|
||||
m = strings.TrimPrefix(m, "openai/")
|
||||
} else if strings.Contains(m, "/") {
|
||||
return codexDefaultModel, "non-openai model namespace"
|
||||
}
|
||||
|
||||
unsupportedPrefixes := []string{
|
||||
"glm",
|
||||
"claude",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"google",
|
||||
"moonshot",
|
||||
"kimi",
|
||||
"qwen",
|
||||
"deepseek",
|
||||
"llama",
|
||||
"meta-llama",
|
||||
"mistral",
|
||||
"grok",
|
||||
"xai",
|
||||
"zhipu",
|
||||
}
|
||||
for _, prefix := range unsupportedPrefixes {
|
||||
if strings.HasPrefix(m, prefix) {
|
||||
return codexDefaultModel, "unsupported model prefix"
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(m, "gpt-") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") {
|
||||
return m, ""
|
||||
}
|
||||
|
||||
return codexDefaultModel, "unsupported model family"
|
||||
}
|
||||
|
||||
func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) responses.ResponseNewParams {
|
||||
var inputItems responses.ResponseInputParam
|
||||
var instructions string
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
instructions = msg.Content
|
||||
case "user":
|
||||
if msg.ToolCallID != "" {
|
||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
||||
CallID: msg.ToolCallID,
|
||||
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||
OfMessage: &responses.EasyInputMessageParam{
|
||||
Role: responses.EasyInputMessageRoleUser,
|
||||
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},
|
||||
},
|
||||
})
|
||||
}
|
||||
case "assistant":
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
if msg.Content != "" {
|
||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||
OfMessage: &responses.EasyInputMessageParam{
|
||||
Role: responses.EasyInputMessageRoleAssistant,
|
||||
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, tc := range msg.ToolCalls {
|
||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||
OfFunctionCall: &responses.ResponseFunctionToolCallParam{
|
||||
CallID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||
OfMessage: &responses.EasyInputMessageParam{
|
||||
Role: responses.EasyInputMessageRoleAssistant,
|
||||
Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)},
|
||||
},
|
||||
})
|
||||
}
|
||||
case "tool":
|
||||
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
|
||||
OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{
|
||||
CallID: msg.ToolCallID,
|
||||
Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
params := responses.ResponseNewParams{
|
||||
Model: model,
|
||||
Input: responses.ResponseNewParamsInputUnion{
|
||||
OfInputItemList: inputItems,
|
||||
},
|
||||
Instructions: openai.Opt(instructions),
|
||||
Store: openai.Opt(false),
|
||||
}
|
||||
|
||||
if instructions != "" {
|
||||
params.Instructions = openai.Opt(instructions)
|
||||
} else {
|
||||
// ChatGPT Codex backend requires instructions to be present.
|
||||
params.Instructions = openai.Opt(defaultCodexInstructions)
|
||||
}
|
||||
|
||||
if maxTokens, ok := options["max_tokens"].(int); ok {
|
||||
params.MaxOutputTokens = openai.Opt(int64(maxTokens))
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
params.Tools = translateToolsForCodex(tools)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam {
|
||||
result := make([]responses.ToolUnionParam, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
ft := responses.FunctionToolParam{
|
||||
Name: t.Function.Name,
|
||||
Parameters: t.Function.Parameters,
|
||||
Strict: openai.Opt(false),
|
||||
}
|
||||
if t.Function.Description != "" {
|
||||
ft.Description = openai.Opt(t.Function.Description)
|
||||
}
|
||||
result = append(result, responses.ToolUnionParam{OfFunction: &ft})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseCodexResponse(resp *responses.Response) *LLMResponse {
|
||||
var content strings.Builder
|
||||
var toolCalls []ToolCall
|
||||
|
||||
for _, item := range resp.Output {
|
||||
switch item.Type {
|
||||
case "message":
|
||||
for _, c := range item.Content {
|
||||
if c.Type == "output_text" {
|
||||
content.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
case "function_call":
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil {
|
||||
args = map[string]interface{}{"raw": item.Arguments}
|
||||
}
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: item.CallID,
|
||||
Name: item.Name,
|
||||
Arguments: args,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
if resp.Status == "incomplete" {
|
||||
finishReason = "length"
|
||||
}
|
||||
|
||||
var usage *UsageInfo
|
||||
if resp.Usage.TotalTokens > 0 {
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: int(resp.Usage.InputTokens),
|
||||
CompletionTokens: int(resp.Usage.OutputTokens),
|
||||
TotalTokens: int(resp.Usage.TotalTokens),
|
||||
}
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: content.String(),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func createCodexTokenSource() func() (string, string, error) {
|
||||
return func() (string, string, error) {
|
||||
cred, err := auth.GetCredential("openai")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("loading auth credentials: %w", err)
|
||||
}
|
||||
if cred == nil {
|
||||
return "", "", fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
|
||||
}
|
||||
|
||||
if cred.AuthMethod == "oauth" && cred.NeedsRefresh() && cred.RefreshToken != "" {
|
||||
oauthCfg := auth.OpenAIOAuthConfig()
|
||||
refreshed, err := auth.RefreshAccessToken(cred, oauthCfg)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("refreshing token: %w", err)
|
||||
}
|
||||
if refreshed.AccountID == "" {
|
||||
refreshed.AccountID = cred.AccountID
|
||||
}
|
||||
if err := auth.SetCredential("openai", refreshed); err != nil {
|
||||
return "", "", fmt.Errorf("saving refreshed token: %w", err)
|
||||
}
|
||||
return refreshed.AccessToken, refreshed.AccountID, nil
|
||||
}
|
||||
|
||||
return cred.AccessToken, cred.AccountID, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/openai/openai-go/v3"
|
||||
openaiopt "github.com/openai/openai-go/v3/option"
|
||||
"github.com/openai/openai-go/v3/responses"
|
||||
)
|
||||
|
||||
func TestBuildCodexParams_BasicMessage(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.7,
|
||||
})
|
||||
if params.Model != "gpt-4o" {
|
||||
t.Errorf("Model = %q, want %q", params.Model, "gpt-4o")
|
||||
}
|
||||
if !params.Instructions.Valid() {
|
||||
t.Fatal("Instructions should be set")
|
||||
}
|
||||
if params.Instructions.Or("") != defaultCodexInstructions {
|
||||
t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful"},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{})
|
||||
if !params.Instructions.Valid() {
|
||||
t.Fatal("Instructions should be set")
|
||||
}
|
||||
if params.Instructions.Or("") != "You are helpful" {
|
||||
t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), "You are helpful")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexParams_ToolCallConversation(t *testing.T) {
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{
|
||||
{ID: "call_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "SF"}},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
||||
}
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{})
|
||||
if params.Input.OfInputItemList == nil {
|
||||
t.Fatal("Input.OfInputItemList should not be nil")
|
||||
}
|
||||
if len(params.Input.OfInputItemList) != 3 {
|
||||
t.Errorf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexParams_WithTools(t *testing.T) {
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"city": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{})
|
||||
if len(params.Tools) != 1 {
|
||||
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
||||
}
|
||||
if params.Tools[0].OfFunction == nil {
|
||||
t.Fatal("Tool should be a function tool")
|
||||
}
|
||||
if params.Tools[0].OfFunction.Name != "get_weather" {
|
||||
t.Errorf("Tool name = %q, want %q", params.Tools[0].OfFunction.Name, "get_weather")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexParams_StoreIsFalse(t *testing.T) {
|
||||
params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{})
|
||||
if !params.Store.Valid() || params.Store.Or(true) != false {
|
||||
t.Error("Store should be explicitly set to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodexResponse_TextOutput(t *testing.T) {
|
||||
respJSON := `{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "Hello there!"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
"input_tokens_details": {"cached_tokens": 0},
|
||||
"output_tokens_details": {"reasoning_tokens": 0}
|
||||
}
|
||||
}`
|
||||
|
||||
var resp responses.Response
|
||||
if err := json.Unmarshal([]byte(respJSON), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
result := parseCodexResponse(&resp)
|
||||
if result.Content != "Hello there!" {
|
||||
t.Errorf("Content = %q, want %q", result.Content, "Hello there!")
|
||||
}
|
||||
if result.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
|
||||
}
|
||||
if result.Usage.TotalTokens != 15 {
|
||||
t.Errorf("TotalTokens = %d, want 15", result.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCodexResponse_FunctionCall(t *testing.T) {
|
||||
respJSON := `{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"id": "fc_1",
|
||||
"type": "function_call",
|
||||
"call_id": "call_abc",
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"city\":\"SF\"}",
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 8,
|
||||
"total_tokens": 18,
|
||||
"input_tokens_details": {"cached_tokens": 0},
|
||||
"output_tokens_details": {"reasoning_tokens": 0}
|
||||
}
|
||||
}`
|
||||
|
||||
var resp responses.Response
|
||||
if err := json.Unmarshal([]byte(respJSON), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
result := parseCodexResponse(&resp)
|
||||
if len(result.ToolCalls) != 1 {
|
||||
t.Fatalf("len(ToolCalls) = %d, want 1", len(result.ToolCalls))
|
||||
}
|
||||
tc := result.ToolCalls[0]
|
||||
if tc.Name != "get_weather" {
|
||||
t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "get_weather")
|
||||
}
|
||||
if tc.ID != "call_abc" {
|
||||
t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc")
|
||||
}
|
||||
if tc.Arguments["city"] != "SF" {
|
||||
t.Errorf("ToolCall.Arguments[city] = %v, want SF", tc.Arguments["city"])
|
||||
}
|
||||
if result.FinishReason != "tool_calls" {
|
||||
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "tool_calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProvider_ChatRoundTrip(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/responses" {
|
||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Chatgpt-Account-Id") != "acc-123" {
|
||||
http.Error(w, "missing account id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if reqBody["stream"] != true {
|
||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": []map[string]interface{}{
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": []map[string]interface{}{
|
||||
{"type": "output_text", "text": "Hi from Codex!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": 12,
|
||||
"output_tokens": 6,
|
||||
"total_tokens": 18,
|
||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
||||
},
|
||||
}
|
||||
writeCompletedSSE(w, resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewCodexProvider("test-token", "acc-123")
|
||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"max_tokens": 1024})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if resp.Content != "Hi from Codex!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if resp.Usage.TotalTokens != 18 {
|
||||
t.Errorf("TotalTokens = %d, want 18", resp.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/responses" {
|
||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer refreshed-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Chatgpt-Account-Id") != "acc-123" {
|
||||
http.Error(w, "missing account id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := reqBody["instructions"]; !ok {
|
||||
http.Error(w, "missing instructions", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if reqBody["instructions"] == "" {
|
||||
http.Error(w, "instructions must not be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := reqBody["temperature"]; ok {
|
||||
http.Error(w, "temperature is not supported", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if reqBody["stream"] != true {
|
||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": []map[string]interface{}{
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": []map[string]interface{}{
|
||||
{"type": "output_text", "text": "Hi from Codex!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": 8,
|
||||
"output_tokens": 4,
|
||||
"total_tokens": 12,
|
||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
||||
},
|
||||
}
|
||||
writeCompletedSSE(w, resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewCodexProvider("stale-token", "acc-123")
|
||||
provider.client = createOpenAITestClient(server.URL, "stale-token", "")
|
||||
provider.tokenSource = func() (string, string, error) {
|
||||
return "refreshed-token", "", nil
|
||||
}
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"temperature": 0.7})
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if resp.Content != "Hi from Codex!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/responses" {
|
||||
http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var reqBody map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if reqBody["model"] != codexDefaultModel {
|
||||
http.Error(w, "unsupported model", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if reqBody["stream"] != true {
|
||||
http.Error(w, "stream must be true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if reqBody["instructions"] != codexDefaultInstructions {
|
||||
http.Error(w, "missing default instructions", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": []map[string]interface{}{
|
||||
{
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": []map[string]interface{}{
|
||||
{"type": "output_text", "text": "Hi from Codex!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": 8,
|
||||
"output_tokens": 4,
|
||||
"total_tokens": 12,
|
||||
"input_tokens_details": map[string]interface{}{"cached_tokens": 0},
|
||||
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
|
||||
},
|
||||
}
|
||||
writeCompletedSSE(w, resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewCodexProvider("test-token", "acc-123")
|
||||
provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
|
||||
|
||||
messages := []Message{{Role: "user", Content: "Hello"}}
|
||||
resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error: %v", err)
|
||||
}
|
||||
if resp.Content != "Hi from Codex!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProvider_GetDefaultModel(t *testing.T) {
|
||||
p := NewCodexProvider("test-token", "")
|
||||
if got := p.GetDefaultModel(); got != codexDefaultModel {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, codexDefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCodexModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantModel string
|
||||
wantFallback bool
|
||||
}{
|
||||
{name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true},
|
||||
{name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true},
|
||||
{name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
|
||||
{name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false},
|
||||
{name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotModel, reason := resolveCodexModel(tt.input)
|
||||
if gotModel != tt.wantModel {
|
||||
t.Fatalf("resolveCodexModel(%q) model = %q, want %q", tt.input, gotModel, tt.wantModel)
|
||||
}
|
||||
if tt.wantFallback && reason == "" {
|
||||
t.Fatalf("resolveCodexModel(%q) expected fallback reason", tt.input)
|
||||
}
|
||||
if !tt.wantFallback && reason != "" {
|
||||
t.Fatalf("resolveCodexModel(%q) unexpected fallback reason: %q", tt.input, reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createOpenAITestClient(baseURL, token, accountID string) *openai.Client {
|
||||
opts := []openaiopt.RequestOption{
|
||||
openaiopt.WithBaseURL(baseURL),
|
||||
openaiopt.WithAPIKey(token),
|
||||
}
|
||||
if accountID != "" {
|
||||
opts = append(opts, openaiopt.WithHeader("Chatgpt-Account-Id", accountID))
|
||||
}
|
||||
c := openai.NewClient(opts...)
|
||||
return &c
|
||||
}
|
||||
|
||||
func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) {
|
||||
event := map[string]interface{}{
|
||||
"type": "response.completed",
|
||||
"sequence_number": 1,
|
||||
"response": response,
|
||||
}
|
||||
b, _ := json.Marshal(event)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprintf(w, "event: response.completed\n")
|
||||
fmt.Fprintf(w, "data: %s\n\n", string(b))
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
json "encoding/json"
|
||||
|
||||
copilot "github.com/github/copilot-sdk/go"
|
||||
)
|
||||
|
||||
type GitHubCopilotProvider struct {
|
||||
uri string
|
||||
connectMode string // `stdio` or `grpc``
|
||||
|
||||
session *copilot.Session
|
||||
}
|
||||
|
||||
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
|
||||
|
||||
var session *copilot.Session
|
||||
if connectMode == "" {
|
||||
connectMode = "grpc"
|
||||
}
|
||||
switch connectMode {
|
||||
|
||||
case "stdio":
|
||||
//todo
|
||||
case "grpc":
|
||||
client := copilot.NewClient(&copilot.ClientOptions{
|
||||
CLIUrl: uri,
|
||||
})
|
||||
if err := client.Start(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
|
||||
}
|
||||
defer client.Stop()
|
||||
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
|
||||
Model: model,
|
||||
Hooks: &copilot.SessionHooks{},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
return &GitHubCopilotProvider{
|
||||
uri: uri,
|
||||
connectMode: connectMode,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Chat sends a chat request to GitHub Copilot
|
||||
func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
type tempMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
out := make([]tempMessage, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
out = append(out, tempMessage{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
fullcontent, _ := json.Marshal(out)
|
||||
|
||||
content, _ := p.session.Send(ctx, copilot.MessageOptions{
|
||||
Prompt: string(fullcontent),
|
||||
})
|
||||
|
||||
return &LLMResponse{
|
||||
FinishReason: "stop",
|
||||
Content: content,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func (p *GitHubCopilotProvider) GetDefaultModel() string {
|
||||
|
||||
return "gpt-4.1"
|
||||
}
|
||||
+266
-61
@@ -13,8 +13,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
@@ -24,13 +27,24 @@ type HTTPProvider struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPProvider(apiKey, apiBase string) *HTTPProvider {
|
||||
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||
client := &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
if proxy != "" {
|
||||
proxyURL, err := url.Parse(proxy)
|
||||
if err == nil {
|
||||
client.Transport = &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &HTTPProvider{
|
||||
apiKey: apiKey,
|
||||
apiBase: apiBase,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 0,
|
||||
},
|
||||
apiKey: apiKey,
|
||||
apiBase: strings.TrimRight(apiBase, "/"),
|
||||
httpClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +53,14 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
||||
return nil, fmt.Errorf("API base not configured")
|
||||
}
|
||||
|
||||
// Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5, groq/openai/gpt-oss-120b -> openai/gpt-oss-120b, ollama/qwen2.5:14b -> qwen2.5:14b)
|
||||
if idx := strings.Index(model, "/"); idx != -1 {
|
||||
prefix := model[:idx]
|
||||
if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" {
|
||||
model = model[idx+1:]
|
||||
}
|
||||
}
|
||||
|
||||
requestBody := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
@@ -50,11 +72,22 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
||||
}
|
||||
|
||||
if maxTokens, ok := options["max_tokens"].(int); ok {
|
||||
requestBody["max_tokens"] = maxTokens
|
||||
lowerModel := strings.ToLower(model)
|
||||
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
|
||||
requestBody["max_completion_tokens"] = maxTokens
|
||||
} else {
|
||||
requestBody["max_tokens"] = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
if temperature, ok := options["temperature"].(float64); ok {
|
||||
requestBody["temperature"] = temperature
|
||||
lowerModel := strings.ToLower(model)
|
||||
// Kimi k2 models only support temperature=1
|
||||
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
|
||||
requestBody["temperature"] = 1.0
|
||||
} else {
|
||||
requestBody["temperature"] = temperature
|
||||
}
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(requestBody)
|
||||
@@ -69,8 +102,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.apiKey != "" {
|
||||
authHeader := "Bearer " + p.apiKey
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
@@ -85,7 +117,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API error: %s", string(body))
|
||||
return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return p.parseResponse(body)
|
||||
@@ -165,71 +197,244 @@ func (p *HTTPProvider) GetDefaultModel() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func createClaudeAuthProvider() (LLMProvider, error) {
|
||||
cred, err := auth.GetCredential("anthropic")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
||||
}
|
||||
if cred == nil {
|
||||
return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
|
||||
}
|
||||
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
|
||||
}
|
||||
|
||||
func createCodexAuthProvider() (LLMProvider, error) {
|
||||
cred, err := auth.GetCredential("openai")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading auth credentials: %w", err)
|
||||
}
|
||||
if cred == nil {
|
||||
return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
|
||||
}
|
||||
return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
|
||||
}
|
||||
|
||||
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||
model := cfg.Agents.Defaults.Model
|
||||
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
|
||||
|
||||
var apiKey, apiBase string
|
||||
var apiKey, apiBase, proxy string
|
||||
|
||||
lowerModel := strings.ToLower(model)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
|
||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
||||
} else {
|
||||
apiBase = "https://openrouter.ai/api/v1"
|
||||
// First, try to use explicitly configured provider
|
||||
if providerName != "" {
|
||||
switch providerName {
|
||||
case "groq":
|
||||
if cfg.Providers.Groq.APIKey != "" {
|
||||
apiKey = cfg.Providers.Groq.APIKey
|
||||
apiBase = cfg.Providers.Groq.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.groq.com/openai/v1"
|
||||
}
|
||||
}
|
||||
case "openai", "gpt":
|
||||
if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
|
||||
if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
|
||||
return NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()), nil
|
||||
}
|
||||
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
||||
return createCodexAuthProvider()
|
||||
}
|
||||
apiKey = cfg.Providers.OpenAI.APIKey
|
||||
apiBase = cfg.Providers.OpenAI.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
case "anthropic", "claude":
|
||||
if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
|
||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
||||
return createClaudeAuthProvider()
|
||||
}
|
||||
apiKey = cfg.Providers.Anthropic.APIKey
|
||||
apiBase = cfg.Providers.Anthropic.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.anthropic.com/v1"
|
||||
}
|
||||
}
|
||||
case "openrouter":
|
||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
||||
} else {
|
||||
apiBase = "https://openrouter.ai/api/v1"
|
||||
}
|
||||
}
|
||||
case "zhipu", "glm":
|
||||
if cfg.Providers.Zhipu.APIKey != "" {
|
||||
apiKey = cfg.Providers.Zhipu.APIKey
|
||||
apiBase = cfg.Providers.Zhipu.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
}
|
||||
case "gemini", "google":
|
||||
if cfg.Providers.Gemini.APIKey != "" {
|
||||
apiKey = cfg.Providers.Gemini.APIKey
|
||||
apiBase = cfg.Providers.Gemini.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
}
|
||||
case "vllm":
|
||||
if cfg.Providers.VLLM.APIBase != "" {
|
||||
apiKey = cfg.Providers.VLLM.APIKey
|
||||
apiBase = cfg.Providers.VLLM.APIBase
|
||||
}
|
||||
case "shengsuanyun":
|
||||
if cfg.Providers.ShengSuanYun.APIKey != "" {
|
||||
apiKey = cfg.Providers.ShengSuanYun.APIKey
|
||||
apiBase = cfg.Providers.ShengSuanYun.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://router.shengsuanyun.com/api/v1"
|
||||
}
|
||||
}
|
||||
case "claude-cli", "claudecode", "claude-code":
|
||||
workspace := cfg.WorkspacePath()
|
||||
if workspace == "" {
|
||||
workspace = "."
|
||||
}
|
||||
return NewClaudeCliProvider(workspace), nil
|
||||
case "codex-cli", "codex-code":
|
||||
workspace := cfg.WorkspacePath()
|
||||
if workspace == "" {
|
||||
workspace = "."
|
||||
}
|
||||
return NewCodexCliProvider(workspace), nil
|
||||
case "deepseek":
|
||||
if cfg.Providers.DeepSeek.APIKey != "" {
|
||||
apiKey = cfg.Providers.DeepSeek.APIKey
|
||||
apiBase = cfg.Providers.DeepSeek.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.deepseek.com/v1"
|
||||
}
|
||||
if model != "deepseek-chat" && model != "deepseek-reasoner" {
|
||||
model = "deepseek-chat"
|
||||
}
|
||||
}
|
||||
case "github_copilot", "copilot":
|
||||
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||
apiBase = cfg.Providers.GitHubCopilot.APIBase
|
||||
} else {
|
||||
apiBase = "localhost:4321"
|
||||
}
|
||||
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
|
||||
|
||||
}
|
||||
|
||||
case strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/"):
|
||||
apiKey = cfg.Providers.Anthropic.APIKey
|
||||
apiBase = cfg.Providers.Anthropic.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.anthropic.com/v1"
|
||||
}
|
||||
}
|
||||
|
||||
case strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/"):
|
||||
apiKey = cfg.Providers.OpenAI.APIKey
|
||||
apiBase = cfg.Providers.OpenAI.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.openai.com/v1"
|
||||
}
|
||||
// Fallback: detect provider from model name
|
||||
if apiKey == "" && apiBase == "" {
|
||||
switch {
|
||||
case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
|
||||
apiKey = cfg.Providers.Moonshot.APIKey
|
||||
apiBase = cfg.Providers.Moonshot.APIBase
|
||||
proxy = cfg.Providers.Moonshot.Proxy
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.moonshot.cn/v1"
|
||||
}
|
||||
|
||||
case strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/"):
|
||||
apiKey = cfg.Providers.Gemini.APIKey
|
||||
apiBase = cfg.Providers.Gemini.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
|
||||
case strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai"):
|
||||
apiKey = cfg.Providers.Zhipu.APIKey
|
||||
apiBase = cfg.Providers.Zhipu.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
|
||||
case strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/"):
|
||||
apiKey = cfg.Providers.Groq.APIKey
|
||||
apiBase = cfg.Providers.Groq.APIBase
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.groq.com/openai/v1"
|
||||
}
|
||||
|
||||
case cfg.Providers.VLLM.APIBase != "":
|
||||
apiKey = cfg.Providers.VLLM.APIKey
|
||||
apiBase = cfg.Providers.VLLM.APIBase
|
||||
|
||||
default:
|
||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
|
||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
||||
proxy = cfg.Providers.OpenRouter.Proxy
|
||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
||||
} else {
|
||||
apiBase = "https://openrouter.ai/api/v1"
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("no API key configured for model: %s", model)
|
||||
|
||||
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
|
||||
if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
|
||||
return createClaudeAuthProvider()
|
||||
}
|
||||
apiKey = cfg.Providers.Anthropic.APIKey
|
||||
apiBase = cfg.Providers.Anthropic.APIBase
|
||||
proxy = cfg.Providers.Anthropic.Proxy
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.anthropic.com/v1"
|
||||
}
|
||||
|
||||
case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
|
||||
if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
|
||||
return createCodexAuthProvider()
|
||||
}
|
||||
apiKey = cfg.Providers.OpenAI.APIKey
|
||||
apiBase = cfg.Providers.OpenAI.APIBase
|
||||
proxy = cfg.Providers.OpenAI.Proxy
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
|
||||
apiKey = cfg.Providers.Gemini.APIKey
|
||||
apiBase = cfg.Providers.Gemini.APIBase
|
||||
proxy = cfg.Providers.Gemini.Proxy
|
||||
if apiBase == "" {
|
||||
apiBase = "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
|
||||
case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
|
||||
apiKey = cfg.Providers.Zhipu.APIKey
|
||||
apiBase = cfg.Providers.Zhipu.APIBase
|
||||
proxy = cfg.Providers.Zhipu.Proxy
|
||||
if apiBase == "" {
|
||||
apiBase = "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
|
||||
case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
|
||||
apiKey = cfg.Providers.Groq.APIKey
|
||||
apiBase = cfg.Providers.Groq.APIBase
|
||||
proxy = cfg.Providers.Groq.Proxy
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.groq.com/openai/v1"
|
||||
}
|
||||
|
||||
case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
|
||||
apiKey = cfg.Providers.Nvidia.APIKey
|
||||
apiBase = cfg.Providers.Nvidia.APIBase
|
||||
proxy = cfg.Providers.Nvidia.Proxy
|
||||
if apiBase == "" {
|
||||
apiBase = "https://integrate.api.nvidia.com/v1"
|
||||
}
|
||||
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
|
||||
fmt.Println("Ollama provider selected based on model name prefix")
|
||||
apiKey = cfg.Providers.Ollama.APIKey
|
||||
apiBase = cfg.Providers.Ollama.APIBase
|
||||
proxy = cfg.Providers.Ollama.Proxy
|
||||
if apiBase == "" {
|
||||
apiBase = "http://localhost:11434/v1"
|
||||
}
|
||||
fmt.Println("Ollama apiBase:", apiBase)
|
||||
case cfg.Providers.VLLM.APIBase != "":
|
||||
apiKey = cfg.Providers.VLLM.APIKey
|
||||
apiBase = cfg.Providers.VLLM.APIBase
|
||||
proxy = cfg.Providers.VLLM.Proxy
|
||||
|
||||
default:
|
||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
apiKey = cfg.Providers.OpenRouter.APIKey
|
||||
proxy = cfg.Providers.OpenRouter.Proxy
|
||||
if cfg.Providers.OpenRouter.APIBase != "" {
|
||||
apiBase = cfg.Providers.OpenRouter.APIBase
|
||||
} else {
|
||||
apiBase = "https://openrouter.ai/api/v1"
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("no API key configured for model: %s", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,5 +446,5 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
|
||||
}
|
||||
|
||||
return NewHTTPProvider(apiKey, apiBase), nil
|
||||
return NewHTTPProvider(apiKey, apiBase, proxy), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// extractToolCallsFromText parses tool call JSON from response text.
|
||||
// Both ClaudeCliProvider and CodexCliProvider use this to extract
|
||||
// tool calls that the model outputs in its response text.
|
||||
func extractToolCallsFromText(text string) []ToolCall {
|
||||
start := strings.Index(text, `{"tool_calls"`)
|
||||
if start == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
end := findMatchingBrace(text, start)
|
||||
if end == start {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonStr := text[start:end]
|
||||
|
||||
var wrapper struct {
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []ToolCall
|
||||
for _, tc := range wrapper.ToolCalls {
|
||||
var args map[string]interface{}
|
||||
json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
|
||||
result = append(result, ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: tc.Type,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: args,
|
||||
Function: &FunctionCall{
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// stripToolCallsFromText removes tool call JSON from response text.
|
||||
func stripToolCallsFromText(text string) string {
|
||||
start := strings.Index(text, `{"tool_calls"`)
|
||||
if start == -1 {
|
||||
return text
|
||||
}
|
||||
|
||||
end := findMatchingBrace(text, start)
|
||||
if end == start {
|
||||
return text
|
||||
}
|
||||
|
||||
return strings.TrimSpace(text[:start] + text[end:])
|
||||
}
|
||||
+162
-23
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
type Session struct {
|
||||
Key string `json:"key"`
|
||||
Messages []providers.Message `json:"messages"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
@@ -38,26 +40,35 @@ func NewSessionManager(storage string) *SessionManager {
|
||||
}
|
||||
|
||||
func (sm *SessionManager) GetOrCreate(key string) *Session {
|
||||
sm.mu.RLock()
|
||||
session, ok := sm.sessions[key]
|
||||
sm.mu.RUnlock()
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
sm.mu.Lock()
|
||||
session = &Session{
|
||||
Key: key,
|
||||
Messages: []providers.Message{},
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
}
|
||||
sm.sessions[key] = session
|
||||
sm.mu.Unlock()
|
||||
session, ok := sm.sessions[key]
|
||||
if ok {
|
||||
return session
|
||||
}
|
||||
|
||||
session = &Session{
|
||||
Key: key,
|
||||
Messages: []providers.Message{},
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
}
|
||||
sm.sessions[key] = session
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
|
||||
sm.AddFullMessage(sessionKey, providers.Message{
|
||||
Role: role,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// AddFullMessage adds a complete message with tool calls and tool call ID to the session.
|
||||
// This is used to save the full conversation flow including tool calls and tool results.
|
||||
func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
@@ -71,10 +82,7 @@ func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
|
||||
sm.sessions[sessionKey] = session
|
||||
}
|
||||
|
||||
session.Messages = append(session.Messages, providers.Message{
|
||||
Role: role,
|
||||
Content: content,
|
||||
})
|
||||
session.Messages = append(session.Messages, msg)
|
||||
session.Updated = time.Now()
|
||||
}
|
||||
|
||||
@@ -92,22 +100,137 @@ func (sm *SessionManager) GetHistory(key string) []providers.Message {
|
||||
return history
|
||||
}
|
||||
|
||||
func (sm *SessionManager) Save(session *Session) error {
|
||||
func (sm *SessionManager) GetSummary(key string) string {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return session.Summary
|
||||
}
|
||||
|
||||
func (sm *SessionManager) SetSummary(key string, summary string) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
if ok {
|
||||
session.Summary = summary
|
||||
session.Updated = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if keepLast <= 0 {
|
||||
session.Messages = []providers.Message{}
|
||||
session.Updated = time.Now()
|
||||
return
|
||||
}
|
||||
|
||||
if len(session.Messages) <= keepLast {
|
||||
return
|
||||
}
|
||||
|
||||
session.Messages = session.Messages[len(session.Messages)-keepLast:]
|
||||
session.Updated = time.Now()
|
||||
}
|
||||
|
||||
// sanitizeFilename converts a session key into a cross-platform safe filename.
|
||||
// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
|
||||
// volume separator on Windows, so filepath.Base would misinterpret the key.
|
||||
// We replace it with '_'. The original key is preserved inside the JSON file,
|
||||
// so loadSessions still maps back to the right in-memory key.
|
||||
func sanitizeFilename(key string) string {
|
||||
return strings.ReplaceAll(key, ":", "_")
|
||||
}
|
||||
|
||||
func (sm *SessionManager) Save(key string) error {
|
||||
if sm.storage == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
filename := sanitizeFilename(key)
|
||||
|
||||
sessionPath := filepath.Join(sm.storage, session.Key+".json")
|
||||
// filepath.IsLocal rejects empty names, "..", absolute paths, and
|
||||
// OS-reserved device names (NUL, COM1 … on Windows).
|
||||
// The extra checks reject "." and any directory separators so that
|
||||
// the session file is always written directly inside sm.storage.
|
||||
if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
// Snapshot under read lock, then perform slow file I/O after unlock.
|
||||
sm.mu.RLock()
|
||||
stored, ok := sm.sessions[key]
|
||||
if !ok {
|
||||
sm.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
snapshot := Session{
|
||||
Key: stored.Key,
|
||||
Summary: stored.Summary,
|
||||
Created: stored.Created,
|
||||
Updated: stored.Updated,
|
||||
}
|
||||
if len(stored.Messages) > 0 {
|
||||
snapshot.Messages = make([]providers.Message, len(stored.Messages))
|
||||
copy(snapshot.Messages, stored.Messages)
|
||||
} else {
|
||||
snapshot.Messages = []providers.Message{}
|
||||
}
|
||||
sm.mu.RUnlock()
|
||||
|
||||
data, err := json.MarshalIndent(snapshot, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(sessionPath, data, 0644)
|
||||
sessionPath := filepath.Join(sm.storage, filename+".json")
|
||||
tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpPath := tmpFile.Name()
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmpFile.Write(data); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Chmod(0644); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Sync(); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, sessionPath); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SessionManager) loadSessions() error {
|
||||
@@ -141,3 +264,19 @@ func (sm *SessionManager) loadSessions() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetHistory updates the messages of a session.
|
||||
func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
session, ok := sm.sessions[key]
|
||||
if ok {
|
||||
// Create a deep copy to strictly isolate internal state
|
||||
// from the caller's slice.
|
||||
msgs := make([]providers.Message, len(history))
|
||||
copy(msgs, history)
|
||||
session.Messages = msgs
|
||||
session.Updated = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"simple", "simple"},
|
||||
{"telegram:123456", "telegram_123456"},
|
||||
{"discord:987654321", "discord_987654321"},
|
||||
{"slack:C01234", "slack_C01234"},
|
||||
{"no-colons-here", "no-colons-here"},
|
||||
{"multiple:colons:here", "multiple_colons_here"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := sanitizeFilename(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_WithColonInKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sm := NewSessionManager(tmpDir)
|
||||
|
||||
// Create a session with a key containing colon (typical channel session key).
|
||||
key := "telegram:123456"
|
||||
sm.GetOrCreate(key)
|
||||
sm.AddMessage(key, "user", "hello")
|
||||
|
||||
// Save should succeed even though the key contains ':'
|
||||
if err := sm.Save(key); err != nil {
|
||||
t.Fatalf("Save(%q) failed: %v", key, err)
|
||||
}
|
||||
|
||||
// The file on disk should use sanitized name.
|
||||
expectedFile := filepath.Join(tmpDir, "telegram_123456.json")
|
||||
if _, err := os.Stat(expectedFile); os.IsNotExist(err) {
|
||||
t.Fatalf("expected session file %s to exist", expectedFile)
|
||||
}
|
||||
|
||||
// Load into a fresh manager and verify the session round-trips.
|
||||
sm2 := NewSessionManager(tmpDir)
|
||||
history := sm2.GetHistory(key)
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("expected 1 message after reload, got %d", len(history))
|
||||
}
|
||||
if history[0].Content != "hello" {
|
||||
t.Errorf("expected message content %q, got %q", "hello", history[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_RejectsPathTraversal(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sm := NewSessionManager(tmpDir)
|
||||
|
||||
badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"}
|
||||
for _, key := range badKeys {
|
||||
sm.GetOrCreate(key)
|
||||
if err := sm.Save(key); err == nil {
|
||||
t.Errorf("Save(%q) should have failed but didn't", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
-120
@@ -2,24 +2,25 @@ package skills
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SkillMetadata struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Always bool `json:"always"`
|
||||
Requires *SkillRequirements `json:"requires,omitempty"`
|
||||
}
|
||||
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
|
||||
|
||||
type SkillRequirements struct {
|
||||
Bins []string `json:"bins"`
|
||||
Env []string `json:"env"`
|
||||
const (
|
||||
MaxNameLength = 64
|
||||
MaxDescriptionLength = 1024
|
||||
)
|
||||
|
||||
type SkillMetadata struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type SkillInfo struct {
|
||||
@@ -27,25 +28,46 @@ type SkillInfo struct {
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Description string `json:"description"`
|
||||
Available bool `json:"available"`
|
||||
Missing string `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
func (info SkillInfo) validate() error {
|
||||
var errs error
|
||||
if info.Name == "" {
|
||||
errs = errors.Join(errs, errors.New("name is required"))
|
||||
} else {
|
||||
if len(info.Name) > MaxNameLength {
|
||||
errs = errors.Join(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
|
||||
}
|
||||
if !namePattern.MatchString(info.Name) {
|
||||
errs = errors.Join(errs, errors.New("name must be alphanumeric with hyphens"))
|
||||
}
|
||||
}
|
||||
|
||||
if info.Description == "" {
|
||||
errs = errors.Join(errs, errors.New("description is required"))
|
||||
} else if len(info.Description) > MaxDescriptionLength {
|
||||
errs = errors.Join(errs, fmt.Errorf("description exceeds %d character", MaxDescriptionLength))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
type SkillsLoader struct {
|
||||
workspace string
|
||||
workspaceSkills string
|
||||
builtinSkills string
|
||||
workspaceSkills string // workspace skills (项目级别)
|
||||
globalSkills string // 全局 skills (~/.picoclaw/skills)
|
||||
builtinSkills string // 内置 skills
|
||||
}
|
||||
|
||||
func NewSkillsLoader(workspace string, builtinSkills string) *SkillsLoader {
|
||||
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
|
||||
return &SkillsLoader{
|
||||
workspace: workspace,
|
||||
workspaceSkills: filepath.Join(workspace, "skills"),
|
||||
globalSkills: globalSkills, // ~/.picoclaw/skills
|
||||
builtinSkills: builtinSkills,
|
||||
}
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
|
||||
func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
||||
skills := make([]SkillInfo, 0)
|
||||
|
||||
if sl.workspaceSkills != "" {
|
||||
@@ -62,12 +84,51 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
|
||||
metadata := sl.getSkillMetadata(skillFile)
|
||||
if metadata != nil {
|
||||
info.Description = metadata.Description
|
||||
info.Available = sl.checkRequirements(metadata.Requires)
|
||||
if !info.Available {
|
||||
info.Missing = sl.getMissingRequirements(metadata.Requires)
|
||||
info.Name = metadata.Name
|
||||
}
|
||||
if err := info.validate(); err != nil {
|
||||
slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
skills = append(skills, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局 skills (~/.picoclaw/skills) - 被 workspace skills 覆盖
|
||||
if sl.globalSkills != "" {
|
||||
if dirs, err := os.ReadDir(sl.globalSkills); err == nil {
|
||||
for _, dir := range dirs {
|
||||
if dir.IsDir() {
|
||||
skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md")
|
||||
if _, err := os.Stat(skillFile); err == nil {
|
||||
// 检查是否已被 workspace skills 覆盖
|
||||
exists := false
|
||||
for _, s := range skills {
|
||||
if s.Name == dir.Name() && s.Source == "workspace" {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
info.Available = true
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
info := SkillInfo{
|
||||
Name: dir.Name(),
|
||||
Path: skillFile,
|
||||
Source: "global",
|
||||
}
|
||||
metadata := sl.getSkillMetadata(skillFile)
|
||||
if metadata != nil {
|
||||
info.Description = metadata.Description
|
||||
info.Name = metadata.Name
|
||||
}
|
||||
if err := info.validate(); err != nil {
|
||||
slog.Warn("invalid skill from global", "name", info.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
skills = append(skills, info)
|
||||
}
|
||||
@@ -82,9 +143,10 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
|
||||
if dir.IsDir() {
|
||||
skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md")
|
||||
if _, err := os.Stat(skillFile); err == nil {
|
||||
// 检查是否已被 workspace 或 global skills 覆盖
|
||||
exists := false
|
||||
for _, s := range skills {
|
||||
if s.Name == dir.Name() && s.Source == "workspace" {
|
||||
if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
@@ -101,12 +163,11 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
|
||||
metadata := sl.getSkillMetadata(skillFile)
|
||||
if metadata != nil {
|
||||
info.Description = metadata.Description
|
||||
info.Available = sl.checkRequirements(metadata.Requires)
|
||||
if !info.Available {
|
||||
info.Missing = sl.getMissingRequirements(metadata.Requires)
|
||||
}
|
||||
} else {
|
||||
info.Available = true
|
||||
info.Name = metadata.Name
|
||||
}
|
||||
if err := info.validate(); err != nil {
|
||||
slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
skills = append(skills, info)
|
||||
}
|
||||
@@ -115,20 +176,11 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
|
||||
}
|
||||
}
|
||||
|
||||
if filterUnavailable {
|
||||
filtered := make([]SkillInfo, 0)
|
||||
for _, s := range skills {
|
||||
if s.Available {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
||||
// 1. 优先从 workspace skills 加载(项目级别)
|
||||
if sl.workspaceSkills != "" {
|
||||
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
|
||||
if content, err := os.ReadFile(skillFile); err == nil {
|
||||
@@ -136,6 +188,15 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 其次从全局 skills 加载 (~/.picoclaw/skills)
|
||||
if sl.globalSkills != "" {
|
||||
skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md")
|
||||
if content, err := os.ReadFile(skillFile); err == nil {
|
||||
return sl.stripFrontmatter(string(content)), true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 最后从内置 skills 加载
|
||||
if sl.builtinSkills != "" {
|
||||
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
|
||||
if content, err := os.ReadFile(skillFile); err == nil {
|
||||
@@ -163,7 +224,7 @@ func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string {
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) BuildSkillsSummary() string {
|
||||
allSkills := sl.ListSkills(false)
|
||||
allSkills := sl.ListSkills()
|
||||
if len(allSkills) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -175,21 +236,11 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
|
||||
escapedDesc := escapeXML(s.Description)
|
||||
escapedPath := escapeXML(s.Path)
|
||||
|
||||
available := "true"
|
||||
if !s.Available {
|
||||
available = "false"
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf(" <skill available=\"%s\">", available))
|
||||
lines = append(lines, fmt.Sprintf(" <skill>"))
|
||||
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
|
||||
lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc))
|
||||
lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath))
|
||||
|
||||
if !s.Available && s.Missing != "" {
|
||||
escapedMissing := escapeXML(s.Missing)
|
||||
lines = append(lines, fmt.Sprintf(" <requires>%s</requires>", escapedMissing))
|
||||
}
|
||||
|
||||
lines = append(lines, fmt.Sprintf(" <source>%s</source>", s.Source))
|
||||
lines = append(lines, " </skill>")
|
||||
}
|
||||
lines = append(lines, "</skills>")
|
||||
@@ -197,18 +248,6 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) GetAlwaysSkills() []string {
|
||||
skills := sl.ListSkills(true)
|
||||
var always []string
|
||||
for _, s := range skills {
|
||||
metadata := sl.getSkillMetadata(s.Path)
|
||||
if metadata != nil && metadata.Always {
|
||||
always = append(always, s.Name)
|
||||
}
|
||||
}
|
||||
return always
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
|
||||
content, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
@@ -222,27 +261,54 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
var metadata struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Always bool `json:"always"`
|
||||
Requires *SkillRequirements `json:"requires"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(frontmatter), &metadata); err != nil {
|
||||
return nil
|
||||
// Try JSON first (for backward compatibility)
|
||||
var jsonMeta struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil {
|
||||
return &SkillMetadata{
|
||||
Name: jsonMeta.Name,
|
||||
Description: jsonMeta.Description,
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to simple YAML parsing
|
||||
yamlMeta := sl.parseSimpleYAML(frontmatter)
|
||||
return &SkillMetadata{
|
||||
Name: metadata.Name,
|
||||
Description: metadata.Description,
|
||||
Always: metadata.Always,
|
||||
Requires: metadata.Requires,
|
||||
Name: yamlMeta["name"],
|
||||
Description: yamlMeta["description"],
|
||||
}
|
||||
}
|
||||
|
||||
// parseSimpleYAML parses simple key: value YAML format
|
||||
// Example: name: github\n description: "..."
|
||||
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
// Remove quotes if present
|
||||
value = strings.Trim(value, "\"'")
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) extractFrontmatter(content string) string {
|
||||
re := regexp.MustCompile(`^---\n(.*?)\n---`)
|
||||
// (?s) enables DOTALL mode so . matches newlines
|
||||
// Match first ---, capture everything until next --- on its own line
|
||||
re := regexp.MustCompile(`(?s)^---\n(.*)\n---`)
|
||||
match := re.FindStringSubmatch(content)
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
@@ -255,49 +321,6 @@ func (sl *SkillsLoader) stripFrontmatter(content string) string {
|
||||
return re.ReplaceAllString(content, "")
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) checkRequirements(requires *SkillRequirements) bool {
|
||||
if requires == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, bin := range requires.Bins {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
continue
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, env := range requires.Env {
|
||||
if os.Getenv(env) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (sl *SkillsLoader) getMissingRequirements(requires *SkillRequirements) string {
|
||||
if requires == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, bin := range requires.Bins {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
missing = append(missing, fmt.Sprintf("CLI: %s", bin))
|
||||
}
|
||||
}
|
||||
|
||||
for _, env := range requires.Env {
|
||||
if os.Getenv(env) == "" {
|
||||
missing = append(missing, fmt.Sprintf("ENV: %s", env))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(missing, ", ")
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSkillsInfoValidate(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
skillName string
|
||||
description string
|
||||
wantErr bool
|
||||
errContains []string
|
||||
}{
|
||||
{
|
||||
name: "valid-skill",
|
||||
skillName: "valid-skill",
|
||||
description: "a valid skill description",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty-name",
|
||||
skillName: "",
|
||||
description: "description without name",
|
||||
wantErr: true,
|
||||
errContains: []string{"name is required"},
|
||||
},
|
||||
{
|
||||
name: "empty-description",
|
||||
skillName: "skill-without-description",
|
||||
description: "",
|
||||
wantErr: true,
|
||||
errContains: []string{"description is required"},
|
||||
},
|
||||
{
|
||||
name: "empty-both",
|
||||
skillName: "",
|
||||
description: "",
|
||||
wantErr: true,
|
||||
errContains: []string{"name is required", "description is required"},
|
||||
},
|
||||
{
|
||||
name: "name-with-spaces",
|
||||
skillName: "skill with spaces",
|
||||
description: "invalid name with spaces",
|
||||
wantErr: true,
|
||||
errContains: []string{"name must be alphanumeric with hyphens"},
|
||||
},
|
||||
{
|
||||
name: "name-with-underscore",
|
||||
skillName: "skill_underscore",
|
||||
description: "invalid name with underscore",
|
||||
wantErr: true,
|
||||
errContains: []string{"name must be alphanumeric with hyphens"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
info := SkillInfo{
|
||||
Name: tc.skillName,
|
||||
Description: tc.description,
|
||||
}
|
||||
err := info.validate()
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
for _, msg := range tc.errContains {
|
||||
assert.ErrorContains(t, err, msg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State represents the persistent state for a workspace.
|
||||
// It includes information about the last active channel/chat.
|
||||
type State struct {
|
||||
// LastChannel is the last channel used for communication
|
||||
LastChannel string `json:"last_channel,omitempty"`
|
||||
|
||||
// LastChatID is the last chat ID used for communication
|
||||
LastChatID string `json:"last_chat_id,omitempty"`
|
||||
|
||||
// Timestamp is the last time this state was updated
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Manager manages persistent state with atomic saves.
|
||||
type Manager struct {
|
||||
workspace string
|
||||
state *State
|
||||
mu sync.RWMutex
|
||||
stateFile string
|
||||
}
|
||||
|
||||
// NewManager creates a new state manager for the given workspace.
|
||||
func NewManager(workspace string) *Manager {
|
||||
stateDir := filepath.Join(workspace, "state")
|
||||
stateFile := filepath.Join(stateDir, "state.json")
|
||||
oldStateFile := filepath.Join(workspace, "state.json")
|
||||
|
||||
// Create state directory if it doesn't exist
|
||||
os.MkdirAll(stateDir, 0755)
|
||||
|
||||
sm := &Manager{
|
||||
workspace: workspace,
|
||||
stateFile: stateFile,
|
||||
state: &State{},
|
||||
}
|
||||
|
||||
// Try to load from new location first
|
||||
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
|
||||
// New file doesn't exist, try migrating from old location
|
||||
if data, err := os.ReadFile(oldStateFile); err == nil {
|
||||
if err := json.Unmarshal(data, sm.state); err == nil {
|
||||
// Migrate to new location
|
||||
sm.saveAtomic()
|
||||
log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Load from new location
|
||||
sm.load()
|
||||
}
|
||||
|
||||
return sm
|
||||
}
|
||||
|
||||
// SetLastChannel atomically updates the last channel and saves the state.
|
||||
// This method uses a temp file + rename pattern for atomic writes,
|
||||
// ensuring that the state file is never corrupted even if the process crashes.
|
||||
func (sm *Manager) SetLastChannel(channel string) error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
// Update state
|
||||
sm.state.LastChannel = channel
|
||||
sm.state.Timestamp = time.Now()
|
||||
|
||||
// Atomic save using temp file + rename
|
||||
if err := sm.saveAtomic(); err != nil {
|
||||
return fmt.Errorf("failed to save state atomically: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLastChatID atomically updates the last chat ID and saves the state.
|
||||
func (sm *Manager) SetLastChatID(chatID string) error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
// Update state
|
||||
sm.state.LastChatID = chatID
|
||||
sm.state.Timestamp = time.Now()
|
||||
|
||||
// Atomic save using temp file + rename
|
||||
if err := sm.saveAtomic(); err != nil {
|
||||
return fmt.Errorf("failed to save state atomically: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLastChannel returns the last channel from the state.
|
||||
func (sm *Manager) GetLastChannel() string {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return sm.state.LastChannel
|
||||
}
|
||||
|
||||
// GetLastChatID returns the last chat ID from the state.
|
||||
func (sm *Manager) GetLastChatID() string {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return sm.state.LastChatID
|
||||
}
|
||||
|
||||
// GetTimestamp returns the timestamp of the last state update.
|
||||
func (sm *Manager) GetTimestamp() time.Time {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return sm.state.Timestamp
|
||||
}
|
||||
|
||||
// saveAtomic performs an atomic save using temp file + rename.
|
||||
// This ensures that the state file is never corrupted:
|
||||
// 1. Write to a temp file
|
||||
// 2. Rename temp file to target (atomic on POSIX systems)
|
||||
// 3. If rename fails, cleanup the temp file
|
||||
//
|
||||
// Must be called with the lock held.
|
||||
func (sm *Manager) saveAtomic() error {
|
||||
// Create temp file in the same directory as the target
|
||||
tempFile := sm.stateFile + ".tmp"
|
||||
|
||||
// Marshal state to JSON
|
||||
data, err := json.MarshalIndent(sm.state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal state: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename from temp to target
|
||||
if err := os.Rename(tempFile, sm.stateFile); err != nil {
|
||||
// Cleanup temp file if rename fails
|
||||
os.Remove(tempFile)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// load loads the state from disk.
|
||||
func (sm *Manager) load() error {
|
||||
data, err := os.ReadFile(sm.stateFile)
|
||||
if err != nil {
|
||||
// File doesn't exist yet, that's OK
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read state file: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, sm.state); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAtomicSave(t *testing.T) {
|
||||
// Create temp workspace
|
||||
tmpDir, err := os.MkdirTemp("", "state-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
sm := NewManager(tmpDir)
|
||||
|
||||
// Test SetLastChannel
|
||||
err = sm.SetLastChannel("test-channel")
|
||||
if err != nil {
|
||||
t.Fatalf("SetLastChannel failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the channel was saved
|
||||
lastChannel := sm.GetLastChannel()
|
||||
if lastChannel != "test-channel" {
|
||||
t.Errorf("Expected channel 'test-channel', got '%s'", lastChannel)
|
||||
}
|
||||
|
||||
// Verify timestamp was updated
|
||||
if sm.GetTimestamp().IsZero() {
|
||||
t.Error("Expected timestamp to be updated")
|
||||
}
|
||||
|
||||
// Verify state file exists
|
||||
stateFile := filepath.Join(tmpDir, "state", "state.json")
|
||||
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
|
||||
t.Error("Expected state file to exist")
|
||||
}
|
||||
|
||||
// Create a new manager to verify persistence
|
||||
sm2 := NewManager(tmpDir)
|
||||
if sm2.GetLastChannel() != "test-channel" {
|
||||
t.Errorf("Expected persistent channel 'test-channel', got '%s'", sm2.GetLastChannel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLastChatID(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "state-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
sm := NewManager(tmpDir)
|
||||
|
||||
// Test SetLastChatID
|
||||
err = sm.SetLastChatID("test-chat-id")
|
||||
if err != nil {
|
||||
t.Fatalf("SetLastChatID failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the chat ID was saved
|
||||
lastChatID := sm.GetLastChatID()
|
||||
if lastChatID != "test-chat-id" {
|
||||
t.Errorf("Expected chat ID 'test-chat-id', got '%s'", lastChatID)
|
||||
}
|
||||
|
||||
// Verify timestamp was updated
|
||||
if sm.GetTimestamp().IsZero() {
|
||||
t.Error("Expected timestamp to be updated")
|
||||
}
|
||||
|
||||
// Create a new manager to verify persistence
|
||||
sm2 := NewManager(tmpDir)
|
||||
if sm2.GetLastChatID() != "test-chat-id" {
|
||||
t.Errorf("Expected persistent chat ID 'test-chat-id', got '%s'", sm2.GetLastChatID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicity_NoCorruptionOnInterrupt(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "state-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
sm := NewManager(tmpDir)
|
||||
|
||||
// Write initial state
|
||||
err = sm.SetLastChannel("initial-channel")
|
||||
if err != nil {
|
||||
t.Fatalf("SetLastChannel failed: %v", err)
|
||||
}
|
||||
|
||||
// Simulate a crash scenario by manually creating a corrupted temp file
|
||||
tempFile := filepath.Join(tmpDir, "state", "state.json.tmp")
|
||||
err = os.WriteFile(tempFile, []byte("corrupted data"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
|
||||
// Verify that the original state is still intact
|
||||
lastChannel := sm.GetLastChannel()
|
||||
if lastChannel != "initial-channel" {
|
||||
t.Errorf("Expected channel 'initial-channel' after corrupted temp file, got '%s'", lastChannel)
|
||||
}
|
||||
|
||||
// Clean up the temp file manually
|
||||
os.Remove(tempFile)
|
||||
|
||||
// Now do a proper save
|
||||
err = sm.SetLastChannel("new-channel")
|
||||
if err != nil {
|
||||
t.Fatalf("SetLastChannel failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the new state was saved
|
||||
if sm.GetLastChannel() != "new-channel" {
|
||||
t.Errorf("Expected channel 'new-channel', got '%s'", sm.GetLastChannel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "state-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
sm := NewManager(tmpDir)
|
||||
|
||||
// Test concurrent writes
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(idx int) {
|
||||
channel := fmt.Sprintf("channel-%d", idx)
|
||||
sm.SetLastChannel(channel)
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify the final state is consistent
|
||||
lastChannel := sm.GetLastChannel()
|
||||
if lastChannel == "" {
|
||||
t.Error("Expected non-empty channel after concurrent writes")
|
||||
}
|
||||
|
||||
// Verify state file is valid JSON
|
||||
stateFile := filepath.Join(tmpDir, "state", "state.json")
|
||||
data, err := os.ReadFile(stateFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read state file: %v", err)
|
||||
}
|
||||
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
t.Errorf("State file contains invalid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewManager_ExistingState(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "state-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create initial state
|
||||
sm1 := NewManager(tmpDir)
|
||||
sm1.SetLastChannel("existing-channel")
|
||||
sm1.SetLastChatID("existing-chat-id")
|
||||
|
||||
// Create new manager with same workspace
|
||||
sm2 := NewManager(tmpDir)
|
||||
|
||||
// Verify state was loaded
|
||||
if sm2.GetLastChannel() != "existing-channel" {
|
||||
t.Errorf("Expected channel 'existing-channel', got '%s'", sm2.GetLastChannel())
|
||||
}
|
||||
|
||||
if sm2.GetLastChatID() != "existing-chat-id" {
|
||||
t.Errorf("Expected chat ID 'existing-chat-id', got '%s'", sm2.GetLastChatID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewManager_EmptyWorkspace(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "state-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
sm := NewManager(tmpDir)
|
||||
|
||||
// Verify default state
|
||||
if sm.GetLastChannel() != "" {
|
||||
t.Errorf("Expected empty channel, got '%s'", sm.GetLastChannel())
|
||||
}
|
||||
|
||||
if sm.GetLastChatID() != "" {
|
||||
t.Errorf("Expected empty chat ID, got '%s'", sm.GetLastChatID())
|
||||
}
|
||||
|
||||
if !sm.GetTimestamp().IsZero() {
|
||||
t.Error("Expected zero timestamp for new state")
|
||||
}
|
||||
}
|
||||
+61
-1
@@ -2,11 +2,71 @@ package tools
|
||||
|
||||
import "context"
|
||||
|
||||
// Tool is the interface that all tools must implement.
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Parameters() map[string]interface{}
|
||||
Execute(ctx context.Context, args map[string]interface{}) (string, error)
|
||||
Execute(ctx context.Context, args map[string]interface{}) *ToolResult
|
||||
}
|
||||
|
||||
// ContextualTool is an optional interface that tools can implement
|
||||
// to receive the current message context (channel, chatID)
|
||||
type ContextualTool interface {
|
||||
Tool
|
||||
SetContext(channel, chatID string)
|
||||
}
|
||||
|
||||
// AsyncCallback is a function type that async tools use to notify completion.
|
||||
// When an async tool finishes its work, it calls this callback with the result.
|
||||
//
|
||||
// The ctx parameter allows the callback to be canceled if the agent is shutting down.
|
||||
// The result parameter contains the tool's execution result.
|
||||
//
|
||||
// Example usage in an async tool:
|
||||
//
|
||||
// func (t *MyAsyncTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
// // Start async work in background
|
||||
// go func() {
|
||||
// result := doAsyncWork()
|
||||
// if t.callback != nil {
|
||||
// t.callback(ctx, result)
|
||||
// }
|
||||
// }()
|
||||
// return AsyncResult("Async task started")
|
||||
// }
|
||||
type AsyncCallback func(ctx context.Context, result *ToolResult)
|
||||
|
||||
// AsyncTool is an optional interface that tools can implement to support
|
||||
// asynchronous execution with completion callbacks.
|
||||
//
|
||||
// Async tools return immediately with an AsyncResult, then notify completion
|
||||
// via the callback set by SetCallback.
|
||||
//
|
||||
// This is useful for:
|
||||
// - Long-running operations that shouldn't block the agent loop
|
||||
// - Subagent spawns that complete independently
|
||||
// - Background tasks that need to report results later
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// type SpawnTool struct {
|
||||
// callback AsyncCallback
|
||||
// }
|
||||
//
|
||||
// func (t *SpawnTool) SetCallback(cb AsyncCallback) {
|
||||
// t.callback = cb
|
||||
// }
|
||||
//
|
||||
// func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
// go t.runSubagent(ctx, args)
|
||||
// return AsyncResult("Subagent spawned, will report back")
|
||||
// }
|
||||
type AsyncTool interface {
|
||||
Tool
|
||||
// SetCallback registers a callback function to be invoked when the async operation completes.
|
||||
// The callback will be called from a goroutine and should handle thread-safety if needed.
|
||||
SetCallback(cb AsyncCallback)
|
||||
}
|
||||
|
||||
func ToolToSchema(tool Tool) map[string]interface{} {
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BrowserToolOptions configures the BrowserTool.
|
||||
type BrowserToolOptions struct {
|
||||
Session string // Session name for isolation
|
||||
Headless bool // Run in headless mode (default true)
|
||||
Timeout int // Command timeout in seconds (default 30)
|
||||
CDPPort int // Chrome DevTools Protocol port (default 9222)
|
||||
}
|
||||
|
||||
// BrowserTool wraps the agent-browser CLI for headless browser automation.
|
||||
// It delegates all browser complexity to the external `agent-browser` binary.
|
||||
type BrowserTool struct {
|
||||
session string
|
||||
headless bool
|
||||
timeout time.Duration
|
||||
cdpPort int
|
||||
}
|
||||
|
||||
// NewBrowserTool creates a new BrowserTool with the given options.
|
||||
func NewBrowserTool(opts BrowserToolOptions) *BrowserTool {
|
||||
timeout := 30
|
||||
if opts.Timeout > 0 {
|
||||
timeout = opts.Timeout
|
||||
}
|
||||
cdpPort := 9222
|
||||
if opts.CDPPort > 0 {
|
||||
cdpPort = opts.CDPPort
|
||||
}
|
||||
return &BrowserTool{
|
||||
session: opts.Session,
|
||||
headless: opts.Headless,
|
||||
timeout: time.Duration(timeout) * time.Second,
|
||||
cdpPort: cdpPort,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BrowserTool) Name() string {
|
||||
return "browser"
|
||||
}
|
||||
|
||||
func (t *BrowserTool) Description() string {
|
||||
return `Automate a headless browser via agent-browser CLI. Pass the subcommand as 'command'.
|
||||
The browser daemon persists between calls — open a page first, then interact with it.
|
||||
|
||||
Core workflow:
|
||||
browser open <url> → Navigate to URL
|
||||
browser snapshot -i → Get interactive elements with refs (@e1, @e2, ...)
|
||||
browser click @e2 → Click element by ref
|
||||
browser fill @e3 "text" → Fill input by ref
|
||||
browser type @e3 "text" → Type into element
|
||||
browser press Enter → Press a key
|
||||
browser screenshot [path] → Take screenshot
|
||||
browser get text @e1 → Get text content of element
|
||||
browser get title → Get page title
|
||||
browser get url → Get current URL
|
||||
browser eval "js code" → Run JavaScript
|
||||
browser scroll down [px] → Scroll page
|
||||
browser wait <selector|ms> → Wait for element or time
|
||||
browser close → Close browser
|
||||
|
||||
CSS selectors also work: browser click "#submit"
|
||||
|
||||
Examples:
|
||||
command: "open https://example.com"
|
||||
command: "snapshot -i"
|
||||
command: "click @e2"
|
||||
command: "fill @e3 \"user@example.com\""
|
||||
command: "get title"
|
||||
command: "screenshot /tmp/page.png"
|
||||
command: "close"`
|
||||
}
|
||||
|
||||
func (t *BrowserTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"command": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The agent-browser subcommand to execute (e.g. 'open https://example.com', 'snapshot -i', 'click @e2')",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BrowserTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
command, ok := args["command"].(string)
|
||||
if !ok || strings.TrimSpace(command) == "" {
|
||||
return ErrorResult("command is required (e.g. 'open https://example.com')")
|
||||
}
|
||||
|
||||
// Build the full agent-browser command line
|
||||
cmdArgs := t.buildArgs(command)
|
||||
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(cmdCtx, "agent-browser", cmdArgs...)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
output := stdout.String()
|
||||
if stderr.Len() > 0 {
|
||||
errOut := stderr.String()
|
||||
// Filter out noise from stderr (daemon startup messages, etc.)
|
||||
if !strings.Contains(errOut, "Daemon started") {
|
||||
if output != "" {
|
||||
output += "\n"
|
||||
}
|
||||
output += errOut
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if cmdCtx.Err() == context.DeadlineExceeded {
|
||||
msg := fmt.Sprintf("Browser command timed out after %v: %s", t.timeout, command)
|
||||
return &ToolResult{
|
||||
ForLLM: msg,
|
||||
ForUser: msg,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
// Include output even on error — agent-browser often puts useful info in stdout
|
||||
if output == "" {
|
||||
output = fmt.Sprintf("command failed: %v", err)
|
||||
} else {
|
||||
output += fmt.Sprintf("\nExit code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if output == "" {
|
||||
output = "(no output)"
|
||||
}
|
||||
|
||||
// Truncate long output
|
||||
maxLen := 10000
|
||||
if len(output) > maxLen {
|
||||
output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return &ToolResult{
|
||||
ForLLM: output,
|
||||
ForUser: output,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
return &ToolResult{
|
||||
ForLLM: output,
|
||||
ForUser: output,
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
// buildArgs constructs the argument list for the agent-browser command.
|
||||
// It splits the user command string and prepends global flags.
|
||||
func (t *BrowserTool) buildArgs(command string) []string {
|
||||
var globalArgs []string
|
||||
|
||||
// Add CDP port
|
||||
globalArgs = append(globalArgs, "--cdp", fmt.Sprintf("%d", t.cdpPort))
|
||||
|
||||
// Add session flag if configured
|
||||
if t.session != "" {
|
||||
globalArgs = append(globalArgs, "--session", t.session)
|
||||
}
|
||||
|
||||
// Add --headed if not headless (agent-browser defaults to headless)
|
||||
if !t.headless {
|
||||
globalArgs = append(globalArgs, "--headed")
|
||||
}
|
||||
|
||||
// Add --json for machine-readable output
|
||||
globalArgs = append(globalArgs, "--json")
|
||||
|
||||
// Parse the command string into arguments, respecting quotes
|
||||
cmdArgs := splitCommand(command)
|
||||
|
||||
return append(globalArgs, cmdArgs...)
|
||||
}
|
||||
|
||||
// splitCommand splits a command string into arguments, respecting quoted strings.
|
||||
func splitCommand(command string) []string {
|
||||
var args []string
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
quoteChar := byte(0)
|
||||
|
||||
for i := 0; i < len(command); i++ {
|
||||
ch := command[i]
|
||||
switch {
|
||||
case inQuote:
|
||||
if ch == quoteChar {
|
||||
inQuote = false
|
||||
} else {
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
case ch == '"' || ch == '\'':
|
||||
inQuote = true
|
||||
quoteChar = ch
|
||||
case ch == ' ' || ch == '\t':
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
default:
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBrowserTool_Name(t *testing.T) {
|
||||
tool := NewBrowserTool(BrowserToolOptions{})
|
||||
if tool.Name() != "browser" {
|
||||
t.Errorf("Expected name 'browser', got %q", tool.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserTool_Description(t *testing.T) {
|
||||
tool := NewBrowserTool(BrowserToolOptions{})
|
||||
desc := tool.Description()
|
||||
if !strings.Contains(desc, "agent-browser") {
|
||||
t.Error("Description should mention agent-browser")
|
||||
}
|
||||
if !strings.Contains(desc, "snapshot") {
|
||||
t.Error("Description should mention snapshot command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserTool_Parameters(t *testing.T) {
|
||||
tool := NewBrowserTool(BrowserToolOptions{})
|
||||
params := tool.Parameters()
|
||||
|
||||
props, ok := params["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected properties map")
|
||||
}
|
||||
|
||||
if _, ok := props["command"]; !ok {
|
||||
t.Error("Expected 'command' in properties")
|
||||
}
|
||||
|
||||
required, ok := params["required"].([]string)
|
||||
if !ok {
|
||||
t.Fatal("Expected required slice")
|
||||
}
|
||||
if len(required) != 1 || required[0] != "command" {
|
||||
t.Errorf("Expected required=['command'], got %v", required)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserTool_MissingCommand(t *testing.T) {
|
||||
tool := NewBrowserTool(BrowserToolOptions{})
|
||||
ctx := context.Background()
|
||||
|
||||
// Empty args
|
||||
result := tool.Execute(ctx, map[string]interface{}{})
|
||||
if !result.IsError {
|
||||
t.Error("Expected error for missing command")
|
||||
}
|
||||
|
||||
// Empty string
|
||||
result = tool.Execute(ctx, map[string]interface{}{"command": ""})
|
||||
if !result.IsError {
|
||||
t.Error("Expected error for empty command")
|
||||
}
|
||||
|
||||
// Whitespace only
|
||||
result = tool.Execute(ctx, map[string]interface{}{"command": " "})
|
||||
if !result.IsError {
|
||||
t.Error("Expected error for whitespace-only command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserTool_BuildArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
session string
|
||||
command string
|
||||
wantArgs []string
|
||||
}{
|
||||
{
|
||||
name: "simple command",
|
||||
command: "open https://example.com",
|
||||
wantArgs: []string{"--cdp", "9222", "--headed", "--json", "open", "https://example.com"},
|
||||
},
|
||||
{
|
||||
name: "with session",
|
||||
session: "test-session",
|
||||
command: "snapshot -i",
|
||||
wantArgs: []string{"--cdp", "9222", "--session", "test-session", "--headed", "--json", "snapshot", "-i"},
|
||||
},
|
||||
{
|
||||
name: "quoted arguments",
|
||||
command: `fill @e3 "hello world"`,
|
||||
wantArgs: []string{"--cdp", "9222", "--headed", "--json", "fill", "@e3", "hello world"},
|
||||
},
|
||||
{
|
||||
name: "single quoted",
|
||||
command: `fill @e3 'hello world'`,
|
||||
wantArgs: []string{"--cdp", "9222", "--headed", "--json", "fill", "@e3", "hello world"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tool := NewBrowserTool(BrowserToolOptions{Session: tt.session})
|
||||
got := tool.buildArgs(tt.command)
|
||||
|
||||
if len(got) != len(tt.wantArgs) {
|
||||
t.Errorf("buildArgs(%q) = %v (len %d), want %v (len %d)",
|
||||
tt.command, got, len(got), tt.wantArgs, len(tt.wantArgs))
|
||||
return
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != tt.wantArgs[i] {
|
||||
t.Errorf("buildArgs(%q)[%d] = %q, want %q",
|
||||
tt.command, i, got[i], tt.wantArgs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"open https://example.com", []string{"open", "https://example.com"}},
|
||||
{`fill @e3 "test@example.com"`, []string{"fill", "@e3", "test@example.com"}},
|
||||
{"snapshot -i -c -d 3", []string{"snapshot", "-i", "-c", "-d", "3"}},
|
||||
{`eval "document.title"`, []string{"eval", "document.title"}},
|
||||
{" click @e2 ", []string{"click", "@e2"}},
|
||||
{`get text @e1`, []string{"get", "text", "@e1"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := splitCommand(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("splitCommand(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("splitCommand(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/cron"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// JobExecutor is the interface for executing cron jobs through the agent
|
||||
type JobExecutor interface {
|
||||
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
|
||||
}
|
||||
|
||||
// CronTool provides scheduling capabilities for the agent
|
||||
type CronTool struct {
|
||||
cronService *cron.CronService
|
||||
executor JobExecutor
|
||||
msgBus *bus.MessageBus
|
||||
execTool *ExecTool
|
||||
channel string
|
||||
chatID string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCronTool creates a new CronTool
|
||||
func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool) *CronTool {
|
||||
return &CronTool{
|
||||
cronService: cronService,
|
||||
executor: executor,
|
||||
msgBus: msgBus,
|
||||
execTool: NewExecTool(workspace, restrict),
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the tool name
|
||||
func (t *CronTool) Name() string {
|
||||
return "cron"
|
||||
}
|
||||
|
||||
// Description returns the tool description
|
||||
func (t *CronTool) Description() string {
|
||||
return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly."
|
||||
}
|
||||
|
||||
// Parameters returns the tool parameters schema
|
||||
func (t *CronTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"add", "list", "remove", "enable", "disable"},
|
||||
"description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.",
|
||||
},
|
||||
"message": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.",
|
||||
},
|
||||
"command": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.",
|
||||
},
|
||||
"at_seconds": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.",
|
||||
},
|
||||
"every_seconds": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.",
|
||||
},
|
||||
"cron_expr": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.",
|
||||
},
|
||||
"job_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Job ID (for remove/enable/disable)",
|
||||
},
|
||||
"deliver": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
}
|
||||
}
|
||||
|
||||
// SetContext sets the current session context for job creation
|
||||
func (t *CronTool) SetContext(channel, chatID string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.channel = channel
|
||||
t.chatID = chatID
|
||||
}
|
||||
|
||||
// Execute runs the tool with the given arguments
|
||||
func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
action, ok := args["action"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("action is required")
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
return t.addJob(args)
|
||||
case "list":
|
||||
return t.listJobs()
|
||||
case "remove":
|
||||
return t.removeJob(args)
|
||||
case "enable":
|
||||
return t.enableJob(args, true)
|
||||
case "disable":
|
||||
return t.enableJob(args, false)
|
||||
default:
|
||||
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *CronTool) addJob(args map[string]interface{}) *ToolResult {
|
||||
t.mu.RLock()
|
||||
channel := t.channel
|
||||
chatID := t.chatID
|
||||
t.mu.RUnlock()
|
||||
|
||||
if channel == "" || chatID == "" {
|
||||
return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.")
|
||||
}
|
||||
|
||||
message, ok := args["message"].(string)
|
||||
if !ok || message == "" {
|
||||
return ErrorResult("message is required for add")
|
||||
}
|
||||
|
||||
var schedule cron.CronSchedule
|
||||
|
||||
// Check for at_seconds (one-time), every_seconds (recurring), or cron_expr
|
||||
atSeconds, hasAt := args["at_seconds"].(float64)
|
||||
everySeconds, hasEvery := args["every_seconds"].(float64)
|
||||
cronExpr, hasCron := args["cron_expr"].(string)
|
||||
|
||||
// Priority: at_seconds > every_seconds > cron_expr
|
||||
if hasAt {
|
||||
atMS := time.Now().UnixMilli() + int64(atSeconds)*1000
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "at",
|
||||
AtMS: &atMS,
|
||||
}
|
||||
} else if hasEvery {
|
||||
everyMS := int64(everySeconds) * 1000
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "every",
|
||||
EveryMS: &everyMS,
|
||||
}
|
||||
} else if hasCron {
|
||||
schedule = cron.CronSchedule{
|
||||
Kind: "cron",
|
||||
Expr: cronExpr,
|
||||
}
|
||||
} else {
|
||||
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
|
||||
}
|
||||
|
||||
// Read deliver parameter, default to true
|
||||
deliver := true
|
||||
if d, ok := args["deliver"].(bool); ok {
|
||||
deliver = d
|
||||
}
|
||||
|
||||
command, _ := args["command"].(string)
|
||||
if command != "" {
|
||||
// Commands must be processed by agent/exec tool, so deliver must be false (or handled specifically)
|
||||
// Actually, let's keep deliver=false to let the system know it's not a simple chat message
|
||||
// But for our new logic in ExecuteJob, we can handle it regardless of deliver flag if Payload.Command is set.
|
||||
// However, logically, it's not "delivered" to chat directly as is.
|
||||
deliver = false
|
||||
}
|
||||
|
||||
// Truncate message for job name (max 30 chars)
|
||||
messagePreview := utils.Truncate(message, 30)
|
||||
|
||||
job, err := t.cronService.AddJob(
|
||||
messagePreview,
|
||||
schedule,
|
||||
message,
|
||||
deliver,
|
||||
channel,
|
||||
chatID,
|
||||
)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("Error adding job: %v", err))
|
||||
}
|
||||
|
||||
if command != "" {
|
||||
job.Payload.Command = command
|
||||
// Need to save the updated payload
|
||||
t.cronService.UpdateJob(job)
|
||||
}
|
||||
|
||||
return SilentResult(fmt.Sprintf("Cron job added: %s (id: %s)", job.Name, job.ID))
|
||||
}
|
||||
|
||||
func (t *CronTool) listJobs() *ToolResult {
|
||||
jobs := t.cronService.ListJobs(false)
|
||||
|
||||
if len(jobs) == 0 {
|
||||
return SilentResult("No scheduled jobs")
|
||||
}
|
||||
|
||||
result := "Scheduled jobs:\n"
|
||||
for _, j := range jobs {
|
||||
var scheduleInfo string
|
||||
if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil {
|
||||
scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000)
|
||||
} else if j.Schedule.Kind == "cron" {
|
||||
scheduleInfo = j.Schedule.Expr
|
||||
} else if j.Schedule.Kind == "at" {
|
||||
scheduleInfo = "one-time"
|
||||
} else {
|
||||
scheduleInfo = "unknown"
|
||||
}
|
||||
result += fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)
|
||||
}
|
||||
|
||||
return SilentResult(result)
|
||||
}
|
||||
|
||||
func (t *CronTool) removeJob(args map[string]interface{}) *ToolResult {
|
||||
jobID, ok := args["job_id"].(string)
|
||||
if !ok || jobID == "" {
|
||||
return ErrorResult("job_id is required for remove")
|
||||
}
|
||||
|
||||
if t.cronService.RemoveJob(jobID) {
|
||||
return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID))
|
||||
}
|
||||
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||
}
|
||||
|
||||
func (t *CronTool) enableJob(args map[string]interface{}, enable bool) *ToolResult {
|
||||
jobID, ok := args["job_id"].(string)
|
||||
if !ok || jobID == "" {
|
||||
return ErrorResult("job_id is required for enable/disable")
|
||||
}
|
||||
|
||||
job := t.cronService.EnableJob(jobID, enable)
|
||||
if job == nil {
|
||||
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||
}
|
||||
|
||||
status := "enabled"
|
||||
if !enable {
|
||||
status = "disabled"
|
||||
}
|
||||
return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status))
|
||||
}
|
||||
|
||||
// ExecuteJob executes a cron job through the agent
|
||||
func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||
// Get channel/chatID from job payload
|
||||
channel := job.Payload.Channel
|
||||
chatID := job.Payload.To
|
||||
|
||||
// Default values if not set
|
||||
if channel == "" {
|
||||
channel = "cli"
|
||||
}
|
||||
if chatID == "" {
|
||||
chatID = "direct"
|
||||
}
|
||||
|
||||
// Execute command if present
|
||||
if job.Payload.Command != "" {
|
||||
args := map[string]interface{}{
|
||||
"command": job.Payload.Command,
|
||||
}
|
||||
|
||||
result := t.execTool.Execute(ctx, args)
|
||||
var output string
|
||||
if result.IsError {
|
||||
output = fmt.Sprintf("Error executing scheduled command: %s", result.ForLLM)
|
||||
} else {
|
||||
output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM)
|
||||
}
|
||||
|
||||
t.msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Content: output,
|
||||
})
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// If deliver=true, send message directly without agent processing
|
||||
if job.Payload.Deliver {
|
||||
t.msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Content: job.Payload.Message,
|
||||
})
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// For deliver=false, process through agent (for complex tasks)
|
||||
sessionKey := fmt.Sprintf("cron-%s", job.ID)
|
||||
|
||||
// Call agent with job's message
|
||||
response, err := t.executor.ProcessDirectWithChannel(
|
||||
ctx,
|
||||
job.Payload.Message,
|
||||
sessionKey,
|
||||
channel,
|
||||
chatID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Error: %v", err)
|
||||
}
|
||||
|
||||
// Response is automatically sent via MessageBus by AgentLoop
|
||||
_ = response // Will be sent by AgentLoop
|
||||
return "ok"
|
||||
}
|
||||
+50
-33
@@ -4,14 +4,22 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EditFileTool struct{}
|
||||
// EditFileTool edits a file by replacing old_text with new_text.
|
||||
// The old_text must exist exactly in the file.
|
||||
type EditFileTool struct {
|
||||
allowedDir string
|
||||
restrict bool
|
||||
}
|
||||
|
||||
func NewEditFileTool() *EditFileTool {
|
||||
return &EditFileTool{}
|
||||
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
|
||||
func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool {
|
||||
return &EditFileTool{
|
||||
allowedDir: allowedDir,
|
||||
restrict: restrict,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *EditFileTool) Name() string {
|
||||
@@ -43,57 +51,63 @@ func (t *EditFileTool) Parameters() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("path is required")
|
||||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
oldText, ok := args["old_text"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("old_text is required")
|
||||
return ErrorResult("old_text is required")
|
||||
}
|
||||
|
||||
newText, ok := args["new_text"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("new_text is required")
|
||||
return ErrorResult("new_text is required")
|
||||
}
|
||||
|
||||
filePath := filepath.Clean(path)
|
||||
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("file not found: %s", path)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath)
|
||||
resolvedPath, err := validatePath(path, t.allowedDir, t.restrict)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
|
||||
return ErrorResult(fmt.Sprintf("file not found: %s", path))
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
|
||||
if !strings.Contains(contentStr, oldText) {
|
||||
return "", fmt.Errorf("old_text not found in file. Make sure it matches exactly")
|
||||
return ErrorResult("old_text not found in file. Make sure it matches exactly")
|
||||
}
|
||||
|
||||
count := strings.Count(contentStr, oldText)
|
||||
if count > 1 {
|
||||
return "", fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
|
||||
return ErrorResult(fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count))
|
||||
}
|
||||
|
||||
newContent := strings.Replace(contentStr, oldText, newText, 1)
|
||||
|
||||
if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
if err := os.WriteFile(resolvedPath, []byte(newContent), 0644); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Successfully edited %s", path), nil
|
||||
return SilentResult(fmt.Sprintf("File edited: %s", path))
|
||||
}
|
||||
|
||||
type AppendFileTool struct{}
|
||||
type AppendFileTool struct {
|
||||
workspace string
|
||||
restrict bool
|
||||
}
|
||||
|
||||
func NewAppendFileTool() *AppendFileTool {
|
||||
return &AppendFileTool{}
|
||||
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
|
||||
return &AppendFileTool{workspace: workspace, restrict: restrict}
|
||||
}
|
||||
|
||||
func (t *AppendFileTool) Name() string {
|
||||
@@ -121,28 +135,31 @@ func (t *AppendFileTool) Parameters() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("path is required")
|
||||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
content, ok := args["content"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("content is required")
|
||||
return ErrorResult("content is required")
|
||||
}
|
||||
|
||||
filePath := filepath.Clean(path)
|
||||
|
||||
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
resolvedPath, err := validatePath(path, t.workspace, t.restrict)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %w", err)
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to open file: %v", err))
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
return "", fmt.Errorf("failed to append to file: %w", err)
|
||||
return ErrorResult(fmt.Sprintf("failed to append to file: %v", err))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Successfully appended to %s", path), nil
|
||||
return SilentResult(fmt.Sprintf("Appended to %s", path))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEditTool_EditFile_Success verifies successful file editing
|
||||
func TestEditTool_EditFile_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0644)
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": testFile,
|
||||
"old_text": "World",
|
||||
"new_text": "Universe",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Should return SilentResult
|
||||
if !result.Silent {
|
||||
t.Errorf("Expected Silent=true for EditFile, got false")
|
||||
}
|
||||
|
||||
// ForUser should be empty (silent result)
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
||||
}
|
||||
|
||||
// Verify file was actually edited
|
||||
content, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read edited file: %v", err)
|
||||
}
|
||||
contentStr := string(content)
|
||||
if !strings.Contains(contentStr, "Hello Universe") {
|
||||
t.Errorf("Expected file to contain 'Hello Universe', got: %s", contentStr)
|
||||
}
|
||||
if strings.Contains(contentStr, "Hello World") {
|
||||
t.Errorf("Expected 'Hello World' to be replaced, got: %s", contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_NotFound verifies error handling for non-existent file
|
||||
func TestEditTool_EditFile_NotFound(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "nonexistent.txt")
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": testFile,
|
||||
"old_text": "old",
|
||||
"new_text": "new",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error for non-existent file")
|
||||
}
|
||||
|
||||
// Should mention file not found
|
||||
if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") {
|
||||
t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_OldTextNotFound verifies error when old_text doesn't exist
|
||||
func TestEditTool_EditFile_OldTextNotFound(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("Hello World"), 0644)
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": testFile,
|
||||
"old_text": "Goodbye",
|
||||
"new_text": "Hello",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when old_text not found")
|
||||
}
|
||||
|
||||
// Should mention old_text not found
|
||||
if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") {
|
||||
t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_MultipleMatches verifies error when old_text appears multiple times
|
||||
func TestEditTool_EditFile_MultipleMatches(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("test test test"), 0644)
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": testFile,
|
||||
"old_text": "test",
|
||||
"new_text": "done",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when old_text appears multiple times")
|
||||
}
|
||||
|
||||
// Should mention multiple occurrences
|
||||
if !strings.Contains(result.ForLLM, "times") && !strings.Contains(result.ForUser, "times") {
|
||||
t.Errorf("Expected 'multiple times' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_OutsideAllowedDir verifies error when path is outside allowed directory
|
||||
func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
otherDir := t.TempDir()
|
||||
testFile := filepath.Join(otherDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("content"), 0644)
|
||||
|
||||
tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": testFile,
|
||||
"old_text": "content",
|
||||
"new_text": "new",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when path is outside allowed directory")
|
||||
}
|
||||
|
||||
// Should mention outside allowed directory
|
||||
if !strings.Contains(result.ForLLM, "outside") && !strings.Contains(result.ForUser, "outside") {
|
||||
t.Errorf("Expected 'outside allowed' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_MissingPath verifies error handling for missing path
|
||||
func TestEditTool_EditFile_MissingPath(t *testing.T) {
|
||||
tool := NewEditFileTool("", false)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"old_text": "old",
|
||||
"new_text": "new",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when path is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text
|
||||
func TestEditTool_EditFile_MissingOldText(t *testing.T) {
|
||||
tool := NewEditFileTool("", false)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": "/tmp/test.txt",
|
||||
"new_text": "new",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when old_text is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text
|
||||
func TestEditTool_EditFile_MissingNewText(t *testing.T) {
|
||||
tool := NewEditFileTool("", false)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": "/tmp/test.txt",
|
||||
"old_text": "old",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when new_text is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_AppendFile_Success verifies successful file appending
|
||||
func TestEditTool_AppendFile_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("Initial content"), 0644)
|
||||
|
||||
tool := NewAppendFileTool("", false)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": testFile,
|
||||
"content": "\nAppended content",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Should return SilentResult
|
||||
if !result.Silent {
|
||||
t.Errorf("Expected Silent=true for AppendFile, got false")
|
||||
}
|
||||
|
||||
// ForUser should be empty (silent result)
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
||||
}
|
||||
|
||||
// Verify content was actually appended
|
||||
content, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
contentStr := string(content)
|
||||
if !strings.Contains(contentStr, "Initial content") {
|
||||
t.Errorf("Expected original content to remain, got: %s", contentStr)
|
||||
}
|
||||
if !strings.Contains(contentStr, "Appended content") {
|
||||
t.Errorf("Expected appended content, got: %s", contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_AppendFile_MissingPath verifies error handling for missing path
|
||||
func TestEditTool_AppendFile_MissingPath(t *testing.T) {
|
||||
tool := NewAppendFileTool("", false)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"content": "test",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when path is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditTool_AppendFile_MissingContent verifies error handling for missing content
|
||||
func TestEditTool_AppendFile_MissingContent(t *testing.T) {
|
||||
tool := NewAppendFileTool("", false)
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": "/tmp/test.txt",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when content is missing")
|
||||
}
|
||||
}
|
||||
+126
-20
@@ -5,9 +5,86 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ReadFileTool struct{}
|
||||
// validatePath ensures the given path is within the workspace if restrict is true.
|
||||
func validatePath(path, workspace string, restrict bool) (string, error) {
|
||||
if workspace == "" {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
absWorkspace, err := filepath.Abs(workspace)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve workspace path: %w", err)
|
||||
}
|
||||
|
||||
var absPath string
|
||||
if filepath.IsAbs(path) {
|
||||
absPath = filepath.Clean(path)
|
||||
} else {
|
||||
absPath, err = filepath.Abs(filepath.Join(absWorkspace, path))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve file path: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if restrict {
|
||||
if !isWithinWorkspace(absPath, absWorkspace) {
|
||||
return "", fmt.Errorf("access denied: path is outside the workspace")
|
||||
}
|
||||
|
||||
workspaceReal := absWorkspace
|
||||
if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil {
|
||||
workspaceReal = resolved
|
||||
}
|
||||
|
||||
if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
|
||||
if !isWithinWorkspace(resolved, workspaceReal) {
|
||||
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
|
||||
}
|
||||
} else if os.IsNotExist(err) {
|
||||
if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
|
||||
if !isWithinWorkspace(parentResolved, workspaceReal) {
|
||||
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("failed to resolve path: %w", err)
|
||||
}
|
||||
} else {
|
||||
return "", fmt.Errorf("failed to resolve path: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func resolveExistingAncestor(path string) (string, error) {
|
||||
for current := filepath.Clean(path); ; current = filepath.Dir(current) {
|
||||
if resolved, err := filepath.EvalSymlinks(current); err == nil {
|
||||
return resolved, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
if filepath.Dir(current) == current {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isWithinWorkspace(candidate, workspace string) bool {
|
||||
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
type ReadFileTool struct {
|
||||
workspace string
|
||||
restrict bool
|
||||
}
|
||||
|
||||
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
|
||||
return &ReadFileTool{workspace: workspace, restrict: restrict}
|
||||
}
|
||||
|
||||
func (t *ReadFileTool) Name() string {
|
||||
return "read_file"
|
||||
@@ -30,21 +107,33 @@ func (t *ReadFileTool) Parameters() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("path is required")
|
||||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
resolvedPath, err := validatePath(path, t.workspace, t.restrict)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read file: %w", err)
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
content, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
|
||||
}
|
||||
|
||||
return NewToolResult(string(content))
|
||||
}
|
||||
|
||||
type WriteFileTool struct{}
|
||||
type WriteFileTool struct {
|
||||
workspace string
|
||||
restrict bool
|
||||
}
|
||||
|
||||
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
|
||||
return &WriteFileTool{workspace: workspace, restrict: restrict}
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Name() string {
|
||||
return "write_file"
|
||||
@@ -71,30 +160,42 @@ func (t *WriteFileTool) Parameters() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("path is required")
|
||||
return ErrorResult("path is required")
|
||||
}
|
||||
|
||||
content, ok := args["content"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("content is required")
|
||||
return ErrorResult("content is required")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
resolvedPath, err := validatePath(path, t.workspace, t.restrict)
|
||||
if err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
dir := filepath.Dir(resolvedPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create directory: %w", err)
|
||||
return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write file: %w", err)
|
||||
if err := os.WriteFile(resolvedPath, []byte(content), 0644); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
|
||||
}
|
||||
|
||||
return "File written successfully", nil
|
||||
return SilentResult(fmt.Sprintf("File written: %s", path))
|
||||
}
|
||||
|
||||
type ListDirTool struct{}
|
||||
type ListDirTool struct {
|
||||
workspace string
|
||||
restrict bool
|
||||
}
|
||||
|
||||
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
|
||||
return &ListDirTool{workspace: workspace, restrict: restrict}
|
||||
}
|
||||
|
||||
func (t *ListDirTool) Name() string {
|
||||
return "list_dir"
|
||||
@@ -117,15 +218,20 @@ func (t *ListDirTool) Parameters() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok {
|
||||
path = "."
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(path)
|
||||
resolvedPath, err := validatePath(path, t.workspace, t.restrict)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read directory: %w", err)
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(resolvedPath)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
|
||||
}
|
||||
|
||||
result := ""
|
||||
@@ -137,5 +243,5 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return NewToolResult(result)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFilesystemTool_ReadFile_Success verifies successful file reading
|
||||
func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
|
||||
tool := &ReadFileTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": testFile,
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// ForLLM should contain file content
|
||||
if !strings.Contains(result.ForLLM, "test content") {
|
||||
t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// ReadFile returns NewToolResult which only sets ForLLM, not ForUser
|
||||
// This is the expected behavior - file content goes to LLM, not directly to user
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
||||
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
||||
tool := &ReadFileTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": "/nonexistent_file_12345.txt",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Failure should be marked as error
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error for missing file, got IsError=false")
|
||||
}
|
||||
|
||||
// Should contain error message
|
||||
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path
|
||||
func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
|
||||
tool := &ReadFileTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when path is missing")
|
||||
}
|
||||
|
||||
// Should mention required parameter
|
||||
if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
|
||||
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_WriteFile_Success verifies successful file writing
|
||||
func TestFilesystemTool_WriteFile_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "newfile.txt")
|
||||
|
||||
tool := &WriteFileTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": testFile,
|
||||
"content": "hello world",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// WriteFile returns SilentResult
|
||||
if !result.Silent {
|
||||
t.Errorf("Expected Silent=true for WriteFile, got false")
|
||||
}
|
||||
|
||||
// ForUser should be empty (silent result)
|
||||
if result.ForUser != "" {
|
||||
t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser)
|
||||
}
|
||||
|
||||
// Verify file was actually written
|
||||
content, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read written file: %v", err)
|
||||
}
|
||||
if string(content) != "hello world" {
|
||||
t.Errorf("Expected file content 'hello world', got: %s", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_WriteFile_CreateDir verifies directory creation
|
||||
func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
|
||||
|
||||
tool := &WriteFileTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": testFile,
|
||||
"content": "test",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Verify directory was created and file written
|
||||
content, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read written file: %v", err)
|
||||
}
|
||||
if string(content) != "test" {
|
||||
t.Errorf("Expected file content 'test', got: %s", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
|
||||
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
|
||||
tool := &WriteFileTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"content": "test",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when path is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
|
||||
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
|
||||
tool := &WriteFileTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": "/tmp/test.txt",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should return error result
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error when content is missing")
|
||||
}
|
||||
|
||||
// Should mention required parameter
|
||||
if !strings.Contains(result.ForLLM, "content is required") && !strings.Contains(result.ForUser, "content is required") {
|
||||
t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ListDir_Success verifies successful directory listing
|
||||
func TestFilesystemTool_ListDir_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0644)
|
||||
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0644)
|
||||
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0755)
|
||||
|
||||
tool := &ListDirTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": tmpDir,
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Success should not be an error
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// Should list files and directories
|
||||
if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
|
||||
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "subdir") {
|
||||
t.Errorf("Expected subdir in listing, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
|
||||
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
|
||||
tool := &ListDirTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"path": "/nonexistent_directory_12345",
|
||||
}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Failure should be marked as error
|
||||
if !result.IsError {
|
||||
t.Errorf("Expected error for non-existent directory, got IsError=false")
|
||||
}
|
||||
|
||||
// Should contain error message
|
||||
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
|
||||
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
|
||||
tool := &ListDirTool{}
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{}
|
||||
|
||||
result := tool.Execute(ctx, args)
|
||||
|
||||
// Should use "." as default path
|
||||
if result.IsError {
|
||||
t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
|
||||
// Block paths that look inside workspace but point outside via symlink.
|
||||
func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
|
||||
|
||||
root := t.TempDir()
|
||||
workspace := filepath.Join(root, "workspace")
|
||||
if err := os.MkdirAll(workspace, 0755); err != nil {
|
||||
t.Fatalf("failed to create workspace: %v", err)
|
||||
}
|
||||
|
||||
secret := filepath.Join(root, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("top secret"), 0644); err != nil {
|
||||
t.Fatalf("failed to write secret file: %v", err)
|
||||
}
|
||||
|
||||
link := filepath.Join(workspace, "leak.txt")
|
||||
if err := os.Symlink(secret, link); err != nil {
|
||||
t.Skipf("symlink not supported in this environment: %v", err)
|
||||
}
|
||||
|
||||
tool := NewReadFileTool(workspace, true)
|
||||
result := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"path": link,
|
||||
})
|
||||
|
||||
if !result.IsError {
|
||||
t.Fatalf("expected symlink escape to be blocked")
|
||||
}
|
||||
if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") {
|
||||
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals.
|
||||
type I2CTool struct{}
|
||||
|
||||
func NewI2CTool() *I2CTool {
|
||||
return &I2CTool{}
|
||||
}
|
||||
|
||||
func (t *I2CTool) Name() string {
|
||||
return "i2c"
|
||||
}
|
||||
|
||||
func (t *I2CTool) Description() string {
|
||||
return "Interact with I2C bus devices for reading sensors and controlling peripherals. Actions: detect (list buses), scan (find devices on a bus), read (read bytes from device), write (send bytes to device). Linux only."
|
||||
}
|
||||
|
||||
func (t *I2CTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"detect", "scan", "read", "write"},
|
||||
"description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)",
|
||||
},
|
||||
"bus": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.",
|
||||
},
|
||||
"address": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "7-bit I2C device address (0x03-0x77). Required for read/write.",
|
||||
},
|
||||
"register": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Register address to read from or write to. If set, sends register byte before read/write.",
|
||||
},
|
||||
"data": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"type": "integer"},
|
||||
"description": "Bytes to write (0-255 each). Required for write action.",
|
||||
},
|
||||
"length": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Number of bytes to read (1-256). Default: 1. Used with read action.",
|
||||
},
|
||||
"confirm": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "Must be true for write operations. Safety guard to prevent accidental writes.",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *I2CTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
|
||||
if runtime.GOOS != "linux" {
|
||||
return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.")
|
||||
}
|
||||
|
||||
action, ok := args["action"].(string)
|
||||
if !ok {
|
||||
return ErrorResult("action is required")
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "detect":
|
||||
return t.detect()
|
||||
case "scan":
|
||||
return t.scan(args)
|
||||
case "read":
|
||||
return t.readDevice(args)
|
||||
case "write":
|
||||
return t.writeDevice(args)
|
||||
default:
|
||||
return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action))
|
||||
}
|
||||
}
|
||||
|
||||
// detect lists available I2C buses by globbing /dev/i2c-*
|
||||
func (t *I2CTool) detect() *ToolResult {
|
||||
matches, err := filepath.Glob("/dev/i2c-*")
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to scan for I2C buses: %v", err))
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return SilentResult("No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)")
|
||||
}
|
||||
|
||||
type busInfo struct {
|
||||
Path string `json:"path"`
|
||||
Bus string `json:"bus"`
|
||||
}
|
||||
|
||||
buses := make([]busInfo, 0, len(matches))
|
||||
re := regexp.MustCompile(`/dev/i2c-(\d+)`)
|
||||
for _, m := range matches {
|
||||
if sub := re.FindStringSubmatch(m); sub != nil {
|
||||
buses = append(buses, busInfo{Path: m, Bus: sub[1]})
|
||||
}
|
||||
}
|
||||
|
||||
result, _ := json.MarshalIndent(buses, "", " ")
|
||||
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
|
||||
}
|
||||
|
||||
// isValidBusID checks that a bus identifier is a simple number (prevents path injection)
|
||||
func isValidBusID(id string) bool {
|
||||
matched, _ := regexp.MatchString(`^\d+$`, id)
|
||||
return matched
|
||||
}
|
||||
|
||||
// parseI2CAddress extracts and validates an I2C address from args
|
||||
func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) {
|
||||
addrFloat, ok := args["address"].(float64)
|
||||
if !ok {
|
||||
return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)")
|
||||
}
|
||||
addr := int(addrFloat)
|
||||
if addr < 0x03 || addr > 0x77 {
|
||||
return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)")
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// parseI2CBus extracts and validates an I2C bus from args
|
||||
func parseI2CBus(args map[string]interface{}) (string, *ToolResult) {
|
||||
bus, ok := args["bus"].(string)
|
||||
if !ok || bus == "" {
|
||||
return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)")
|
||||
}
|
||||
if !isValidBusID(bus) {
|
||||
return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")")
|
||||
}
|
||||
return bus, nil
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// I2C ioctl constants from Linux kernel headers (<linux/i2c-dev.h>, <linux/i2c.h>)
|
||||
const (
|
||||
i2cSlave = 0x0703 // Set slave address (fails if in use by driver)
|
||||
i2cFuncs = 0x0705 // Query adapter functionality bitmask
|
||||
i2cSmbus = 0x0720 // Perform SMBus transaction
|
||||
|
||||
// I2C_FUNC capability bits
|
||||
i2cFuncSmbusQuick = 0x00010000
|
||||
i2cFuncSmbusReadByte = 0x00020000
|
||||
|
||||
// SMBus transaction types
|
||||
i2cSmbusRead = 0
|
||||
i2cSmbusWrite = 1
|
||||
|
||||
// SMBus protocol sizes
|
||||
i2cSmbusQuick = 0
|
||||
i2cSmbusByte = 1
|
||||
)
|
||||
|
||||
// i2cSmbusData matches the kernel union i2c_smbus_data (34 bytes max).
|
||||
// For quick and byte transactions only the first byte is used (if at all).
|
||||
type i2cSmbusData [34]byte
|
||||
|
||||
// i2cSmbusArgs matches the kernel struct i2c_smbus_ioctl_data.
|
||||
type i2cSmbusArgs struct {
|
||||
readWrite uint8
|
||||
command uint8
|
||||
size uint32
|
||||
data *i2cSmbusData
|
||||
}
|
||||
|
||||
// smbusProbe performs a single SMBus probe at the given address.
|
||||
// Uses SMBus Quick Write (safest) or falls back to SMBus Read Byte for
|
||||
// EEPROM address ranges where quick write can corrupt AT24RF08 chips.
|
||||
// This matches i2cdetect's MODE_AUTO behavior.
|
||||
func smbusProbe(fd int, addr int, hasQuick bool) bool {
|
||||
// EEPROM ranges: use read byte (quick write can corrupt AT24RF08)
|
||||
useReadByte := (addr >= 0x30 && addr <= 0x37) || (addr >= 0x50 && addr <= 0x5F)
|
||||
|
||||
if !useReadByte && hasQuick {
|
||||
// SMBus Quick Write: [START] [ADDR|W] [ACK/NACK] [STOP]
|
||||
// Safest probe — no data transferred
|
||||
args := i2cSmbusArgs{
|
||||
readWrite: i2cSmbusWrite,
|
||||
command: 0,
|
||||
size: i2cSmbusQuick,
|
||||
data: nil,
|
||||
}
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args)))
|
||||
return errno == 0
|
||||
}
|
||||
|
||||
// SMBus Read Byte: [START] [ADDR|R] [ACK/NACK] [DATA] [STOP]
|
||||
var data i2cSmbusData
|
||||
args := i2cSmbusArgs{
|
||||
readWrite: i2cSmbusRead,
|
||||
command: 0,
|
||||
size: i2cSmbusByte,
|
||||
data: &data,
|
||||
}
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args)))
|
||||
return errno == 0
|
||||
}
|
||||
|
||||
// scan probes valid 7-bit addresses on a bus for connected devices.
|
||||
// Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO:
|
||||
// SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges.
|
||||
func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
|
||||
bus, errResult := parseI2CBus(args)
|
||||
if errResult != nil {
|
||||
return errResult
|
||||
}
|
||||
|
||||
devPath := fmt.Sprintf("/dev/i2c-%s", bus)
|
||||
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err))
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
// Query adapter capabilities to determine available probe methods.
|
||||
// I2C_FUNCS writes an unsigned long, which is word-sized on Linux.
|
||||
var funcs uintptr
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs)))
|
||||
if errno != 0 {
|
||||
return ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno))
|
||||
}
|
||||
|
||||
hasQuick := funcs&i2cFuncSmbusQuick != 0
|
||||
hasReadByte := funcs&i2cFuncSmbusReadByte != 0
|
||||
|
||||
if !hasQuick && !hasReadByte {
|
||||
return ErrorResult(fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath))
|
||||
}
|
||||
|
||||
type deviceEntry struct {
|
||||
Address string `json:"address"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
var found []deviceEntry
|
||||
// Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07
|
||||
for addr := 0x08; addr <= 0x77; addr++ {
|
||||
// Set slave address — EBUSY means a kernel driver owns this address
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
|
||||
if errno != 0 {
|
||||
if errno == syscall.EBUSY {
|
||||
found = append(found, deviceEntry{
|
||||
Address: fmt.Sprintf("0x%02x", addr),
|
||||
Status: "busy (in use by kernel driver)",
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if smbusProbe(fd, addr, hasQuick) {
|
||||
found = append(found, deviceEntry{
|
||||
Address: fmt.Sprintf("0x%02x", addr),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(found) == 0 {
|
||||
return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath))
|
||||
}
|
||||
|
||||
result, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"bus": devPath,
|
||||
"devices": found,
|
||||
"count": len(found),
|
||||
}, "", " ")
|
||||
return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result)))
|
||||
}
|
||||
|
||||
// readDevice reads bytes from an I2C device, optionally at a specific register
|
||||
func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
|
||||
bus, errResult := parseI2CBus(args)
|
||||
if errResult != nil {
|
||||
return errResult
|
||||
}
|
||||
|
||||
addr, errResult := parseI2CAddress(args)
|
||||
if errResult != nil {
|
||||
return errResult
|
||||
}
|
||||
|
||||
length := 1
|
||||
if l, ok := args["length"].(float64); ok {
|
||||
length = int(l)
|
||||
}
|
||||
if length < 1 || length > 256 {
|
||||
return ErrorResult("length must be between 1 and 256")
|
||||
}
|
||||
|
||||
devPath := fmt.Sprintf("/dev/i2c-%s", bus)
|
||||
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err))
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
// Set slave address
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
|
||||
if errno != 0 {
|
||||
return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno))
|
||||
}
|
||||
|
||||
// If register is specified, write it first
|
||||
if regFloat, ok := args["register"].(float64); ok {
|
||||
reg := int(regFloat)
|
||||
if reg < 0 || reg > 255 {
|
||||
return ErrorResult("register must be between 0x00 and 0xFF")
|
||||
}
|
||||
_, err := syscall.Write(fd, []byte{byte(reg)})
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Read data
|
||||
buf := make([]byte, length)
|
||||
n, err := syscall.Read(fd, buf)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to read from device 0x%02x: %v", addr, err))
|
||||
}
|
||||
|
||||
// Format as hex bytes
|
||||
hexBytes := make([]string, n)
|
||||
intBytes := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
hexBytes[i] = fmt.Sprintf("0x%02x", buf[i])
|
||||
intBytes[i] = int(buf[i])
|
||||
}
|
||||
|
||||
result, _ := json.MarshalIndent(map[string]interface{}{
|
||||
"bus": devPath,
|
||||
"address": fmt.Sprintf("0x%02x", addr),
|
||||
"bytes": intBytes,
|
||||
"hex": hexBytes,
|
||||
"length": n,
|
||||
}, "", " ")
|
||||
return SilentResult(string(result))
|
||||
}
|
||||
|
||||
// writeDevice writes bytes to an I2C device, optionally at a specific register
|
||||
func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult {
|
||||
confirm, _ := args["confirm"].(bool)
|
||||
if !confirm {
|
||||
return ErrorResult("write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.")
|
||||
}
|
||||
|
||||
bus, errResult := parseI2CBus(args)
|
||||
if errResult != nil {
|
||||
return errResult
|
||||
}
|
||||
|
||||
addr, errResult := parseI2CAddress(args)
|
||||
if errResult != nil {
|
||||
return errResult
|
||||
}
|
||||
|
||||
dataRaw, ok := args["data"].([]interface{})
|
||||
if !ok || len(dataRaw) == 0 {
|
||||
return ErrorResult("data is required for write (array of byte values 0-255)")
|
||||
}
|
||||
if len(dataRaw) > 256 {
|
||||
return ErrorResult("data too long: maximum 256 bytes per I2C transaction")
|
||||
}
|
||||
|
||||
data := make([]byte, 0, len(dataRaw)+1)
|
||||
|
||||
// If register is specified, prepend it to the data
|
||||
if regFloat, ok := args["register"].(float64); ok {
|
||||
reg := int(regFloat)
|
||||
if reg < 0 || reg > 255 {
|
||||
return ErrorResult("register must be between 0x00 and 0xFF")
|
||||
}
|
||||
data = append(data, byte(reg))
|
||||
}
|
||||
|
||||
for i, v := range dataRaw {
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i))
|
||||
}
|
||||
b := int(f)
|
||||
if b < 0 || b > 255 {
|
||||
return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b))
|
||||
}
|
||||
data = append(data, byte(b))
|
||||
}
|
||||
|
||||
devPath := fmt.Sprintf("/dev/i2c-%s", bus)
|
||||
fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err))
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
// Set slave address
|
||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
|
||||
if errno != 0 {
|
||||
return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno))
|
||||
}
|
||||
|
||||
// Write data
|
||||
n, err := syscall.Write(fd, data)
|
||||
if err != nil {
|
||||
return ErrorResult(fmt.Sprintf("failed to write to device 0x%02x: %v", addr, err))
|
||||
}
|
||||
|
||||
return SilentResult(fmt.Sprintf("Wrote %d byte(s) to device 0x%02x on %s", n, addr, devPath))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user