Merge remote-tracking branch 'refs/remotes/origin/main' into fix/deny-reading-binary-files
This commit is contained in:
commit
c69095457f
138 changed files with 9432 additions and 953 deletions
|
|
@ -5,13 +5,14 @@
|
||||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||||
# OPENAI_API_KEY=sk-xxx
|
# OPENAI_API_KEY=sk-xxx
|
||||||
# GEMINI_API_KEY=xxx
|
# GEMINI_API_KEY=xxx
|
||||||
# CEREBRAS_API_KEY=xxx
|
# CLAUDE_CODE_OAUTH=xxx
|
||||||
|
|
||||||
# ── Chat Channel ──────────────────────────
|
# ── Chat Channel ──────────────────────────
|
||||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||||
# DISCORD_BOT_TOKEN=xxx
|
# DISCORD_BOT_TOKEN=xxx
|
||||||
# LINE_CHANNEL_SECRET=xxx
|
# Feishu (飞书)
|
||||||
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
# PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx
|
||||||
|
# PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx
|
||||||
|
# PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond
|
||||||
|
|
||||||
# ── Web Search (optional) ────────────────
|
# ── Web Search (optional) ────────────────
|
||||||
# BRAVE_SEARCH_API_KEY=BSA...
|
# BRAVE_SEARCH_API_KEY=BSA...
|
||||||
|
|
|
||||||
19
.github/workflows/pr.yml
vendored
19
.github/workflows/pr.yml
vendored
|
|
@ -24,6 +24,25 @@ jobs:
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.10.1
|
||||||
|
|
||||||
|
vuln_check:
|
||||||
|
name: Security Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Run Govulncheck
|
||||||
|
uses: golang/govulncheck-action@v1
|
||||||
|
with:
|
||||||
|
go-package: ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -17,6 +17,11 @@ on:
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
upload_tos:
|
||||||
|
description: "Upload to Volcengine TOS"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
create-tag:
|
create-tag:
|
||||||
|
|
@ -100,3 +105,12 @@ jobs:
|
||||||
gh release edit "${{ inputs.tag }}" \
|
gh release edit "${{ inputs.tag }}" \
|
||||||
--draft=${{ inputs.draft }} \
|
--draft=${{ inputs.draft }} \
|
||||||
--prerelease=${{ inputs.prerelease }}
|
--prerelease=${{ inputs.prerelease }}
|
||||||
|
|
||||||
|
upload-tos:
|
||||||
|
name: Upload to TOS
|
||||||
|
needs: release
|
||||||
|
if: ${{ inputs.upload_tos }}
|
||||||
|
uses: ./.github/workflows/upload-tos.yml
|
||||||
|
with:
|
||||||
|
tag: ${{ inputs.tag }}
|
||||||
|
secrets: inherit
|
||||||
|
|
|
||||||
49
.github/workflows/upload-tos.yml
vendored
Normal file
49
.github/workflows/upload-tos.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: Upload to Volcengine TOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to download and upload (e.g. v0.2.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Release tag to download and upload"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload-tos:
|
||||||
|
name: Upload to Volcengine TOS
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download release assets
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
gh release download "${{ inputs.tag }}" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--dir artifacts \
|
||||||
|
--pattern "*.tar.gz" \
|
||||||
|
--pattern "*.zip" \
|
||||||
|
--pattern "*.rpm" \
|
||||||
|
--pattern "*.deb"
|
||||||
|
|
||||||
|
- name: Upload to Volcengine TOS
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.VOLC_TOS_ACCESS_KEY }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.VOLC_TOS_SECRET_KEY }}
|
||||||
|
AWS_DEFAULT_REGION: cn-beijing
|
||||||
|
run: |
|
||||||
|
aws configure set default.s3.addressing_style virtual
|
||||||
|
TOS_ENDPOINT="https://tos-s3-cn-beijing.volces.com"
|
||||||
|
# Upload to versioned directory
|
||||||
|
aws s3 sync artifacts/ "s3://picoclaw-downloads/${{ inputs.tag }}/" \
|
||||||
|
--endpoint-url "$TOS_ENDPOINT"
|
||||||
|
# Upload to latest (overwrite)
|
||||||
|
aws s3 sync artifacts/ "s3://picoclaw-downloads/latest/" \
|
||||||
|
--endpoint-url "$TOS_ENDPOINT" \
|
||||||
|
--delete
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -38,6 +38,9 @@ ralph/
|
||||||
.ralph/
|
.ralph/
|
||||||
tasks/
|
tasks/
|
||||||
|
|
||||||
|
# Plans
|
||||||
|
docs/plans/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
4
LICENSE
4
LICENSE
|
|
@ -19,7 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PicoClaw is heavily inspired by and based on [nanobot](https://github.com/HKUDS/nanobot) by HKUDS.
|
|
||||||
|
|
|
||||||
36
Makefile
36
Makefile
|
|
@ -18,6 +18,28 @@ LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(G
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
|
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||||
|
#
|
||||||
|
# Bytes (octal): \004 \024 \000 \160 → little-endian 0x70001404
|
||||||
|
# 0x70000000 EF_MIPS_ARCH_32R2 MIPS32 Release 2
|
||||||
|
# 0x00001000 EF_MIPS_ABI_O32 O32 ABI
|
||||||
|
# 0x00000400 EF_MIPS_NAN2008 IEEE 754-2008 NaN encoding
|
||||||
|
# 0x00000004 EF_MIPS_CPIC PIC calling sequence
|
||||||
|
#
|
||||||
|
# Go's GOMIPS=softfloat emits no FP instructions, so the NaN mode is irrelevant
|
||||||
|
# at runtime — this is purely an ELF metadata fix to satisfy the kernel's check.
|
||||||
|
# patchelf cannot modify e_flags; dd at a fixed offset is the most portable way.
|
||||||
|
#
|
||||||
|
# Ref: https://codebrowser.dev/linux/linux/arch/mips/include/asm/elf.h.html
|
||||||
|
define PATCH_MIPS_FLAGS
|
||||||
|
@if [ -f "$(1)" ]; then \
|
||||||
|
printf '\004\024\000\160' | dd of=$(1) bs=1 seek=36 count=4 conv=notrunc 2>/dev/null || \
|
||||||
|
{ echo "Error: failed to patch MIPS e_flags for $(1)"; exit 1; }; \
|
||||||
|
else \
|
||||||
|
echo "Error: $(1) not found, cannot patch MIPS e_flags"; exit 1; \
|
||||||
|
fi
|
||||||
|
endef
|
||||||
|
|
||||||
# Golangci-lint
|
# Golangci-lint
|
||||||
GOLANGCI_LINT?=golangci-lint
|
GOLANGCI_LINT?=golangci-lint
|
||||||
|
|
||||||
|
|
@ -50,6 +72,8 @@ ifeq ($(UNAME_S),Linux)
|
||||||
ARCH=loong64
|
ARCH=loong64
|
||||||
else ifeq ($(UNAME_M),riscv64)
|
else ifeq ($(UNAME_M),riscv64)
|
||||||
ARCH=riscv64
|
ARCH=riscv64
|
||||||
|
else ifeq ($(UNAME_M),mipsel)
|
||||||
|
ARCH=mipsle
|
||||||
else
|
else
|
||||||
ARCH=$(UNAME_M)
|
ARCH=$(UNAME_M)
|
||||||
endif
|
endif
|
||||||
|
|
@ -97,6 +121,8 @@ build-whatsapp-native: generate
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
|
|
@ -117,6 +143,14 @@ build-linux-arm64: generate
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||||
|
|
||||||
|
## build-linux-mipsle: Build for Linux MIPS32 LE
|
||||||
|
build-linux-mipsle: generate
|
||||||
|
@echo "Building for linux/mipsle (softfloat)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
||||||
|
|
||||||
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||||
build-pi-zero: build-linux-arm build-linux-arm64
|
build-pi-zero: build-linux-arm build-linux-arm64
|
||||||
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||||
|
|
@ -130,6 +164,8 @@ build-all: generate
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(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)
|
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<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/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/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
||||||
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
|
⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
|
||||||
|
|
||||||
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti !
|
🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM, MIPS et x86. Un clic et c'est parti !
|
||||||
|
|
||||||
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
|
🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
<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/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/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
||||||
|
|
||||||
🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go!
|
🌍 **真のポータビリティ**: RISC-V、ARM、MIPS、x86 対応の単一バイナリ。ワンクリックで Go!
|
||||||
|
|
||||||
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
||||||
|
|
||||||
|
|
|
||||||
181
README.md
181
README.md
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<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/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/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
|
|
||||||
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
|
⚡️ **Lightning Fast**: 400X Faster startup time, boot in 1 second even in 0.6GHz single core.
|
||||||
|
|
||||||
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, and x86, One-click to Go!
|
🌍 **True Portability**: Single self-contained binary across RISC-V, ARM, MIPS, and x86, One-click to Go!
|
||||||
|
|
||||||
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
||||||
|
|
||||||
|
|
@ -216,7 +216,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> 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)
|
> 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 [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
|
|
@ -265,6 +265,16 @@ picoclaw onboard
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -277,7 +287,12 @@ picoclaw onboard
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **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): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
* **Web Search** (optional):
|
||||||
|
* [Brave Search](https://brave.com/search/api) - Paid ($5/1000 queries, ~$5-6/month)
|
||||||
|
* [Perplexity](https://www.perplexity.ai) - AI-powered search with chat interface
|
||||||
|
* [SearXNG](https://github.com/searxng/searxng) - Self-hosted metasearch engine (free, no API key needed)
|
||||||
|
* [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month)
|
||||||
|
* DuckDuckGo - Built-in fallback (no API key required)
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
|
|
@ -293,7 +308,7 @@ That's it! You have a working AI assistant in 2 minutes.
|
||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom
|
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
|
||||||
|
|
||||||
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
||||||
|
|
||||||
|
|
@ -302,6 +317,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
||||||
|
| **Matrix** | Medium (homeserver + bot access token) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
| **LINE** | Medium (credentials + webhook URL) |
|
| **LINE** | Medium (credentials + webhook URL) |
|
||||||
|
|
@ -338,6 +354,13 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**4. Telegram command menu (auto-registered at startup)**
|
||||||
|
|
||||||
|
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`) so command menu and runtime behavior stay in sync.
|
||||||
|
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
|
||||||
|
|
||||||
|
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -506,6 +529,40 @@ picoclaw gateway
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Matrix</b></summary>
|
||||||
|
|
||||||
|
**1. Prepare bot account**
|
||||||
|
|
||||||
|
* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
|
||||||
|
* Create a bot user and obtain its access token
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>LINE</b></summary>
|
<summary><b>LINE</b></summary>
|
||||||
|
|
||||||
|
|
@ -735,6 +792,12 @@ For advanced/test setups, you can override the builtin skills root with:
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Unified Command Execution Policy
|
||||||
|
|
||||||
|
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
|
||||||
|
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
|
||||||
|
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
|
||||||
|
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
|
||||||
### 🔒 Security Sandbox
|
### 🔒 Security Sandbox
|
||||||
|
|
||||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||||
|
|
@ -924,6 +987,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||||
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
|
||||||
|
| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
|
||||||
|
|
||||||
### Model Configuration (model_list)
|
### Model Configuration (model_list)
|
||||||
|
|
||||||
|
|
@ -951,11 +1015,12 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
|
||||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
|
||||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1 | OpenAI | Your LiteLLM proxy key |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
|
||||||
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
|
||||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||||
|
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
|
||||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
|
||||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||||
|
|
||||||
|
|
@ -1190,6 +1255,10 @@ picoclaw agent -m "Hello"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
@ -1241,6 +1310,16 @@ picoclaw agent -m "Hello"
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://localhost:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
|
@ -1298,10 +1377,69 @@ discord: <https://discord.gg/V4sAZ9XWpN>
|
||||||
|
|
||||||
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
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:
|
#### Search Provider Priority
|
||||||
|
|
||||||
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.
|
PicoClaw automatically selects the best available search provider in this order:
|
||||||
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
|
1. **Perplexity** (if enabled and API key configured) - AI-powered search with citations
|
||||||
|
2. **Brave Search** (if enabled and API key configured) - Privacy-focused paid API ($5/1000 queries)
|
||||||
|
3. **SearXNG** (if enabled and base_url configured) - Self-hosted metasearch aggregating 70+ engines (free)
|
||||||
|
4. **DuckDuckGo** (if enabled, default fallback) - No API key required (free)
|
||||||
|
|
||||||
|
#### Web Search Configuration Options
|
||||||
|
|
||||||
|
**Option 1 (Best Results)**: Perplexity AI Search
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2 (Paid API)**: Get an API key at [https://brave.com/search/api](https://brave.com/search/api) ($5/1000 queries, ~$5-6/month)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"brave": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3 (Self-Hosted)**: Deploy your own [SearXNG](https://github.com/searxng/searxng) instance
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"searxng": {
|
||||||
|
"enabled": true,
|
||||||
|
"base_url": "http://your-server:8888",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits of SearXNG:
|
||||||
|
- **Zero cost**: No API fees or rate limits
|
||||||
|
- **Privacy-focused**: Self-hosted, no tracking
|
||||||
|
- **Aggregate results**: Queries 70+ search engines simultaneously
|
||||||
|
- **Perfect for cloud VMs**: Solves datacenter IP blocking issues (Oracle Cloud, GCP, AWS, Azure)
|
||||||
|
- **No API key needed**: Just deploy and configure the base URL
|
||||||
|
|
||||||
|
**Option 4 (No Setup Required)**: DuckDuckGo is enabled by default as fallback (no API key needed)
|
||||||
|
|
||||||
Add the key to `~/.picoclaw/config.json` if using Brave:
|
Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
|
|
||||||
|
|
@ -1317,6 +1455,16 @@ Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"perplexity": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_PERPLEXITY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://your-searxng-instance:8888",
|
||||||
|
"max_results": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1335,10 +1483,11 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
||||||
|
|
||||||
## 📝 API Key Comparison
|
## 📝 API Key Comparison
|
||||||
|
|
||||||
| Service | Free Tier | Use Case |
|
| Service | Free Tier | Use Case |
|
||||||
| ---------------- | ------------------- | ------------------------------------- |
|
| ---------------- | ------------------------ | ------------------------------------- |
|
||||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
|
||||||
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
|
||||||
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
|
||||||
|
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<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/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/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
|
|
||||||
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
|
||||||
|
|
||||||
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era!
|
🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM, MIPS e x86. Um clique e já era!
|
||||||
|
|
||||||
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<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/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/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
||||||
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
|
||||||
|
|
||||||
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy!
|
🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM, MIPS và x86. Một click là chạy!
|
||||||
|
|
||||||
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
|
||||||
|
|
||||||
|
|
|
||||||
22
README.zh.md
22
README.zh.md
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
<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/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/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
<br>
|
<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://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
|
|
||||||
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
||||||
|
|
||||||
🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
|
🌍 **真正可移植**: 跨 RISC-V、ARM、MIPS 和 x86 架构的单二进制文件,一键运行!
|
||||||
|
|
||||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
||||||
|
|
||||||
|
|
@ -299,6 +299,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
||||||
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
||||||
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
||||||
|
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) |
|
||||||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
||||||
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
||||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
|
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
|
||||||
|
|
@ -307,6 +308,13 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||||
|
|
||||||
|
### Telegram 命令注册(启动时自动同步)
|
||||||
|
|
||||||
|
PicoClaw 现在使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start`、`/help`、`/show`、`/list`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
|
||||||
|
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
|
||||||
|
|
||||||
|
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
@ -376,6 +384,12 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 统一命令执行策略
|
||||||
|
|
||||||
|
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
||||||
|
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
|
||||||
|
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
|
||||||
|
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
|
||||||
### 心跳 / 周期性任务 (Heartbeat)
|
### 心跳 / 周期性任务 (Heartbeat)
|
||||||
|
|
||||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
||||||
|
|
@ -715,6 +729,10 @@ picoclaw agent -m "你好"
|
||||||
"model": "anthropic/claude-opus-4-5"
|
"model": "anthropic/claude-opus-4-5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"session": {
|
||||||
|
"dm_scope": "per-channel-peer",
|
||||||
|
"backlog_limit": 20
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"api_key": "sk-or-v1-xxx"
|
"api_key": "sk-or-v1-xxx"
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 386 KiB |
|
|
@ -423,7 +423,7 @@ func (s *appState) hasEnabledChannel() bool {
|
||||||
c := s.config.Channels
|
c := s.config.Channels
|
||||||
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
||||||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
||||||
c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,12 @@ func (s *appState) buildChannelMenuItems() []MenuItem {
|
||||||
s.config.Channels.Slack.Enabled,
|
s.config.Channels.Slack.Enabled,
|
||||||
func() { s.push("channel-slack", s.slackForm()) },
|
func() { s.push("channel-slack", s.slackForm()) },
|
||||||
),
|
),
|
||||||
|
channelItem(
|
||||||
|
"Matrix",
|
||||||
|
"Matrix bot settings",
|
||||||
|
s.config.Channels.Matrix.Enabled,
|
||||||
|
func() { s.push("channel-matrix", s.matrixForm()) },
|
||||||
|
),
|
||||||
channelItem(
|
channelItem(
|
||||||
"LINE",
|
"LINE",
|
||||||
"LINE bot settings",
|
"LINE bot settings",
|
||||||
|
|
@ -233,6 +239,28 @@ func (s *appState) lineForm() tview.Primitive {
|
||||||
return wrapWithBack(form, s)
|
return wrapWithBack(form, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *appState) matrixForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Matrix
|
||||||
|
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
|
||||||
|
cfg.Homeserver = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
|
||||||
|
cfg.UserID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.AccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
|
||||||
|
cfg.DeviceID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
|
||||||
|
cfg.JoinOnInvite = checked
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *appState) onebotForm() tview.Primitive {
|
func (s *appState) onebotForm() tview.Primitive {
|
||||||
cfg := &s.config.Channels.OneBot
|
cfg := &s.config.Channels.OneBot
|
||||||
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,11 @@ func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
s.showMessage("Test OK", resp.Status)
|
s.showMessage("Test OK", resp.Status)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
if err != nil {
|
||||||
|
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
s.showMessage(
|
s.showMessage(
|
||||||
"Test failed",
|
"Test failed",
|
||||||
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ A standalone launcher for PicoClaw, providing visual JSON editing and OAuth prov
|
||||||
|
|
||||||
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
||||||
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
||||||
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
- 📡 **Channel Configuration** — Form-based settings for 13 channel types (Telegram, Discord, Slack, Matrix, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
||||||
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
||||||
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
||||||
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -538,6 +538,7 @@
|
||||||
<div class="sidebar-item" data-panel="panelCh_telegram">Telegram</div>
|
<div class="sidebar-item" data-panel="panelCh_telegram">Telegram</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_discord">Discord</div>
|
<div class="sidebar-item" data-panel="panelCh_discord">Discord</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_slack">Slack</div>
|
<div class="sidebar-item" data-panel="panelCh_slack">Slack</div>
|
||||||
|
<div class="sidebar-item" data-panel="panelCh_matrix">Matrix</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_wecom">WeCom</div>
|
<div class="sidebar-item" data-panel="panelCh_wecom">WeCom</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_wecom_app">WeCom App</div>
|
<div class="sidebar-item" data-panel="panelCh_wecom_app">WeCom App</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_dingtalk">DingTalk</div>
|
<div class="sidebar-item" data-panel="panelCh_dingtalk">DingTalk</div>
|
||||||
|
|
@ -606,6 +607,7 @@
|
||||||
<div class="content-panel" id="panelCh_telegram"></div>
|
<div class="content-panel" id="panelCh_telegram"></div>
|
||||||
<div class="content-panel" id="panelCh_discord"></div>
|
<div class="content-panel" id="panelCh_discord"></div>
|
||||||
<div class="content-panel" id="panelCh_slack"></div>
|
<div class="content-panel" id="panelCh_slack"></div>
|
||||||
|
<div class="content-panel" id="panelCh_matrix"></div>
|
||||||
<div class="content-panel" id="panelCh_wecom"></div>
|
<div class="content-panel" id="panelCh_wecom"></div>
|
||||||
<div class="content-panel" id="panelCh_wecom_app"></div>
|
<div class="content-panel" id="panelCh_wecom_app"></div>
|
||||||
<div class="content-panel" id="panelCh_dingtalk"></div>
|
<div class="content-panel" id="panelCh_dingtalk"></div>
|
||||||
|
|
@ -1011,6 +1013,16 @@ const channelSchemas = {
|
||||||
{ key: 'app_token', label: 'App Token', type: 'password', placeholder: 'xapp-...' },
|
{ key: 'app_token', label: 'App Token', type: 'password', placeholder: 'xapp-...' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
matrix: {
|
||||||
|
title: 'Matrix', configKey: 'matrix', docSlug: null,
|
||||||
|
fields: [
|
||||||
|
{ key: 'homeserver', label: 'Homeserver', type: 'text', placeholder: 'https://matrix.org' },
|
||||||
|
{ key: 'user_id', label: 'User ID', type: 'text', placeholder: '@bot:matrix.org' },
|
||||||
|
{ key: 'access_token', label: 'Access Token', type: 'password', placeholder: 'syt_...' },
|
||||||
|
{ key: 'device_id', label: 'Device ID', type: 'text', placeholder: 'Optional device ID' },
|
||||||
|
{ key: 'join_on_invite', label: 'Join On Invite', type: 'toggle' },
|
||||||
|
]
|
||||||
|
},
|
||||||
wecom: {
|
wecom: {
|
||||||
title: 'WeCom (Bot)', configKey: 'wecom', docSlug: 'wecom-bot',
|
title: 'WeCom (Bot)', configKey: 'wecom', docSlug: 'wecom-bot',
|
||||||
fields: [
|
fields: [
|
||||||
|
|
@ -1392,9 +1404,7 @@ function saveModelFromModal() {
|
||||||
saveConfig().then(renderModels);
|
saveConfig().then(renderModels);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('modelModal').addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) closeModelModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Channel Forms ───────────────────────────────────
|
// ── Channel Forms ───────────────────────────────────
|
||||||
function renderChannelForm(chKey) {
|
function renderChannelForm(chKey) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -15,14 +16,17 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
const (
|
||||||
|
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||||
|
defaultAnthropicModel = "claude-sonnet-4.6"
|
||||||
|
)
|
||||||
|
|
||||||
func authLoginCmd(provider string, useDeviceCode bool) error {
|
func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
return authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
return authLoginPasteToken(provider)
|
return authLoginAnthropic(useOauth)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
return authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity()
|
||||||
default:
|
default:
|
||||||
|
|
@ -163,6 +167,81 @@ func authLoginGoogleAntigravity() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authLoginAnthropic(useOauth bool) error {
|
||||||
|
if useOauth {
|
||||||
|
return authLoginAnthropicSetupToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Anthropic login method:")
|
||||||
|
fmt.Println(" 1) Setup token (from `claude setup-token`) (Recommended)")
|
||||||
|
fmt.Println(" 2) API key (from console.anthropic.com)")
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
for {
|
||||||
|
fmt.Print("Choose [1]: ")
|
||||||
|
choice := "1"
|
||||||
|
if scanner.Scan() {
|
||||||
|
text := strings.TrimSpace(scanner.Text())
|
||||||
|
if text != "" {
|
||||||
|
choice = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch choice {
|
||||||
|
case "1":
|
||||||
|
return authLoginAnthropicSetupToken()
|
||||||
|
case "2":
|
||||||
|
return authLoginPasteToken("anthropic")
|
||||||
|
default:
|
||||||
|
fmt.Printf("Invalid choice: %s. Please enter 1 or 2.\n", choice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authLoginAnthropicSetupToken() error {
|
||||||
|
cred, err := auth.LoginSetupToken(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("login failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = auth.SetCredential("anthropic", cred); err != nil {
|
||||||
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := internal.LoadConfig()
|
||||||
|
if err == nil {
|
||||||
|
appCfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: defaultAnthropicModel,
|
||||||
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
// Only set default model if user has no default configured yet
|
||||||
|
if appCfg.Agents.Defaults.GetModelName() == "" {
|
||||||
|
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Setup token saved for Anthropic!")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -177,7 +256,10 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading userinfo response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -217,13 +299,12 @@ func authLoginPasteToken(provider string) error {
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
ModelName: "claude-sonnet-4.6",
|
ModelName: defaultAnthropicModel,
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
Model: "anthropic/" + defaultAnthropicModel,
|
||||||
AuthMethod: "token",
|
AuthMethod: "token",
|
||||||
})
|
})
|
||||||
|
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
|
||||||
}
|
}
|
||||||
// Update default model
|
|
||||||
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
|
||||||
case "openai":
|
case "openai":
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
|
|
@ -360,6 +441,16 @@ func authStatusCmd() error {
|
||||||
if !cred.ExpiresAt.IsZero() {
|
if !cred.ExpiresAt.IsZero() {
|
||||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if provider == "anthropic" && cred.AuthMethod == "oauth" {
|
||||||
|
usage, err := auth.FetchAnthropicUsage(cred.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Usage: unavailable (%v)\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Usage (5h): %.1f%%\n", usage.FiveHourUtilization*100)
|
||||||
|
fmt.Printf(" Usage (7d): %.1f%%\n", usage.SevenDayUtilization*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ func newLoginCommand() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
provider string
|
provider string
|
||||||
useDeviceCode bool
|
useDeviceCode bool
|
||||||
|
useOauth bool
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -13,12 +14,16 @@ func newLoginCommand() *cobra.Command {
|
||||||
Short: "Login via OAuth or paste token",
|
Short: "Login via OAuth or paste token",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return authLoginCmd(provider, useDeviceCode)
|
return authLoginCmd(provider, useDeviceCode, useOauth)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||||
|
cmd.Flags().BoolVar(
|
||||||
|
&useOauth, "setup-token", false,
|
||||||
|
"Use setup-token flow for Anthropic (from `claude setup-token`)",
|
||||||
|
)
|
||||||
_ = cmd.MarkFlagRequired("provider")
|
_ = cmd.MarkFlagRequired("provider")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@ import (
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||||
|
|
@ -230,19 +232,25 @@ func setupCronTool(
|
||||||
// Create cron service
|
// Create cron service
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool
|
// Create and register CronTool if enabled
|
||||||
cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
var cronTool *tools.CronTool
|
||||||
if err != nil {
|
if cfg.Tools.IsToolEnabled("cron") {
|
||||||
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
var err error
|
||||||
|
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error during CronTool initialization: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentLoop.RegisterTool(cronTool)
|
||||||
}
|
}
|
||||||
|
|
||||||
agentLoop.RegisterTool(cronTool)
|
// Set onJob handler
|
||||||
|
if cronTool != nil {
|
||||||
// Set the onJob handler
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
result := cronTool.ExecuteJob(context.Background(), job)
|
||||||
result := cronTool.ExecuteJob(context.Background(), job)
|
return result, nil
|
||||||
return result, nil
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
return cronService
|
return cronService
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,21 @@ var (
|
||||||
goVersion string
|
goVersion string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
|
func GetPicoclawHome() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".picoclaw")
|
||||||
|
}
|
||||||
|
|
||||||
func GetConfigPath() string {
|
func GetConfigPath() string {
|
||||||
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
|
||||||
return configPath
|
return configPath
|
||||||
}
|
}
|
||||||
home, _ := os.UserHomeDir()
|
return filepath.Join(GetPicoclawHome(), "config.json")
|
||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() (*config.Config, error) {
|
func LoadConfig() (*config.Config, error) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,27 @@ func TestGetConfigPath(t *testing.T) {
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := filepath.Join("/custom/picoclaw", "config.json")
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
|
||||||
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := "/custom/config.json"
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
oldVersion, oldGit := version, gitCommit
|
oldVersion, oldGit := version, gitCommit
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ picoclaw skills install --registry clawhub github
|
||||||
`,
|
`,
|
||||||
Args: func(cmd *cobra.Command, args []string) error {
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
if registry != "" {
|
if registry != "" {
|
||||||
if len(args) != 2 {
|
if len(args) != 1 {
|
||||||
return fmt.Errorf("when --registry is set, exactly 2 arguments are required: <name> <slug>")
|
return fmt.Errorf("when --registry is set, exactly 1 argument is required: <slug>")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ picoclaw skills install --registry clawhub github
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallFromRegistry(cfg, args[0], args[1])
|
return skillsInstallFromRegistry(cfg, registry, args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
return skillsInstallCmd(installer, args[0])
|
return skillsInstallCmd(installer, args[0])
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,72 @@ func TestNewInstallSubcommand(t *testing.T) {
|
||||||
|
|
||||||
assert.Len(t, cmd.Aliases, 0)
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstallCommandArgs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
registry string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no registry, one arg",
|
||||||
|
args: []string{"sipeed/picoclaw-skills/weather"},
|
||||||
|
registry: "",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no registry, no args",
|
||||||
|
args: []string{},
|
||||||
|
registry: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "exactly 1 argument is required: <github>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no registry, too many args",
|
||||||
|
args: []string{"arg1", "arg2"},
|
||||||
|
registry: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "exactly 1 argument is required: <github>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, one arg",
|
||||||
|
args: []string{"weather-skill"},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, no args",
|
||||||
|
args: []string{},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with registry, too many args",
|
||||||
|
args: []string{"arg1", "arg2"},
|
||||||
|
registry: "clawhub",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "when --registry is set, exactly 1 argument is required: <slug>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cmd := newInstallCommand(nil)
|
||||||
|
|
||||||
|
if tt.registry != "" {
|
||||||
|
require.NoError(t, cmd.Flags().Set("registry", tt.registry))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cmd.Args(cmd, tt.args)
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, tt.errorMsg, err.Error())
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
"model": "anthropic/claude-sonnet-4.6",
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
"api_key": "sk-ant-your-key",
|
"api_key": "sk-ant-your-key",
|
||||||
"api_base": "https://api.anthropic.com/v1"
|
"api_base": "https://api.anthropic.com/v1",
|
||||||
|
"thinking_level": "high"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "gemini",
|
"model_name": "gemini",
|
||||||
|
|
@ -97,7 +98,8 @@
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
"verification_token": "",
|
"verification_token": "",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": "",
|
||||||
|
"random_reaction_emoji": []
|
||||||
},
|
},
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
@ -113,6 +115,23 @@
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
|
"matrix": {
|
||||||
|
"enabled": false,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking... 💭"
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||||
|
|
@ -163,6 +182,28 @@
|
||||||
"max_steps": 10,
|
"max_steps": 10,
|
||||||
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
|
"irc": {
|
||||||
|
"enabled": false,
|
||||||
|
"server": "irc.libera.chat:6697",
|
||||||
|
"tls": true,
|
||||||
|
"nick": "mybot",
|
||||||
|
"user": "",
|
||||||
|
"real_name": "",
|
||||||
|
"password": "",
|
||||||
|
"nickserv_password": "",
|
||||||
|
"sasl_user": "",
|
||||||
|
"sasl_password": "",
|
||||||
|
"channels": ["#mychannel"],
|
||||||
|
"request_caps": ["server-time", "message-tags"],
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"typing": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
@ -224,27 +265,53 @@
|
||||||
"mistral": {
|
"mistral": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": "https://api.mistral.ai/v1"
|
"api_base": "https://api.mistral.ai/v1"
|
||||||
|
},
|
||||||
|
"avian": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": "https://api.avian.io/v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
"allow_read_paths": null,
|
||||||
|
"allow_write_paths": null,
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "",
|
||||||
|
"max_results": 0
|
||||||
|
},
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"proxy": ""
|
"searxng": {
|
||||||
|
"enabled": false,
|
||||||
|
"base_url": "http://localhost:8888",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"glm_search": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "https://open.bigmodel.cn/api/paas/v4/web_search",
|
||||||
|
"search_engine": "search_std",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"fetch_limit_bytes": 10485760
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
|
|
@ -313,19 +380,75 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exec": {
|
"exec": {
|
||||||
"enable_deny_patterns": false,
|
"enabled": true,
|
||||||
"custom_deny_patterns": []
|
"enable_deny_patterns": true,
|
||||||
|
"custom_deny_patterns": null,
|
||||||
|
"custom_allow_patterns": null
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
"enabled": true,
|
||||||
"registries": {
|
"registries": {
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
"search_path": "/api/v1/search",
|
"auth_token": "",
|
||||||
"skills_path": "/api/v1/skills",
|
"search_path": "",
|
||||||
"download_path": "/api/v1/download"
|
"skills_path": "",
|
||||||
|
"download_path": "",
|
||||||
|
"timeout": 0,
|
||||||
|
"max_zip_size": 0,
|
||||||
|
"max_response_size": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"max_concurrent_searches": 2,
|
||||||
|
"search_cache": {
|
||||||
|
"max_size": 50,
|
||||||
|
"ttl_seconds": 300
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"media_cleanup": {
|
||||||
|
"enabled": true,
|
||||||
|
"max_age_minutes": 30,
|
||||||
|
"interval_minutes": 5
|
||||||
|
},
|
||||||
|
"append_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"edit_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"find_skills": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"i2c": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"install_skill": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"list_dir": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"read_file": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"spawn": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"spi": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"subagent": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"web_fetch": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"write_file": {
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heartbeat": {
|
"heartbeat": {
|
||||||
|
|
|
||||||
145
docs/agent-refactor/README.md
Normal file
145
docs/agent-refactor/README.md
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# Agent Refactor
|
||||||
|
|
||||||
|
## What this directory is for
|
||||||
|
|
||||||
|
This directory is the working area for the current Agent refactor.
|
||||||
|
|
||||||
|
The purpose of this refactor is simple:
|
||||||
|
|
||||||
|
the project needs a smaller, clearer, and more stable Agent model before more Agent-related behavior is added.
|
||||||
|
|
||||||
|
The codebase already contains meaningful Agent behavior. What it still lacks is a sufficiently explicit and stable semantic boundary around that behavior.
|
||||||
|
|
||||||
|
This refactor exists to fix that first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactor stance
|
||||||
|
|
||||||
|
This is a maintenance-led consolidation effort.
|
||||||
|
|
||||||
|
It is not a general invitation to expand Agent behavior in parallel.
|
||||||
|
|
||||||
|
During this refactor window, Agent-related work should converge on the current refactor track instead of branching into new semantics.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- concept clarification before feature expansion
|
||||||
|
- boundary tightening before abstraction growth
|
||||||
|
- semantic consolidation before new behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core rule: minimum concepts only
|
||||||
|
|
||||||
|
This refactor follows one hard rule:
|
||||||
|
|
||||||
|
**do not introduce a new concept unless it is strictly necessary**
|
||||||
|
|
||||||
|
More explicitly:
|
||||||
|
|
||||||
|
- if an existing concept can be clarified, reuse it
|
||||||
|
- if an existing boundary can be made explicit, do that first
|
||||||
|
- if a behavior can be expressed without a new abstraction, do not add one
|
||||||
|
- "future flexibility" is not enough justification on its own
|
||||||
|
|
||||||
|
The goal of this refactor is not to grow the model.
|
||||||
|
|
||||||
|
The goal is to reduce ambiguity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is being clarified
|
||||||
|
|
||||||
|
This refactor is currently concerned with the following questions:
|
||||||
|
|
||||||
|
1. what an `Agent` is
|
||||||
|
2. what an `AgentLoop` is
|
||||||
|
3. what the lifecycle of `AgentLoop` is
|
||||||
|
4. what the event surface around `AgentLoop` is
|
||||||
|
5. how persona / identity is assembled
|
||||||
|
6. how capabilities are represented
|
||||||
|
7. how context boundaries and compression work
|
||||||
|
8. how subagent coordination works
|
||||||
|
|
||||||
|
These are the current working boundaries.
|
||||||
|
|
||||||
|
If they need to be adjusted, they should be adjusted explicitly rather than drift implicitly in code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status of this directory
|
||||||
|
|
||||||
|
The documents here are working materials.
|
||||||
|
|
||||||
|
They are not final or immutable.
|
||||||
|
|
||||||
|
If current notes are incomplete, incorrectly split, or too broad, they should be revised. This directory should evolve with the refactor rather than pretending the first draft is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested document split
|
||||||
|
|
||||||
|
This directory may eventually contain notes such as:
|
||||||
|
|
||||||
|
- `agent-overview.md`
|
||||||
|
- what an Agent is
|
||||||
|
- `agent-loop.md`
|
||||||
|
- AgentLoop contract, lifecycle, event surface
|
||||||
|
- `persona.md`
|
||||||
|
- persona and identity assembly
|
||||||
|
- `capability.md`
|
||||||
|
- tools / skills / MCP capability semantics
|
||||||
|
- `context.md`
|
||||||
|
- context scope, history, summary, compression
|
||||||
|
- `subagent.md`
|
||||||
|
- subagent coordination rules
|
||||||
|
|
||||||
|
These files should be added only when they help clarify the current refactor work.
|
||||||
|
|
||||||
|
This directory should not turn into a generic architecture dump.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this directory is not for
|
||||||
|
|
||||||
|
This directory is not intended for:
|
||||||
|
|
||||||
|
- broad speculative architecture
|
||||||
|
- future multi-node protocol design not required by the current refactor
|
||||||
|
- parallel feature planning unrelated to Agent consolidation
|
||||||
|
- adding new concepts before current ones are made clear
|
||||||
|
|
||||||
|
If a topic does not directly help reduce ambiguity in the current Agent model, it probably does not belong here yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationship to implementation
|
||||||
|
|
||||||
|
Implementation changes should not keep redefining Agent semantics implicitly.
|
||||||
|
|
||||||
|
If a PR changes or depends on Agent semantics, those semantics should either already exist here or be clarified in a linked issue first.
|
||||||
|
|
||||||
|
This directory is here to make implementation narrower and more disciplined.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationship to GitHub tracking
|
||||||
|
|
||||||
|
The umbrella issue for this refactor should point here.
|
||||||
|
|
||||||
|
The issue is the coordination surface.
|
||||||
|
|
||||||
|
This directory is the repository-local working surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The main question of this refactor is not:
|
||||||
|
|
||||||
|
- what more can Agent do
|
||||||
|
|
||||||
|
The main question is:
|
||||||
|
|
||||||
|
- what is the smallest stable model that current Agent behavior can be organized around
|
||||||
|
|
@ -26,7 +26,8 @@
|
||||||
| app_secret | string | 是 | 飞书应用的 App Secret |
|
| app_secret | string | 是 | 飞书应用的 App Secret |
|
||||||
| encrypt_key | string | 否 | 事件回调加密密钥 |
|
| encrypt_key | string | 否 | 事件回调加密密钥 |
|
||||||
| verification_token | string | 否 | 用于Webhook事件验证的Token |
|
| verification_token | string | 否 | 用于Webhook事件验证的Token |
|
||||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
| allow_from | array | 否 | 用户ID白名单,空表示所有用户 |
|
||||||
|
| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
|
||||||
|
|
||||||
## 设置流程
|
## 设置流程
|
||||||
|
|
||||||
|
|
@ -35,3 +36,4 @@
|
||||||
3. 配置事件订阅和Webhook URL
|
3. 配置事件订阅和Webhook URL
|
||||||
4. 设置加密(可选,生产环境建议启用)
|
4. 设置加密(可选,生产环境建议启用)
|
||||||
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
|
||||||
|
6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
|
||||||
|
|
|
||||||
59
docs/channels/matrix/README.md
Normal file
59
docs/channels/matrix/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Matrix Channel Configuration Guide
|
||||||
|
|
||||||
|
## 1. Example Configuration
|
||||||
|
|
||||||
|
Add this to `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking..."
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Field Reference
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|----------------------|----------|----------|-------------|
|
||||||
|
| enabled | bool | Yes | Enable or disable the Matrix channel |
|
||||||
|
| homeserver | string | Yes | Matrix homeserver URL (for example `https://matrix.org`) |
|
||||||
|
| user_id | string | Yes | Bot Matrix user ID (for example `@bot:matrix.org`) |
|
||||||
|
| access_token | string | Yes | Bot access token |
|
||||||
|
| device_id | string | No | Optional Matrix device ID |
|
||||||
|
| join_on_invite | bool | No | Auto-join invited rooms |
|
||||||
|
| allow_from | []string | No | User whitelist (Matrix user IDs) |
|
||||||
|
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | No | Placeholder message config |
|
||||||
|
| reasoning_channel_id | string | No | Target channel for reasoning output |
|
||||||
|
|
||||||
|
## 3. Currently Supported
|
||||||
|
|
||||||
|
- Text message send/receive
|
||||||
|
- Incoming image/audio/video/file download (MediaStore first, local path fallback)
|
||||||
|
- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
|
||||||
|
- Outgoing image/audio/video/file upload and send
|
||||||
|
- Group trigger rules (including mention-only mode)
|
||||||
|
- Typing state (`m.typing`)
|
||||||
|
- Placeholder message + final reply replacement
|
||||||
|
- Auto-join invited rooms (can be disabled)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- Rich media metadata improvements (for example image/video size and thumbnails)
|
||||||
59
docs/channels/matrix/README.zh.md
Normal file
59
docs/channels/matrix/README.zh.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Matrix 通道配置指南
|
||||||
|
|
||||||
|
## 1. 配置示例
|
||||||
|
|
||||||
|
在 `config.json` 中添加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking... 💭"
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|----------------------|----------|------|------|
|
||||||
|
| enabled | bool | 是 | 是否启用 Matrix 通道 |
|
||||||
|
| homeserver | string | 是 | Matrix 服务器地址(例如 `https://matrix.org`) |
|
||||||
|
| user_id | string | 是 | 机器人 Matrix 用户 ID(例如 `@bot:matrix.org`) |
|
||||||
|
| access_token | string | 是 | 机器人 access token |
|
||||||
|
| device_id | string | 否 | 设备 ID(可选) |
|
||||||
|
| join_on_invite | bool | 否 | 是否自动加入邀请房间 |
|
||||||
|
| allow_from | []string | 否 | 白名单用户(Matrix 用户 ID) |
|
||||||
|
| group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | 否 | 占位消息配置 |
|
||||||
|
| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
|
||||||
|
|
||||||
|
## 3. 当前支持
|
||||||
|
|
||||||
|
- 文本消息收发
|
||||||
|
- 图片/音频/视频/文件消息入站下载(写入 MediaStore / 本地路径回退)
|
||||||
|
- 音频消息按统一标记进入现有转写流程(`[audio: ...]`)
|
||||||
|
- 图片/音频/视频/文件消息出站发送(上传到 Matrix 媒体库后发送)
|
||||||
|
- 群聊触发规则(支持仅 @ 提及时响应)
|
||||||
|
- Typing 状态(`m.typing`)
|
||||||
|
- 占位消息(`Thinking... 💭`)+ 最终回复替换
|
||||||
|
- 自动加入邀请房间(可关闭)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- 富媒体细节增强(如 image/video 的尺寸、缩略图等 metadata)
|
||||||
|
|
@ -180,6 +180,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
||||||
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
|
||||||
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
|
||||||
|
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
||||||
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
|
||||||
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
|
||||||
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
|
||||||
|
|
@ -194,6 +195,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
"clawhub": {
|
"clawhub": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"base_url": "https://clawhub.ai",
|
"base_url": "https://clawhub.ai",
|
||||||
|
"auth_token": "",
|
||||||
"search_path": "/api/v1/search",
|
"search_path": "/api/v1/search",
|
||||||
"skills_path": "/api/v1/skills",
|
"skills_path": "/api/v1/skills",
|
||||||
"download_path": "/api/v1/download"
|
"download_path": "/api/v1/download"
|
||||||
|
|
|
||||||
13
go.mod
13
go.mod
|
|
@ -8,12 +8,14 @@ require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
|
github.com/ergochat/irc-go v0.5.0
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/h2non/filetype v1.1.3
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0
|
github.com/modelcontextprotocol/go-sdk v1.3.1
|
||||||
github.com/mymmrac/telego v1.6.0
|
github.com/mymmrac/telego v1.6.0
|
||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||||
github.com/openai/openai-go/v3 v3.22.0
|
github.com/openai/openai-go/v3 v3.22.0
|
||||||
|
|
@ -26,19 +28,18 @@ require (
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
maunium.net/go/mautrix v0.26.3
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.1 // indirect
|
||||||
github.com/beeper/argo-go v1.1.2 // indirect
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/gdamore/tcell/v2 v2.13.8 // indirect
|
|
||||||
github.com/h2non/filetype v1.1.3 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
|
@ -49,6 +50,8 @@ require (
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/zerolog v1.34.0 // indirect
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.3 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
go.mau.fi/libsignal v0.2.1 // indirect
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
|
|
@ -87,7 +90,7 @@ require (
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
18
go.sum
18
go.sum
|
|
@ -1,6 +1,6 @@
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||||
|
|
@ -48,6 +48,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
|
github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
|
||||||
|
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
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/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
|
|
@ -134,8 +136,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
|
||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
|
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
|
github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
|
||||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
|
@ -171,6 +173,10 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
|
||||||
|
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||||
|
|
@ -265,6 +271,8 @@ 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.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
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/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
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 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
|
@ -355,6 +363,8 @@ 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
|
||||||
|
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ type ContextBuilder struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -602,7 +605,60 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized
|
// Second pass: ensure every assistant message with tool_calls has matching
|
||||||
|
// tool result messages following it. This is required by strict providers
|
||||||
|
// like DeepSeek that enforce: "An assistant message with 'tool_calls' must
|
||||||
|
// be followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
final := make([]providers.Message, 0, len(sanitized))
|
||||||
|
for i := 0; i < len(sanitized); i++ {
|
||||||
|
msg := sanitized[i]
|
||||||
|
if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
|
||||||
|
// Collect expected tool_call IDs
|
||||||
|
expected := make(map[string]bool, len(msg.ToolCalls))
|
||||||
|
for _, tc := range msg.ToolCalls {
|
||||||
|
expected[tc.ID] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check following messages for matching tool results
|
||||||
|
toolMsgCount := 0
|
||||||
|
for j := i + 1; j < len(sanitized); j++ {
|
||||||
|
if sanitized[j].Role != "tool" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
toolMsgCount++
|
||||||
|
if _, exists := expected[sanitized[j].ToolCallID]; exists {
|
||||||
|
expected[sanitized[j].ToolCallID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any tool_call_id is missing, drop this assistant message and its partial tool messages
|
||||||
|
allFound := true
|
||||||
|
for toolCallID, found := range expected {
|
||||||
|
if !found {
|
||||||
|
allFound = false
|
||||||
|
logger.DebugCF(
|
||||||
|
"agent",
|
||||||
|
"Dropping assistant message with incomplete tool results",
|
||||||
|
map[string]any{
|
||||||
|
"missing_tool_call_id": toolCallID,
|
||||||
|
"expected_count": len(expected),
|
||||||
|
"found_count": toolMsgCount,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allFound {
|
||||||
|
// Skip this assistant message and its tool messages
|
||||||
|
i += toolMsgCount
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final = append(final, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return final
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) AddToolResult(
|
func (cb *ContextBuilder) AddToolResult(
|
||||||
|
|
|
||||||
|
|
@ -207,3 +207,77 @@ func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_IncompleteToolResults tests the forward validation
|
||||||
|
// that ensures assistant messages with tool_calls have ALL matching tool results.
|
||||||
|
// This fixes the DeepSeek error: "An assistant message with 'tool_calls' must be
|
||||||
|
// followed by tool messages responding to each 'tool_call_id'."
|
||||||
|
func TestSanitizeHistoryForProvider_IncompleteToolResults(t *testing.T) {
|
||||||
|
// Assistant expects tool results for both A and B, but only A is present
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do two things"),
|
||||||
|
assistantWithTools("A", "B"),
|
||||||
|
toolResult("A"),
|
||||||
|
// toolResult("B") is missing - this would cause DeepSeek to fail
|
||||||
|
msg("user", "next question"),
|
||||||
|
msg("assistant", "answer"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// The assistant message with incomplete tool results should be dropped,
|
||||||
|
// along with its partial tool result. The remaining messages are:
|
||||||
|
// user ("do two things"), user ("next question"), assistant ("answer")
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_MissingAllToolResults tests the case where
|
||||||
|
// an assistant message has tool_calls but no tool results follow at all.
|
||||||
|
func TestSanitizeHistoryForProvider_MissingAllToolResults(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do something"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
// No tool results at all
|
||||||
|
msg("user", "hello"),
|
||||||
|
msg("assistant", "hi"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// The assistant message with no tool results should be dropped.
|
||||||
|
// Remaining: user ("do something"), user ("hello"), assistant ("hi")
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSanitizeHistoryForProvider_PartialToolResultsInMiddle tests that
|
||||||
|
// incomplete tool results in the middle of a conversation are properly handled.
|
||||||
|
func TestSanitizeHistoryForProvider_PartialToolResultsInMiddle(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "first"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
toolResult("A"),
|
||||||
|
msg("assistant", "done"),
|
||||||
|
msg("user", "second"),
|
||||||
|
assistantWithTools("B", "C"),
|
||||||
|
toolResult("B"),
|
||||||
|
// toolResult("C") is missing
|
||||||
|
msg("user", "third"),
|
||||||
|
assistantWithTools("D"),
|
||||||
|
toolResult("D"),
|
||||||
|
msg("assistant", "all done"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
// First round is complete (user, assistant+tools, tool, assistant),
|
||||||
|
// second round is incomplete and dropped (assistant+tools, partial tool),
|
||||||
|
// third round is complete (user, assistant+tools, tool, assistant).
|
||||||
|
// Remaining: user, assistant, tool, assistant, user, user, assistant, tool, assistant
|
||||||
|
if len(result) != 9 {
|
||||||
|
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool", "assistant", "user", "user", "assistant", "tool", "assistant")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ type AgentInstance struct {
|
||||||
MaxIterations int
|
MaxIterations int
|
||||||
MaxTokens int
|
MaxTokens int
|
||||||
Temperature float64
|
Temperature float64
|
||||||
|
ThinkingLevel ThinkingLevel
|
||||||
ContextWindow int
|
ContextWindow int
|
||||||
SummarizeMessageThreshold int
|
SummarizeMessageThreshold int
|
||||||
SummarizeTokenPercent int
|
SummarizeTokenPercent int
|
||||||
|
|
@ -36,6 +37,14 @@ type AgentInstance struct {
|
||||||
Subagents *config.SubagentsConfig
|
Subagents *config.SubagentsConfig
|
||||||
SkillsFilter []string
|
SkillsFilter []string
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
||||||
|
// Router is non-nil when model routing is configured and the light model
|
||||||
|
// was successfully resolved. It scores each incoming message and decides
|
||||||
|
// whether to route to LightCandidates or stay with Candidates.
|
||||||
|
Router *routing.Router
|
||||||
|
// LightCandidates holds the resolved provider candidates for the light model.
|
||||||
|
// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.
|
||||||
|
LightCandidates []providers.FallbackCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentInstance creates an agent instance from config.
|
// NewAgentInstance creates an agent instance from config.
|
||||||
|
|
@ -59,17 +68,30 @@ func NewAgentInstance(
|
||||||
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths))
|
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
|
||||||
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
|
||||||
}
|
|
||||||
toolsRegistry.Register(execTool)
|
|
||||||
|
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("list_dir") {
|
||||||
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||||
|
}
|
||||||
|
toolsRegistry.Register(execTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("append_file") {
|
||||||
|
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
}
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
sessionsManager := session.NewSessionManager(sessionsDir)
|
||||||
|
|
@ -103,6 +125,12 @@ func NewAgentInstance(
|
||||||
temperature = *defaults.Temperature
|
temperature = *defaults.Temperature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var thinkingLevelStr string
|
||||||
|
if mc, err := cfg.GetModelConfig(model); err == nil {
|
||||||
|
thinkingLevelStr = mc.ThinkingLevel
|
||||||
|
}
|
||||||
|
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
|
||||||
|
|
||||||
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
|
||||||
if summarizeMessageThreshold == 0 {
|
if summarizeMessageThreshold == 0 {
|
||||||
summarizeMessageThreshold = 20
|
summarizeMessageThreshold = 20
|
||||||
|
|
@ -160,6 +188,25 @@ func NewAgentInstance(
|
||||||
|
|
||||||
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
|
// Model routing setup: pre-resolve light model candidates at creation time
|
||||||
|
// to avoid repeated model_list lookups on every incoming message.
|
||||||
|
var router *routing.Router
|
||||||
|
var lightCandidates []providers.FallbackCandidate
|
||||||
|
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
|
||||||
|
lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
|
||||||
|
resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
if len(resolved) > 0 {
|
||||||
|
router = routing.New(routing.RouterConfig{
|
||||||
|
LightModel: rc.LightModel,
|
||||||
|
Threshold: rc.Threshold,
|
||||||
|
})
|
||||||
|
lightCandidates = resolved
|
||||||
|
} else {
|
||||||
|
log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q",
|
||||||
|
rc.LightModel, agentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
Name: agentName,
|
Name: agentName,
|
||||||
|
|
@ -169,6 +216,7 @@ func NewAgentInstance(
|
||||||
MaxIterations: maxIter,
|
MaxIterations: maxIter,
|
||||||
MaxTokens: maxTokens,
|
MaxTokens: maxTokens,
|
||||||
Temperature: temperature,
|
Temperature: temperature,
|
||||||
|
ThinkingLevel: thinkingLevel,
|
||||||
ContextWindow: maxTokens,
|
ContextWindow: maxTokens,
|
||||||
SummarizeMessageThreshold: summarizeMessageThreshold,
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
SummarizeTokenPercent: summarizeTokenPercent,
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
|
|
@ -179,6 +227,8 @@ func NewAgentInstance(
|
||||||
Subagents: subagents,
|
Subagents: subagents,
|
||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
|
Router: router,
|
||||||
|
LightCandidates: lightCandidates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,12 +237,13 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
|
||||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||||
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||||
}
|
}
|
||||||
|
// Use the configured default workspace (respects PICOCLAW_HOME)
|
||||||
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
|
||||||
return expandHome(defaults.Workspace)
|
return expandHome(defaults.Workspace)
|
||||||
}
|
}
|
||||||
home, _ := os.UserHomeDir()
|
// For named agents without explicit workspace, use default workspace with agent ID suffix
|
||||||
id := routing.NormalizeAgentID(agentCfg.ID)
|
id := routing.NormalizeAgentID(agentCfg.ID)
|
||||||
return filepath.Join(home, ".picoclaw", "workspace-"+id)
|
return filepath.Join(expandHome(defaults.Workspace), "..", "workspace-"+id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAgentModel resolves the primary model for an agent.
|
// resolveAgentModel resolves the primary model for an agent.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/constants"
|
"github.com/sipeed/picoclaw/pkg/constants"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -46,6 +47,7 @@ type AgentLoop struct {
|
||||||
channelManager *channels.Manager
|
channelManager *channels.Manager
|
||||||
mediaStore media.MediaStore
|
mediaStore media.MediaStore
|
||||||
transcriber voice.Transcriber
|
transcriber voice.Transcriber
|
||||||
|
cmdRegistry *commands.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
|
|
@ -61,7 +63,15 @@ type processOptions struct {
|
||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
const (
|
||||||
|
defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
||||||
|
sessionKeyAgentPrefix = "agent:"
|
||||||
|
metadataKeyAccountID = "account_id"
|
||||||
|
metadataKeyGuildID = "guild_id"
|
||||||
|
metadataKeyTeamID = "team_id"
|
||||||
|
metadataKeyParentPeerKind = "parent_peer_kind"
|
||||||
|
metadataKeyParentPeerID = "parent_peer_id"
|
||||||
|
)
|
||||||
|
|
||||||
func NewAgentLoop(
|
func NewAgentLoop(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
|
@ -84,14 +94,17 @@ func NewAgentLoop(
|
||||||
stateManager = state.NewManager(defaultAgent.Workspace)
|
stateManager = state.NewManager(defaultAgent.Workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AgentLoop{
|
al := &AgentLoop{
|
||||||
bus: msgBus,
|
bus: msgBus,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
state: stateManager,
|
state: stateManager,
|
||||||
summarizing: sync.Map{},
|
summarizing: sync.Map{},
|
||||||
fallback: fallbackChain,
|
fallback: fallbackChain,
|
||||||
|
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return al
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
|
||||||
|
|
@ -108,76 +121,117 @@ func registerSharedTools(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web tools
|
// Web tools
|
||||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
||||||
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
||||||
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
||||||
Proxy: cfg.Tools.Web.Proxy,
|
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||||
})
|
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
||||||
if err != nil {
|
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||||
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
|
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
||||||
} else if searchTool != nil {
|
Proxy: cfg.Tools.Web.Proxy,
|
||||||
agent.Tools.Register(searchTool)
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()})
|
||||||
|
} else if searchTool != nil {
|
||||||
|
agent.Tools.Register(searchTool)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)
|
if cfg.Tools.IsToolEnabled("web_fetch") {
|
||||||
if err != nil {
|
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
if err != nil {
|
||||||
} else {
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
agent.Tools.Register(fetchTool)
|
} else {
|
||||||
|
agent.Tools.Register(fetchTool)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
agent.Tools.Register(tools.NewI2CTool())
|
if cfg.Tools.IsToolEnabled("i2c") {
|
||||||
agent.Tools.Register(tools.NewSPITool())
|
agent.Tools.Register(tools.NewI2CTool())
|
||||||
|
}
|
||||||
|
if cfg.Tools.IsToolEnabled("spi") {
|
||||||
|
agent.Tools.Register(tools.NewSPITool())
|
||||||
|
}
|
||||||
|
|
||||||
// Message tool
|
// Message tool
|
||||||
messageTool := tools.NewMessageTool()
|
if cfg.Tools.IsToolEnabled("message") {
|
||||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
messageTool := tools.NewMessageTool()
|
||||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
defer pubCancel()
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
defer pubCancel()
|
||||||
Channel: channel,
|
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
ChatID: chatID,
|
Channel: channel,
|
||||||
Content: content,
|
ChatID: chatID,
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
agent.Tools.Register(messageTool)
|
||||||
agent.Tools.Register(messageTool)
|
}
|
||||||
|
|
||||||
|
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
||||||
|
if cfg.Tools.IsToolEnabled("send_file") {
|
||||||
|
sendFileTool := tools.NewSendFileTool(
|
||||||
|
agent.Workspace,
|
||||||
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||||
|
cfg.Agents.Defaults.GetMaxMediaSize(),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(sendFileTool)
|
||||||
|
}
|
||||||
|
|
||||||
// Skill discovery and installation tools
|
// Skill discovery and installation tools
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||||
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
|
||||||
})
|
if skills_enabled && (find_skills_enable || install_skills_enable) {
|
||||||
searchCache := skills.NewSearchCache(
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
cfg.Tools.Skills.SearchCache.MaxSize,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
|
||||||
)
|
})
|
||||||
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
|
||||||
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
if find_skills_enable {
|
||||||
|
searchCache := skills.NewSearchCache(
|
||||||
|
cfg.Tools.Skills.SearchCache.MaxSize,
|
||||||
|
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
|
||||||
|
)
|
||||||
|
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
|
||||||
|
}
|
||||||
|
|
||||||
|
if install_skills_enable {
|
||||||
|
agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn tool with allowlist checker
|
// Spawn tool with allowlist checker
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
if cfg.Tools.IsToolEnabled("spawn") {
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
if cfg.Tools.IsToolEnabled("subagent") {
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
||||||
currentAgentID := agentID
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
currentAgentID := agentID
|
||||||
})
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
agent.Tools.Register(spawnTool)
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
|
})
|
||||||
|
agent.Tools.Register(spawnTool)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +239,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
al.running.Store(true)
|
al.running.Store(true)
|
||||||
|
|
||||||
// Initialize MCP servers for all agents
|
// Initialize MCP servers for all agents
|
||||||
if al.cfg.Tools.MCP.Enabled {
|
if al.cfg.Tools.IsToolEnabled("mcp") {
|
||||||
mcpManager := mcp.NewManager()
|
mcpManager := mcp.NewManager()
|
||||||
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
|
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
|
||||||
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
|
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
|
||||||
|
|
@ -227,6 +281,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||||
agent.Tools.Register(mcpTool)
|
agent.Tools.Register(mcpTool)
|
||||||
totalRegistrations++
|
totalRegistrations++
|
||||||
|
|
@ -340,6 +395,13 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
// SetMediaStore injects a MediaStore for media lifecycle management.
|
// SetMediaStore injects a MediaStore for media lifecycle management.
|
||||||
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||||
al.mediaStore = s
|
al.mediaStore = s
|
||||||
|
|
||||||
|
// Propagate store to send_file tools in all agents.
|
||||||
|
al.registry.ForEachTool("send_file", func(t tools.Tool) {
|
||||||
|
if sf, ok := t.(*tools.SendFileTool); ok {
|
||||||
|
sf.SetMediaStore(s)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTranscriber injects a voice transcriber for agent-level audio transcription.
|
// SetTranscriber injects a voice transcriber for agent-level audio transcription.
|
||||||
|
|
@ -518,47 +580,39 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
return al.processSystemMessage(ctx, msg)
|
return al.processSystemMessage(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for commands
|
route, agent, routeErr := al.resolveMessageRoute(msg)
|
||||||
if response, handled := al.handleCommand(ctx, msg); handled {
|
|
||||||
|
// Commands are checked before requiring a successful route.
|
||||||
|
// Global commands (/help, /show, /switch) work even when routing fails;
|
||||||
|
// context-dependent commands check their own Runtime fields and report
|
||||||
|
// "unavailable" when the required capability is nil.
|
||||||
|
if response, handled := al.handleCommand(ctx, msg, agent); handled {
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route to determine agent and session key
|
if routeErr != nil {
|
||||||
route := al.registry.ResolveRoute(routing.RouteInput{
|
return "", routeErr
|
||||||
Channel: msg.Channel,
|
|
||||||
AccountID: msg.Metadata["account_id"],
|
|
||||||
Peer: extractPeer(msg),
|
|
||||||
ParentPeer: extractParentPeer(msg),
|
|
||||||
GuildID: msg.Metadata["guild_id"],
|
|
||||||
TeamID: msg.Metadata["team_id"],
|
|
||||||
})
|
|
||||||
|
|
||||||
agent, ok := al.registry.GetAgent(route.AgentID)
|
|
||||||
if !ok {
|
|
||||||
agent = al.registry.GetDefaultAgent()
|
|
||||||
}
|
|
||||||
if agent == nil {
|
|
||||||
return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||||
if tool, ok := agent.Tools.Get("message"); ok {
|
if tool, ok := agent.Tools.Get("message"); ok {
|
||||||
if mt, ok := tool.(tools.ContextualTool); ok {
|
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
||||||
mt.SetContext(msg.Channel, msg.ChatID)
|
resetter.ResetSentInRound()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
|
// Resolve session key from route, while preserving explicit agent-scoped keys.
|
||||||
sessionKey := route.SessionKey
|
scopeKey := resolveScopeKey(route, msg.SessionKey)
|
||||||
if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") {
|
sessionKey := scopeKey
|
||||||
sessionKey = msg.SessionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.InfoCF("agent", "Routed message",
|
logger.InfoCF("agent", "Routed message",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"session_key": sessionKey,
|
"scope_key": scopeKey,
|
||||||
"matched_by": route.MatchedBy,
|
"session_key": sessionKey,
|
||||||
|
"matched_by": route.MatchedBy,
|
||||||
|
"route_agent": route.AgentID,
|
||||||
|
"route_channel": route.Channel,
|
||||||
})
|
})
|
||||||
|
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
|
|
@ -573,6 +627,34 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
|
||||||
|
route := al.registry.ResolveRoute(routing.RouteInput{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
AccountID: inboundMetadata(msg, metadataKeyAccountID),
|
||||||
|
Peer: extractPeer(msg),
|
||||||
|
ParentPeer: extractParentPeer(msg),
|
||||||
|
GuildID: inboundMetadata(msg, metadataKeyGuildID),
|
||||||
|
TeamID: inboundMetadata(msg, metadataKeyTeamID),
|
||||||
|
})
|
||||||
|
|
||||||
|
agent, ok := al.registry.GetAgent(route.AgentID)
|
||||||
|
if !ok {
|
||||||
|
agent = al.registry.GetDefaultAgent()
|
||||||
|
}
|
||||||
|
if agent == nil {
|
||||||
|
return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return route, agent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string {
|
||||||
|
if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) {
|
||||||
|
return msgSessionKey
|
||||||
|
}
|
||||||
|
return route.SessionKey
|
||||||
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) processSystemMessage(
|
func (al *AgentLoop) processSystemMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
msg bus.InboundMessage,
|
msg bus.InboundMessage,
|
||||||
|
|
@ -644,9 +726,8 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
opts processOptions,
|
opts processOptions,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
// 0. Record last channel for heartbeat notifications (skip internal channels)
|
// 0. Record last channel for heartbeat notifications (skip internal channels and cli)
|
||||||
if opts.Channel != "" && opts.ChatID != "" {
|
if opts.Channel != "" && opts.ChatID != "" {
|
||||||
// Don't record internal channels (cli, system, subagent)
|
|
||||||
if !constants.IsInternalChannel(opts.Channel) {
|
if !constants.IsInternalChannel(opts.Channel) {
|
||||||
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
||||||
if err := al.RecordLastChannel(channelKey); err != nil {
|
if err := al.RecordLastChannel(channelKey); err != nil {
|
||||||
|
|
@ -659,10 +740,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Update tool contexts
|
// 1. Build messages (skip history for heartbeat)
|
||||||
al.updateToolContexts(agent, opts.Channel, opts.ChatID)
|
|
||||||
|
|
||||||
// 2. Build messages (skip history for heartbeat)
|
|
||||||
var history []providers.Message
|
var history []providers.Message
|
||||||
var summary string
|
var summary string
|
||||||
if !opts.NoHistory {
|
if !opts.NoHistory {
|
||||||
|
|
@ -682,10 +760,10 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
|
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize()
|
||||||
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
|
||||||
|
|
||||||
// 3. Save user message to session
|
// 2. Save user message to session
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
|
||||||
|
|
||||||
// 4. Run LLM iteration loop
|
// 3. Run LLM iteration loop
|
||||||
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
|
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -694,21 +772,21 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
// If last tool had ForUser content and we already sent it, we might not need to send final response
|
// If last tool had ForUser content and we already sent it, we might not need to send final response
|
||||||
// This is controlled by the tool's Silent flag and ForUser content
|
// This is controlled by the tool's Silent flag and ForUser content
|
||||||
|
|
||||||
// 5. Handle empty response
|
// 4. Handle empty response
|
||||||
if finalContent == "" {
|
if finalContent == "" {
|
||||||
finalContent = opts.DefaultResponse
|
finalContent = opts.DefaultResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Save final assistant message to session
|
// 5. Save final assistant message to session
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
|
||||||
agent.Sessions.Save(opts.SessionKey)
|
agent.Sessions.Save(opts.SessionKey)
|
||||||
|
|
||||||
// 7. Optional: summarization
|
// 6. Optional: summarization
|
||||||
if opts.EnableSummary {
|
if opts.EnableSummary {
|
||||||
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. Optional: send response via bus
|
// 7. Optional: send response via bus
|
||||||
if opts.SendResponse {
|
if opts.SendResponse {
|
||||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
|
|
@ -717,7 +795,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Log response
|
// 8. Log response
|
||||||
responsePreview := utils.Truncate(finalContent, 120)
|
responsePreview := utils.Truncate(finalContent, 120)
|
||||||
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -796,6 +874,12 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
iteration := 0
|
iteration := 0
|
||||||
var finalContent string
|
var finalContent string
|
||||||
|
|
||||||
|
// Determine effective model tier for this conversation turn.
|
||||||
|
// selectCandidates evaluates routing once and the decision is sticky for
|
||||||
|
// all tool-follow-up iterations within the same turn so that a multi-step
|
||||||
|
// tool chain doesn't switch models mid-way through.
|
||||||
|
activeCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages)
|
||||||
|
|
||||||
for iteration < agent.MaxIterations {
|
for iteration < agent.MaxIterations {
|
||||||
iteration++
|
iteration++
|
||||||
|
|
||||||
|
|
@ -814,7 +898,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"model": agent.Model,
|
"model": activeModel,
|
||||||
"messages_count": len(messages),
|
"messages_count": len(messages),
|
||||||
"tools_count": len(providerToolDefs),
|
"tools_count": len(providerToolDefs),
|
||||||
"max_tokens": agent.MaxTokens,
|
"max_tokens": agent.MaxTokens,
|
||||||
|
|
@ -830,27 +914,33 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"tools_json": formatToolsForLog(providerToolDefs),
|
"tools_json": formatToolsForLog(providerToolDefs),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Call LLM with fallback chain if candidates are configured.
|
// Call LLM with fallback chain if multiple candidates are configured.
|
||||||
var response *providers.LLMResponse
|
var response *providers.LLMResponse
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
llmOpts := map[string]any{
|
||||||
|
"max_tokens": agent.MaxTokens,
|
||||||
|
"temperature": agent.Temperature,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
|
}
|
||||||
|
// parseThinkingLevel guarantees ThinkingOff for empty/unknown values,
|
||||||
|
// so checking != ThinkingOff is sufficient.
|
||||||
|
if agent.ThinkingLevel != ThinkingOff {
|
||||||
|
if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
|
||||||
|
llmOpts["thinking_level"] = string(agent.ThinkingLevel)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring",
|
||||||
|
map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
callLLM := func() (*providers.LLMResponse, error) {
|
callLLM := func() (*providers.LLMResponse, error) {
|
||||||
if len(agent.Candidates) > 1 && al.fallback != nil {
|
if len(activeCandidates) > 1 && al.fallback != nil {
|
||||||
fbResult, fbErr := al.fallback.Execute(
|
fbResult, fbErr := al.fallback.Execute(
|
||||||
ctx,
|
ctx,
|
||||||
agent.Candidates,
|
activeCandidates,
|
||||||
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
|
||||||
return agent.Provider.Chat(
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
|
||||||
ctx,
|
|
||||||
messages,
|
|
||||||
providerToolDefs,
|
|
||||||
model,
|
|
||||||
map[string]any{
|
|
||||||
"max_tokens": agent.MaxTokens,
|
|
||||||
"temperature": agent.Temperature,
|
|
||||||
"prompt_cache_key": agent.ID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if fbErr != nil {
|
if fbErr != nil {
|
||||||
|
|
@ -866,11 +956,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
return fbResult.Response, nil
|
return fbResult.Response, nil
|
||||||
}
|
}
|
||||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts)
|
||||||
"max_tokens": agent.MaxTokens,
|
|
||||||
"temperature": agent.Temperature,
|
|
||||||
"prompt_cache_key": agent.ID,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry loop for context/token errors
|
// Retry loop for context/token errors
|
||||||
|
|
@ -969,9 +1055,12 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"target_channel": al.targetReasoningChannelID(opts.Channel),
|
"target_channel": al.targetReasoningChannelID(opts.Channel),
|
||||||
"channel": opts.Channel,
|
"channel": opts.Channel,
|
||||||
})
|
})
|
||||||
// Check if no tool calls - we're done
|
// Check if no tool calls - then check reasoning content if any
|
||||||
if len(response.ToolCalls) == 0 {
|
if len(response.ToolCalls) == 0 {
|
||||||
finalContent = response.Content
|
finalContent = response.Content
|
||||||
|
if finalContent == "" && response.ReasoningContent != "" {
|
||||||
|
finalContent = response.ReasoningContent
|
||||||
|
}
|
||||||
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agent.ID,
|
"agent_id": agent.ID,
|
||||||
|
|
@ -1057,15 +1146,47 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create async callback for tools that implement AsyncTool
|
// Create async callback for tools that implement AsyncExecutor.
|
||||||
asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
|
// When the background work completes, this publishes the result
|
||||||
|
// as an inbound system message so processSystemMessage routes it
|
||||||
|
// back to the user via the normal agent loop.
|
||||||
|
asyncCallback := func(_ context.Context, result *tools.ToolResult) {
|
||||||
|
// Send ForUser content directly to the user (immediate feedback),
|
||||||
|
// mirroring the synchronous tool execution path.
|
||||||
if !result.Silent && result.ForUser != "" {
|
if !result.Silent && result.ForUser != "" {
|
||||||
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
|
outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
map[string]any{
|
defer outCancel()
|
||||||
"tool": tc.Name,
|
_ = al.bus.PublishOutbound(outCtx, bus.OutboundMessage{
|
||||||
"content_len": len(result.ForUser),
|
Channel: opts.Channel,
|
||||||
})
|
ChatID: opts.ChatID,
|
||||||
|
Content: result.ForUser,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine content for the agent loop (ForLLM or error).
|
||||||
|
content := result.ForLLM
|
||||||
|
if content == "" && result.Err != nil {
|
||||||
|
content = result.Err.Error()
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Async tool completed, publishing result",
|
||||||
|
map[string]any{
|
||||||
|
"tool": tc.Name,
|
||||||
|
"content_len": len(content),
|
||||||
|
"channel": opts.Channel,
|
||||||
|
})
|
||||||
|
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
_ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{
|
||||||
|
Channel: "system",
|
||||||
|
SenderID: fmt.Sprintf("async:%s", tc.Name),
|
||||||
|
ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID),
|
||||||
|
Content: content,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResult := agent.Tools.ExecuteWithContext(
|
toolResult := agent.Tools.ExecuteWithContext(
|
||||||
|
|
@ -1098,7 +1219,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
|
|
||||||
// If tool returned media refs, publish them as outbound media
|
// If tool returned media refs, publish them as outbound media
|
||||||
if len(r.result.Media) > 0 && opts.SendResponse {
|
if len(r.result.Media) > 0 {
|
||||||
parts := make([]bus.MediaPart, 0, len(r.result.Media))
|
parts := make([]bus.MediaPart, 0, len(r.result.Media))
|
||||||
for _, ref := range r.result.Media {
|
for _, ref := range r.result.Media {
|
||||||
part := bus.MediaPart{Ref: ref}
|
part := bus.MediaPart{Ref: ref}
|
||||||
|
|
@ -1139,24 +1260,42 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
return finalContent, iteration, nil
|
return finalContent, iteration, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateToolContexts updates the context for tools that need channel/chatID info.
|
// selectCandidates returns the model candidates and resolved model name to use
|
||||||
func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
|
// for a conversation turn. When model routing is configured and the incoming
|
||||||
// Use ContextualTool interface instead of type assertions
|
// message scores below the complexity threshold, it returns the light model
|
||||||
if tool, ok := agent.Tools.Get("message"); ok {
|
// candidates instead of the primary ones.
|
||||||
if mt, ok := tool.(tools.ContextualTool); ok {
|
//
|
||||||
mt.SetContext(channel, chatID)
|
// The returned (candidates, model) pair is used for all LLM calls within one
|
||||||
}
|
// turn — tool follow-up iterations use the same tier as the initial call so
|
||||||
|
// that a multi-step tool chain doesn't switch models mid-way.
|
||||||
|
func (al *AgentLoop) selectCandidates(
|
||||||
|
agent *AgentInstance,
|
||||||
|
userMsg string,
|
||||||
|
history []providers.Message,
|
||||||
|
) (candidates []providers.FallbackCandidate, model string) {
|
||||||
|
if agent.Router == nil || len(agent.LightCandidates) == 0 {
|
||||||
|
return agent.Candidates, agent.Model
|
||||||
}
|
}
|
||||||
if tool, ok := agent.Tools.Get("spawn"); ok {
|
|
||||||
if st, ok := tool.(tools.ContextualTool); ok {
|
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
|
||||||
st.SetContext(channel, chatID)
|
if !usedLight {
|
||||||
}
|
logger.DebugCF("agent", "Model routing: primary model selected",
|
||||||
}
|
map[string]any{
|
||||||
if tool, ok := agent.Tools.Get("subagent"); ok {
|
"agent_id": agent.ID,
|
||||||
if st, ok := tool.(tools.ContextualTool); ok {
|
"score": score,
|
||||||
st.SetContext(channel, chatID)
|
"threshold": agent.Router.Threshold(),
|
||||||
}
|
})
|
||||||
|
return agent.Candidates, agent.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Model routing: light model selected",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"light_model": agent.Router.LightModel(),
|
||||||
|
"score": score,
|
||||||
|
"threshold": agent.Router.Threshold(),
|
||||||
|
})
|
||||||
|
return agent.LightCandidates, agent.Router.LightModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
// maybeSummarize triggers summarization if the session history exceeds thresholds.
|
||||||
|
|
@ -1450,94 +1589,87 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
|
||||||
return totalChars * 2 / 5
|
return totalChars * 2 / 5
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
|
func (al *AgentLoop) handleCommand(
|
||||||
content := strings.TrimSpace(msg.Content)
|
ctx context.Context,
|
||||||
if !strings.HasPrefix(content, "/") {
|
msg bus.InboundMessage,
|
||||||
|
agent *AgentInstance,
|
||||||
|
) (string, bool) {
|
||||||
|
if !commands.HasCommandPrefix(msg.Content) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Fields(content)
|
if al.cmdRegistry == nil {
|
||||||
if len(parts) == 0 {
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := parts[0]
|
rt := al.buildCommandsRuntime(agent)
|
||||||
args := parts[1:]
|
executor := commands.NewExecutor(al.cmdRegistry, rt)
|
||||||
|
|
||||||
switch cmd {
|
var commandReply string
|
||||||
case "/show":
|
result := executor.Execute(ctx, commands.Request{
|
||||||
if len(args) < 1 {
|
Channel: msg.Channel,
|
||||||
return "Usage: /show [model|channel|agents]", true
|
ChatID: msg.ChatID,
|
||||||
}
|
SenderID: msg.SenderID,
|
||||||
switch args[0] {
|
Text: msg.Content,
|
||||||
case "model":
|
Reply: func(text string) error {
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
commandReply = text
|
||||||
if defaultAgent == nil {
|
return nil
|
||||||
return "No default agent configured", true
|
},
|
||||||
}
|
})
|
||||||
return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
|
|
||||||
case "channel":
|
|
||||||
return fmt.Sprintf("Current channel: %s", msg.Channel), true
|
|
||||||
case "agents":
|
|
||||||
agentIDs := al.registry.ListAgentIDs()
|
|
||||||
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("Unknown show target: %s", args[0]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "/list":
|
switch result.Outcome {
|
||||||
if len(args) < 1 {
|
case commands.OutcomeHandled:
|
||||||
return "Usage: /list [models|channels|agents]", true
|
if result.Err != nil {
|
||||||
|
return mapCommandError(result), true
|
||||||
}
|
}
|
||||||
switch args[0] {
|
if commandReply != "" {
|
||||||
case "models":
|
return commandReply, true
|
||||||
return "Available models: configured in config.json per agent", true
|
}
|
||||||
case "channels":
|
return "", true
|
||||||
|
default: // OutcomePassthrough — let the message fall through to LLM
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtime {
|
||||||
|
rt := &commands.Runtime{
|
||||||
|
Config: al.cfg,
|
||||||
|
ListAgentIDs: al.registry.ListAgentIDs,
|
||||||
|
ListDefinitions: al.cmdRegistry.Definitions,
|
||||||
|
GetEnabledChannels: func() []string {
|
||||||
if al.channelManager == nil {
|
if al.channelManager == nil {
|
||||||
return "Channel manager not initialized", true
|
return nil
|
||||||
}
|
}
|
||||||
channels := al.channelManager.GetEnabledChannels()
|
return al.channelManager.GetEnabledChannels()
|
||||||
if len(channels) == 0 {
|
},
|
||||||
return "No channels enabled", true
|
SwitchChannel: func(value string) error {
|
||||||
}
|
|
||||||
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
|
|
||||||
case "agents":
|
|
||||||
agentIDs := al.registry.ListAgentIDs()
|
|
||||||
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("Unknown list target: %s", args[0]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "/switch":
|
|
||||||
if len(args) < 3 || args[1] != "to" {
|
|
||||||
return "Usage: /switch [model|channel] to <name>", true
|
|
||||||
}
|
|
||||||
target := args[0]
|
|
||||||
value := args[2]
|
|
||||||
|
|
||||||
switch target {
|
|
||||||
case "model":
|
|
||||||
defaultAgent := al.registry.GetDefaultAgent()
|
|
||||||
if defaultAgent == nil {
|
|
||||||
return "No default agent configured", true
|
|
||||||
}
|
|
||||||
oldModel := defaultAgent.Model
|
|
||||||
defaultAgent.Model = value
|
|
||||||
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
|
|
||||||
case "channel":
|
|
||||||
if al.channelManager == nil {
|
if al.channelManager == nil {
|
||||||
return "Channel manager not initialized", true
|
return fmt.Errorf("channel manager not initialized")
|
||||||
}
|
}
|
||||||
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
|
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
|
||||||
return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
|
return fmt.Errorf("channel '%s' not found or not enabled", value)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("Switched target channel to %s", value), true
|
return nil
|
||||||
default:
|
},
|
||||||
return fmt.Sprintf("Unknown switch target: %s", target), true
|
}
|
||||||
|
if agent != nil {
|
||||||
|
rt.GetModelInfo = func() (string, string) {
|
||||||
|
return agent.Model, al.cfg.Agents.Defaults.Provider
|
||||||
|
}
|
||||||
|
rt.SwitchModel = func(value string) (string, error) {
|
||||||
|
oldModel := agent.Model
|
||||||
|
agent.Model = value
|
||||||
|
return oldModel, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
return "", false
|
func mapCommandError(result commands.ExecuteResult) string {
|
||||||
|
if result.Command == "" {
|
||||||
|
return fmt.Sprintf("Failed to execute command: %v", result.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Failed to execute /%s: %v", result.Command, result.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
||||||
|
|
@ -1556,10 +1688,17 @@ func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inboundMetadata(msg bus.InboundMessage, key string) string {
|
||||||
|
if msg.Metadata == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return msg.Metadata[key]
|
||||||
|
}
|
||||||
|
|
||||||
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
||||||
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
parentKind := msg.Metadata["parent_peer_kind"]
|
parentKind := inboundMetadata(msg, metadataKeyParentPeerKind)
|
||||||
parentID := msg.Metadata["parent_peer_id"]
|
parentID := inboundMetadata(msg, metadataKeyParentPeerID)
|
||||||
if parentKind == "" || parentID == "" {
|
if parentKind == "" || parentID == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -164,35 +165,21 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestToolContext_Updates verifies tool context is updated with channel/chatID
|
// TestToolContext_Updates verifies tool context helpers work correctly
|
||||||
func TestToolContext_Updates(t *testing.T) {
|
func TestToolContext_Updates(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
if got := tools.ToolChannel(ctx); got != "telegram" {
|
||||||
Agents: config.AgentsConfig{
|
t.Errorf("expected channel 'telegram', got %q", got)
|
||||||
Defaults: config.AgentDefaults{
|
}
|
||||||
Workspace: tmpDir,
|
if got := tools.ToolChatID(ctx); got != "chat-42" {
|
||||||
Model: "test-model",
|
t.Errorf("expected chatID 'chat-42', got %q", got)
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
// Empty context returns empty strings
|
||||||
provider := &simpleMockProvider{response: "OK"}
|
if got := tools.ToolChannel(context.Background()); got != "" {
|
||||||
_ = NewAgentLoop(cfg, msgBus, provider)
|
t.Errorf("expected empty channel from bare context, got %q", got)
|
||||||
|
}
|
||||||
// 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
|
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
||||||
|
|
@ -241,16 +228,11 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := config.DefaultConfig()
|
||||||
Agents: config.AgentsConfig{
|
cfg.Agents.Defaults.Workspace = tmpDir
|
||||||
Defaults: config.AgentDefaults{
|
cfg.Agents.Defaults.Model = "test-model"
|
||||||
Workspace: tmpDir,
|
cfg.Agents.Defaults.MaxTokens = 4096
|
||||||
Model: "test-model",
|
cfg.Agents.Defaults.MaxToolIterations = 10
|
||||||
MaxTokens: 4096,
|
|
||||||
MaxToolIterations: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &mockProvider{}
|
provider := &mockProvider{}
|
||||||
|
|
@ -337,6 +319,29 @@ func (m *simpleMockProvider) GetDefaultModel() string {
|
||||||
return "mock-model"
|
return "mock-model"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type countingMockProvider struct {
|
||||||
|
response string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
m.calls++
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: m.response,
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *countingMockProvider) GetDefaultModel() string {
|
||||||
|
return "counting-mock-model"
|
||||||
|
}
|
||||||
|
|
||||||
// mockCustomTool is a simple mock tool for registration testing
|
// mockCustomTool is a simple mock tool for registration testing
|
||||||
type mockCustomTool struct{}
|
type mockCustomTool struct{}
|
||||||
|
|
||||||
|
|
@ -359,36 +364,6 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool
|
||||||
return tools.SilentResult("Custom tool executed")
|
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]any {
|
|
||||||
return map[string]any{
|
|
||||||
"type": "object",
|
|
||||||
"properties": map[string]any{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *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
|
// testHelper executes a message and returns the response
|
||||||
type testHelper struct {
|
type testHelper struct {
|
||||||
al *AgentLoop
|
al *AgentLoop
|
||||||
|
|
@ -408,6 +383,198 @@ func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, ms
|
||||||
|
|
||||||
const responseTimeout = 3 * time.Second
|
const responseTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
func TestProcessMessage_UsesRouteSessionKey(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"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
msg := bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
route := al.registry.ResolveRoute(routing.RouteInput{
|
||||||
|
Channel: msg.Channel,
|
||||||
|
Peer: extractPeer(msg),
|
||||||
|
})
|
||||||
|
sessionKey := route.SessionKey
|
||||||
|
|
||||||
|
defaultAgent := al.registry.GetDefaultAgent()
|
||||||
|
if defaultAgent == nil {
|
||||||
|
t.Fatal("No default agent found")
|
||||||
|
}
|
||||||
|
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
_ = helper.executeAndGetResponse(t, context.Background(), msg)
|
||||||
|
|
||||||
|
history := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||||
|
if len(history) != 2 {
|
||||||
|
t.Fatalf("expected session history len=2, got %d", len(history))
|
||||||
|
}
|
||||||
|
if history[0].Role != "user" || history[0].Content != "hello" {
|
||||||
|
t.Fatalf("unexpected first message in session: %+v", history[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_CommandOutcomes(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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Session: config.SessionConfig{
|
||||||
|
DMScope: "per-channel-peer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
baseMsg := bus.InboundMessage{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/show channel",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if showResp != "Current Channel: whatsapp" {
|
||||||
|
t.Fatalf("unexpected /show reply: %q", showResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/foo",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if fooResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /foo reply: %q", fooResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 1 {
|
||||||
|
t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: baseMsg.Channel,
|
||||||
|
SenderID: baseMsg.SenderID,
|
||||||
|
ChatID: baseMsg.ChatID,
|
||||||
|
Content: "/new",
|
||||||
|
Peer: baseMsg.Peer,
|
||||||
|
})
|
||||||
|
if newResp != "LLM reply" {
|
||||||
|
t.Fatalf("unexpected /new reply: %q", newResp)
|
||||||
|
}
|
||||||
|
if provider.calls != 2 {
|
||||||
|
t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_SwitchModelShowModelConsistency(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,
|
||||||
|
Provider: "openai",
|
||||||
|
Model: "before-switch",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
helper := testHelper{al: al}
|
||||||
|
|
||||||
|
switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/switch model to after-switch",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") {
|
||||||
|
t.Fatalf("unexpected /switch reply: %q", switchResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "/show model",
|
||||||
|
Peer: bus.Peer{
|
||||||
|
Kind: "direct",
|
||||||
|
ID: "user1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !strings.Contains(showResp, "Current Model: after-switch (Provider: openai)") {
|
||||||
|
t.Fatalf("unexpected /show model reply after switch: %q", showResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if provider.calls != 0 {
|
||||||
|
t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
||||||
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentRegistry manages multiple agent instances and routes messages to them.
|
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||||
|
|
@ -100,6 +101,19 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForEachTool calls fn for every tool registered under the given name
|
||||||
|
// across all agents. This is useful for propagating dependencies (e.g.
|
||||||
|
// MediaStore) to tools after registry construction.
|
||||||
|
func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
if t, ok := agent.Tools.Get(name); ok {
|
||||||
|
fn(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultAgent returns the default agent instance.
|
// GetDefaultAgent returns the default agent instance.
|
||||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
|
||||||
39
pkg/agent/thinking.go
Normal file
39
pkg/agent/thinking.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ThinkingLevel controls how the provider sends thinking parameters.
|
||||||
|
//
|
||||||
|
// - "adaptive": sends {thinking: {type: "adaptive"}} + output_config.effort (Claude 4.6+)
|
||||||
|
// - "low"/"medium"/"high"/"xhigh": sends {thinking: {type: "enabled", budget_tokens: N}} (all models)
|
||||||
|
// - "off": disables thinking
|
||||||
|
type ThinkingLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ThinkingOff ThinkingLevel = "off"
|
||||||
|
ThinkingLow ThinkingLevel = "low"
|
||||||
|
ThinkingMedium ThinkingLevel = "medium"
|
||||||
|
ThinkingHigh ThinkingLevel = "high"
|
||||||
|
ThinkingXHigh ThinkingLevel = "xhigh"
|
||||||
|
ThinkingAdaptive ThinkingLevel = "adaptive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseThinkingLevel normalizes a config string to a ThinkingLevel.
|
||||||
|
// Case-insensitive and whitespace-tolerant for user-facing config values.
|
||||||
|
// Returns ThinkingOff for unknown or empty values.
|
||||||
|
func parseThinkingLevel(level string) ThinkingLevel {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case "adaptive":
|
||||||
|
return ThinkingAdaptive
|
||||||
|
case "low":
|
||||||
|
return ThinkingLow
|
||||||
|
case "medium":
|
||||||
|
return ThinkingMedium
|
||||||
|
case "high":
|
||||||
|
return ThinkingHigh
|
||||||
|
case "xhigh":
|
||||||
|
return ThinkingXHigh
|
||||||
|
default:
|
||||||
|
return ThinkingOff
|
||||||
|
}
|
||||||
|
}
|
||||||
35
pkg/agent/thinking_test.go
Normal file
35
pkg/agent/thinking_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseThinkingLevel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want ThinkingLevel
|
||||||
|
}{
|
||||||
|
{"off", "off", ThinkingOff},
|
||||||
|
{"empty", "", ThinkingOff},
|
||||||
|
{"low", "low", ThinkingLow},
|
||||||
|
{"medium", "medium", ThinkingMedium},
|
||||||
|
{"high", "high", ThinkingHigh},
|
||||||
|
{"xhigh", "xhigh", ThinkingXHigh},
|
||||||
|
{"adaptive", "adaptive", ThinkingAdaptive},
|
||||||
|
{"unknown", "unknown", ThinkingOff},
|
||||||
|
// Case-insensitive and whitespace-tolerant
|
||||||
|
{"upper_Medium", "Medium", ThinkingMedium},
|
||||||
|
{"upper_HIGH", "HIGH", ThinkingHigh},
|
||||||
|
{"mixed_Adaptive", "Adaptive", ThinkingAdaptive},
|
||||||
|
{"leading_space", " high", ThinkingHigh},
|
||||||
|
{"trailing_space", "low ", ThinkingLow},
|
||||||
|
{"both_spaces", " medium ", ThinkingMedium},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := parseThinkingLevel(tt.input); got != tt.want {
|
||||||
|
t.Errorf("parseThinkingLevel(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
71
pkg/auth/anthropic_usage.go
Normal file
71
pkg/auth/anthropic_usage.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
anthropicBetaHeader = "oauth-2025-04-20"
|
||||||
|
anthropicAPIVersion = "2023-06-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
// anthropicUsageURL is the endpoint for fetching OAuth usage stats.
|
||||||
|
// It is a var (not const) to allow overriding in tests.
|
||||||
|
var anthropicUsageURL = "https://api.anthropic.com/api/oauth/usage"
|
||||||
|
|
||||||
|
func setAnthropicUsageURL(url string) { anthropicUsageURL = url }
|
||||||
|
|
||||||
|
type AnthropicUsage struct {
|
||||||
|
FiveHourUtilization float64
|
||||||
|
SevenDayUtilization float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchAnthropicUsage(token string) (*AnthropicUsage, error) {
|
||||||
|
req, err := http.NewRequest("GET", anthropicUsageURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Anthropic-Version", anthropicAPIVersion)
|
||||||
|
req.Header.Set("Anthropic-Beta", anthropicBetaHeader)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading usage response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if resp.StatusCode == http.StatusForbidden {
|
||||||
|
return nil, fmt.Errorf("insufficient scope: usage endpoint requires oauth scope")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("usage request failed (%d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
FiveHour struct {
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
} `json:"five_hour"`
|
||||||
|
SevenDay struct {
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
} `json:"seven_day"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing usage response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AnthropicUsage{
|
||||||
|
FiveHourUtilization: result.FiveHour.Utilization,
|
||||||
|
SevenDayUtilization: result.SevenDay.Utilization,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
98
pkg/auth/anthropic_usage_test.go
Normal file
98
pkg/auth/anthropic_usage_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||||
|
t.Errorf("Authorization = %q, want %q", got, "Bearer test-token")
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader {
|
||||||
|
t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"five_hour":{"utilization":0.42},"seven_day":{"utilization":0.85}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Temporarily override the URL by using the test server
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
usage, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if usage.FiveHourUtilization != 0.42 {
|
||||||
|
t.Errorf("FiveHourUtilization = %v, want 0.42", usage.FiveHourUtilization)
|
||||||
|
}
|
||||||
|
if usage.SevenDayUtilization != 0.85 {
|
||||||
|
t.Errorf("SevenDayUtilization = %v, want 0.85", usage.SevenDayUtilization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_Forbidden(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"error":"forbidden"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 403, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "insufficient scope") {
|
||||||
|
t.Errorf("expected 'insufficient scope' error, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`internal error`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 500, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "500") {
|
||||||
|
t.Errorf("expected error containing '500', got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAnthropicUsage_MalformedJSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`not json`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origURL := anthropicUsageURL
|
||||||
|
defer func() { setAnthropicUsageURL(origURL) }()
|
||||||
|
setAnthropicUsageURL(srv.URL)
|
||||||
|
|
||||||
|
_, err := FetchAnthropicUsage("test-token")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for malformed JSON, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "parsing usage response") {
|
||||||
|
t.Errorf("expected 'parsing usage response' error, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -212,7 +212,10 @@ func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +303,10 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device code response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
return nil, fmt.Errorf("device code request failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +366,10 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
|
||||||
return nil, fmt.Errorf("pending")
|
return nil, fmt.Errorf("pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading device token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var tokenResp struct {
|
var tokenResp struct {
|
||||||
AuthorizationCode string `json:"authorization_code"`
|
AuthorizationCode string `json:"authorization_code"`
|
||||||
|
|
@ -401,7 +410,10 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token refresh response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +506,10 @@ func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading token exchange response: %w", err)
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
return nil, fmt.Errorf("token exchange failed: %s", string(body))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ func (c *AuthCredential) NeedsRefresh() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func authFilePath() string {
|
func authFilePath() string {
|
||||||
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
|
return filepath.Join(home, "auth.json")
|
||||||
|
}
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return filepath.Join(home, ".picoclaw", "auth.json")
|
return filepath.Join(home, ".picoclaw", "auth.json")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,35 @@ func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoginSetupToken(r io.Reader) (*AuthCredential, error) {
|
||||||
|
fmt.Println("Paste your setup token from `claude setup-token`:")
|
||||||
|
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 !strings.HasPrefix(token, "sk-ant-oat01-") {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: expected prefix sk-ant-oat01-")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(token) < 80 {
|
||||||
|
return nil, fmt.Errorf("invalid setup token: too short (expected at least 80 characters)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthCredential{
|
||||||
|
AccessToken: token,
|
||||||
|
Provider: "anthropic",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func providerDisplayName(provider string) string {
|
func providerDisplayName(provider string) string {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
|
|
|
||||||
61
pkg/auth/token_test.go
Normal file
61
pkg/auth/token_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginSetupToken(t *testing.T) {
|
||||||
|
// A valid token: correct prefix + at least 80 chars
|
||||||
|
validToken := "sk-ant-oat01-" + strings.Repeat("a", 80)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"valid token", validToken, ""},
|
||||||
|
{"empty input", "", "expected prefix sk-ant-oat01-"},
|
||||||
|
{"wrong prefix", "sk-ant-api-" + strings.Repeat("a", 80), "expected prefix sk-ant-oat01-"},
|
||||||
|
{"too short", "sk-ant-oat01-short", "too short"},
|
||||||
|
{"whitespace only", " ", "expected prefix sk-ant-oat01-"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := strings.NewReader(tt.input + "\n")
|
||||||
|
cred, err := LoginSetupToken(r)
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if cred.AccessToken != validToken {
|
||||||
|
t.Errorf("AccessToken = %q, want %q", cred.AccessToken, validToken)
|
||||||
|
}
|
||||||
|
if cred.Provider != "anthropic" {
|
||||||
|
t.Errorf("Provider = %q, want %q", cred.Provider, "anthropic")
|
||||||
|
}
|
||||||
|
if cred.AuthMethod != "oauth" {
|
||||||
|
t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSetupToken_EmptyReader(t *testing.T) {
|
||||||
|
r := strings.NewReader("")
|
||||||
|
_, err := LoginSetupToken(r)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty reader, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -26,6 +27,12 @@ const (
|
||||||
sendTimeout = 10 * time.Second
|
sendTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call)
|
||||||
|
channelRefRe = regexp.MustCompile(`<#(\d+)>`)
|
||||||
|
msgLinkRe = regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`)
|
||||||
|
)
|
||||||
|
|
||||||
type DiscordChannel struct {
|
type DiscordChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
session *discordgo.Session
|
session *discordgo.Session
|
||||||
|
|
@ -338,6 +345,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
||||||
content = c.stripBotMention(content)
|
content = c.stripBotMention(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve Discord refs in main content before concatenation to avoid
|
||||||
|
// double-expanding links that appear in the referenced message.
|
||||||
|
content = c.resolveDiscordRefs(s, content, m.GuildID)
|
||||||
|
|
||||||
|
// Prepend referenced (quoted) message content if this is a reply
|
||||||
|
if m.MessageReference != nil && m.ReferencedMessage != nil {
|
||||||
|
refContent := m.ReferencedMessage.Content
|
||||||
|
if refContent != "" {
|
||||||
|
refAuthor := "unknown"
|
||||||
|
if m.ReferencedMessage.Author != nil {
|
||||||
|
refAuthor = m.ReferencedMessage.Author.Username
|
||||||
|
}
|
||||||
|
refContent = c.resolveDiscordRefs(s, refContent, m.GuildID)
|
||||||
|
content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s",
|
||||||
|
refAuthor, refContent, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
senderID := m.Author.ID
|
senderID := m.Author.ID
|
||||||
|
|
||||||
mediaPaths := make([]string, 0, len(m.Attachments))
|
mediaPaths := make([]string, 0, len(m.Attachments))
|
||||||
|
|
@ -508,6 +533,51 @@ func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveDiscordRefs resolves channel references (<#id> → #channel-name) and
|
||||||
|
// expands Discord message links to show the linked message content.
|
||||||
|
// Only links pointing to the same guild are expanded to prevent cross-guild leakage.
|
||||||
|
func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {
|
||||||
|
// 1. Resolve channel references: <#id> → #channel-name
|
||||||
|
text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
parts := channelRefRe.FindStringSubmatch(match)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
// Prefer session state cache to avoid API calls
|
||||||
|
if ch, err := s.State.Channel(parts[1]); err == nil {
|
||||||
|
return "#" + ch.Name
|
||||||
|
}
|
||||||
|
if ch, err := s.Channel(parts[1]); err == nil {
|
||||||
|
return "#" + ch.Name
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Expand Discord message links (max 3, same guild only)
|
||||||
|
matches := msgLinkRe.FindAllStringSubmatch(text, 3)
|
||||||
|
for _, m := range matches {
|
||||||
|
if len(m) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
linkGuildID, channelID, messageID := m[1], m[2], m[3]
|
||||||
|
// Security: only expand links from the same guild
|
||||||
|
if linkGuildID != guildID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg, err := s.ChannelMessage(channelID, messageID)
|
||||||
|
if err != nil || msg == nil || msg.Content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
author := "unknown"
|
||||||
|
if msg.Author != nil {
|
||||||
|
author = msg.Author.Username
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
// stripBotMention removes the bot mention from the message content.
|
// stripBotMention removes the bot mention from the message content.
|
||||||
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
|
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
|
||||||
func (c *DiscordChannel) stripBotMention(text string) string {
|
func (c *DiscordChannel) stripBotMention(text string) string {
|
||||||
|
|
|
||||||
98
pkg/channels/discord/discord_resolve_test.go
Normal file
98
pkg/channels/discord/discord_resolve_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package discord
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChannelRefRegex(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantID string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"basic channel ref", "<#123456789>", "123456789", true},
|
||||||
|
{"long id", "<#9876543210123456>", "9876543210123456", true},
|
||||||
|
{"no match plain text", "hello world", "", false},
|
||||||
|
{"no match partial", "<#>", "", false},
|
||||||
|
{"no match letters", "<#abc>", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
matches := channelRefRe.FindStringSubmatch(tt.input)
|
||||||
|
if tt.wantOK {
|
||||||
|
if len(matches) < 2 || matches[1] != tt.wantID {
|
||||||
|
t.Errorf("channelRefRe(%q) = %v, want ID %q", tt.input, matches, tt.wantID)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
t.Errorf("channelRefRe(%q) should not match, got %v", tt.input, matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgLinkRegex(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantGuild string
|
||||||
|
wantChan string
|
||||||
|
wantMsg string
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"discord.com link",
|
||||||
|
"https://discord.com/channels/111/222/333",
|
||||||
|
"111", "222", "333", true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"discordapp.com link",
|
||||||
|
"https://discordapp.com/channels/111/222/333",
|
||||||
|
"111", "222", "333", true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"real world ids",
|
||||||
|
"check this https://discord.com/channels/9000000000000001/9000000000000002/9000000000000003 please",
|
||||||
|
"9000000000000001", "9000000000000002", "9000000000000003", true,
|
||||||
|
},
|
||||||
|
{"no match http", "http://discord.com/channels/1/2/3", "", "", "", false},
|
||||||
|
{"no match missing segment", "https://discord.com/channels/1/2", "", "", "", false},
|
||||||
|
{"no match plain text", "hello world", "", "", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
matches := msgLinkRe.FindStringSubmatch(tt.input)
|
||||||
|
if tt.wantOK {
|
||||||
|
if len(matches) < 4 {
|
||||||
|
t.Fatalf("msgLinkRe(%q) didn't match, want guild=%s chan=%s msg=%s",
|
||||||
|
tt.input, tt.wantGuild, tt.wantChan, tt.wantMsg)
|
||||||
|
}
|
||||||
|
if matches[1] != tt.wantGuild || matches[2] != tt.wantChan || matches[3] != tt.wantMsg {
|
||||||
|
t.Errorf("msgLinkRe(%q) = guild=%s chan=%s msg=%s, want %s/%s/%s",
|
||||||
|
tt.input, matches[1], matches[2], matches[3],
|
||||||
|
tt.wantGuild, tt.wantChan, tt.wantMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(matches) >= 4 {
|
||||||
|
t.Errorf("msgLinkRe(%q) should not match, got %v", tt.input, matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMsgLinkRegex_MultipleMatches(t *testing.T) {
|
||||||
|
input := "see https://discord.com/channels/1/2/3 and https://discord.com/channels/4/5/6 and https://discord.com/channels/7/8/9 and https://discord.com/channels/10/11/12"
|
||||||
|
matches := msgLinkRe.FindAllStringSubmatch(input, 3)
|
||||||
|
if len(matches) != 3 {
|
||||||
|
t.Fatalf("expected 3 matches (capped), got %d", len(matches))
|
||||||
|
}
|
||||||
|
// Verify the 3rd match is 7/8/9 (not 10/11/12)
|
||||||
|
if matches[2][1] != "7" || matches[2][2] != "8" || matches[2][3] != "9" {
|
||||||
|
t.Errorf("3rd match = %v, want guild=7 chan=8 msg=9", matches[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,11 @@ package feishu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -195,18 +197,35 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReactToMessage implements channels.ReactionCapable.
|
// ReactToMessage implements channels.ReactionCapable.
|
||||||
// Adds an "Pin" reaction and returns an undo function to remove it.
|
// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
|
||||||
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
|
||||||
|
// Get emoji list from config
|
||||||
|
emojiList := c.config.RandomReactionEmoji
|
||||||
|
if len(emojiList) == 0 {
|
||||||
|
// Default to "Pin" if no config
|
||||||
|
emojiList = []string{"Pin"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomly choose one from the list using crypto/rand for better distribution
|
||||||
|
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList))))
|
||||||
|
var chosenEmoji string
|
||||||
|
if err != nil {
|
||||||
|
chosenEmoji = emojiList[0]
|
||||||
|
} else {
|
||||||
|
chosenEmoji = emojiList[idx.Int64()]
|
||||||
|
}
|
||||||
|
|
||||||
req := larkim.NewCreateMessageReactionReqBuilder().
|
req := larkim.NewCreateMessageReactionReqBuilder().
|
||||||
MessageId(messageID).
|
MessageId(messageID).
|
||||||
Body(larkim.NewCreateMessageReactionReqBodyBuilder().
|
Body(larkim.NewCreateMessageReactionReqBodyBuilder().
|
||||||
ReactionType(larkim.NewEmojiBuilder().EmojiType("Pin").Build()).
|
ReactionType(larkim.NewEmojiBuilder().EmojiType(chosenEmoji).Build()).
|
||||||
Build()).
|
Build()).
|
||||||
Build()
|
Build()
|
||||||
|
|
||||||
resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)
|
resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{
|
logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{
|
||||||
|
"emoji": chosenEmoji,
|
||||||
"message_id": messageID,
|
"message_id": messageID,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
|
@ -214,6 +233,7 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st
|
||||||
}
|
}
|
||||||
if !resp.Success() {
|
if !resp.Success() {
|
||||||
logger.ErrorCF("feishu", "Reaction API error", map[string]any{
|
logger.ErrorCF("feishu", "Reaction API error", map[string]any{
|
||||||
|
"emoji": chosenEmoji,
|
||||||
"message_id": messageID,
|
"message_id": messageID,
|
||||||
"code": resp.Code,
|
"code": resp.Code,
|
||||||
"msg": resp.Msg,
|
"msg": resp.Msg,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package channels
|
package channels
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
)
|
||||||
|
|
||||||
// TypingCapable — channels that can show a typing/thinking indicator.
|
// TypingCapable — channels that can show a typing/thinking indicator.
|
||||||
// StartTyping begins the indicator and returns a stop function.
|
// StartTyping begins the indicator and returns a stop function.
|
||||||
|
|
@ -39,3 +43,10 @@ type PlaceholderRecorder interface {
|
||||||
RecordTypingStop(channel, chatID string, stop func())
|
RecordTypingStop(channel, chatID string, stop func())
|
||||||
RecordReactionUndo(channel, chatID string, undo func())
|
RecordReactionUndo(channel, chatID string, undo func())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommandRegistrarCapable is implemented by channels that can register
|
||||||
|
// command menus with their upstream platform (e.g. Telegram BotCommand).
|
||||||
|
// Channels that do not support platform-level command menus can ignore it.
|
||||||
|
type CommandRegistrarCapable interface {
|
||||||
|
RegisterCommands(ctx context.Context, defs []commands.Definition) error
|
||||||
|
}
|
||||||
|
|
|
||||||
16
pkg/channels/interfaces_command_test.go
Normal file
16
pkg/channels/interfaces_command_test.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockRegistrar struct{}
|
||||||
|
|
||||||
|
func (mockRegistrar) RegisterCommands(context.Context, []commands.Definition) error { return nil }
|
||||||
|
|
||||||
|
func TestCommandRegistrarCapable_Compiles(t *testing.T) {
|
||||||
|
var _ CommandRegistrarCapable = mockRegistrar{}
|
||||||
|
}
|
||||||
154
pkg/channels/irc/handler.go
Normal file
154
pkg/channels/irc/handler.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/ergochat/irc-go/ircevent"
|
||||||
|
"github.com/ergochat/irc-go/ircmsg"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// onConnect is called after a successful connection (and on reconnect).
|
||||||
|
func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
|
||||||
|
// NickServ auth (only if SASL is not configured)
|
||||||
|
if c.config.NickServPassword != "" && c.config.SASLUser == "" {
|
||||||
|
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join configured channels
|
||||||
|
for _, ch := range c.config.Channels {
|
||||||
|
conn.Join(ch)
|
||||||
|
logger.InfoCF("irc", "Joined IRC channel", map[string]any{
|
||||||
|
"channel": ch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onPrivmsg handles incoming PRIVMSG events.
|
||||||
|
func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) {
|
||||||
|
if len(e.Params) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nick := e.Nick()
|
||||||
|
currentNick := conn.CurrentNick()
|
||||||
|
|
||||||
|
// Ignore own messages
|
||||||
|
if strings.EqualFold(nick, currentNick) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target := e.Params[0] // channel name or bot's nick
|
||||||
|
content := e.Params[1] // message text
|
||||||
|
|
||||||
|
// Determine if this is a DM or channel message
|
||||||
|
isDM := !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&")
|
||||||
|
|
||||||
|
var chatID string
|
||||||
|
var peer bus.Peer
|
||||||
|
|
||||||
|
if isDM {
|
||||||
|
chatID = nick
|
||||||
|
peer = bus.Peer{Kind: "direct", ID: nick}
|
||||||
|
} else {
|
||||||
|
chatID = target
|
||||||
|
peer = bus.Peer{Kind: "group", ID: target}
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "irc",
|
||||||
|
PlatformID: nick,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("irc", nick),
|
||||||
|
Username: nick,
|
||||||
|
DisplayName: nick,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For channel messages, check group trigger (mention detection)
|
||||||
|
if !isDM {
|
||||||
|
isMentioned := isBotMentioned(content, currentNick)
|
||||||
|
if isMentioned {
|
||||||
|
content = stripBotMention(content, currentNick)
|
||||||
|
}
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
|
if !respond {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID := fmt.Sprintf("%s-%d", nick, time.Now().UnixNano())
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"platform": "irc",
|
||||||
|
"server": c.config.Server,
|
||||||
|
}
|
||||||
|
if !isDM {
|
||||||
|
metadata["channel"] = target
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nickMentionedAt returns the byte index where botNick is mentioned in content
|
||||||
|
// with word-boundary checks, or -1 if not found. Also checks for "nick:" /
|
||||||
|
// "nick," prefix convention.
|
||||||
|
func nickMentionedAt(content, botNick string) int {
|
||||||
|
lower := strings.ToLower(content)
|
||||||
|
lowerNick := strings.ToLower(botNick)
|
||||||
|
|
||||||
|
// "nick:" or "nick," at start (most common IRC convention)
|
||||||
|
if strings.HasPrefix(lower, lowerNick+":") || strings.HasPrefix(lower, lowerNick+",") {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word-boundary match anywhere in the message
|
||||||
|
idx := strings.Index(lower, lowerNick)
|
||||||
|
if idx < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
runes := []rune(lower)
|
||||||
|
nickRunes := []rune(lowerNick)
|
||||||
|
endIdx := idx + len(string(nickRunes))
|
||||||
|
before := idx == 0 || !unicode.IsLetter(runes[idx-1]) && !unicode.IsDigit(runes[idx-1])
|
||||||
|
after := endIdx >= len(lower) || !unicode.IsLetter(rune(lower[endIdx])) && !unicode.IsDigit(rune(lower[endIdx]))
|
||||||
|
if before && after {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBotMentioned checks if the bot's nick appears in the message.
|
||||||
|
func isBotMentioned(content, botNick string) bool {
|
||||||
|
return nickMentionedAt(content, botNick) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripBotMention removes "nick: " or "nick, " prefix from content.
|
||||||
|
func stripBotMention(content, botNick string) string {
|
||||||
|
idx := nickMentionedAt(content, botNick)
|
||||||
|
if idx != 0 {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
lowerNick := strings.ToLower(botNick)
|
||||||
|
lower := strings.ToLower(content)
|
||||||
|
for _, sep := range []string{":", ","} {
|
||||||
|
prefix := lowerNick + sep
|
||||||
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
return strings.TrimSpace(content[len(prefix):])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
16
pkg/channels/irc/init.go
Normal file
16
pkg/channels/irc/init.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("irc", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
if !cfg.Channels.IRC.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return NewIRCChannel(cfg.Channels.IRC, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
194
pkg/channels/irc/irc.go
Normal file
194
pkg/channels/irc/irc.go
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ergochat/irc-go/ircevent"
|
||||||
|
"github.com/ergochat/irc-go/ircmsg"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IRCChannel implements the Channel interface for IRC servers.
|
||||||
|
type IRCChannel struct {
|
||||||
|
*channels.BaseChannel
|
||||||
|
config config.IRCConfig
|
||||||
|
conn *ircevent.Connection
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIRCChannel creates a new IRC channel.
|
||||||
|
func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {
|
||||||
|
if cfg.Server == "" {
|
||||||
|
return nil, fmt.Errorf("irc server is required")
|
||||||
|
}
|
||||||
|
if cfg.Nick == "" {
|
||||||
|
return nil, fmt.Errorf("irc nick is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(400),
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &IRCChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start connects to the IRC server and begins listening.
|
||||||
|
func (c *IRCChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("irc", "Starting IRC channel")
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
user := c.config.User
|
||||||
|
if user == "" {
|
||||||
|
user = c.config.Nick
|
||||||
|
}
|
||||||
|
realName := c.config.RealName
|
||||||
|
if realName == "" {
|
||||||
|
realName = c.config.Nick
|
||||||
|
}
|
||||||
|
caps := []string(c.config.RequestCaps)
|
||||||
|
if len(caps) == 0 {
|
||||||
|
caps = []string{"server-time", "message-tags"}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn := &ircevent.Connection{
|
||||||
|
Server: c.config.Server,
|
||||||
|
Nick: c.config.Nick,
|
||||||
|
User: user,
|
||||||
|
RealName: realName,
|
||||||
|
Password: c.config.Password,
|
||||||
|
UseTLS: c.config.TLS,
|
||||||
|
RequestCaps: caps,
|
||||||
|
QuitMessage: "Goodbye",
|
||||||
|
Debug: false,
|
||||||
|
Log: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.TLS {
|
||||||
|
conn.TLSConfig = &tls.Config{
|
||||||
|
ServerName: extractHost(c.config.Server),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SASL auth (takes priority over NickServ)
|
||||||
|
if c.config.SASLUser != "" && c.config.SASLPassword != "" {
|
||||||
|
conn.SASLLogin = c.config.SASLUser
|
||||||
|
conn.SASLPassword = c.config.SASLPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register event handlers
|
||||||
|
conn.AddConnectCallback(func(e ircmsg.Message) {
|
||||||
|
c.onConnect(conn)
|
||||||
|
})
|
||||||
|
conn.AddCallback("PRIVMSG", func(e ircmsg.Message) {
|
||||||
|
c.onPrivmsg(conn, e)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := conn.Connect(); err != nil {
|
||||||
|
return fmt.Errorf("irc connect failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn = conn
|
||||||
|
|
||||||
|
// ircevent.Connection.Loop() handles reconnection internally.
|
||||||
|
go conn.Loop()
|
||||||
|
|
||||||
|
c.SetRunning(true)
|
||||||
|
logger.InfoCF("irc", "IRC channel started", map[string]any{
|
||||||
|
"server": c.config.Server,
|
||||||
|
"nick": c.config.Nick,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop disconnects from the IRC server.
|
||||||
|
func (c *IRCChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("irc", "Stopping IRC channel")
|
||||||
|
c.SetRunning(false)
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Quit()
|
||||||
|
}
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoC("irc", "IRC channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to an IRC channel or user.
|
||||||
|
func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
target := msg.ChatID
|
||||||
|
if target == "" {
|
||||||
|
return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(msg.Content) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send each line separately (IRC is line-oriented)
|
||||||
|
lines := strings.Split(msg.Content, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimRight(line, "\r")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.conn.Privmsg(target, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("irc", "Message sent", map[string]any{
|
||||||
|
"target": target,
|
||||||
|
"lines": len(lines),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.
|
||||||
|
// Requires typing.enabled in config and server support for message-tags capability.
|
||||||
|
func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
noop := func() {}
|
||||||
|
|
||||||
|
if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {
|
||||||
|
return noop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server supports message-tags (required for TAGMSG)
|
||||||
|
if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok {
|
||||||
|
return noop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
if c.IsRunning() && c.conn != nil {
|
||||||
|
c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID)
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHost returns the hostname portion of a host:port string.
|
||||||
|
func extractHost(server string) string {
|
||||||
|
host, _, found := strings.Cut(server, ":")
|
||||||
|
if found {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return server
|
||||||
|
}
|
||||||
145
pkg/channels/irc/irc_test.go
Normal file
145
pkg/channels/irc/irc_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
package irc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewIRCChannel(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
|
t.Run("missing server", func(t *testing.T) {
|
||||||
|
cfg := config.IRCConfig{Nick: "bot"}
|
||||||
|
_, err := NewIRCChannel(cfg, msgBus)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing server, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing nick", func(t *testing.T) {
|
||||||
|
cfg := config.IRCConfig{Server: "irc.example.com:6667"}
|
||||||
|
_, err := NewIRCChannel(cfg, msgBus)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing nick, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid config", func(t *testing.T) {
|
||||||
|
cfg := config.IRCConfig{
|
||||||
|
Server: "irc.example.com:6667",
|
||||||
|
Nick: "testbot",
|
||||||
|
Channels: []string{"#test"},
|
||||||
|
}
|
||||||
|
ch, err := NewIRCChannel(cfg, msgBus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ch.Name() != "irc" {
|
||||||
|
t.Errorf("Name() = %q, want %q", ch.Name(), "irc")
|
||||||
|
}
|
||||||
|
if ch.IsRunning() {
|
||||||
|
t.Error("new channel should not be running")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractHost(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
server string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"irc.libera.chat:6697", "irc.libera.chat"},
|
||||||
|
{"localhost:6667", "localhost"},
|
||||||
|
{"irc.example.com", "irc.example.com"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.server, func(t *testing.T) {
|
||||||
|
got := extractHost(tt.server)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNickMentionedAt(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
nick string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"colon prefix", "bot: hello", "bot", 0},
|
||||||
|
{"comma prefix", "bot, hello", "bot", 0},
|
||||||
|
{"case insensitive", "BOT: hello", "bot", 0},
|
||||||
|
{"word boundary mid", "hey bot what's up", "bot", 4},
|
||||||
|
{"no mention", "hello world", "bot", -1},
|
||||||
|
{"substring mismatch", "robotics are cool", "bot", -1},
|
||||||
|
{"nick at end", "hello bot", "bot", 6},
|
||||||
|
{"empty content", "", "bot", -1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := nickMentionedAt(tt.content, tt.nick)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBotMentioned(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
nick string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"colon prefix", "bot: hello", "bot", true},
|
||||||
|
{"comma prefix", "bot, hello", "bot", true},
|
||||||
|
{"case insensitive", "BOT: hello", "bot", true},
|
||||||
|
{"word boundary mid", "hey bot what's up", "bot", true},
|
||||||
|
{"no mention", "hello world", "bot", false},
|
||||||
|
{"substring mismatch", "robotics are cool", "bot", false},
|
||||||
|
{"nick at end", "hello bot", "bot", true},
|
||||||
|
{"empty content", "", "bot", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := isBotMentioned(tt.content, tt.nick)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripBotMention(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
nick string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"colon prefix", "bot: hello there", "bot", "hello there"},
|
||||||
|
{"comma prefix", "bot, help me", "bot", "help me"},
|
||||||
|
{"case insensitive", "BOT: hello", "bot", "hello"},
|
||||||
|
{"no prefix match", "hello bot", "bot", "hello bot"},
|
||||||
|
{"only prefix", "bot:", "bot", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := stripBotMention(tt.content, tt.nick)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -654,7 +654,10 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err))
|
||||||
|
}
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,9 @@ var channelRateConfig = map[string]float64{
|
||||||
"telegram": 20,
|
"telegram": 20,
|
||||||
"discord": 1,
|
"discord": 1,
|
||||||
"slack": 1,
|
"slack": 1,
|
||||||
|
"matrix": 2,
|
||||||
"line": 10,
|
"line": 10,
|
||||||
|
"irc": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelWorker struct {
|
type channelWorker struct {
|
||||||
|
|
@ -243,6 +245,13 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("slack", "Slack")
|
m.initChannel("slack", "Slack")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.Matrix.Enabled &&
|
||||||
|
m.config.Channels.Matrix.Homeserver != "" &&
|
||||||
|
m.config.Channels.Matrix.UserID != "" &&
|
||||||
|
m.config.Channels.Matrix.AccessToken != "" {
|
||||||
|
m.initChannel("matrix", "Matrix")
|
||||||
|
}
|
||||||
|
|
||||||
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
|
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
|
||||||
m.initChannel("line", "LINE")
|
m.initChannel("line", "LINE")
|
||||||
}
|
}
|
||||||
|
|
@ -267,6 +276,10 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("pico", "Pico")
|
m.initChannel("pico", "Pico")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" {
|
||||||
|
m.initChannel("irc", "IRC")
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
13
pkg/channels/matrix/init.go
Normal file
13
pkg/channels/matrix/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewMatrixChannel(cfg.Channels.Matrix, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
1115
pkg/channels/matrix/matrix.go
Normal file
1115
pkg/channels/matrix/matrix.go
Normal file
File diff suppressed because it is too large
Load diff
291
pkg/channels/matrix/matrix_test.go
Normal file
291
pkg/channels/matrix/matrix_test.go
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
|
||||||
|
re := localpartMentionRegexp("picoclaw")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{text: "@picoclaw hello", want: true},
|
||||||
|
{text: "hi @picoclaw:matrix.org", want: true},
|
||||||
|
{
|
||||||
|
text: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
|
||||||
|
want: false, // historical false-positive case in PR #356
|
||||||
|
},
|
||||||
|
{text: "mail test@example.com", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := re.MatchString(tc.text); got != tc.want {
|
||||||
|
t.Fatalf("text=%q match=%v want=%v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripUserMention(t *testing.T) {
|
||||||
|
userID := id.UserID("@picoclaw:matrix.org")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{in: "@picoclaw:matrix.org hello", want: "hello"},
|
||||||
|
{in: "@picoclaw, hello", want: "hello"},
|
||||||
|
{in: "no mention here", want: "no mention here"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := stripUserMention(tc.in, userID); got != tc.want {
|
||||||
|
t.Fatalf("stripUserMention(%q)=%q want=%q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBotMentioned(t *testing.T) {
|
||||||
|
ch := &MatrixChannel{
|
||||||
|
client: &mautrix.Client{
|
||||||
|
UserID: id.UserID("@picoclaw:matrix.org"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg event.MessageEventContent
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "mentions field",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "hello",
|
||||||
|
Mentions: &event.Mentions{
|
||||||
|
UserIDs: []id.UserID{id.UserID("@picoclaw:matrix.org")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full user id in body",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "@picoclaw:matrix.org hello",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "localpart with at sign",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "@picoclaw hello",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "localpart without at sign should not match",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "formatted mention href matrix.to plain",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "hello bot",
|
||||||
|
FormattedBody: `<a href="https://matrix.to/#/@picoclaw:matrix.org">PicoClaw</a> hello`,
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "formatted mention href matrix.to encoded",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "hello bot",
|
||||||
|
FormattedBody: `<a href="https://matrix.to/#/%40picoclaw%3Amatrix.org">PicoClaw</a> hello`,
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := ch.isBotMentioned(&tc.msg); got != tc.want {
|
||||||
|
t.Fatalf("%s: got=%v want=%v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomKindCache_ExpiresEntries(t *testing.T) {
|
||||||
|
cache := newRoomKindCache(4, 5*time.Second)
|
||||||
|
now := time.Unix(100, 0)
|
||||||
|
cache.set("!room:matrix.org", true, now)
|
||||||
|
|
||||||
|
if got, ok := cache.get("!room:matrix.org", now.Add(2*time.Second)); !ok || !got {
|
||||||
|
t.Fatalf("expected cached group room before ttl, got ok=%v group=%v", ok, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cache.get("!room:matrix.org", now.Add(6*time.Second)); ok {
|
||||||
|
t.Fatal("expected cache miss after ttl expiry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomKindCache_EvictsOldestWhenFull(t *testing.T) {
|
||||||
|
cache := newRoomKindCache(2, time.Minute)
|
||||||
|
now := time.Unix(200, 0)
|
||||||
|
|
||||||
|
cache.set("!room1:matrix.org", false, now)
|
||||||
|
cache.set("!room2:matrix.org", false, now.Add(1*time.Second))
|
||||||
|
cache.set("!room3:matrix.org", true, now.Add(2*time.Second))
|
||||||
|
|
||||||
|
if _, ok := cache.get("!room1:matrix.org", now.Add(2*time.Second)); ok {
|
||||||
|
t.Fatal("expected oldest cache entry to be evicted")
|
||||||
|
}
|
||||||
|
if got, ok := cache.get("!room2:matrix.org", now.Add(2*time.Second)); !ok || got {
|
||||||
|
t.Fatalf("expected room2 to remain and be direct, got ok=%v group=%v", ok, got)
|
||||||
|
}
|
||||||
|
if got, ok := cache.get("!room3:matrix.org", now.Add(2*time.Second)); !ok || !got {
|
||||||
|
t.Fatalf("expected room3 to remain and be group, got ok=%v group=%v", ok, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrixMediaTempDir(t *testing.T) {
|
||||||
|
dir, err := matrixMediaTempDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("matrixMediaTempDir failed: %v", err)
|
||||||
|
}
|
||||||
|
if filepath.Base(dir) != matrixMediaTempDirName {
|
||||||
|
t.Fatalf("unexpected media dir base: %q", filepath.Base(dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("media dir not created: %v", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Fatalf("expected directory, got mode=%v", info.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrixMediaExt(t *testing.T) {
|
||||||
|
if got := matrixMediaExt("photo.png", "", "image"); got != ".png" {
|
||||||
|
t.Fatalf("filename extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "image/webp", "image"); got != ".webp" {
|
||||||
|
t.Fatalf("content-type extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "", "image"); got != ".jpg" {
|
||||||
|
t.Fatalf("default image extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "", "audio"); got != ".ogg" {
|
||||||
|
t.Fatalf("default audio extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "", "video"); got != ".mp4" {
|
||||||
|
t.Fatalf("default video extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "", "file"); got != ".bin" {
|
||||||
|
t.Fatalf("default file extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
|
||||||
|
ch := &MatrixChannel{}
|
||||||
|
msg := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgImage,
|
||||||
|
Body: "test.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok for image fallback")
|
||||||
|
}
|
||||||
|
if content != "[image: test.png]" {
|
||||||
|
t.Fatalf("unexpected content: %q", content)
|
||||||
|
}
|
||||||
|
if len(mediaRefs) != 0 {
|
||||||
|
t.Fatalf("expected no media refs, got %d", len(mediaRefs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractInboundContent_AudioNoURLFallback(t *testing.T) {
|
||||||
|
ch := &MatrixChannel{}
|
||||||
|
msg := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgAudio,
|
||||||
|
FileName: "voice.ogg",
|
||||||
|
Body: "please transcribe",
|
||||||
|
}
|
||||||
|
|
||||||
|
content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok for audio fallback")
|
||||||
|
}
|
||||||
|
if content != "please transcribe\n[audio: voice.ogg]" {
|
||||||
|
t.Fatalf("unexpected content: %q", content)
|
||||||
|
}
|
||||||
|
if len(mediaRefs) != 0 {
|
||||||
|
t.Fatalf("expected no media refs, got %d", len(mediaRefs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrixOutboundMsgType(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
partType string
|
||||||
|
filename string
|
||||||
|
contentType string
|
||||||
|
want event.MessageType
|
||||||
|
}{
|
||||||
|
{name: "explicit image", partType: "image", want: event.MsgImage},
|
||||||
|
{name: "explicit audio", partType: "audio", want: event.MsgAudio},
|
||||||
|
{name: "mime fallback video", contentType: "video/mp4", want: event.MsgVideo},
|
||||||
|
{name: "extension fallback audio", filename: "voice.ogg", want: event.MsgAudio},
|
||||||
|
{name: "unknown defaults file", filename: "report.txt", want: event.MsgFile},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := matrixOutboundMsgType(tc.partType, tc.filename, tc.contentType); got != tc.want {
|
||||||
|
t.Fatalf("%s: got=%q want=%q", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrixOutboundContent(t *testing.T) {
|
||||||
|
content := matrixOutboundContent(
|
||||||
|
"please review",
|
||||||
|
"voice.ogg",
|
||||||
|
event.MsgAudio,
|
||||||
|
"audio/ogg",
|
||||||
|
1234,
|
||||||
|
id.ContentURIString("mxc://matrix.org/abc"),
|
||||||
|
)
|
||||||
|
if content.Body != "please review" {
|
||||||
|
t.Fatalf("unexpected body: %q", content.Body)
|
||||||
|
}
|
||||||
|
if content.FileName != "voice.ogg" {
|
||||||
|
t.Fatalf("unexpected filename: %q", content.FileName)
|
||||||
|
}
|
||||||
|
if content.Info == nil || content.Info.MimeType != "audio/ogg" {
|
||||||
|
t.Fatalf("unexpected content type: %+v", content.Info)
|
||||||
|
}
|
||||||
|
if content.Info == nil || content.Info.Size != 1234 {
|
||||||
|
t.Fatalf("unexpected size: %+v", content.Info)
|
||||||
|
}
|
||||||
|
|
||||||
|
noCaption := matrixOutboundContent(
|
||||||
|
"",
|
||||||
|
"image.png",
|
||||||
|
event.MsgImage,
|
||||||
|
"image/png",
|
||||||
|
0,
|
||||||
|
id.ContentURIString("mxc://matrix.org/def"),
|
||||||
|
)
|
||||||
|
if noCaption.Body != "image.png" {
|
||||||
|
t.Fatalf("unexpected fallback body: %q", noCaption.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
116
pkg/channels/telegram/command_registration.go
Normal file
116
pkg/channels/telegram/command_registration.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var commandRegistrationBackoff = []time.Duration{
|
||||||
|
5 * time.Second,
|
||||||
|
15 * time.Second,
|
||||||
|
60 * time.Second,
|
||||||
|
5 * time.Minute,
|
||||||
|
10 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandRegistrationDelay(attempt int) time.Duration {
|
||||||
|
if len(commandRegistrationBackoff) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
base := commandRegistrationBackoff[min(attempt, len(commandRegistrationBackoff)-1)]
|
||||||
|
// Full jitter in [0.5, 1.0) to avoid synchronized retries across instances.
|
||||||
|
return time.Duration(float64(base) * (0.5 + rand.Float64()*0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterCommands registers bot commands on Telegram platform.
|
||||||
|
func (c *TelegramChannel) RegisterCommands(ctx context.Context, defs []commands.Definition) error {
|
||||||
|
botCommands := make([]telego.BotCommand, 0, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Name == "" || def.Description == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
botCommands = append(botCommands, telego.BotCommand{
|
||||||
|
Command: def.Name,
|
||||||
|
Description: def.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{})
|
||||||
|
if err != nil {
|
||||||
|
// If we can't read current commands, fall through to set them.
|
||||||
|
logger.WarnCF("telegram", "Failed to get current commands, will set unconditionally",
|
||||||
|
map[string]any{"error": err.Error()})
|
||||||
|
} else if slices.Equal(current, botCommands) {
|
||||||
|
logger.DebugCF("telegram", "Bot commands are up to date", nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
|
||||||
|
Commands: botCommands,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []commands.Definition) {
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
register := c.registerFunc
|
||||||
|
if register == nil {
|
||||||
|
register = c.RegisterCommands
|
||||||
|
}
|
||||||
|
|
||||||
|
regCtx, cancel := context.WithCancel(ctx)
|
||||||
|
c.commandRegCancel = cancel
|
||||||
|
|
||||||
|
// Registration runs asynchronously so Telegram message intake is never blocked
|
||||||
|
// by temporary upstream API failures. Retry stops on success or channel shutdown.
|
||||||
|
go func() {
|
||||||
|
attempt := 0
|
||||||
|
timer := time.NewTimer(0)
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
err := register(regCtx, defs)
|
||||||
|
if err == nil {
|
||||||
|
logger.InfoCF("telegram", "Telegram commands registered", map[string]any{
|
||||||
|
"count": len(defs),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delay := commandRegistrationDelay(attempt)
|
||||||
|
logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"retry_after": delay.String(),
|
||||||
|
})
|
||||||
|
attempt++
|
||||||
|
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Reset(delay)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-regCtx.Done():
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
96
pkg/channels/telegram/command_registration_test.go
Normal file
96
pkg/channels/telegram/command_registration_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartCommandRegistration_DoesNotBlock(t *testing.T) {
|
||||||
|
ch := &TelegramChannel{}
|
||||||
|
started := make(chan struct{}, 1)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ch.registerFunc = func(context.Context, []commands.Definition) error {
|
||||||
|
started <- struct{}{}
|
||||||
|
return errors.New("temporary failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help"}})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("registration did not start asynchronously")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) {
|
||||||
|
ch := &TelegramChannel{}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
origBackoff := commandRegistrationBackoff
|
||||||
|
commandRegistrationBackoff = []time.Duration{5 * time.Millisecond}
|
||||||
|
defer func() { commandRegistrationBackoff = origBackoff }()
|
||||||
|
|
||||||
|
var attempts atomic.Int32
|
||||||
|
ch.registerFunc = func(context.Context, []commands.Definition) error {
|
||||||
|
n := attempts.Add(1)
|
||||||
|
if n < 3 {
|
||||||
|
return errors.New("temporary failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(250 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if attempts.Load() >= 3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if attempts.Load() < 3 {
|
||||||
|
t.Fatalf("expected at least 3 attempts, got %d", attempts.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
stable := attempts.Load()
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if attempts.Load() != stable {
|
||||||
|
t.Fatalf("expected retries to stop after success, got %d -> %d", stable, attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) {
|
||||||
|
ch := &TelegramChannel{}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
origBackoff := commandRegistrationBackoff
|
||||||
|
commandRegistrationBackoff = []time.Duration{5 * time.Millisecond}
|
||||||
|
defer func() { commandRegistrationBackoff = origBackoff }()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var attempts atomic.Int32
|
||||||
|
ch.registerFunc = func(context.Context, []commands.Definition) error {
|
||||||
|
attempts.Add(1)
|
||||||
|
return errors.New("always fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.startCommandRegistration(ctx, []commands.Definition{{Name: "help", Description: "Help"}})
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
time.Sleep(20 * time.Millisecond) // allow in-flight attempt to settle
|
||||||
|
stable := attempts.Load()
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
if attempts.Load() != stable {
|
||||||
|
t.Fatalf("expected retries to quiesce after cancel, got %d -> %d", stable, attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -18,6 +17,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -40,13 +40,15 @@ var (
|
||||||
|
|
||||||
type TelegramChannel struct {
|
type TelegramChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
bot *telego.Bot
|
bot *telego.Bot
|
||||||
bh *th.BotHandler
|
bh *th.BotHandler
|
||||||
commands TelegramCommander
|
config *config.Config
|
||||||
config *config.Config
|
chatIDs map[string]int64
|
||||||
chatIDs map[string]int64
|
ctx context.Context
|
||||||
ctx context.Context
|
cancel context.CancelFunc
|
||||||
cancel context.CancelFunc
|
|
||||||
|
registerFunc func(context.Context, []commands.Definition) error
|
||||||
|
commandRegCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||||
|
|
@ -86,14 +88,13 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
telegramCfg,
|
telegramCfg,
|
||||||
bus,
|
bus,
|
||||||
telegramCfg.AllowFrom,
|
telegramCfg.AllowFrom,
|
||||||
channels.WithMaxMessageLength(4096),
|
channels.WithMaxMessageLength(4000),
|
||||||
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
|
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
|
||||||
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
|
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
|
||||||
)
|
)
|
||||||
|
|
||||||
return &TelegramChannel{
|
return &TelegramChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
commands: NewTelegramCommands(bot, cfg),
|
|
||||||
bot: bot,
|
bot: bot,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
chatIDs: make(map[string]int64),
|
chatIDs: make(map[string]int64),
|
||||||
|
|
@ -105,12 +106,6 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
if err := c.initBotCommands(c.ctx); err != nil {
|
|
||||||
logger.WarnCF("telegram", "Failed to initialize bot commands", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
|
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
|
||||||
Timeout: 30,
|
Timeout: 30,
|
||||||
})
|
})
|
||||||
|
|
@ -126,21 +121,6 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
c.bh = bh
|
c.bh = bh
|
||||||
|
|
||||||
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.Help(ctx, message)
|
|
||||||
}, th.CommandEqual("help"))
|
|
||||||
|
|
||||||
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 {
|
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||||
return c.handleMessage(ctx, &message)
|
return c.handleMessage(ctx, &message)
|
||||||
}, th.AnyMessage())
|
}, th.AnyMessage())
|
||||||
|
|
@ -150,6 +130,8 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
"username": c.bot.Username(),
|
"username": c.bot.Username(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions())
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err = bh.Start(); err != nil {
|
if err = bh.Start(); err != nil {
|
||||||
logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
|
logger.ErrorCF("telegram", "Bot handler failed", map[string]any{
|
||||||
|
|
@ -174,50 +156,8 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||||
if c.cancel != nil {
|
if c.cancel != nil {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
if c.commandRegCancel != nil {
|
||||||
return nil
|
c.commandRegCancel()
|
||||||
}
|
|
||||||
|
|
||||||
func (c *TelegramChannel) initBotCommands(ctx context.Context) error {
|
|
||||||
currentCommands, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{
|
|
||||||
Scope: tu.ScopeDefault(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get commands: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
commands := []telego.BotCommand{
|
|
||||||
{
|
|
||||||
Command: "start",
|
|
||||||
Description: "Start the bot",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Command: "help",
|
|
||||||
Description: "Show a help message",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Command: "show",
|
|
||||||
Description: "Show current configuration",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Command: "list",
|
|
||||||
Description: "List available options",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setting commands on each start will hit the rate limit very quickly, that's why we check if an update is needed
|
|
||||||
if !slices.Equal(currentCommands, commands) {
|
|
||||||
logger.InfoC("telegram", "Updating bot commands")
|
|
||||||
|
|
||||||
err = c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
|
|
||||||
Commands: commands,
|
|
||||||
Scope: tu.ScopeDefault(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set commands: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.DebugC("telegram", "Bot commands are up to date")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -233,22 +173,57 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlContent := markdownToTelegramHTML(msg.Content)
|
if msg.Content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Typing/placeholder handled by Manager.preSend — just send the message
|
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
|
||||||
|
// so msg.Content is guaranteed to be within that limit. We still need to
|
||||||
|
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
|
||||||
|
queue := []string{msg.Content}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
chunk := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
|
||||||
|
htmlContent := markdownToTelegramHTML(chunk)
|
||||||
|
|
||||||
|
if len([]rune(htmlContent)) > 4096 {
|
||||||
|
ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent)))
|
||||||
|
smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
|
||||||
|
if smallerLen < 100 {
|
||||||
|
smallerLen = 100
|
||||||
|
}
|
||||||
|
// Push sub-chunks back to the front of the queue for
|
||||||
|
// re-validation instead of sending them blindly.
|
||||||
|
subChunks := channels.SplitMessage(chunk, smallerLen)
|
||||||
|
queue = append(subChunks, queue...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendHTMLChunk sends a single HTML message, falling back to the original
|
||||||
|
// markdown as plain text on parse failure so users never see raw HTML tags.
|
||||||
|
func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error {
|
||||||
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
|
||||||
tgMsg.ParseMode = telego.ModeHTML
|
tgMsg.ParseMode = telego.ModeHTML
|
||||||
|
|
||||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||||
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
|
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
tgMsg.Text = mdFallback
|
||||||
tgMsg.ParseMode = ""
|
tgMsg.ParseMode = ""
|
||||||
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
|
||||||
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -721,34 +696,34 @@ func escapeHTML(text string) string {
|
||||||
|
|
||||||
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
// isBotMentioned checks if the bot is mentioned in the message via entities.
|
||||||
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
||||||
botUsername := c.bot.Username()
|
text, entities := telegramEntityTextAndList(message)
|
||||||
if botUsername == "" {
|
if text == "" || len(entities) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
entities := message.Entities
|
botUsername := ""
|
||||||
if entities == nil {
|
if c.bot != nil {
|
||||||
entities = message.CaptionEntities
|
botUsername = c.bot.Username()
|
||||||
}
|
}
|
||||||
|
runes := []rune(text)
|
||||||
|
|
||||||
for _, entity := range entities {
|
for _, entity := range entities {
|
||||||
if entity.Type == "mention" {
|
entityText, ok := telegramEntityText(runes, entity)
|
||||||
// Extract the mention text from the message
|
if !ok {
|
||||||
text := message.Text
|
continue
|
||||||
if text == "" {
|
|
||||||
text = message.Caption
|
|
||||||
}
|
|
||||||
runes := []rune(text)
|
|
||||||
end := entity.Offset + entity.Length
|
|
||||||
if end <= len(runes) {
|
|
||||||
mention := string(runes[entity.Offset:end])
|
|
||||||
if strings.EqualFold(mention, "@"+botUsername) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if entity.Type == "text_mention" && entity.User != nil {
|
|
||||||
if entity.User.Username == botUsername {
|
switch entity.Type {
|
||||||
|
case telego.EntityTypeMention:
|
||||||
|
if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case telego.EntityTypeTextMention:
|
||||||
|
if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case telego.EntityTypeBotCommand:
|
||||||
|
if isBotCommandEntityForThisBot(entityText, botUsername) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -756,6 +731,46 @@ func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func telegramEntityTextAndList(message *telego.Message) (string, []telego.MessageEntity) {
|
||||||
|
if message.Text != "" {
|
||||||
|
return message.Text, message.Entities
|
||||||
|
}
|
||||||
|
return message.Caption, message.CaptionEntities
|
||||||
|
}
|
||||||
|
|
||||||
|
func telegramEntityText(runes []rune, entity telego.MessageEntity) (string, bool) {
|
||||||
|
if entity.Offset < 0 || entity.Length <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
end := entity.Offset + entity.Length
|
||||||
|
if entity.Offset >= len(runes) || end > len(runes) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(runes[entity.Offset:end]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBotCommandEntityForThisBot(entityText, botUsername string) bool {
|
||||||
|
if !strings.HasPrefix(entityText, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
command := strings.TrimPrefix(entityText, "/")
|
||||||
|
if command == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
at := strings.IndexRune(command, '@')
|
||||||
|
if at == -1 {
|
||||||
|
// A bare /command delivered to this bot is intended for this bot.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mentionUsername := command[at+1:]
|
||||||
|
if mentionUsername == "" || botUsername == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(mentionUsername, botUsername)
|
||||||
|
}
|
||||||
|
|
||||||
// stripBotMention removes the @bot mention from the content.
|
// stripBotMention removes the @bot mention from the content.
|
||||||
func (c *TelegramChannel) stripBotMention(content string) string {
|
func (c *TelegramChannel) stripBotMention(content string) string {
|
||||||
botUsername := c.bot.Username()
|
botUsername := c.bot.Username()
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
package telegram
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TelegramCommander interface {
|
|
||||||
Help(ctx context.Context, message telego.Message) error
|
|
||||||
Start(ctx context.Context, message telego.Message) error
|
|
||||||
Show(ctx context.Context, message telego.Message) error
|
|
||||||
List(ctx context.Context, message telego.Message) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmd struct {
|
|
||||||
bot *telego.Bot
|
|
||||||
config *config.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander {
|
|
||||||
return &cmd{
|
|
||||||
bot: bot,
|
|
||||||
config: cfg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandArgs(text string) string {
|
|
||||||
parts := strings.SplitN(text, " ", 2)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) Help(ctx context.Context, message telego.Message) error {
|
|
||||||
msg := `/start - Start the bot
|
|
||||||
/help - Show this help message
|
|
||||||
/show [model|channel] - Show current configuration
|
|
||||||
/list [models|channels] - List available options
|
|
||||||
`
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: msg,
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) Start(ctx context.Context, message telego.Message) error {
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: "Hello! I am PicoClaw 🦞",
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) Show(ctx context.Context, message telego.Message) error {
|
|
||||||
args := commandArgs(message.Text)
|
|
||||||
if args == "" {
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: "Usage: /show [model|channel]",
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response string
|
|
||||||
switch args {
|
|
||||||
case "model":
|
|
||||||
response = fmt.Sprintf("Current Model: %s (Provider: %s)",
|
|
||||||
c.config.Agents.Defaults.GetModelName(),
|
|
||||||
c.config.Agents.Defaults.Provider)
|
|
||||||
case "channel":
|
|
||||||
response = "Current Channel: telegram"
|
|
||||||
default:
|
|
||||||
response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: response,
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmd) List(ctx context.Context, message telego.Message) error {
|
|
||||||
args := commandArgs(message.Text)
|
|
||||||
if args == "" {
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: "Usage: /list [models|channels]",
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response string
|
|
||||||
switch args {
|
|
||||||
case "models":
|
|
||||||
provider := c.config.Agents.Defaults.Provider
|
|
||||||
if provider == "" {
|
|
||||||
provider = "configured default"
|
|
||||||
}
|
|
||||||
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
|
||||||
c.config.Agents.Defaults.GetModelName(), provider)
|
|
||||||
|
|
||||||
case "channels":
|
|
||||||
var enabled []string
|
|
||||||
if c.config.Channels.Telegram.Enabled {
|
|
||||||
enabled = append(enabled, "telegram")
|
|
||||||
}
|
|
||||||
if c.config.Channels.WhatsApp.Enabled {
|
|
||||||
enabled = append(enabled, "whatsapp")
|
|
||||||
}
|
|
||||||
if c.config.Channels.Feishu.Enabled {
|
|
||||||
enabled = append(enabled, "feishu")
|
|
||||||
}
|
|
||||||
if c.config.Channels.Discord.Enabled {
|
|
||||||
enabled = append(enabled, "discord")
|
|
||||||
}
|
|
||||||
if c.config.Channels.Slack.Enabled {
|
|
||||||
enabled = append(enabled, "slack")
|
|
||||||
}
|
|
||||||
response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))
|
|
||||||
|
|
||||||
default:
|
|
||||||
response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
|
|
||||||
ChatID: telego.ChatID{ID: message.Chat.ID},
|
|
||||||
Text: response,
|
|
||||||
ReplyParameters: &telego.ReplyParameters{
|
|
||||||
MessageID: message.MessageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
52
pkg/channels/telegram/telegram_dispatch_test.go
Normal file
52
pkg/channels/telegram/telegram_dispatch_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "/new",
|
||||||
|
MessageID: 9,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: 123,
|
||||||
|
Type: "private",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 42,
|
||||||
|
FirstName: "Alice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ch.handleMessage(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Channel != "telegram" {
|
||||||
|
t.Fatalf("channel=%q", inbound.Channel)
|
||||||
|
}
|
||||||
|
if inbound.Content != "/new" {
|
||||||
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
pkg/channels/telegram/telegram_group_command_filter_test.go
Normal file
147
pkg/channels/telegram/telegram_group_command_filter_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
ta "github.com/mymmrac/telego/telegoapi"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type getMeCaller struct {
|
||||||
|
username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c getMeCaller) Call(_ context.Context, url string, _ *ta.RequestData) (*ta.Response, error) {
|
||||||
|
if strings.HasSuffix(url, "/getMe") {
|
||||||
|
result := fmt.Sprintf(`{"id":1,"is_bot":true,"first_name":"bot","username":%q}`, c.username)
|
||||||
|
return &ta.Response{Ok: true, Result: []byte(result)}, nil
|
||||||
|
}
|
||||||
|
return &ta.Response{Ok: true, Result: []byte("true")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestTelegramBot(t *testing.T, username string) *telego.Bot {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
token := "123456:" + strings.Repeat("a", 35)
|
||||||
|
bot, err := telego.NewBot(token,
|
||||||
|
telego.WithAPICaller(getMeCaller{username: username}),
|
||||||
|
telego.WithDiscardLogger(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBot error: %v", err)
|
||||||
|
}
|
||||||
|
return bot
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGroupMentionOnlyChannel(t *testing.T, botUsername string) (*TelegramChannel, *bus.MessageBus) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil,
|
||||||
|
channels.WithGroupTrigger(config.GroupTriggerConfig{MentionOnly: true}),
|
||||||
|
),
|
||||||
|
bot: newTestTelegramBot(t, botUsername),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
return ch, messageBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
wantForwarded bool
|
||||||
|
wantContent string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "command with bot username",
|
||||||
|
text: "/new@testbot",
|
||||||
|
wantForwarded: true,
|
||||||
|
wantContent: "/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare command",
|
||||||
|
text: "/new",
|
||||||
|
wantForwarded: true,
|
||||||
|
wantContent: "/new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command for another bot",
|
||||||
|
text: "/new@otherbot",
|
||||||
|
wantForwarded: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ch, messageBus := newGroupMentionOnlyChannel(t, "testbot")
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: tc.text,
|
||||||
|
Entities: []telego.MessageEntity{{
|
||||||
|
Type: telego.EntityTypeBotCommand,
|
||||||
|
Offset: 0,
|
||||||
|
Length: len([]rune(tc.text)),
|
||||||
|
}},
|
||||||
|
MessageID: 42,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: 123,
|
||||||
|
Type: "group",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 7,
|
||||||
|
FirstName: "Alice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ch.handleMessage(context.Background(), msg); err != nil {
|
||||||
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if tc.wantForwarded {
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Content != tc.wantContent {
|
||||||
|
t.Fatalf("content=%q want=%q", inbound.Content, tc.wantContent)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected message to be filtered, got content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBotMentioned_MentionEntityUnaffected(t *testing.T) {
|
||||||
|
ch, _ := newGroupMentionOnlyChannel(t, "testbot")
|
||||||
|
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "@testbot hello",
|
||||||
|
Entities: []telego.MessageEntity{{
|
||||||
|
Type: telego.EntityTypeMention,
|
||||||
|
Offset: 0,
|
||||||
|
Length: len("@testbot"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ch.isBotMentioned(msg) {
|
||||||
|
t.Fatal("expected mention entity to be treated as bot mention")
|
||||||
|
}
|
||||||
|
}
|
||||||
273
pkg/channels/telegram/telegram_test.go
Normal file
273
pkg/channels/telegram/telegram_test.go
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mymmrac/telego"
|
||||||
|
ta "github.com/mymmrac/telego/telegoapi"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
|
||||||
|
|
||||||
|
// stubCaller implements ta.Caller for testing.
|
||||||
|
type stubCaller struct {
|
||||||
|
calls []stubCall
|
||||||
|
callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubCall struct {
|
||||||
|
URL string
|
||||||
|
Data *ta.RequestData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
s.calls = append(s.calls, stubCall{URL: url, Data: data})
|
||||||
|
return s.callFn(ctx, url, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubConstructor implements ta.RequestConstructor for testing.
|
||||||
|
type stubConstructor struct{}
|
||||||
|
|
||||||
|
func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
|
||||||
|
return &ta.RequestData{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubConstructor) MultipartRequest(
|
||||||
|
parameters map[string]string,
|
||||||
|
files map[string]ta.NamedReader,
|
||||||
|
) (*ta.RequestData, error) {
|
||||||
|
return &ta.RequestData{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
|
||||||
|
func successResponse(t *testing.T) *ta.Response {
|
||||||
|
t.Helper()
|
||||||
|
msg := &telego.Message{MessageID: 1}
|
||||||
|
b, err := json.Marshal(msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return &ta.Response{Ok: true, Result: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
|
||||||
|
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
bot, err := telego.NewBot(testToken,
|
||||||
|
telego.WithAPICaller(caller),
|
||||||
|
telego.WithRequestConstructor(&stubConstructor{}),
|
||||||
|
telego.WithDiscardLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
base := channels.NewBaseChannel("telegram", nil, nil, nil,
|
||||||
|
channels.WithMaxMessageLength(4000),
|
||||||
|
)
|
||||||
|
base.SetRunning(true)
|
||||||
|
|
||||||
|
return &TelegramChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
bot: bot,
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_EmptyContent(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
t.Fatal("SendMessage should not be called for empty content")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, caller.calls, "no API calls should be made for empty content")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_ShortMessage_SingleCall(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "Hello, world!",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_LongMessage_SingleCall(t *testing.T) {
|
||||||
|
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
|
||||||
|
// they reach Send(). A message at exactly 4000 chars should go through
|
||||||
|
// as a single SendMessage call (no re-split needed since HTML expansion
|
||||||
|
// won't exceed 4096 for plain text).
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
longContent := strings.Repeat("a", 4000)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: longContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_HTMLFallback_PerChunk(t *testing.T) {
|
||||||
|
callCount := 0
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
callCount++
|
||||||
|
// Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback)
|
||||||
|
if callCount%2 == 1 {
|
||||||
|
return nil, errors.New("Bad Request: can't parse entities")
|
||||||
|
}
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "Hello **world**",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls
|
||||||
|
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_HTMLFallback_BothFail(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return nil, errors.New("send failed")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "Hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary")
|
||||||
|
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
|
||||||
|
// With a long message that gets split into 2 chunks, if both HTML and
|
||||||
|
// plain text fail on the first chunk, Send should return early.
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return nil, errors.New("send failed")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
longContent := strings.Repeat("x", 4001)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: longContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
// Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk.
|
||||||
|
assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
return successResponse(t), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
// Create markdown whose length is <= 4000 but whose HTML expansion is much longer.
|
||||||
|
// "**a** " (6 chars) becomes "<b>a</b> " (9 chars) in HTML, so repeating it many times
|
||||||
|
// yields HTML that exceeds Telegram's limit while markdown stays within it.
|
||||||
|
markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars
|
||||||
|
assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size")
|
||||||
|
|
||||||
|
htmlExpanded := markdownToTelegramHTML(markdownContent)
|
||||||
|
assert.Greater(
|
||||||
|
t, len([]rune(htmlExpanded)), 4096,
|
||||||
|
"HTML expansion must exceed Telegram limit for this test to be meaningful",
|
||||||
|
)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: markdownContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Greater(
|
||||||
|
t, len(caller.calls), 1,
|
||||||
|
"markdown-short but HTML-long message should be split into multiple SendMessage calls",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_NotRunning(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
t.Fatal("should not be called")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
ch.SetRunning(false)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "12345",
|
||||||
|
Content: "Hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, channels.ErrNotRunning)
|
||||||
|
assert.Empty(t, caller.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_InvalidChatID(t *testing.T) {
|
||||||
|
caller := &stubCaller{
|
||||||
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
t.Fatal("should not be called")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ch := newTestChannel(t, caller)
|
||||||
|
|
||||||
|
err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
ChatID: "not-a-number",
|
||||||
|
Content: "Hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
|
||||||
|
assert.Empty(t, caller.calls)
|
||||||
|
}
|
||||||
|
|
@ -793,7 +793,10 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err)
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case resp.StatusCode == http.StatusTooManyRequests:
|
case resp.StatusCode == http.StatusTooManyRequests:
|
||||||
return fmt.Errorf("response_url rate limited (%d): %s: %w",
|
return fmt.Errorf("response_url rate limited (%d): %s: %w",
|
||||||
|
|
|
||||||
|
|
@ -321,8 +321,17 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, readErr := io.ReadAll(resp.Body)
|
||||||
return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody)))
|
if readErr != nil {
|
||||||
|
return "", channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("reading wecom upload error response: %w", readErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "", channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("wecom upload error: %s", string(respBody)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
|
|
@ -371,8 +380,17 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, readErr := io.ReadAll(resp.Body)
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody)))
|
if readErr != nil {
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("reading wecom_app error response: %w", readErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("wecom_app API error: %s", string(respBody)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
|
|
||||||
|
|
@ -453,8 +453,17 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body)))
|
if readErr != nil {
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("reading webhook error response: %w", readErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return channels.ClassifySendError(
|
||||||
|
resp.StatusCode,
|
||||||
|
fmt.Errorf("webhook API error: %s", string(body)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
|
|
||||||
41
pkg/channels/whatsapp/whatsapp_command_test.go
Normal file
41
pkg/channels/whatsapp/whatsapp_command_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &WhatsAppChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.handleIncomingMessage(map[string]any{
|
||||||
|
"type": "message",
|
||||||
|
"id": "mid1",
|
||||||
|
"from": "user1",
|
||||||
|
"chat": "chat1",
|
||||||
|
"content": "/help",
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Channel != "whatsapp" {
|
||||||
|
t.Fatalf("channel=%q", inbound.Channel)
|
||||||
|
}
|
||||||
|
if inbound.Content != "/help" {
|
||||||
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
pkg/channels/whatsapp_native/whatsapp_command_test.go
Normal file
56
pkg/channels/whatsapp_native/whatsapp_command_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
//go:build whatsapp_native
|
||||||
|
|
||||||
|
package whatsapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||||
|
"go.mau.fi/whatsmeow/types"
|
||||||
|
"go.mau.fi/whatsmeow/types/events"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &WhatsAppNativeChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil),
|
||||||
|
runCtx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := &events.Message{
|
||||||
|
Info: types.MessageInfo{
|
||||||
|
MessageSource: types.MessageSource{
|
||||||
|
Sender: types.NewJID("1001", types.DefaultUserServer),
|
||||||
|
Chat: types.NewJID("1001", types.DefaultUserServer),
|
||||||
|
},
|
||||||
|
ID: "mid1",
|
||||||
|
PushName: "Alice",
|
||||||
|
},
|
||||||
|
Message: &waE2E.Message{
|
||||||
|
Conversation: proto.String("/new"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.handleIncoming(evt)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
}
|
||||||
|
if inbound.Channel != "whatsapp_native" {
|
||||||
|
t.Fatalf("channel=%q", inbound.Channel)
|
||||||
|
}
|
||||||
|
if inbound.Content != "/new" {
|
||||||
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pkg/commands/builtin.go
Normal file
16
pkg/commands/builtin.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
// BuiltinDefinitions returns all built-in command definitions.
|
||||||
|
// Each command group is defined in its own cmd_*.go file.
|
||||||
|
// Definitions are stateless — runtime dependencies are provided
|
||||||
|
// via the Runtime parameter passed to handlers at execution time.
|
||||||
|
func BuiltinDefinitions() []Definition {
|
||||||
|
return []Definition{
|
||||||
|
startCommand(),
|
||||||
|
helpCommand(),
|
||||||
|
showCommand(),
|
||||||
|
listCommand(),
|
||||||
|
switchCommand(),
|
||||||
|
checkCommand(),
|
||||||
|
}
|
||||||
|
}
|
||||||
145
pkg/commands/builtin_test.go
Normal file
145
pkg/commands/builtin_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func findDefinitionByName(t *testing.T, defs []Definition, name string) Definition {
|
||||||
|
t.Helper()
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Name == name {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("missing /%s definition", name)
|
||||||
|
return Definition{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
helpDef := findDefinitionByName(t, defs, "help")
|
||||||
|
if helpDef.Handler == nil {
|
||||||
|
t.Fatalf("/help handler should not be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
err := helpDef.Handler(context.Background(), Request{
|
||||||
|
Text: "/help",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("/help handler error: %v", err)
|
||||||
|
}
|
||||||
|
// Now uses auto-generated EffectiveUsage which includes agents
|
||||||
|
if !strings.Contains(reply, "/show [model|channel|agents]") {
|
||||||
|
t.Fatalf("/help reply missing /show usage, got %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "/list [models|channels|agents]") {
|
||||||
|
t.Fatalf("/help reply missing /list usage, got %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
cases := []string{"telegram", "whatsapp"}
|
||||||
|
for _, channel := range cases {
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: channel,
|
||||||
|
Text: "/show channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("/show channel on %s: outcome=%v, want=%v", channel, res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
want := "Current Channel: " + channel
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("/show channel reply=%q, want=%q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinListChannels_UsesGetEnabledChannels(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
GetEnabledChannels: func() []string {
|
||||||
|
return []string{"telegram", "slack"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
ex := NewExecutor(NewRegistry(defs), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/list channels",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("/list channels: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "telegram") || !strings.Contains(reply, "slack") {
|
||||||
|
t.Fatalf("/list channels reply=%q, want telegram and slack", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinShowAgents_RestoresOldBehavior(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
ListAgentIDs: func() []string {
|
||||||
|
return []string{"default", "coder"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
ex := NewExecutor(NewRegistry(defs), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/show agents",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("/show agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") {
|
||||||
|
t.Fatalf("/show agents reply=%q, want agent IDs", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
ListAgentIDs: func() []string {
|
||||||
|
return []string{"default", "coder"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defs := BuiltinDefinitions()
|
||||||
|
ex := NewExecutor(NewRegistry(defs), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/list agents",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("/list agents: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "default") || !strings.Contains(reply, "coder") {
|
||||||
|
t.Fatalf("/list agents reply=%q, want agent IDs", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
pkg/commands/cmd_check.go
Normal file
33
pkg/commands/cmd_check.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "check",
|
||||||
|
Description: "Check channel availability",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{
|
||||||
|
Name: "channel",
|
||||||
|
Description: "Check if a channel is available",
|
||||||
|
ArgsUsage: "<name>",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.SwitchChannel == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
value := nthToken(req.Text, 2)
|
||||||
|
if value == "" {
|
||||||
|
return req.Reply("Usage: /check channel <name>")
|
||||||
|
}
|
||||||
|
if err := rt.SwitchChannel(value); err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Channel '%s' is available and enabled", value))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
44
pkg/commands/cmd_help.go
Normal file
44
pkg/commands/cmd_help.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func helpCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "help",
|
||||||
|
Description: "Show this help message",
|
||||||
|
Usage: "/help",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
var defs []Definition
|
||||||
|
if rt != nil && rt.ListDefinitions != nil {
|
||||||
|
defs = rt.ListDefinitions()
|
||||||
|
} else {
|
||||||
|
defs = BuiltinDefinitions()
|
||||||
|
}
|
||||||
|
return req.Reply(formatHelpMessage(defs))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatHelpMessage(defs []Definition) string {
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return "No commands available."
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]string, 0, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
usage := def.EffectiveUsage()
|
||||||
|
if usage == "" {
|
||||||
|
usage = "/" + def.Name
|
||||||
|
}
|
||||||
|
desc := def.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = "No description"
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%s - %s", usage, desc))
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
52
pkg/commands/cmd_list.go
Normal file
52
pkg/commands/cmd_list.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "list",
|
||||||
|
Description: "List available options",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{
|
||||||
|
Name: "models",
|
||||||
|
Description: "Configured models",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.GetModelInfo == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
name, provider := rt.GetModelInfo()
|
||||||
|
if provider == "" {
|
||||||
|
provider = "configured default"
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf(
|
||||||
|
"Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
|
||||||
|
name, provider,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "channels",
|
||||||
|
Description: "Enabled channels",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.GetEnabledChannels == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
enabled := rt.GetEnabledChannels()
|
||||||
|
if len(enabled) == 0 {
|
||||||
|
return req.Reply("No channels enabled")
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "agents",
|
||||||
|
Description: "Registered agents",
|
||||||
|
Handler: agentsHandler(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
38
pkg/commands/cmd_show.go
Normal file
38
pkg/commands/cmd_show.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func showCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "show",
|
||||||
|
Description: "Show current configuration",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{
|
||||||
|
Name: "model",
|
||||||
|
Description: "Current model and provider",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.GetModelInfo == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
name, provider := rt.GetModelInfo()
|
||||||
|
return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "channel",
|
||||||
|
Description: "Current channel",
|
||||||
|
Handler: func(_ context.Context, req Request, _ *Runtime) error {
|
||||||
|
return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "agents",
|
||||||
|
Description: "Registered agents",
|
||||||
|
Handler: agentsHandler(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
14
pkg/commands/cmd_start.go
Normal file
14
pkg/commands/cmd_start.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func startCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "start",
|
||||||
|
Description: "Start the bot",
|
||||||
|
Usage: "/start",
|
||||||
|
Handler: func(_ context.Context, req Request, _ *Runtime) error {
|
||||||
|
return req.Reply("Hello! I am PicoClaw 🦞")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
42
pkg/commands/cmd_switch.go
Normal file
42
pkg/commands/cmd_switch.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func switchCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "switch",
|
||||||
|
Description: "Switch model",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{
|
||||||
|
Name: "model",
|
||||||
|
Description: "Switch to a different model",
|
||||||
|
ArgsUsage: "to <name>",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.SwitchModel == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
// Parse: /switch model to <value>
|
||||||
|
value := nthToken(req.Text, 3) // tokens: [/switch, model, to, <value>]
|
||||||
|
if nthToken(req.Text, 2) != "to" || value == "" {
|
||||||
|
return req.Reply("Usage: /switch model to <name>")
|
||||||
|
}
|
||||||
|
oldModel, err := rt.SwitchModel(value)
|
||||||
|
if err != nil {
|
||||||
|
return req.Reply(err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Switched model from %s to %s", oldModel, value))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "channel",
|
||||||
|
Description: "Moved to /check channel",
|
||||||
|
Handler: func(_ context.Context, req Request, _ *Runtime) error {
|
||||||
|
return req.Reply("This command has moved. Please use: /check channel <name>")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
279
pkg/commands/cmd_switch_test.go
Normal file
279
pkg/commands/cmd_switch_test.go
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSwitchModel_Success(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "old-model", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model to gpt-4",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
want := "Switched model from old-model to gpt-4"
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("reply=%q, want=%q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchModel_MissingToKeyword(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "old", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model gpt-4",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /switch model to <name>" {
|
||||||
|
t.Fatalf("reply=%q, want usage message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchModel_MissingValue(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "old", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model to",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /switch model to <name>" {
|
||||||
|
t.Fatalf("reply=%q, want usage message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchModel_Error(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "", fmt.Errorf("model not found")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model to bad-model",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "model not found" {
|
||||||
|
t.Fatalf("reply=%q, want error message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchModel_NilDep(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch model to gpt-4",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Command unavailable in current context." {
|
||||||
|
t.Fatalf("reply=%q, want unavailable message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchChannel_Redirect(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch channel to telegram",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
want := "This command has moved. Please use: /check channel <name>"
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("reply=%q, want=%q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckChannel_Success(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchChannel: func(value string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/check channel telegram",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
want := "Channel 'telegram' is available and enabled"
|
||||||
|
if reply != want {
|
||||||
|
t.Fatalf("reply=%q, want=%q", reply, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckChannel_Error(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchChannel: func(value string) error {
|
||||||
|
return fmt.Errorf("channel '%s' not found", value)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/check channel unknown",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "channel 'unknown' not found" {
|
||||||
|
t.Fatalf("reply=%q, want error message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckChannel_NilDep(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/check channel telegram",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Command unavailable in current context." {
|
||||||
|
t.Fatalf("reply=%q, want unavailable message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckChannel_MissingValue(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchChannel: func(value string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/check channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /check channel <name>" {
|
||||||
|
t.Fatalf("reply=%q, want usage message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitch_BangPrefix(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
SwitchModel: func(value string) (string, error) {
|
||||||
|
return "old", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "!switch model to gpt-4",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("! prefix: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Switched model from old to gpt-4" {
|
||||||
|
t.Fatalf("! prefix: reply=%q, want success message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitch_NoSubCommand(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/switch",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
// Should get usage message from executor's sub-command routing
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("expected usage reply for bare /switch")
|
||||||
|
}
|
||||||
|
}
|
||||||
48
pkg/commands/definition.go
Normal file
48
pkg/commands/definition.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubCommand defines a single sub-command within a parent command.
|
||||||
|
type SubCommand struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
ArgsUsage string // optional, e.g. "<session-id>"
|
||||||
|
Handler Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition is the single-source metadata and behavior contract for a slash command.
|
||||||
|
//
|
||||||
|
// Design notes (phase 1):
|
||||||
|
// - Every channel reads command shape from this type instead of keeping local copies.
|
||||||
|
// - Visibility is global: all definitions are considered available to all channels.
|
||||||
|
// - Platform menu registration (for example Telegram BotCommand) also derives from this
|
||||||
|
// same definition so UI labels and runtime behavior stay aligned.
|
||||||
|
type Definition struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Usage string // for simple commands; ignored when SubCommands is set
|
||||||
|
Aliases []string
|
||||||
|
SubCommands []SubCommand // optional; when set, Executor routes to sub-command handlers
|
||||||
|
Handler Handler // for simple commands without sub-commands
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveUsage returns the usage string. When SubCommands are present,
|
||||||
|
// it is auto-generated from sub-command names so metadata and behavior
|
||||||
|
// cannot drift.
|
||||||
|
func (d Definition) EffectiveUsage() string {
|
||||||
|
if len(d.SubCommands) == 0 {
|
||||||
|
return d.Usage
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(d.SubCommands))
|
||||||
|
for _, sc := range d.SubCommands {
|
||||||
|
name := sc.Name
|
||||||
|
if sc.ArgsUsage != "" {
|
||||||
|
name += " " + sc.ArgsUsage
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("/%s [%s]", d.Name, strings.Join(names, "|"))
|
||||||
|
}
|
||||||
41
pkg/commands/definition_test.go
Normal file
41
pkg/commands/definition_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefinition_EffectiveUsage_NoSubCommands(t *testing.T) {
|
||||||
|
d := Definition{Name: "start", Usage: "/start"}
|
||||||
|
if got := d.EffectiveUsage(); got != "/start" {
|
||||||
|
t.Fatalf("EffectiveUsage()=%q, want %q", got, "/start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefinition_EffectiveUsage_WithSubCommands(t *testing.T) {
|
||||||
|
d := Definition{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"},
|
||||||
|
{Name: "channel"},
|
||||||
|
{Name: "agents"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
want := "/show [model|channel|agents]"
|
||||||
|
if got := d.EffectiveUsage(); got != want {
|
||||||
|
t.Fatalf("EffectiveUsage()=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefinition_EffectiveUsage_WithArgsUsage(t *testing.T) {
|
||||||
|
d := Definition{
|
||||||
|
Name: "session",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "list"},
|
||||||
|
{Name: "resume", ArgsUsage: "<id>"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
want := "/session [list|resume <id>]"
|
||||||
|
if got := d.EffectiveUsage(); got != want {
|
||||||
|
t.Fatalf("EffectiveUsage()=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
pkg/commands/executor.go
Normal file
89
pkg/commands/executor.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Outcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OutcomePassthrough means this input should continue through normal agent flow.
|
||||||
|
OutcomePassthrough Outcome = iota
|
||||||
|
// OutcomeHandled means a command handler executed (with or without handler error).
|
||||||
|
OutcomeHandled
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExecuteResult struct {
|
||||||
|
Outcome Outcome
|
||||||
|
Command string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Executor struct {
|
||||||
|
reg *Registry
|
||||||
|
rt *Runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExecutor(reg *Registry, rt *Runtime) *Executor {
|
||||||
|
return &Executor{reg: reg, rt: rt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute implements a two-state command decision:
|
||||||
|
// 1) handled: execute command immediately;
|
||||||
|
// 2) passthrough: not a command or intentionally deferred to agent logic.
|
||||||
|
func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult {
|
||||||
|
cmdName, ok := parseCommandName(req.Text)
|
||||||
|
if !ok {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough}
|
||||||
|
}
|
||||||
|
|
||||||
|
if e == nil || e.reg == nil {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
||||||
|
}
|
||||||
|
|
||||||
|
def, found := e.reg.Lookup(cmdName)
|
||||||
|
if !found {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.executeDefinition(ctx, req, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) executeDefinition(ctx context.Context, req Request, def Definition) ExecuteResult {
|
||||||
|
// Ensure Reply is always non-nil so handlers don't need to check.
|
||||||
|
if req.Reply == nil {
|
||||||
|
req.Reply = func(string) error { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple command — no sub-commands
|
||||||
|
if len(def.SubCommands) == 0 {
|
||||||
|
if def.Handler == nil {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
|
||||||
|
}
|
||||||
|
err := def.Handler(ctx, req, e.rt)
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-command routing
|
||||||
|
subName := nthToken(req.Text, 1)
|
||||||
|
if subName == "" {
|
||||||
|
err := req.Reply("Usage: " + def.EffectiveUsage())
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := normalizeCommandName(subName)
|
||||||
|
for _, sc := range def.SubCommands {
|
||||||
|
if normalizeCommandName(sc.Name) == normalized {
|
||||||
|
if sc.Handler == nil {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
|
||||||
|
}
|
||||||
|
err := sc.Handler(ctx, req, e.rt)
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown sub-command
|
||||||
|
err := req.Reply(fmt.Sprintf("Unknown option: %s. Usage: %s", subName, def.EffectiveUsage()))
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
260
pkg/commands/executor_test.go
Normal file
260
pkg/commands/executor_test.go
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{{Name: "show"}}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/show"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_UnknownSlashCommand_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{{Name: "show"}}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/unknown"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SupportedCommandWithHandler_ReturnsHandled(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "help",
|
||||||
|
Handler: func(context.Context, Request, *Runtime) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help@my_bot"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatalf("expected handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_AliasWithoutHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
Aliases: []string{"display"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/display"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if res.Command != "show" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "show")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_AliasWithHandler_ReturnsHandled(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "clear",
|
||||||
|
Aliases: []string{"reset"},
|
||||||
|
Handler: func(context.Context, Request, *Runtime) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/reset"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if res.Command != "clear" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "clear")
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatalf("expected handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SupportedCommandWithNilHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{Name: "placeholder"},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder list"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if res.Command != "placeholder" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) {
|
||||||
|
// With Lookup-based dispatch, the first registered definition for a name wins.
|
||||||
|
// A definition with nil Handler and no SubCommands returns Passthrough.
|
||||||
|
defs := []Definition{
|
||||||
|
{Name: "placeholder"},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if res.Command != "placeholder" {
|
||||||
|
t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_HandlerErrorIsPropagated(t *testing.T) {
|
||||||
|
wantErr := errors.New("handler failed")
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "help",
|
||||||
|
Handler: func(context.Context, Request, *Runtime) error {
|
||||||
|
return wantErr
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !errors.Is(res.Err, wantErr) {
|
||||||
|
t.Fatalf("err=%v, want=%v", res.Err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SupportsBangPrefixAndCaseInsensitiveCommand(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "help",
|
||||||
|
Handler: func(context.Context, Request, *Runtime) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "!HELP"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatalf("expected handler to be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_RoutesToCorrectHandler(t *testing.T) {
|
||||||
|
modelCalled := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model", Handler: func(_ context.Context, _ Request, _ *Runtime) error {
|
||||||
|
modelCalled = true
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Name: "channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Text: "/show model"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !modelCalled {
|
||||||
|
t.Fatal("model sub-command handler was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_NoArg_RepliesUsage(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"},
|
||||||
|
{Name: "channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/show",
|
||||||
|
Reply: func(text string) error { reply = text; return nil },
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /show [model|channel]" {
|
||||||
|
t.Fatalf("reply=%q, want usage message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_UnknownArg_RepliesError(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/show foobar",
|
||||||
|
Reply: func(text string) error { reply = text; return nil },
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "foobar") {
|
||||||
|
t.Fatalf("reply=%q, should mention unknown sub-command", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_NilHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"}, // nil Handler
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Text: "/show model"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
pkg/commands/handler_agents.go
Normal file
21
pkg/commands/handler_agents.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentsHandler returns a shared handler for both /show agents and /list agents.
|
||||||
|
func agentsHandler() Handler {
|
||||||
|
return func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.ListAgentIDs == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
ids := rt.ListAgentIDs()
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return req.Reply("No agents registered")
|
||||||
|
}
|
||||||
|
return req.Reply(fmt.Sprintf("Registered agents: %s", strings.Join(ids, ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
55
pkg/commands/registry.go
Normal file
55
pkg/commands/registry.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
defs []Definition
|
||||||
|
index map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry stores the canonical command set used by both dispatch and
|
||||||
|
// optional platform registration adapters.
|
||||||
|
func NewRegistry(defs []Definition) *Registry {
|
||||||
|
stored := make([]Definition, len(defs))
|
||||||
|
copy(stored, defs)
|
||||||
|
|
||||||
|
index := make(map[string]int, len(stored)*2)
|
||||||
|
for i, def := range stored {
|
||||||
|
registerCommandName(index, def.Name, i)
|
||||||
|
for _, alias := range def.Aliases {
|
||||||
|
registerCommandName(index, alias, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Registry{defs: stored, index: index}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definitions returns all registered command definitions.
|
||||||
|
// Command availability is global and no longer channel-scoped.
|
||||||
|
func (r *Registry) Definitions() []Definition {
|
||||||
|
out := make([]Definition, len(r.defs))
|
||||||
|
copy(out, r.defs)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns a command definition by normalized command name or alias.
|
||||||
|
func (r *Registry) Lookup(name string) (Definition, bool) {
|
||||||
|
key := normalizeCommandName(name)
|
||||||
|
if key == "" {
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
idx, ok := r.index[key]
|
||||||
|
if !ok {
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
return r.defs[idx], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCommandName(index map[string]int, name string, defIndex int) {
|
||||||
|
key := normalizeCommandName(name)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := index[key]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
index[key] = defIndex
|
||||||
|
}
|
||||||
49
pkg/commands/registry_test.go
Normal file
49
pkg/commands/registry_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRegistry_Definitions_ReturnsCopy(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{Name: "help", Description: "Show help"},
|
||||||
|
{Name: "admin", Description: "Admin command"},
|
||||||
|
}
|
||||||
|
r := NewRegistry(defs)
|
||||||
|
|
||||||
|
got := r.Definitions()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("definitions len = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
got[0].Name = "mutated"
|
||||||
|
again := r.Definitions()
|
||||||
|
if again[0].Name != "help" {
|
||||||
|
t.Fatalf("registry should not be mutated by caller, got first name %q", again[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_Lookup_MatchesByLowercaseNameAndAlias(t *testing.T) {
|
||||||
|
r := NewRegistry([]Definition{
|
||||||
|
{Name: "Help", Aliases: []string{"Assist"}},
|
||||||
|
{Name: "List"},
|
||||||
|
})
|
||||||
|
|
||||||
|
def, ok := r.Lookup("help")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by lowercase name failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
def, ok = r.Lookup("HELP")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by uppercase name failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
def, ok = r.Lookup("assist")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by lowercase alias failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
|
||||||
|
def, ok = r.Lookup("ASSIST")
|
||||||
|
if !ok || def.Name != "Help" {
|
||||||
|
t.Fatalf("lookup by uppercase alias failed: ok=%v def=%+v", ok, def)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
pkg/commands/request.go
Normal file
75
pkg/commands/request.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler func(ctx context.Context, req Request, rt *Runtime) error
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Channel string
|
||||||
|
ChatID string
|
||||||
|
SenderID string
|
||||||
|
Text string
|
||||||
|
Reply func(text string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailableMsg = "Command unavailable in current context."
|
||||||
|
|
||||||
|
var commandPrefixes = []string{"/", "!"}
|
||||||
|
|
||||||
|
// parseCommandName accepts "/name", "!name", and Telegram's "/name@bot", then
|
||||||
|
// normalizes to lowercase command names.
|
||||||
|
func parseCommandName(input string) (string, bool) {
|
||||||
|
token := nthToken(input, 0)
|
||||||
|
if token == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
name, ok := trimCommandPrefix(token)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if i := strings.Index(name, "@"); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
name = normalizeCommandName(name)
|
||||||
|
if name == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimCommandPrefix(token string) (string, bool) {
|
||||||
|
for _, prefix := range commandPrefixes {
|
||||||
|
if strings.HasPrefix(token, prefix) {
|
||||||
|
return strings.TrimPrefix(token, prefix), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCommandPrefix returns true if the input starts with a recognized
|
||||||
|
// command prefix (e.g. "/" or "!").
|
||||||
|
func HasCommandPrefix(input string) bool {
|
||||||
|
token := nthToken(input, 0)
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := trimCommandPrefix(token)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// nthToken returns the 0-indexed token from whitespace-split input.
|
||||||
|
func nthToken(input string, n int) string {
|
||||||
|
parts := strings.Fields(strings.TrimSpace(input))
|
||||||
|
if n >= len(parts) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[n]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCommandName(name string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(name))
|
||||||
|
}
|
||||||
28
pkg/commands/request_test.go
Normal file
28
pkg/commands/request_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHasCommandPrefix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"/help", true},
|
||||||
|
{"!help", true},
|
||||||
|
{"/switch model to gpt-4", true},
|
||||||
|
{"!switch model to gpt-4", true},
|
||||||
|
{"hello", false},
|
||||||
|
{"", false},
|
||||||
|
{" ", false},
|
||||||
|
{"hello /world", false},
|
||||||
|
{"/", true},
|
||||||
|
{"!", true},
|
||||||
|
{" /help", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := HasCommandPrefix(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("HasCommandPrefix(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pkg/commands/runtime.go
Normal file
16
pkg/commands/runtime.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
||||||
|
// Runtime provides runtime dependencies to command handlers. It is constructed
|
||||||
|
// per-request by the agent loop so that per-request state (like session scope)
|
||||||
|
// can coexist with long-lived callbacks (like GetModelInfo).
|
||||||
|
type Runtime struct {
|
||||||
|
Config *config.Config
|
||||||
|
GetModelInfo func() (name, provider string)
|
||||||
|
ListAgentIDs func() []string
|
||||||
|
ListDefinitions func() []Definition
|
||||||
|
GetEnabledChannels func() []string
|
||||||
|
SwitchModel func(value string) (oldModel string, err error)
|
||||||
|
SwitchChannel func(value string) error
|
||||||
|
}
|
||||||
85
pkg/commands/show_list_handlers_test.go
Normal file
85
pkg/commands/show_list_handlers_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShowListHandlers_ChannelPolicy(t *testing.T) {
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), nil)
|
||||||
|
|
||||||
|
var telegramReply string
|
||||||
|
handled := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "telegram",
|
||||||
|
Text: "/show channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
telegramReply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if handled.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("telegram /show outcome=%v, want=%v", handled.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if telegramReply != "Current Channel: telegram" {
|
||||||
|
t.Fatalf("telegram /show reply=%q, want=%q", telegramReply, "Current Channel: telegram")
|
||||||
|
}
|
||||||
|
|
||||||
|
var whatsappReply string
|
||||||
|
handledWhatsApp := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
Text: "/show channel",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
whatsappReply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if handledWhatsApp.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("whatsapp /show outcome=%v, want=%v", handledWhatsApp.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if handledWhatsApp.Command != "show" {
|
||||||
|
t.Fatalf("whatsapp /show command=%q, want=%q", handledWhatsApp.Command, "show")
|
||||||
|
}
|
||||||
|
if whatsappReply != "Current Channel: whatsapp" {
|
||||||
|
t.Fatalf("whatsapp /show reply=%q, want=%q", whatsappReply, "Current Channel: whatsapp")
|
||||||
|
}
|
||||||
|
|
||||||
|
passthrough := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
Text: "/foo",
|
||||||
|
})
|
||||||
|
if passthrough.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("whatsapp /foo outcome=%v, want=%v", passthrough.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
if passthrough.Command != "foo" {
|
||||||
|
t.Fatalf("whatsapp /foo command=%q, want=%q", passthrough.Command, "foo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
|
||||||
|
rt := &Runtime{
|
||||||
|
GetEnabledChannels: func() []string {
|
||||||
|
return []string{"telegram"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Channel: "whatsapp",
|
||||||
|
Text: "/list channels",
|
||||||
|
Reply: func(text string) error {
|
||||||
|
reply = text
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("whatsapp /list outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if res.Command != "list" {
|
||||||
|
t.Fatalf("whatsapp /list command=%q, want=%q", res.Command, "list")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "telegram") {
|
||||||
|
t.Fatalf("whatsapp /list reply=%q, expected enabled channels content", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -167,22 +167,35 @@ type SessionConfig struct {
|
||||||
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
IdentityLinks map[string][]string `json:"identity_links,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoutingConfig controls the intelligent model routing feature.
|
||||||
|
// When enabled, each incoming message is scored against structural features
|
||||||
|
// (message length, code blocks, tool call history, conversation depth, attachments).
|
||||||
|
// Messages scoring below Threshold are sent to LightModel; all others use the
|
||||||
|
// agent's primary model. This reduces cost and latency for simple tasks without
|
||||||
|
// requiring any keyword matching — all scoring is language-agnostic.
|
||||||
|
type RoutingConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks
|
||||||
|
Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model
|
||||||
|
}
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
|
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
|
||||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
|
||||||
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
|
||||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||||
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
|
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
|
||||||
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
|
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
|
||||||
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
|
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
|
||||||
|
Routing *RoutingConfig `json:"routing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
|
||||||
|
|
@ -212,12 +225,14 @@ type ChannelsConfig struct {
|
||||||
QQ QQConfig `json:"qq"`
|
QQ QQConfig `json:"qq"`
|
||||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
|
Matrix MatrixConfig `json:"matrix"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
WeCom WeComConfig `json:"wecom"`
|
WeCom WeComConfig `json:"wecom"`
|
||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
|
IRC IRCConfig `json:"irc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
|
|
@ -259,15 +274,16 @@ type TelegramConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeishuConfig struct {
|
type FeishuConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
|
||||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
|
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
|
||||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
||||||
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||||
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
|
||||||
|
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiscordConfig struct {
|
type DiscordConfig struct {
|
||||||
|
|
@ -319,6 +335,19 @@ type SlackConfig struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MatrixConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
||||||
|
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||||
|
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||||
|
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
||||||
|
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
||||||
|
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
type LINEConfig struct {
|
type LINEConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
||||||
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||||
|
|
@ -401,6 +430,25 @@ type PicoConfig struct {
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IRCConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
|
||||||
|
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
|
||||||
|
TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
|
||||||
|
Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
|
||||||
|
User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
|
||||||
|
RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
|
||||||
|
Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
|
||||||
|
NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
|
||||||
|
SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
|
||||||
|
SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
|
||||||
|
Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
|
||||||
|
RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
@ -426,11 +474,13 @@ type ProvidersConfig struct {
|
||||||
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
||||||
DeepSeek ProviderConfig `json:"deepseek"`
|
DeepSeek ProviderConfig `json:"deepseek"`
|
||||||
Cerebras ProviderConfig `json:"cerebras"`
|
Cerebras ProviderConfig `json:"cerebras"`
|
||||||
|
Vivgrid ProviderConfig `json:"vivgrid"`
|
||||||
VolcEngine ProviderConfig `json:"volcengine"`
|
VolcEngine ProviderConfig `json:"volcengine"`
|
||||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||||
Antigravity ProviderConfig `json:"antigravity"`
|
Antigravity ProviderConfig `json:"antigravity"`
|
||||||
Qwen ProviderConfig `json:"qwen"`
|
Qwen ProviderConfig `json:"qwen"`
|
||||||
Mistral ProviderConfig `json:"mistral"`
|
Mistral ProviderConfig `json:"mistral"`
|
||||||
|
Avian ProviderConfig `json:"avian"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
|
||||||
|
|
@ -450,11 +500,13 @@ func (p ProvidersConfig) IsEmpty() bool {
|
||||||
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
|
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
|
||||||
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
|
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
|
||||||
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
|
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
|
||||||
|
p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" &&
|
||||||
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
|
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
|
||||||
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
|
||||||
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
|
||||||
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
|
||||||
p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
|
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
|
||||||
|
p.Avian.APIKey == "" && p.Avian.APIBase == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
|
||||||
|
|
@ -505,6 +557,7 @@ type ModelConfig struct {
|
||||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||||
RequestTimeout int `json:"request_timeout,omitempty"`
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
|
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the ModelConfig has all required fields.
|
// Validate checks if the ModelConfig has all required fields.
|
||||||
|
|
@ -523,6 +576,10 @@ type GatewayConfig struct {
|
||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToolConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"ENABLED"`
|
||||||
|
}
|
||||||
|
|
||||||
type BraveConfig struct {
|
type BraveConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||||
|
|
@ -547,6 +604,12 @@ type PerplexityConfig struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearXNGConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"`
|
||||||
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"`
|
||||||
|
}
|
||||||
|
|
||||||
type GLMSearchConfig struct {
|
type GLMSearchConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
|
||||||
|
|
@ -558,11 +621,13 @@ type GLMSearchConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
Brave BraveConfig `json:"brave"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||||
Tavily TavilyConfig `json:"tavily"`
|
Brave BraveConfig ` json:"brave"`
|
||||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
Tavily TavilyConfig ` json:"tavily"`
|
||||||
Perplexity PerplexityConfig `json:"perplexity"`
|
DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"`
|
||||||
GLMSearch GLMSearchConfig `json:"glm_search"`
|
Perplexity PerplexityConfig ` json:"perplexity"`
|
||||||
|
SearXNG SearXNGConfig ` json:"searxng"`
|
||||||
|
GLMSearch GLMSearchConfig ` json:"glm_search"`
|
||||||
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
||||||
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||||
|
|
@ -570,19 +635,29 @@ type WebToolsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronToolsConfig struct {
|
type CronToolsConfig struct {
|
||||||
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
||||||
|
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
|
||||||
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
|
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
|
||||||
CustomAllowPatterns []string `json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"`
|
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
|
||||||
|
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
|
||||||
|
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillsToolsConfig struct {
|
||||||
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
|
||||||
|
Registries SkillsRegistriesConfig ` json:"registries"`
|
||||||
|
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
||||||
|
SearchCache SearchCacheConfig ` json:"search_cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaCleanupConfig struct {
|
type MediaCleanupConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"`
|
ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"`
|
||||||
MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"`
|
MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"`
|
||||||
Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
|
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
|
|
@ -594,12 +669,20 @@ type ToolsConfig struct {
|
||||||
Skills SkillsToolsConfig `json:"skills"`
|
Skills SkillsToolsConfig `json:"skills"`
|
||||||
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
|
||||||
MCP MCPConfig `json:"mcp"`
|
MCP MCPConfig `json:"mcp"`
|
||||||
}
|
AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"`
|
||||||
|
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
|
||||||
type SkillsToolsConfig struct {
|
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
|
||||||
Registries SkillsRegistriesConfig `json:"registries"`
|
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
|
||||||
MaxConcurrentSearches int `json:"max_concurrent_searches" env:"PICOCLAW_SKILLS_MAX_CONCURRENT_SEARCHES"`
|
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||||
SearchCache SearchCacheConfig `json:"search_cache"`
|
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
|
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
||||||
|
ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
|
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||||
|
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
||||||
|
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchCacheConfig struct {
|
type SearchCacheConfig struct {
|
||||||
|
|
@ -645,8 +728,7 @@ type MCPServerConfig struct {
|
||||||
|
|
||||||
// MCPConfig defines configuration for all MCP servers
|
// MCPConfig defines configuration for all MCP servers
|
||||||
type MCPConfig struct {
|
type MCPConfig struct {
|
||||||
// Enabled globally enables/disables MCP integration
|
ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"`
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_MCP_ENABLED"`
|
|
||||||
// Servers is a map of server name to server configuration
|
// Servers is a map of server name to server configuration
|
||||||
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -832,3 +914,50 @@ func (c *Config) ValidateModelList() error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "web":
|
||||||
|
return t.Web.Enabled
|
||||||
|
case "cron":
|
||||||
|
return t.Cron.Enabled
|
||||||
|
case "exec":
|
||||||
|
return t.Exec.Enabled
|
||||||
|
case "skills":
|
||||||
|
return t.Skills.Enabled
|
||||||
|
case "media_cleanup":
|
||||||
|
return t.MediaCleanup.Enabled
|
||||||
|
case "append_file":
|
||||||
|
return t.AppendFile.Enabled
|
||||||
|
case "edit_file":
|
||||||
|
return t.EditFile.Enabled
|
||||||
|
case "find_skills":
|
||||||
|
return t.FindSkills.Enabled
|
||||||
|
case "i2c":
|
||||||
|
return t.I2C.Enabled
|
||||||
|
case "install_skill":
|
||||||
|
return t.InstallSkill.Enabled
|
||||||
|
case "list_dir":
|
||||||
|
return t.ListDir.Enabled
|
||||||
|
case "message":
|
||||||
|
return t.Message.Enabled
|
||||||
|
case "read_file":
|
||||||
|
return t.ReadFile.Enabled
|
||||||
|
case "spawn":
|
||||||
|
return t.Spawn.Enabled
|
||||||
|
case "spi":
|
||||||
|
return t.SPI.Enabled
|
||||||
|
case "subagent":
|
||||||
|
return t.Subagent.Enabled
|
||||||
|
case "web_fetch":
|
||||||
|
return t.WebFetch.Enabled
|
||||||
|
case "send_file":
|
||||||
|
return t.SendFile.Enabled
|
||||||
|
case "write_file":
|
||||||
|
return t.WriteFile.Enabled
|
||||||
|
case "mcp":
|
||||||
|
return t.MCP.Enabled
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,9 @@ func TestDefaultConfig_Channels(t *testing.T) {
|
||||||
if cfg.Channels.Slack.Enabled {
|
if cfg.Channels.Slack.Enabled {
|
||||||
t.Error("Slack should be disabled by default")
|
t.Error("Slack should be disabled by default")
|
||||||
}
|
}
|
||||||
|
if cfg.Channels.Matrix.Enabled {
|
||||||
|
t.Error("Matrix should be disabled by default")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_WebTools verifies web tools config
|
// TestDefaultConfig_WebTools verifies web tools config
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,22 @@ func DefaultConfig() *Config {
|
||||||
AppToken: "",
|
AppToken: "",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
Matrix: MatrixConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Homeserver: "https://matrix.org",
|
||||||
|
UserID: "",
|
||||||
|
AccessToken: "",
|
||||||
|
DeviceID: "",
|
||||||
|
JoinOnInvite: true,
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
GroupTrigger: GroupTriggerConfig{
|
||||||
|
MentionOnly: true,
|
||||||
|
},
|
||||||
|
Placeholder: PlaceholderConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Text: "Thinking... 💭",
|
||||||
|
},
|
||||||
|
},
|
||||||
LINE: LINEConfig{
|
LINE: LINEConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
ChannelSecret: "",
|
ChannelSecret: "",
|
||||||
|
|
@ -261,6 +277,14 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Vivgrid - https://vivgrid.com
|
||||||
|
{
|
||||||
|
ModelName: "vivgrid-auto",
|
||||||
|
Model: "vivgrid/auto",
|
||||||
|
APIBase: "https://api.vivgrid.com/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
// Volcengine (火山引擎) - https://console.volcengine.com/ark
|
||||||
{
|
{
|
||||||
ModelName: "doubao-pro",
|
ModelName: "doubao-pro",
|
||||||
|
|
@ -308,6 +332,20 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Avian - https://avian.io
|
||||||
|
{
|
||||||
|
ModelName: "deepseek-v3.2",
|
||||||
|
Model: "avian/deepseek/deepseek-v3.2",
|
||||||
|
APIBase: "https://api.avian.io/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "kimi-k2.5",
|
||||||
|
Model: "avian/moonshotai/kimi-k2.5",
|
||||||
|
APIBase: "https://api.avian.io/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
// VLLM (local) - http://localhost:8000
|
// VLLM (local) - http://localhost:8000
|
||||||
{
|
{
|
||||||
ModelName: "local-model",
|
ModelName: "local-model",
|
||||||
|
|
@ -322,11 +360,16 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
MediaCleanup: MediaCleanupConfig{
|
MediaCleanup: MediaCleanupConfig{
|
||||||
Enabled: true,
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
MaxAge: 30,
|
MaxAge: 30,
|
||||||
Interval: 5,
|
Interval: 5,
|
||||||
},
|
},
|
||||||
Web: WebToolsConfig{
|
Web: WebToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
Proxy: "",
|
Proxy: "",
|
||||||
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
|
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
|
|
@ -343,6 +386,11 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
|
SearXNG: SearXNGConfig{
|
||||||
|
Enabled: false,
|
||||||
|
BaseURL: "",
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
GLMSearch: GLMSearchConfig{
|
GLMSearch: GLMSearchConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
|
@ -352,12 +400,22 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Cron: CronToolsConfig{
|
Cron: CronToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
ExecTimeoutMinutes: 5,
|
ExecTimeoutMinutes: 5,
|
||||||
},
|
},
|
||||||
Exec: ExecConfig{
|
Exec: ExecConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
EnableDenyPatterns: true,
|
EnableDenyPatterns: true,
|
||||||
|
TimeoutSeconds: 60,
|
||||||
},
|
},
|
||||||
Skills: SkillsToolsConfig{
|
Skills: SkillsToolsConfig{
|
||||||
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
Registries: SkillsRegistriesConfig{
|
Registries: SkillsRegistriesConfig{
|
||||||
ClawHub: ClawHubRegistryConfig{
|
ClawHub: ClawHubRegistryConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -370,10 +428,54 @@ func DefaultConfig() *Config {
|
||||||
TTLSeconds: 300,
|
TTLSeconds: 300,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
SendFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
MCP: MCPConfig{
|
MCP: MCPConfig{
|
||||||
Enabled: false,
|
ToolConfig: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
Servers: map[string]MCPServerConfig{},
|
Servers: map[string]MCPServerConfig{},
|
||||||
},
|
},
|
||||||
|
AppendFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
EditFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
FindSkills: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
I2C: ToolConfig{
|
||||||
|
Enabled: false, // Hardware tool - Linux only
|
||||||
|
},
|
||||||
|
InstallSkill: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
ListDir: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
Message: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
ReadFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
Spawn: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
SPI: ToolConfig{
|
||||||
|
Enabled: false, // Hardware tool - Linux only
|
||||||
|
},
|
||||||
|
Subagent: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
WebFetch: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
WriteFile: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Heartbeat: HeartbeatConfig{
|
Heartbeat: HeartbeatConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"vivgrid"},
|
||||||
|
protocol: "vivgrid",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "vivgrid",
|
||||||
|
Model: "vivgrid/auto",
|
||||||
|
APIKey: p.Vivgrid.APIKey,
|
||||||
|
APIBase: p.Vivgrid.APIBase,
|
||||||
|
Proxy: p.Vivgrid.Proxy,
|
||||||
|
RequestTimeout: p.Vivgrid.RequestTimeout,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
providerNames: []string{"volcengine", "doubao"},
|
providerNames: []string{"volcengine", "doubao"},
|
||||||
protocol: "volcengine",
|
protocol: "volcengine",
|
||||||
|
|
@ -373,6 +390,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
|
||||||
}, true
|
}, true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
providerNames: []string{"avian"},
|
||||||
|
protocol: "avian",
|
||||||
|
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
|
||||||
|
if p.Avian.APIKey == "" && p.Avian.APIBase == "" {
|
||||||
|
return ModelConfig{}, false
|
||||||
|
}
|
||||||
|
return ModelConfig{
|
||||||
|
ModelName: "avian",
|
||||||
|
Model: "avian/deepseek/deepseek-v3.2",
|
||||||
|
APIKey: p.Avian.APIKey,
|
||||||
|
APIBase: p.Avian.APIBase,
|
||||||
|
Proxy: p.Avian.Proxy,
|
||||||
|
RequestTimeout: p.Avian.RequestTimeout,
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each provider migration
|
// Process each provider migration
|
||||||
|
|
|
||||||
|
|
@ -155,19 +155,21 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
|
||||||
ShengSuanYun: ProviderConfig{APIKey: "key11"},
|
ShengSuanYun: ProviderConfig{APIKey: "key11"},
|
||||||
DeepSeek: ProviderConfig{APIKey: "key12"},
|
DeepSeek: ProviderConfig{APIKey: "key12"},
|
||||||
Cerebras: ProviderConfig{APIKey: "key13"},
|
Cerebras: ProviderConfig{APIKey: "key13"},
|
||||||
VolcEngine: ProviderConfig{APIKey: "key14"},
|
Vivgrid: ProviderConfig{APIKey: "key14"},
|
||||||
|
VolcEngine: ProviderConfig{APIKey: "key15"},
|
||||||
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
|
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
|
||||||
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
Antigravity: ProviderConfig{AuthMethod: "oauth"},
|
||||||
Qwen: ProviderConfig{APIKey: "key17"},
|
Qwen: ProviderConfig{APIKey: "key17"},
|
||||||
Mistral: ProviderConfig{APIKey: "key18"},
|
Mistral: ProviderConfig{APIKey: "key18"},
|
||||||
|
Avian: ProviderConfig{APIKey: "key19"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ConvertProvidersToModelList(cfg)
|
result := ConvertProvidersToModelList(cfg)
|
||||||
|
|
||||||
// All 19 providers should be converted
|
// All 21 providers should be converted
|
||||||
if len(result) != 19 {
|
if len(result) != 21 {
|
||||||
t.Errorf("len(result) = %d, want 19", len(result))
|
t.Errorf("len(result) = %d, want 21", len(result))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue