Merge branch 'sipeed:main' into main
This commit is contained in:
commit
770afb1932
302 changed files with 36159 additions and 5299 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...
|
||||||
|
|
|
||||||
204
.github/workflows/nightly.yml
vendored
Normal file
204
.github/workflows/nightly.yml
vendored
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
name: Nightly Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-tag:
|
||||||
|
name: Create Git Tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
|
changelog: ${{ steps.version.outputs.changelog }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Generate and push tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
DATE=$(date -u +%Y%m%d)
|
||||||
|
SHA=$(git rev-parse --short=8 HEAD)
|
||||||
|
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
|
||||||
|
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
|
||||||
|
TAG="v0.0.0-nightly.${DATE}.${SHA}"
|
||||||
|
else
|
||||||
|
TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}"
|
||||||
|
fi
|
||||||
|
VERSION=$TAG
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||||
|
echo "Tag $TAG already exists, reusing existing tag"
|
||||||
|
else
|
||||||
|
git tag -a "$TAG" -m "Nightly build $VERSION"
|
||||||
|
fi
|
||||||
|
git push origin "$TAG"
|
||||||
|
|
||||||
|
COMPARE_URL="https://github.com/${{ github.repository }}/commits/${TAG}"
|
||||||
|
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
|
||||||
|
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...${TAG}"
|
||||||
|
fi
|
||||||
|
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: GoReleaser Release
|
||||||
|
needs: create-tag
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout tag
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ needs.create-tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Setup Go from go.mod
|
||||||
|
id: setup-go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Run GoReleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: ~> v2
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||||
|
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||||
|
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
|
NIGHTLY_BUILD: "true"
|
||||||
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
|
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||||
|
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||||
|
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||||
|
|
||||||
|
update-rolling:
|
||||||
|
name: Update Rolling Nightly
|
||||||
|
needs: [create-tag, release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Update nightly release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ needs.create-tag.outputs.tag }}
|
||||||
|
TITLE: ${{ needs.create-tag.outputs.version }}
|
||||||
|
run: |
|
||||||
|
CHANGELOG='${{ needs.create-tag.outputs.changelog }}'
|
||||||
|
NOTES=$(cat <<EOF
|
||||||
|
Nightly build for **${TITLE}**
|
||||||
|
|
||||||
|
This is an automated build and may be unstable. Use with caution.
|
||||||
|
|
||||||
|
${CHANGELOG}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# Download assets from the newly created release if it exists,
|
||||||
|
# otherwise fall back to using locally built dist/ artifacts.
|
||||||
|
mkdir -p build
|
||||||
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "Downloading assets from GitHub release for $TAG..."
|
||||||
|
gh release download "$TAG" --dir build
|
||||||
|
else
|
||||||
|
echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..."
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
cp -R dist/* build/
|
||||||
|
else
|
||||||
|
echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete existing nightly release and tag to avoid conflicts
|
||||||
|
echo "Deleting existing nightly release and tag..."
|
||||||
|
gh release delete nightly --cleanup-tag -y || true
|
||||||
|
git push origin :refs/tags/nightly || true
|
||||||
|
|
||||||
|
gh release create nightly \
|
||||||
|
--title "Nightly Build" \
|
||||||
|
--notes "$NOTES" \
|
||||||
|
--target "${{ github.sha }}" \
|
||||||
|
--prerelease \
|
||||||
|
build/*
|
||||||
|
|
||||||
|
echo "Cleaning up old nightly releases (keeping only the most recent)..."
|
||||||
|
gh release list --limit 100 --json tagName -q '.[].tagName | select(contains("-nightly."))' | tail -n +2 | while read -r old_tag; do
|
||||||
|
if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then
|
||||||
|
echo "Deleting old nightly release: $old_tag"
|
||||||
|
gh release delete "$old_tag" --cleanup-tag -y || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Cleaning up old 'vX.X.X-nightly...' Docker images on GHCR..."
|
||||||
|
OWNER="${{ github.repository_owner }}"
|
||||||
|
PACKAGE_NAME="${{ github.event.repository.name }}"
|
||||||
|
|
||||||
|
# Check if owner is an organization or user
|
||||||
|
ORG_TEST=$(gh api -H "Accept: application/vnd.github+json" /orgs/$OWNER 2>/dev/null || true)
|
||||||
|
if echo "$ORG_TEST" | grep -q '"login"'; then
|
||||||
|
ACCOUNT_TYPE="orgs"
|
||||||
|
else
|
||||||
|
ACCOUNT_TYPE="users"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PACKAGE_URL="/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
|
||||||
|
OLD_NIGHTLY_VERSIONS=$(gh api --paginate -H "Accept: application/vnd.github+json" \
|
||||||
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
|
"$PACKAGE_URL" \
|
||||||
|
--jq ". | map(select(any(.metadata.container.tags[]; contains(\"-nightly.\") and (. != \"nightly\") and (. != \"$TAG\")))) | .[].id" 2>/dev/null || true)
|
||||||
|
|
||||||
|
for version_id in $OLD_NIGHTLY_VERSIONS; do
|
||||||
|
if [ -n "$version_id" ]; then
|
||||||
|
echo "Deleting Docker image version ID: $version_id"
|
||||||
|
gh api -X DELETE -H "Accept: application/vnd.github+json" \
|
||||||
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
|
"/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/$version_id" || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
19
.github/workflows/release.yml
vendored
19
.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:
|
||||||
|
|
@ -60,6 +65,14 @@ jobs:
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
run: corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
|
@ -91,6 +104,11 @@ jobs:
|
||||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||||
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
|
||||||
|
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||||
|
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||||
|
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||||
|
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||||
|
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||||
|
|
||||||
- name: Apply release flags
|
- name: Apply release flags
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -104,6 +122,7 @@ jobs:
|
||||||
upload-tos:
|
upload-tos:
|
||||||
name: Upload to TOS
|
name: Upload to TOS
|
||||||
needs: release
|
needs: release
|
||||||
|
if: ${{ inputs.upload_tos }}
|
||||||
uses: ./.github/workflows/upload-tos.yml
|
uses: ./.github/workflows/upload-tos.yml
|
||||||
with:
|
with:
|
||||||
tag: ${{ inputs.tag }}
|
tag: ${{ inputs.tag }}
|
||||||
|
|
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -38,12 +38,21 @@ ralph/
|
||||||
.ralph/
|
.ralph/
|
||||||
tasks/
|
tasks/
|
||||||
|
|
||||||
|
# Plans
|
||||||
|
docs/plans/
|
||||||
|
|
||||||
# Editors
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
*.vite/
|
||||||
|
|
||||||
# Windows Application Icon/Resource
|
# Windows Application Icon/Resource
|
||||||
*.syso
|
*.syso
|
||||||
|
|
||||||
|
# Keep embedded backend dist directory placeholder in VCS
|
||||||
|
!web/backend/dist/
|
||||||
|
web/backend/dist/*
|
||||||
|
!web/backend/dist/.gitkeep
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate ./...
|
- go generate ./...
|
||||||
|
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
|
||||||
- go install github.com/tc-hib/go-winres@latest
|
- go install github.com/tc-hib/go-winres@latest
|
||||||
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
|
|
@ -17,10 +18,10 @@ builds:
|
||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
|
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
|
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
|
||||||
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
|
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
|
|
@ -32,9 +33,13 @@ builds:
|
||||||
- riscv64
|
- riscv64
|
||||||
- loong64
|
- loong64
|
||||||
- arm
|
- arm
|
||||||
|
- s390x
|
||||||
|
- mipsle
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "6"
|
||||||
- "7"
|
- "7"
|
||||||
|
gomips:
|
||||||
|
- softfloat
|
||||||
main: ./cmd/picoclaw
|
main: ./cmd/picoclaw
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
|
|
@ -59,10 +64,14 @@ builds:
|
||||||
- riscv64
|
- riscv64
|
||||||
- loong64
|
- loong64
|
||||||
- arm
|
- arm
|
||||||
|
- s390x
|
||||||
|
- mipsle
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "6"
|
||||||
- "7"
|
- "7"
|
||||||
main: ./cmd/picoclaw-launcher
|
gomips:
|
||||||
|
- softfloat
|
||||||
|
main: ./web/backend
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
|
@ -86,9 +95,13 @@ builds:
|
||||||
- riscv64
|
- riscv64
|
||||||
- loong64
|
- loong64
|
||||||
- arm
|
- arm
|
||||||
|
- s390x
|
||||||
|
- mipsle
|
||||||
goarm:
|
goarm:
|
||||||
- "6"
|
- "6"
|
||||||
- "7"
|
- "7"
|
||||||
|
gomips:
|
||||||
|
- softfloat
|
||||||
main: ./cmd/picoclaw-launcher-tui
|
main: ./cmd/picoclaw-launcher-tui
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
|
|
@ -103,15 +116,49 @@ dockers_v2:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
images:
|
images:
|
||||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}"
|
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
|
||||||
tags:
|
tags:
|
||||||
- "{{ .Tag }}"
|
- "{{ .Tag }}"
|
||||||
- "latest"
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
|
||||||
platforms:
|
platforms:
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
- linux/arm64
|
- linux/arm64
|
||||||
- linux/riscv64
|
- linux/riscv64
|
||||||
|
|
||||||
|
- id: picoclaw-launcher
|
||||||
|
dockerfile: docker/Dockerfile.goreleaser.launcher
|
||||||
|
ids:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
|
images:
|
||||||
|
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
|
||||||
|
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
|
||||||
|
tags:
|
||||||
|
- "{{ .Tag }}-launcher"
|
||||||
|
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
- linux/riscv64
|
||||||
|
|
||||||
|
notarize:
|
||||||
|
macos:
|
||||||
|
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
|
||||||
|
ids:
|
||||||
|
- picoclaw
|
||||||
|
- picoclaw-launcher
|
||||||
|
- picoclaw-launcher-tui
|
||||||
|
sign:
|
||||||
|
certificate: "{{.Env.MACOS_SIGN_P12}}"
|
||||||
|
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
|
||||||
|
notarize:
|
||||||
|
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
|
||||||
|
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
|
||||||
|
key: "{{.Env.MACOS_NOTARY_KEY}}"
|
||||||
|
wait: true
|
||||||
|
timeout: 20m
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- formats: [tar.gz]
|
- formats: [tar.gz]
|
||||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||||
|
|
@ -129,7 +176,7 @@ archives:
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
builds:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
- picoclaw-launcher
|
- picoclaw-launcher
|
||||||
- picoclaw-launcher-tui
|
- picoclaw-launcher-tui
|
||||||
|
|
@ -149,6 +196,11 @@ nfpms:
|
||||||
- rpm
|
- rpm
|
||||||
- deb
|
- deb
|
||||||
bindir: /usr/bin
|
bindir: /usr/bin
|
||||||
|
contents:
|
||||||
|
- src: web/picoclaw-launcher.desktop
|
||||||
|
dst: /usr/share/applications/picoclaw-launcher.desktop
|
||||||
|
- src: web/picoclaw-launcher.png
|
||||||
|
dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
|
|
||||||
52
Makefile
52
Makefile
|
|
@ -11,13 +11,35 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
BUILD_TIME=$(shell date +%FT%T%z)
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||||
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
|
LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w"
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
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
|
||||||
|
|
@ -87,6 +111,18 @@ build: generate
|
||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
## build-launcher: Build the picoclaw-launcher (web console) binary
|
||||||
|
build-launcher:
|
||||||
|
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@if [ ! -f web/backend/dist/index.html ]; then \
|
||||||
|
echo "Building frontend..."; \
|
||||||
|
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||||
|
fi
|
||||||
|
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
||||||
|
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||||
|
|
||||||
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||||
build-whatsapp-native: generate
|
build-whatsapp-native: generate
|
||||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||||
|
|
@ -97,6 +133,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 +155,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 +176,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 生成、人間によるレビュー付き。
|
||||||
|
|
||||||
|
|
|
||||||
75
README.md
75
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.
|
||||||
|
|
||||||
|
|
@ -194,6 +194,19 @@ docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
docker compose -f docker/docker-compose.yml --profile gateway down
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Launcher Mode (Web Console)
|
||||||
|
|
||||||
|
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The web console does not yet support authentication. Avoid exposing it to the public internet.
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -308,7 +321,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.
|
||||||
|
|
||||||
|
|
@ -317,6 +330,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) |
|
||||||
|
|
@ -353,6 +367,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>
|
||||||
|
|
@ -521,6 +542,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>
|
||||||
|
|
||||||
|
|
@ -750,6 +805,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.
|
||||||
|
|
@ -939,6 +1000,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)
|
||||||
|
|
||||||
|
|
@ -966,11 +1028,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 | - |
|
||||||
|
|
||||||
|
|
@ -1205,6 +1268,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"
|
||||||
|
|
|
||||||
|
|
@ -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: 348 KiB |
|
|
@ -1,6 +1,7 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -67,6 +68,7 @@ func Run() error {
|
||||||
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
root := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||||
root.AddItem(bannerView(), 6, 0, false)
|
root.AddItem(bannerView(), 6, 0, false)
|
||||||
root.AddItem(state.pages, 0, 1, true)
|
root.AddItem(state.pages, 0, 1, true)
|
||||||
|
root.AddItem(footerView(), 1, 0, false)
|
||||||
|
|
||||||
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -102,7 +104,7 @@ func (s *appState) pop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *appState) mainMenu() tview.Primitive {
|
func (s *appState) mainMenu() tview.Primitive {
|
||||||
menu := NewMenu("Config Menu", nil)
|
menu := NewMenu("Menu", nil)
|
||||||
refreshMainMenu(menu, s)
|
refreshMainMenu(menu, s)
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
switch event.Key() {
|
switch event.Key() {
|
||||||
|
|
@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive {
|
||||||
s.requestExit()
|
s.requestExit()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if event.Rune() == 'q' {
|
|
||||||
s.requestExit()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
return event
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -131,6 +130,32 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *appState) countChannels() (enabled int, total int) {
|
||||||
|
c := s.config.Channels
|
||||||
|
entries := []bool{
|
||||||
|
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.Matrix.Enabled,
|
||||||
|
c.LINE.Enabled,
|
||||||
|
c.OneBot.Enabled,
|
||||||
|
c.WeCom.Enabled,
|
||||||
|
c.WeComApp.Enabled,
|
||||||
|
}
|
||||||
|
total = len(entries)
|
||||||
|
for _, v := range entries {
|
||||||
|
if v {
|
||||||
|
enabled++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enabled, total
|
||||||
|
}
|
||||||
|
|
||||||
func refreshMainMenuIfPresent(s *appState) {
|
func refreshMainMenuIfPresent(s *appState) {
|
||||||
if menu, ok := s.menus["main"]; ok {
|
if menu, ok := s.menus["main"]; ok {
|
||||||
refreshMainMenu(menu, s)
|
refreshMainMenu(menu, s)
|
||||||
|
|
@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
selectedModel := s.selectedModelName()
|
selectedModel := s.selectedModelName()
|
||||||
modelReady := selectedModel != ""
|
modelReady := selectedModel != ""
|
||||||
channelReady := s.hasEnabledChannel()
|
channelReady := s.hasEnabledChannel()
|
||||||
|
enabledCount, totalChannels := s.countChannels()
|
||||||
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
|
||||||
|
|
||||||
gatewayLabel := "Start Gateway"
|
gatewayLabel := "Start Gateway"
|
||||||
|
|
@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
items := []MenuItem{
|
items := []MenuItem{
|
||||||
{
|
{
|
||||||
Label: rootModelLabel(selectedModel),
|
Label: rootModelLabel(selectedModel),
|
||||||
Description: rootModelDescription(selectedModel),
|
Description: rootModelDescription(),
|
||||||
Action: func() {
|
Action: func() {
|
||||||
s.push("model", s.modelMenu())
|
s.push("model", s.modelMenu())
|
||||||
},
|
},
|
||||||
|
|
@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Label: rootChannelLabel(channelReady),
|
Label: rootChannelLabel(channelReady),
|
||||||
Description: rootChannelDescription(channelReady),
|
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
|
||||||
Action: func() {
|
Action: func() {
|
||||||
s.push("channel", s.channelMenu())
|
s.push("channel", s.channelMenu())
|
||||||
},
|
},
|
||||||
|
|
@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
|
||||||
|
|
||||||
func rootModelLabel(selected string) string {
|
func rootModelLabel(selected string) string {
|
||||||
if selected == "" {
|
if selected == "" {
|
||||||
return "Model (no model selected)"
|
return "Model (None)"
|
||||||
}
|
}
|
||||||
return "Model (" + selected + ")"
|
return "Model (" + selected + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootModelDescription(selected string) string {
|
func rootModelDescription() string {
|
||||||
if selected == "" {
|
return "Using SPACE to choose your model"
|
||||||
return "no model selected"
|
|
||||||
}
|
|
||||||
return "selected"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootChannelLabel(valid bool) string {
|
func rootChannelLabel(valid bool) string {
|
||||||
|
|
@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
|
||||||
return "Channel"
|
return "Channel"
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootChannelDescription(valid bool) string {
|
|
||||||
if !valid {
|
|
||||||
return "no channel enabled"
|
|
||||||
}
|
|
||||||
return "enabled"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *appState) startTalk() {
|
func (s *appState) startTalk() {
|
||||||
if !s.isActiveModelValid() {
|
if !s.isActiveModelValid() {
|
||||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||||
|
|
@ -423,7 +439,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()) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
|
|
||||||
func (s *appState) buildChannelMenuItems() []MenuItem {
|
func (s *appState) buildChannelMenuItems() []MenuItem {
|
||||||
return []MenuItem{
|
return []MenuItem{
|
||||||
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
||||||
channelItem(
|
channelItem(
|
||||||
"Telegram",
|
"Telegram",
|
||||||
"Telegram bot settings",
|
"Telegram bot settings",
|
||||||
|
|
@ -61,6 +60,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",
|
||||||
|
|
@ -95,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive {
|
||||||
s.pop()
|
s.pop()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if event.Rune() == 'q' {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
return event
|
||||||
})
|
})
|
||||||
return menu
|
return menu
|
||||||
|
|
@ -233,6 +234,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))
|
||||||
|
|
|
||||||
|
|
@ -14,23 +14,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *appState) modelMenu() tview.Primitive {
|
func (s *appState) modelMenu() tview.Primitive {
|
||||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||||
items = append(items,
|
|
||||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
||||||
MenuItem{
|
|
||||||
Label: "Add model",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
|
||||||
)
|
|
||||||
s.push(
|
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
||||||
s.modelForm(len(s.config.ModelList)-1),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
for i := range s.config.ModelList {
|
for i := range s.config.ModelList {
|
||||||
index := i
|
index := i
|
||||||
|
|
@ -57,6 +41,23 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Add model entry appended at the end so the models map to rows 1..N
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{
|
||||||
|
Label: "**Add model**",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
newName := s.nextAvailableModelName("new-model")
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(
|
||||||
|
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
||||||
|
s.modelForm(len(s.config.ModelList)-1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
menu := NewMenu("Models", items)
|
menu := NewMenu("Models", items)
|
||||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
|
@ -64,14 +65,11 @@ func (s *appState) modelMenu() tview.Primitive {
|
||||||
s.pop()
|
s.pop()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if event.Rune() == 'q' {
|
|
||||||
s.pop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if event.Rune() == ' ' {
|
if event.Rune() == ' ' {
|
||||||
row, _ := menu.GetSelection()
|
row, _ := menu.GetSelection()
|
||||||
if row > 0 && row <= len(s.config.ModelList) {
|
if row >= 0 && row < len(s.config.ModelList) {
|
||||||
model := s.config.ModelList[row-1]
|
model := s.config.ModelList[row]
|
||||||
if !isModelValid(model) {
|
if !isModelValid(model) {
|
||||||
s.showMessage(
|
s.showMessage(
|
||||||
"Invalid model",
|
"Invalid model",
|
||||||
|
|
@ -95,12 +93,23 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
model := &s.config.ModelList[index]
|
model := &s.config.ModelList[index]
|
||||||
form := tview.NewForm()
|
form := tview.NewForm()
|
||||||
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
|
|
||||||
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
|
|
||||||
|
|
||||||
addInput(form, "Model Name", model.ModelName, func(value string) {
|
addInput(form, "Model Name", model.ModelName, func(value string) {
|
||||||
|
if value == "" {
|
||||||
|
s.showMessage("Invalid model name", "Model Name cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.modelNameExists(value, index) {
|
||||||
|
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldName := model.ModelName
|
||||||
model.ModelName = value
|
model.ModelName = value
|
||||||
|
if s.config.Agents.Defaults.Model == oldName {
|
||||||
|
s.config.Agents.Defaults.Model = value
|
||||||
|
}
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
|
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
|
||||||
refreshMainMenuIfPresent(s)
|
refreshMainMenuIfPresent(s)
|
||||||
if menu, ok := s.menus["model"]; ok {
|
if menu, ok := s.menus["model"]; ok {
|
||||||
refreshModelMenuFromState(menu, s)
|
refreshModelMenuFromState(menu, s)
|
||||||
|
|
@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive {
|
||||||
})
|
})
|
||||||
|
|
||||||
form.AddButton("Delete", func() {
|
form.AddButton("Delete", func() {
|
||||||
|
pageName := "confirm-delete-model"
|
||||||
|
if s.pages.HasPage(pageName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modal := tview.NewModal().
|
||||||
|
SetText("Are you sure you want to delete this model?").
|
||||||
|
AddButtons([]string{"Cancel", "Delete"}).
|
||||||
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
s.pages.RemovePage(pageName)
|
||||||
|
if buttonLabel == "Delete" {
|
||||||
s.deleteModel(index)
|
s.deleteModel(index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
modal.SetTitle("Confirm Delete").SetBorder(true)
|
||||||
|
s.pages.AddPage(pageName, modal, true, true)
|
||||||
})
|
})
|
||||||
form.AddButton("Test", func() {
|
form.AddButton("Test", func() {
|
||||||
s.testModel(model)
|
s.testModel(model)
|
||||||
|
|
@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
|
||||||
|
|
||||||
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
|
||||||
for i, model := range models {
|
for i, model := range models {
|
||||||
row := i + 1
|
row := i
|
||||||
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
|
||||||
isValid := isModelValid(model)
|
isValid := isModelValid(model)
|
||||||
if model.ModelName == currentModel && currentModel != "" {
|
if model.ModelName == currentModel && currentModel != "" {
|
||||||
|
|
@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
|
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
|
||||||
items = append(items,
|
|
||||||
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
|
|
||||||
MenuItem{
|
|
||||||
Label: "Add model",
|
|
||||||
Description: "Append a new model entry",
|
|
||||||
Action: func() {
|
|
||||||
s.addModel(
|
|
||||||
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
|
|
||||||
)
|
|
||||||
s.push(
|
|
||||||
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
|
|
||||||
s.modelForm(len(s.config.ModelList)-1),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
|
||||||
for i := range s.config.ModelList {
|
for i := range s.config.ModelList {
|
||||||
index := i
|
index := i
|
||||||
|
|
@ -277,6 +284,19 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
items = append(items,
|
||||||
|
MenuItem{
|
||||||
|
Label: "**Add Model**",
|
||||||
|
Description: "Append a new model entry",
|
||||||
|
Action: func() {
|
||||||
|
newName := s.nextAvailableModelName("new-model")
|
||||||
|
s.addModel(
|
||||||
|
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
|
||||||
|
)
|
||||||
|
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
menu.applyItems(items)
|
menu.applyItems(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool {
|
||||||
return hasKey && hasModel
|
return hasKey && hasModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
|
||||||
|
target := strings.TrimSpace(name)
|
||||||
|
if target == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range s.config.ModelList {
|
||||||
|
if i == excludeIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *appState) nextAvailableModelName(base string) string {
|
||||||
|
name := strings.TrimSpace(base)
|
||||||
|
if name == "" {
|
||||||
|
name = "new-model"
|
||||||
|
}
|
||||||
|
if !s.modelNameExists(name, -1) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
for i := 2; ; i++ {
|
||||||
|
candidate := fmt.Sprintf("%s-%d", name, i)
|
||||||
|
if !s.modelNameExists(candidate, -1) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
|
||||||
if model == nil {
|
if model == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,15 @@ func bannerView() *tview.TextView {
|
||||||
text.SetBorder(false)
|
text.SetBorder(false)
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
|
||||||
|
|
||||||
|
func footerView() *tview.TextView {
|
||||||
|
text := tview.NewTextView()
|
||||||
|
text.SetTextAlign(tview.AlignCenter)
|
||||||
|
text.SetText(footerText)
|
||||||
|
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
|
||||||
|
text.SetTextColor(tview.Styles.PrimaryTextColor)
|
||||||
|
text.SetBorder(false)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
# PicoClaw Launcher
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
|
|
||||||
|
|
||||||
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 📝 **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
|
|
||||||
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, 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)
|
|
||||||
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
|
||||||
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
|
||||||
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build
|
|
||||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
|
||||||
|
|
||||||
# Run with default config path (~/.picoclaw/config.json)
|
|
||||||
./picoclaw-launcher
|
|
||||||
|
|
||||||
# Specify a config file
|
|
||||||
./picoclaw-launcher ./config.json
|
|
||||||
|
|
||||||
# Allow LAN access
|
|
||||||
./picoclaw-launcher -public
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://localhost:18800` in your browser.
|
|
||||||
|
|
||||||
## CLI Options
|
|
||||||
|
|
||||||
```
|
|
||||||
Usage: picoclaw-config [options] [config.json]
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
Base URL: `http://localhost:18800`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Static Files
|
|
||||||
|
|
||||||
#### GET /
|
|
||||||
|
|
||||||
Serves the embedded frontend (`index.html`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Config API
|
|
||||||
|
|
||||||
#### GET /api/config
|
|
||||||
|
|
||||||
Reads the current configuration file.
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"config": { ... },
|
|
||||||
"path": "/Users/xiao/.picoclaw/config.json"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### PUT /api/config
|
|
||||||
|
|
||||||
Saves the configuration. The request body must be a complete Config JSON object.
|
|
||||||
|
|
||||||
**Request Body** — `application/json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
|
||||||
"model_list": [
|
|
||||||
{
|
|
||||||
"model_name": "gpt-5.2",
|
|
||||||
"model": "openai/gpt-5.2",
|
|
||||||
"auth_method": "oauth"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "status": "ok" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error** `400 Bad Request` — Invalid JSON
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Auth API
|
|
||||||
|
|
||||||
#### GET /api/auth/status
|
|
||||||
|
|
||||||
Returns the authentication status of all providers and any in-progress device code login.
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"provider": "openai",
|
|
||||||
"auth_method": "oauth",
|
|
||||||
"status": "active",
|
|
||||||
"account_id": "user-xxx",
|
|
||||||
"expires_at": "2026-03-01T00:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pending_device": {
|
|
||||||
"provider": "openai",
|
|
||||||
"status": "pending",
|
|
||||||
"device_url": "https://auth.openai.com/activate",
|
|
||||||
"user_code": "ABCD-1234"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`status` values: `active` | `expired` | `needs_refresh`
|
|
||||||
|
|
||||||
`pending_device` is only present when a device code login is in progress.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### POST /api/auth/login
|
|
||||||
|
|
||||||
Initiates a provider login.
|
|
||||||
|
|
||||||
**Request Body** — `application/json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "provider": "openai" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
|
|
||||||
|
|
||||||
##### OpenAI (Device Code Flow)
|
|
||||||
|
|
||||||
Returns device code info. The server polls for completion in the background.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "pending",
|
|
||||||
"device_url": "https://auth.openai.com/activate",
|
|
||||||
"user_code": "ABCD-1234",
|
|
||||||
"message": "Open the URL and enter the code to authenticate."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
|
|
||||||
|
|
||||||
##### Anthropic (API Token)
|
|
||||||
|
|
||||||
Requires a `token` field in the request:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "status": "success", "message": "Anthropic token saved" }
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Google Antigravity (Browser OAuth)
|
|
||||||
|
|
||||||
Returns an authorization URL for the frontend to open in a new tab:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "redirect",
|
|
||||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
|
||||||
"message": "Open the URL to authenticate with Google."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### POST /api/auth/logout
|
|
||||||
|
|
||||||
Logs out from a provider.
|
|
||||||
|
|
||||||
**Request Body** — `application/json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "provider": "openai" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Omit or leave `provider` empty to log out from all providers.
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "status": "ok" }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### GET /auth/callback
|
|
||||||
|
|
||||||
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `state` — OAuth state for CSRF validation
|
|
||||||
- `code` — Authorization code
|
|
||||||
|
|
||||||
On success, redirects to `/#auth`.
|
|
||||||
|
|
||||||
|
|
||||||
### Process API
|
|
||||||
|
|
||||||
#### GET /api/process/status
|
|
||||||
|
|
||||||
Gets the running status of the `picoclaw gateway` process.
|
|
||||||
|
|
||||||
**Response** `200 OK` (Running)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"process_status": "running",
|
|
||||||
"status": "ok",
|
|
||||||
"uptime": "1.010814s"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response** `200 OK` (Stopped)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"process_status": "stopped",
|
|
||||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### POST /api/process/start
|
|
||||||
|
|
||||||
Starts the `picoclaw gateway` process in the background.
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"pid": 12345
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### POST /api/process/stop
|
|
||||||
|
|
||||||
Stops the running `picoclaw gateway` process.
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test -v ./cmd/picoclaw-launcher/
|
|
||||||
```
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
# PicoClaw Launcher
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
|
|
||||||
|
|
||||||
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
|
|
||||||
|
|
||||||
## 功能
|
|
||||||
|
|
||||||
- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器
|
|
||||||
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
|
|
||||||
- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
|
|
||||||
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
|
|
||||||
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
|
|
||||||
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
|
|
||||||
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 编译
|
|
||||||
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
|
||||||
|
|
||||||
# 运行(使用默认配置路径 ~/.picoclaw/config.json)
|
|
||||||
./picoclaw-launcher
|
|
||||||
|
|
||||||
# 指定配置文件
|
|
||||||
./picoclaw-launcher ./config.json
|
|
||||||
|
|
||||||
# 允许局域网访问
|
|
||||||
./picoclaw-launcher -public
|
|
||||||
```
|
|
||||||
|
|
||||||
启动后在浏览器中打开 `http://localhost:18800`。
|
|
||||||
|
|
||||||
## 命令行参数
|
|
||||||
|
|
||||||
```
|
|
||||||
Usage: picoclaw-launcher [options] [config.json]
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
config.json 配置文件路径(默认: ~/.picoclaw/config.json)
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-public 监听所有网络接口(0.0.0.0),允许局域网设备访问
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 文档
|
|
||||||
|
|
||||||
Base URL: `http://localhost:18800`
|
|
||||||
|
|
||||||
### 静态文件
|
|
||||||
|
|
||||||
#### GET /
|
|
||||||
|
|
||||||
提供嵌入式前端页面(`index.html`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Config API
|
|
||||||
|
|
||||||
#### GET /api/config
|
|
||||||
|
|
||||||
读取当前配置文件内容。
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"config": { ... },
|
|
||||||
"path": "/Users/xiao/.picoclaw/config.json"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### PUT /api/config
|
|
||||||
|
|
||||||
保存配置。请求体为完整的 Config JSON。
|
|
||||||
|
|
||||||
**Request Body** — `application/json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": { "defaults": { "model_name": "gpt-5.2" } },
|
|
||||||
"model_list": [
|
|
||||||
{
|
|
||||||
"model_name": "gpt-5.2",
|
|
||||||
"model": "openai/gpt-5.2",
|
|
||||||
"auth_method": "oauth"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "status": "ok" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error** `400 Bad Request` — 无效 JSON
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Auth API
|
|
||||||
|
|
||||||
#### GET /api/auth/status
|
|
||||||
|
|
||||||
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": [
|
|
||||||
{
|
|
||||||
"provider": "openai",
|
|
||||||
"auth_method": "oauth",
|
|
||||||
"status": "active",
|
|
||||||
"account_id": "user-xxx",
|
|
||||||
"expires_at": "2026-03-01T00:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pending_device": {
|
|
||||||
"provider": "openai",
|
|
||||||
"status": "pending",
|
|
||||||
"device_url": "https://auth.openai.com/activate",
|
|
||||||
"user_code": "ABCD-1234"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`status` 可选值: `active` | `expired` | `needs_refresh`
|
|
||||||
|
|
||||||
`pending_device` 仅在有进行中的 Device Code 登录时返回。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### POST /api/auth/login
|
|
||||||
|
|
||||||
发起 Provider 登录。
|
|
||||||
|
|
||||||
**Request Body** — `application/json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "provider": "openai" }
|
|
||||||
```
|
|
||||||
|
|
||||||
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
|
|
||||||
|
|
||||||
##### OpenAI (Device Code Flow)
|
|
||||||
|
|
||||||
返回 Device Code 信息,后台自动轮询认证结果:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "pending",
|
|
||||||
"device_url": "https://auth.openai.com/activate",
|
|
||||||
"user_code": "ABCD-1234",
|
|
||||||
"message": "Open the URL and enter the code to authenticate."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。
|
|
||||||
|
|
||||||
##### Anthropic (API Token)
|
|
||||||
|
|
||||||
需在请求中附带 token:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "provider": "anthropic", "token": "sk-ant-xxx" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "status": "success", "message": "Anthropic token saved" }
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Google Antigravity (Browser OAuth)
|
|
||||||
|
|
||||||
返回授权 URL,前端打开新标签页:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "redirect",
|
|
||||||
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
|
|
||||||
"message": "Open the URL to authenticate with Google."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### POST /api/auth/logout
|
|
||||||
|
|
||||||
登出 Provider。
|
|
||||||
|
|
||||||
**Request Body** — `application/json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "provider": "openai" }
|
|
||||||
```
|
|
||||||
|
|
||||||
传空字符串或省略 `provider` 则登出所有 Provider。
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "status": "ok" }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### GET /auth/callback
|
|
||||||
|
|
||||||
OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `state` — OAuth state 校验
|
|
||||||
- `code` — 授权码
|
|
||||||
|
|
||||||
认证成功后重定向到 `/#auth`。
|
|
||||||
|
|
||||||
### Process API
|
|
||||||
|
|
||||||
#### GET /api/process/status
|
|
||||||
|
|
||||||
获取 `picoclaw gateway` 进程的运行状态。
|
|
||||||
|
|
||||||
**Response** `200 OK` (运行中)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"process_status": "running",
|
|
||||||
"status": "ok",
|
|
||||||
"uptime": "1.010814s"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response** `200 OK` (未运行)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"process_status": "stopped",
|
|
||||||
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### POST /api/process/start
|
|
||||||
|
|
||||||
在后台启动 `picoclaw gateway` 进程。
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"pid": 12345
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### POST /api/process/stop
|
|
||||||
|
|
||||||
停止正在运行的 `picoclaw gateway` 进程。
|
|
||||||
|
|
||||||
**Response** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test -v ./cmd/picoclaw-launcher/
|
|
||||||
```
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// updateConfigAfterLogin updates config.json after a successful provider login.
|
|
||||||
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: could not load config to update auth_method: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch provider {
|
|
||||||
case "openai":
|
|
||||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
|
||||||
found := false
|
|
||||||
for i := range cfg.ModelList {
|
|
||||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
|
||||||
cfg.ModelList[i].AuthMethod = "oauth"
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
|
||||||
ModelName: "gpt-5.2",
|
|
||||||
Model: "openai/gpt-5.2",
|
|
||||||
AuthMethod: "oauth",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
cfg.Agents.Defaults.ModelName = "gpt-5.2"
|
|
||||||
|
|
||||||
case "anthropic":
|
|
||||||
cfg.Providers.Anthropic.AuthMethod = "token"
|
|
||||||
found := false
|
|
||||||
for i := range cfg.ModelList {
|
|
||||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
|
||||||
cfg.ModelList[i].AuthMethod = "token"
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
|
||||||
ModelName: "claude-sonnet-4.6",
|
|
||||||
Model: "anthropic/claude-sonnet-4.6",
|
|
||||||
AuthMethod: "token",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
|
||||||
|
|
||||||
case "google-antigravity":
|
|
||||||
cfg.Providers.Antigravity.AuthMethod = "oauth"
|
|
||||||
found := false
|
|
||||||
for i := range cfg.ModelList {
|
|
||||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
|
||||||
cfg.ModelList[i].AuthMethod = "oauth"
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
|
||||||
ModelName: "gemini-flash",
|
|
||||||
Model: "antigravity/gemini-3-flash",
|
|
||||||
AuthMethod: "oauth",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
cfg.Agents.Defaults.ModelName = "gemini-flash"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
||||||
log.Printf("Warning: could not update config: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
|
|
||||||
func clearAuthMethodInConfig(configPath, provider string) {
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range cfg.ModelList {
|
|
||||||
switch provider {
|
|
||||||
case "openai":
|
|
||||||
if isOpenAIModel(cfg.ModelList[i].Model) {
|
|
||||||
cfg.ModelList[i].AuthMethod = ""
|
|
||||||
}
|
|
||||||
case "anthropic":
|
|
||||||
if isAnthropicModel(cfg.ModelList[i].Model) {
|
|
||||||
cfg.ModelList[i].AuthMethod = ""
|
|
||||||
}
|
|
||||||
case "google-antigravity", "antigravity":
|
|
||||||
if isAntigravityModel(cfg.ModelList[i].Model) {
|
|
||||||
cfg.ModelList[i].AuthMethod = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch provider {
|
|
||||||
case "openai":
|
|
||||||
cfg.Providers.OpenAI.AuthMethod = ""
|
|
||||||
case "anthropic":
|
|
||||||
cfg.Providers.Anthropic.AuthMethod = ""
|
|
||||||
case "google-antigravity", "antigravity":
|
|
||||||
cfg.Providers.Antigravity.AuthMethod = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
config.SaveConfig(configPath, cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
|
|
||||||
func clearAllAuthMethodsInConfig(configPath string) {
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i := range cfg.ModelList {
|
|
||||||
cfg.ModelList[i].AuthMethod = ""
|
|
||||||
}
|
|
||||||
cfg.Providers.OpenAI.AuthMethod = ""
|
|
||||||
cfg.Providers.Anthropic.AuthMethod = ""
|
|
||||||
cfg.Providers.Antigravity.AuthMethod = ""
|
|
||||||
config.SaveConfig(configPath, cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Model identification helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func isOpenAIModel(model string) bool {
|
|
||||||
return model == "openai" || strings.HasPrefix(model, "openai/")
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAnthropicModel(model string) bool {
|
|
||||||
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAntigravityModel(model string) bool {
|
|
||||||
return model == "antigravity" || model == "google-antigravity" ||
|
|
||||||
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
|
|
||||||
}
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Model identification helpers ─────────────────────────────────
|
|
||||||
|
|
||||||
func TestIsOpenAIModel(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
model string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"openai", true},
|
|
||||||
{"openai/gpt-4o", true},
|
|
||||||
{"openai/gpt-5.2", true},
|
|
||||||
{"anthropic", false},
|
|
||||||
{"anthropic/claude-sonnet-4.6", false},
|
|
||||||
{"openai-compatible", false},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
if got := isOpenAIModel(tt.model); got != tt.want {
|
|
||||||
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsAnthropicModel(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
model string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"anthropic", true},
|
|
||||||
{"anthropic/claude-sonnet-4.6", true},
|
|
||||||
{"openai", false},
|
|
||||||
{"openai/gpt-4o", false},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
if got := isAnthropicModel(tt.model); got != tt.want {
|
|
||||||
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsAntigravityModel(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
model string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"antigravity", true},
|
|
||||||
{"google-antigravity", true},
|
|
||||||
{"antigravity/gemini-3-flash", true},
|
|
||||||
{"google-antigravity/gemini-3-flash", true},
|
|
||||||
{"openai", false},
|
|
||||||
{"antigravity-custom", false},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
if got := isAntigravityModel(tt.model); got != tt.want {
|
|
||||||
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Config update helpers ────────────────────────────────────────
|
|
||||||
|
|
||||||
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.json")
|
|
||||||
if err := config.SaveConfig(path, cfg); err != nil {
|
|
||||||
t.Fatalf("save config: %v", err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadTempConfig(t *testing.T, path string) *config.Config {
|
|
||||||
t.Helper()
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load config: %v", err)
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
ModelList: []config.ModelConfig{
|
|
||||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
path := writeTempConfigViaSave(t, cfg)
|
|
||||||
|
|
||||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
|
||||||
updateConfigAfterLogin(path, "openai", cred)
|
|
||||||
|
|
||||||
result := loadTempConfig(t, path)
|
|
||||||
|
|
||||||
// Model-level auth_method persists through serialization
|
|
||||||
if len(result.ModelList) != 1 {
|
|
||||||
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
|
|
||||||
}
|
|
||||||
if result.ModelList[0].AuthMethod != "oauth" {
|
|
||||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
ModelList: []config.ModelConfig{
|
|
||||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
path := writeTempConfigViaSave(t, cfg)
|
|
||||||
|
|
||||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
|
||||||
updateConfigAfterLogin(path, "openai", cred)
|
|
||||||
|
|
||||||
result := loadTempConfig(t, path)
|
|
||||||
|
|
||||||
if len(result.ModelList) != 2 {
|
|
||||||
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
|
|
||||||
}
|
|
||||||
if result.ModelList[1].Model != "openai/gpt-5.2" {
|
|
||||||
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
|
|
||||||
}
|
|
||||||
if result.Agents.Defaults.ModelName != "gpt-5.2" {
|
|
||||||
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
path := writeTempConfigViaSave(t, cfg)
|
|
||||||
|
|
||||||
cred := &auth.AuthCredential{AuthMethod: "token"}
|
|
||||||
updateConfigAfterLogin(path, "anthropic", cred)
|
|
||||||
|
|
||||||
result := loadTempConfig(t, path)
|
|
||||||
|
|
||||||
// Model should be added with correct auth_method
|
|
||||||
if len(result.ModelList) != 1 {
|
|
||||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
|
||||||
}
|
|
||||||
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
|
||||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
|
|
||||||
}
|
|
||||||
if result.ModelList[0].AuthMethod != "token" {
|
|
||||||
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
path := writeTempConfigViaSave(t, cfg)
|
|
||||||
|
|
||||||
cred := &auth.AuthCredential{AuthMethod: "oauth"}
|
|
||||||
updateConfigAfterLogin(path, "google-antigravity", cred)
|
|
||||||
|
|
||||||
result := loadTempConfig(t, path)
|
|
||||||
|
|
||||||
// Model should be added with correct auth_method
|
|
||||||
if len(result.ModelList) != 1 {
|
|
||||||
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
|
|
||||||
}
|
|
||||||
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
|
|
||||||
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
|
|
||||||
}
|
|
||||||
if result.ModelList[0].AuthMethod != "oauth" {
|
|
||||||
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClearAuthMethodInConfig(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
ModelList: []config.ModelConfig{
|
|
||||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
|
||||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
path := writeTempConfigViaSave(t, cfg)
|
|
||||||
|
|
||||||
clearAuthMethodInConfig(path, "openai")
|
|
||||||
|
|
||||||
result := loadTempConfig(t, path)
|
|
||||||
|
|
||||||
// Openai model auth_method should be cleared
|
|
||||||
if result.ModelList[0].AuthMethod != "" {
|
|
||||||
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
|
|
||||||
}
|
|
||||||
// Anthropic model should be unchanged
|
|
||||||
if result.ModelList[1].AuthMethod != "token" {
|
|
||||||
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClearAllAuthMethodsInConfig(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
ModelList: []config.ModelConfig{
|
|
||||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
|
|
||||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
|
||||||
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
path := writeTempConfigViaSave(t, cfg)
|
|
||||||
|
|
||||||
clearAllAuthMethodsInConfig(path)
|
|
||||||
|
|
||||||
result := loadTempConfig(t, path)
|
|
||||||
|
|
||||||
for i, m := range result.ModelList {
|
|
||||||
if m.AuthMethod != "" {
|
|
||||||
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
|
||||||
)
|
|
||||||
|
|
||||||
// oauthSession stores in-flight OAuth state for browser-based flows.
|
|
||||||
type oauthSession struct {
|
|
||||||
Provider string
|
|
||||||
PKCE auth.PKCECodes
|
|
||||||
State string
|
|
||||||
RedirectURI string
|
|
||||||
OAuthCfg auth.OAuthProviderConfig
|
|
||||||
ConfigPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// deviceCodeSession stores in-flight device code flow state.
|
|
||||||
type deviceCodeSession struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
Provider string
|
|
||||||
Info *auth.DeviceCodeInfo
|
|
||||||
OAuthCfg auth.OAuthProviderConfig
|
|
||||||
ConfigPath string
|
|
||||||
Status string // "pending", "success", "error"
|
|
||||||
Error string
|
|
||||||
Done bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
oauthSessions = map[string]*oauthSession{} // keyed by state
|
|
||||||
oauthSessionsMu sync.Mutex
|
|
||||||
|
|
||||||
activeDeviceSession *deviceCodeSession
|
|
||||||
activeDeviceSessionMu sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
|
|
||||||
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
|
|
||||||
// Check if there's already a pending device code session
|
|
||||||
activeDeviceSessionMu.Lock()
|
|
||||||
if activeDeviceSession != nil {
|
|
||||||
activeDeviceSession.mu.Lock()
|
|
||||||
if !activeDeviceSession.Done {
|
|
||||||
resp := map[string]any{
|
|
||||||
"status": "pending",
|
|
||||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
|
||||||
"user_code": activeDeviceSession.Info.UserCode,
|
|
||||||
"message": "Device code flow already in progress. Enter the code in your browser.",
|
|
||||||
}
|
|
||||||
activeDeviceSession.mu.Unlock()
|
|
||||||
activeDeviceSessionMu.Unlock()
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeDeviceSession.mu.Unlock()
|
|
||||||
}
|
|
||||||
activeDeviceSessionMu.Unlock()
|
|
||||||
|
|
||||||
// Request a device code
|
|
||||||
oauthCfg := auth.OpenAIOAuthConfig()
|
|
||||||
info, err := auth.RequestDeviceCode(oauthCfg)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session := &deviceCodeSession{
|
|
||||||
Provider: "openai",
|
|
||||||
Info: info,
|
|
||||||
OAuthCfg: oauthCfg,
|
|
||||||
ConfigPath: configPath,
|
|
||||||
Status: "pending",
|
|
||||||
}
|
|
||||||
|
|
||||||
activeDeviceSessionMu.Lock()
|
|
||||||
activeDeviceSession = session
|
|
||||||
activeDeviceSessionMu.Unlock()
|
|
||||||
|
|
||||||
// Start background polling
|
|
||||||
go func() {
|
|
||||||
deadline := time.After(15 * time.Minute)
|
|
||||||
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-deadline:
|
|
||||||
session.mu.Lock()
|
|
||||||
session.Status = "error"
|
|
||||||
session.Error = "Authentication timed out after 15 minutes"
|
|
||||||
session.Done = true
|
|
||||||
session.mu.Unlock()
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
|
|
||||||
if err != nil {
|
|
||||||
continue // Still pending
|
|
||||||
}
|
|
||||||
if cred != nil {
|
|
||||||
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
|
|
||||||
session.mu.Lock()
|
|
||||||
session.Status = "error"
|
|
||||||
session.Error = saveErr.Error()
|
|
||||||
session.Done = true
|
|
||||||
session.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateConfigAfterLogin(configPath, "openai", cred)
|
|
||||||
session.mu.Lock()
|
|
||||||
session.Status = "success"
|
|
||||||
session.Done = true
|
|
||||||
session.mu.Unlock()
|
|
||||||
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Return device code info to frontend
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"status": "pending",
|
|
||||||
"device_url": info.VerifyURL,
|
|
||||||
"user_code": info.UserCode,
|
|
||||||
"message": "Open the URL and enter the code to authenticate.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAnthropicLogin saves a pasted API token for Anthropic.
|
|
||||||
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
|
|
||||||
if token == "" {
|
|
||||||
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cred := &auth.AuthCredential{
|
|
||||||
AccessToken: token,
|
|
||||||
Provider: "anthropic",
|
|
||||||
AuthMethod: "token",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := auth.SetCredential("anthropic", cred); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConfigAfterLogin(configPath, "anthropic", cred)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"status": "success",
|
|
||||||
"message": "Anthropic token saved",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
|
|
||||||
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
|
|
||||||
oauthCfg := auth.GoogleAntigravityOAuthConfig()
|
|
||||||
|
|
||||||
pkce, err := auth.GeneratePKCE()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := auth.GenerateState()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build redirect URI pointing to picoclaw-launcher's own callback
|
|
||||||
scheme := "http"
|
|
||||||
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
|
|
||||||
|
|
||||||
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
|
|
||||||
|
|
||||||
// Store session for callback
|
|
||||||
oauthSessionsMu.Lock()
|
|
||||||
oauthSessions[state] = &oauthSession{
|
|
||||||
Provider: "google-antigravity",
|
|
||||||
PKCE: pkce,
|
|
||||||
State: state,
|
|
||||||
RedirectURI: redirectURI,
|
|
||||||
OAuthCfg: oauthCfg,
|
|
||||||
ConfigPath: configPath,
|
|
||||||
}
|
|
||||||
oauthSessionsMu.Unlock()
|
|
||||||
|
|
||||||
// Clean up stale sessions after 10 minutes
|
|
||||||
go func() {
|
|
||||||
time.Sleep(10 * time.Minute)
|
|
||||||
oauthSessionsMu.Lock()
|
|
||||||
delete(oauthSessions, state)
|
|
||||||
oauthSessionsMu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"status": "redirect",
|
|
||||||
"auth_url": authURL,
|
|
||||||
"message": "Open the URL to authenticate with Google.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
|
|
||||||
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
|
||||||
state := r.URL.Query().Get("state")
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
|
|
||||||
oauthSessionsMu.Lock()
|
|
||||||
session, ok := oauthSessions[state]
|
|
||||||
if ok {
|
|
||||||
delete(oauthSessions, state)
|
|
||||||
}
|
|
||||||
oauthSessionsMu.Unlock()
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if code == "" {
|
|
||||||
errMsg := r.URL.Query().Get("error")
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
fmt.Fprintf(
|
|
||||||
w,
|
|
||||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
|
||||||
errMsg,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI)
|
|
||||||
if err != nil {
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
fmt.Fprintf(
|
|
||||||
w,
|
|
||||||
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
|
|
||||||
err.Error(),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cred.Provider = session.Provider
|
|
||||||
|
|
||||||
// Fetch user info for Google Antigravity
|
|
||||||
if session.Provider == "google-antigravity" {
|
|
||||||
if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil {
|
|
||||||
cred.Email = email
|
|
||||||
}
|
|
||||||
if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil {
|
|
||||||
cred.ProjectID = projectID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := auth.SetCredential(session.Provider, cred); err != nil {
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
fmt.Fprintf(w, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConfigAfterLogin(session.ConfigPath, session.Provider, cred)
|
|
||||||
|
|
||||||
// Redirect back to picoclaw-launcher UI
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
fmt.Fprintf(w, `<html><body>
|
|
||||||
<h2>Authentication successful!</h2>
|
|
||||||
<p>Redirecting back to Config Editor...</p>
|
|
||||||
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
|
|
||||||
</body></html>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint.
|
|
||||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
|
||||||
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("reading userinfo response: %w", err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("userinfo request failed: %s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var userInfo struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &userInfo); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return userInfo.Email, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLogBuffer_Basic(t *testing.T) {
|
|
||||||
buf := NewLogBuffer(5)
|
|
||||||
|
|
||||||
// Empty buffer
|
|
||||||
lines, total, runID := buf.LinesSince(0)
|
|
||||||
assert.Nil(t, lines)
|
|
||||||
assert.Equal(t, 0, total)
|
|
||||||
assert.Equal(t, 0, runID)
|
|
||||||
|
|
||||||
// Append some lines
|
|
||||||
buf.Append("line1")
|
|
||||||
buf.Append("line2")
|
|
||||||
buf.Append("line3")
|
|
||||||
|
|
||||||
lines, total, runID = buf.LinesSince(0)
|
|
||||||
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
|
|
||||||
assert.Equal(t, 3, total)
|
|
||||||
assert.Equal(t, 0, runID)
|
|
||||||
|
|
||||||
// Incremental read
|
|
||||||
lines, total, _ = buf.LinesSince(2)
|
|
||||||
assert.Equal(t, []string{"line3"}, lines)
|
|
||||||
assert.Equal(t, 3, total)
|
|
||||||
|
|
||||||
// No new lines
|
|
||||||
lines, total, _ = buf.LinesSince(3)
|
|
||||||
assert.Nil(t, lines)
|
|
||||||
assert.Equal(t, 3, total)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogBuffer_Wrap(t *testing.T) {
|
|
||||||
buf := NewLogBuffer(3)
|
|
||||||
|
|
||||||
buf.Append("a")
|
|
||||||
buf.Append("b")
|
|
||||||
buf.Append("c")
|
|
||||||
buf.Append("d") // evicts "a"
|
|
||||||
buf.Append("e") // evicts "b"
|
|
||||||
|
|
||||||
lines, total, _ := buf.LinesSince(0)
|
|
||||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
|
||||||
assert.Equal(t, 5, total)
|
|
||||||
|
|
||||||
// Incremental after wrap
|
|
||||||
lines, total, _ = buf.LinesSince(3)
|
|
||||||
assert.Equal(t, []string{"d", "e"}, lines)
|
|
||||||
assert.Equal(t, 5, total)
|
|
||||||
|
|
||||||
// Offset too old (before buffer start), get all buffered
|
|
||||||
lines, total, _ = buf.LinesSince(1)
|
|
||||||
assert.Equal(t, []string{"c", "d", "e"}, lines)
|
|
||||||
assert.Equal(t, 5, total)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogBuffer_Reset(t *testing.T) {
|
|
||||||
buf := NewLogBuffer(5)
|
|
||||||
|
|
||||||
buf.Append("before")
|
|
||||||
assert.Equal(t, 0, buf.RunID())
|
|
||||||
|
|
||||||
buf.Reset()
|
|
||||||
assert.Equal(t, 1, buf.RunID())
|
|
||||||
assert.Equal(t, 0, buf.Total())
|
|
||||||
|
|
||||||
lines, total, runID := buf.LinesSince(0)
|
|
||||||
assert.Nil(t, lines)
|
|
||||||
assert.Equal(t, 0, total)
|
|
||||||
assert.Equal(t, 1, runID)
|
|
||||||
|
|
||||||
buf.Append("after")
|
|
||||||
lines, total, runID = buf.LinesSince(0)
|
|
||||||
assert.Equal(t, []string{"after"}, lines)
|
|
||||||
assert.Equal(t, 1, total)
|
|
||||||
assert.Equal(t, 1, runID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogBuffer_Concurrent(t *testing.T) {
|
|
||||||
buf := NewLogBuffer(100)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
|
|
||||||
// 10 writers
|
|
||||||
for i := range 10 {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(id int) {
|
|
||||||
defer wg.Done()
|
|
||||||
for j := range 50 {
|
|
||||||
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5 readers
|
|
||||||
for range 5 {
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for range 100 {
|
|
||||||
buf.LinesSince(0)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
assert.Equal(t, 500, buf.Total())
|
|
||||||
}
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
|
|
||||||
var gatewayLogs = NewLogBuffer(200)
|
|
||||||
|
|
||||||
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
|
|
||||||
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
|
|
||||||
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
handleStatusGateway(w, r, absPath)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("POST /api/process/start", handleStartGateway)
|
|
||||||
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Locate picoclaw executable:
|
|
||||||
// 1. Try same directory as current executable
|
|
||||||
// 2. Fallback to just "picoclaw" (relies on $PATH)
|
|
||||||
execPath := "picoclaw"
|
|
||||||
|
|
||||||
if exe, err := os.Executable(); err == nil {
|
|
||||||
dir := filepath.Dir(exe)
|
|
||||||
candidate := filepath.Join(dir, "picoclaw")
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
candidate += ".exe"
|
|
||||||
}
|
|
||||||
|
|
||||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
|
||||||
execPath = candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(execPath, "gateway")
|
|
||||||
|
|
||||||
stdoutPipe, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to create stdout pipe: %v\n", err)
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stderrPipe, err := cmd.StderrPipe()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to create stderr pipe: %v\n", err)
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear old logs and increment runID before starting
|
|
||||||
gatewayLogs.Reset()
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
log.Printf("Failed to start picoclaw gateway: %v\n", err)
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read stdout and stderr into the log buffer
|
|
||||||
go scanPipe(stdoutPipe, gatewayLogs)
|
|
||||||
go scanPipe(stderrPipe, gatewayLogs)
|
|
||||||
|
|
||||||
// Wait for the process to exit in the background to avoid zombies
|
|
||||||
go func() {
|
|
||||||
if err := cmd.Wait(); err != nil {
|
|
||||||
log.Printf("Gateway process exited: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"status": "ok",
|
|
||||||
"pid": cmd.Process.Pid,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF.
|
|
||||||
func scanPipe(r io.Reader, buf *LogBuffer) {
|
|
||||||
scanner := bufio.NewScanner(r)
|
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
buf.Append(scanner.Text())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var err error
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
|
|
||||||
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
|
|
||||||
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
|
|
||||||
err = exec.Command("powershell", "-Command", psCmd).Run()
|
|
||||||
} else {
|
|
||||||
// Linux/macOS
|
|
||||||
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
|
|
||||||
// We still return 200 OK because pkill returns an error if no process was found
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"status": "ok", // or "not_found"
|
|
||||||
"msg": "Stop command executed, but returned error (process might not be running).",
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Stopped picoclaw gateway processes.\n")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
|
||||||
"status": "ok",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
|
|
||||||
cfg, cfgErr := config.LoadConfig(absPath)
|
|
||||||
host := "127.0.0.1"
|
|
||||||
port := 18790
|
|
||||||
if cfgErr == nil && cfg != nil {
|
|
||||||
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
|
|
||||||
host = cfg.Gateway.Host
|
|
||||||
}
|
|
||||||
if cfg.Gateway.Port != 0 {
|
|
||||||
port = cfg.Gateway.Port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
|
||||||
client := http.Client{Timeout: 2 * time.Second}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
|
|
||||||
// Build the response data map
|
|
||||||
data := map[string]any{}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
data["process_status"] = "stopped"
|
|
||||||
data["error"] = err.Error()
|
|
||||||
} else {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
data["process_status"] = "error"
|
|
||||||
data["status_code"] = resp.StatusCode
|
|
||||||
} else {
|
|
||||||
var healthData map[string]any
|
|
||||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
|
||||||
data["process_status"] = "error"
|
|
||||||
data["error"] = "invalid response from gateway"
|
|
||||||
} else {
|
|
||||||
// Gateway is running and responded properly — merge health data
|
|
||||||
for k, v := range healthData {
|
|
||||||
data[k] = v
|
|
||||||
}
|
|
||||||
data["process_status"] = "running"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append log data from the buffer
|
|
||||||
appendLogData(r, data)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendLogData reads log_offset and log_run_id query params from the request and
|
|
||||||
// populates the response data map with incremental log lines.
|
|
||||||
func appendLogData(r *http.Request, data map[string]any) {
|
|
||||||
clientOffset := 0
|
|
||||||
clientRunID := -1
|
|
||||||
|
|
||||||
if v := r.URL.Query().Get("log_offset"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
clientOffset = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if v := r.URL.Query().Get("log_run_id"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
|
||||||
clientRunID = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runID := gatewayLogs.RunID()
|
|
||||||
|
|
||||||
// If runID is 0 (never reset = never launched from this launcher), report no source
|
|
||||||
if runID == 0 {
|
|
||||||
data["logs"] = []string{}
|
|
||||||
data["log_total"] = 0
|
|
||||||
data["log_run_id"] = 0
|
|
||||||
data["log_source"] = "none"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
|
|
||||||
offset := clientOffset
|
|
||||||
if clientRunID != runID {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
lines, total, runID := gatewayLogs.LinesSince(offset)
|
|
||||||
if lines == nil {
|
|
||||||
lines = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
data["logs"] = lines
|
|
||||||
data["log_total"] = total
|
|
||||||
data["log_run_id"] = runID
|
|
||||||
data["log_source"] = "launcher"
|
|
||||||
}
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
const DefaultPort = "18800"
|
|
||||||
|
|
||||||
// providerStatus represents the auth status of a single provider in API responses.
|
|
||||||
type providerStatus struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
AuthMethod string `json:"auth_method"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
AccountID string `json:"account_id,omitempty"`
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
|
||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Route registration ───────────────────────────────────────────
|
|
||||||
|
|
||||||
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
|
|
||||||
// GET /api/config — read config
|
|
||||||
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cfg, err := config.LoadConfig(absPath)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
resp := map[string]any{
|
|
||||||
"config": cfg,
|
|
||||||
"path": absPath,
|
|
||||||
}
|
|
||||||
enc := json.NewEncoder(w)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
if err := enc.Encode(resp); err != nil {
|
|
||||||
log.Printf("Failed to encode response: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// PUT /api/config — save config
|
|
||||||
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer r.Body.Close()
|
|
||||||
|
|
||||||
var cfg config.Config
|
|
||||||
if err := json.Unmarshal(body, &cfg); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.SaveConfig(absPath, &cfg); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
|
|
||||||
// GET /api/auth/status — all authenticated providers + pending login state
|
|
||||||
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
store, err := auth.LoadStore()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result := []providerStatus{}
|
|
||||||
for name, cred := range store.Credentials {
|
|
||||||
status := "active"
|
|
||||||
if cred.IsExpired() {
|
|
||||||
status = "expired"
|
|
||||||
} else if cred.NeedsRefresh() {
|
|
||||||
status = "needs_refresh"
|
|
||||||
}
|
|
||||||
ps := providerStatus{
|
|
||||||
Provider: name,
|
|
||||||
AuthMethod: cred.AuthMethod,
|
|
||||||
Status: status,
|
|
||||||
AccountID: cred.AccountID,
|
|
||||||
Email: cred.Email,
|
|
||||||
ProjectID: cred.ProjectID,
|
|
||||||
}
|
|
||||||
if !cred.ExpiresAt.IsZero() {
|
|
||||||
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
result = append(result, ps)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include pending device code state
|
|
||||||
var pendingDevice map[string]any
|
|
||||||
activeDeviceSessionMu.Lock()
|
|
||||||
if activeDeviceSession != nil {
|
|
||||||
activeDeviceSession.mu.Lock()
|
|
||||||
pendingDevice = map[string]any{
|
|
||||||
"provider": activeDeviceSession.Provider,
|
|
||||||
"status": activeDeviceSession.Status,
|
|
||||||
"device_url": activeDeviceSession.Info.VerifyURL,
|
|
||||||
"user_code": activeDeviceSession.Info.UserCode,
|
|
||||||
}
|
|
||||||
if activeDeviceSession.Error != "" {
|
|
||||||
pendingDevice["error"] = activeDeviceSession.Error
|
|
||||||
}
|
|
||||||
if activeDeviceSession.Done {
|
|
||||||
activeDeviceSession.mu.Unlock()
|
|
||||||
activeDeviceSession = nil
|
|
||||||
} else {
|
|
||||||
activeDeviceSession.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeDeviceSessionMu.Unlock()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"providers": result,
|
|
||||||
"pending_device": pendingDevice,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/auth/login — initiate provider login
|
|
||||||
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Token string `json:"token,omitempty"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch req.Provider {
|
|
||||||
case "openai":
|
|
||||||
handleOpenAILogin(w, absPath)
|
|
||||||
case "anthropic":
|
|
||||||
handleAnthropicLogin(w, req.Token, absPath)
|
|
||||||
case "google-antigravity", "antigravity":
|
|
||||||
handleGoogleAntigravityLogin(w, r, absPath)
|
|
||||||
default:
|
|
||||||
http.Error(
|
|
||||||
w,
|
|
||||||
fmt.Sprintf(
|
|
||||||
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
|
|
||||||
req.Provider,
|
|
||||||
),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// POST /api/auth/logout — logout a provider
|
|
||||||
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Provider == "" {
|
|
||||||
if err := auth.DeleteAllCredentials(); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearAllAuthMethodsInConfig(absPath)
|
|
||||||
} else {
|
|
||||||
if err := auth.DeleteCredential(req.Provider); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearAuthMethodInConfig(absPath, req.Provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /auth/callback — OAuth browser callback for Google Antigravity
|
|
||||||
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
|
|
||||||
}
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Config API tests ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.json")
|
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal config: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
|
||||||
t.Fatalf("write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
RegisterConfigAPI(mux, path)
|
|
||||||
RegisterAuthAPI(mux, path)
|
|
||||||
return mux, path
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfig(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
ModelList: []config.ModelConfig{
|
|
||||||
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
mux, path := setupConfigMux(t, cfg)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Config config.Config `json:"config"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Path != path {
|
|
||||||
t.Errorf("expected path %q, got %q", path, resp.Path)
|
|
||||||
}
|
|
||||||
if len(resp.Config.ModelList) != 1 {
|
|
||||||
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/config", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// LoadConfig returns a default empty config when file is missing
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPutConfig(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mux, path := setupConfigMux(t, cfg)
|
|
||||||
|
|
||||||
newCfg := config.Config{
|
|
||||||
ModelList: []config.ModelConfig{
|
|
||||||
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(newCfg)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
saved, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load saved config: %v", err)
|
|
||||||
}
|
|
||||||
if len(saved.ModelList) != 1 {
|
|
||||||
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
|
|
||||||
}
|
|
||||||
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
|
|
||||||
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPutConfig_InvalidJSON(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mux, _ := setupConfigMux(t, cfg)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auth API tests ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestAuthStatus(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mux, _ := setupConfigMux(t, cfg)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/auth/status", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Providers []providerStatus `json:"providers"`
|
|
||||||
PendingDevice map[string]any `json:"pending_device"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// providers should be a non-nil list (could be empty)
|
|
||||||
if resp.Providers == nil {
|
|
||||||
t.Error("providers should not be nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mux, _ := setupConfigMux(t, cfg)
|
|
||||||
|
|
||||||
body := `{"provider": "unsupported"}`
|
|
||||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mux, _ := setupConfigMux(t, cfg)
|
|
||||||
|
|
||||||
body := `{"provider": "anthropic"}`
|
|
||||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthLogin_InvalidBody(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mux, _ := setupConfigMux(t, cfg)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthLogout_InvalidBody(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mux, _ := setupConfigMux(t, cfg)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400 for invalid body, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOAuthCallback_InvalidState(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
mux, _ := setupConfigMux(t, cfg)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
mux.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400 for invalid state, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Utility tests ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestDefaultConfigPath(t *testing.T) {
|
|
||||||
path := DefaultConfigPath()
|
|
||||||
if path == "" {
|
|
||||||
t.Error("defaultConfigPath should not return empty")
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
|
|
||||||
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetLocalIP(t *testing.T) {
|
|
||||||
// Just ensure it doesn't panic; IP may or may not be available
|
|
||||||
ip := GetLocalIP()
|
|
||||||
if ip != "" {
|
|
||||||
// If returned, should look like an IP
|
|
||||||
if !strings.Contains(ip, ".") {
|
|
||||||
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DefaultConfigPath() string {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "config.json"
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetLocalIP() string {
|
|
||||||
addrs, err := net.InterfaceAddrs()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, a := range addrs {
|
|
||||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
|
||||||
return ipnet.IP.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,127 +0,0 @@
|
||||||
// PicoClaw Launcher - Standalone HTTP service
|
|
||||||
//
|
|
||||||
// Provides a web-based JSON editor for picoclaw config files,
|
|
||||||
// with OAuth provider authentication support.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
|
|
||||||
// ./picoclaw-launcher [config.json]
|
|
||||||
// ./picoclaw-launcher -public config.json
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed internal/ui/index.html
|
|
||||||
var staticFiles embed.FS
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0])
|
|
||||||
fmt.Fprintf(
|
|
||||||
os.Stderr,
|
|
||||||
" %s -public ./config.json Allow access from other devices on the network\n",
|
|
||||||
os.Args[0],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
configPath := server.DefaultConfigPath()
|
|
||||||
if flag.NArg() > 0 {
|
|
||||||
configPath = flag.Arg(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
absPath, err := filepath.Abs(configPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to resolve config path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var addr string
|
|
||||||
if *public {
|
|
||||||
addr = "0.0.0.0:" + server.DefaultPort
|
|
||||||
} else {
|
|
||||||
addr = "127.0.0.1:" + server.DefaultPort
|
|
||||||
}
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
server.RegisterConfigAPI(mux, absPath)
|
|
||||||
server.RegisterAuthAPI(mux, absPath)
|
|
||||||
server.RegisterProcessAPI(mux, absPath)
|
|
||||||
|
|
||||||
staticFS, err := fs.Sub(staticFiles, "internal/ui")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to create sub filesystem: %v", err)
|
|
||||||
}
|
|
||||||
mux.Handle("/", http.FileServer(http.FS(staticFS)))
|
|
||||||
|
|
||||||
// Print startup banner
|
|
||||||
fmt.Println("=============================================")
|
|
||||||
fmt.Println(" PicoClaw Launcher")
|
|
||||||
fmt.Println("=============================================")
|
|
||||||
fmt.Printf(" Config file : %s\n", absPath)
|
|
||||||
fmt.Printf(" Listen addr : %s\n\n", addr)
|
|
||||||
fmt.Println(" Open the following URL in your browser")
|
|
||||||
fmt.Println(" to view and edit the configuration:")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
|
|
||||||
if *public {
|
|
||||||
if ip := server.GetLocalIP(); ip != "" {
|
|
||||||
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
// fmt.Println("=============================================")
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
// Wait briefly to ensure the server is ready before opening the browser
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
url := "http://localhost:" + server.DefaultPort
|
|
||||||
if err := openBrowser(url); err != nil {
|
|
||||||
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
||||||
log.Fatalf("Server failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// openBrowser automatically opens the given URL in the default browser.
|
|
||||||
func openBrowser(url string) error {
|
|
||||||
var err error
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "linux":
|
|
||||||
err = exec.Command("xdg-open", url).Start()
|
|
||||||
case "windows":
|
|
||||||
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
|
||||||
case "darwin":
|
|
||||||
err = exec.Command("open", url).Start()
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("unsupported platform")
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
@ -50,6 +50,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
defer msgBus.Close()
|
defer msgBus.Close()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
defer agentLoop.Close()
|
||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -220,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
|
||||||
|
|
@ -363,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
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,42 @@
|
||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
|
var noTruncate bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
Aliases: []string{"g"},
|
Aliases: []string{"g"},
|
||||||
Short: "Start picoclaw gateway",
|
Short: "Start picoclaw gateway",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
|
PreRunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
if noTruncate && !debug {
|
||||||
|
return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if noTruncate {
|
||||||
|
utils.SetDisableTruncation(true)
|
||||||
|
logger.Info("String truncation is globally disabled via 'no-truncate' flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
return gatewayCmd(debug)
|
return gatewayCmd(debug)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
|
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
||||||
|
|
||||||
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"
|
||||||
|
|
@ -212,6 +214,7 @@ func gatewayCmd(debug bool) error {
|
||||||
cronService.Stop()
|
cronService.Stop()
|
||||||
mediaStore.Stop()
|
mediaStore.Stop()
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
|
agentLoop.Close()
|
||||||
fmt.Println("✓ Gateway stopped")
|
fmt.Println("✓ Gateway stopped")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,14 @@
|
||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Logo = "🦞"
|
const Logo = "🦞"
|
||||||
|
|
||||||
var (
|
|
||||||
version = "dev"
|
|
||||||
gitCommit string
|
|
||||||
buildTime string
|
|
||||||
goVersion string
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetPicoclawHome returns the picoclaw home directory.
|
// GetPicoclawHome returns the picoclaw home directory.
|
||||||
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
// Priority: $PICOCLAW_HOME > ~/.picoclaw
|
||||||
func GetPicoclawHome() string {
|
func GetPicoclawHome() string {
|
||||||
|
|
@ -40,25 +31,19 @@ func LoadConfig() (*config.Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatVersion returns the version string with optional git commit
|
// FormatVersion returns the version string with optional git commit
|
||||||
|
// Deprecated: Use pkg/config.FormatVersion instead
|
||||||
func FormatVersion() string {
|
func FormatVersion() string {
|
||||||
v := version
|
return config.FormatVersion()
|
||||||
if gitCommit != "" {
|
|
||||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatBuildInfo returns build time and go version info
|
// FormatBuildInfo returns build time and go version info
|
||||||
|
// Deprecated: Use pkg/config.FormatBuildInfo instead
|
||||||
func FormatBuildInfo() (string, string) {
|
func FormatBuildInfo() (string, string) {
|
||||||
build := buildTime
|
return config.FormatBuildInfo()
|
||||||
goVer := goVersion
|
|
||||||
if goVer == "" {
|
|
||||||
goVer = runtime.Version()
|
|
||||||
}
|
|
||||||
return build, goVer
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVersion returns the version string
|
// GetVersion returns the version string
|
||||||
|
// Deprecated: Use pkg/config.GetVersion instead
|
||||||
func GetVersion() string {
|
func GetVersion() string {
|
||||||
return version
|
return config.GetVersion()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,65 +40,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
|
||||||
assert.Equal(t, want, got)
|
assert.Equal(t, want, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
|
||||||
oldVersion, oldGit := version, gitCommit
|
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
|
||||||
|
|
||||||
version = "1.2.3"
|
|
||||||
gitCommit = ""
|
|
||||||
|
|
||||||
assert.Equal(t, "1.2.3", FormatVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
|
||||||
oldVersion, oldGit := version, gitCommit
|
|
||||||
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
|
||||||
|
|
||||||
version = "1.2.3"
|
|
||||||
gitCommit = "abc123"
|
|
||||||
|
|
||||||
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = "2026-02-20T00:00:00Z"
|
|
||||||
goVersion = "go1.23.0"
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Equal(t, buildTime, build)
|
|
||||||
assert.Equal(t, goVersion, goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = ""
|
|
||||||
goVersion = "go1.23.0"
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Empty(t, build)
|
|
||||||
assert.Equal(t, goVersion, goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
|
||||||
oldBuildTime, oldGoVersion := buildTime, goVersion
|
|
||||||
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
|
||||||
|
|
||||||
buildTime = "x"
|
|
||||||
goVersion = ""
|
|
||||||
|
|
||||||
build, goVer := FormatBuildInfo()
|
|
||||||
|
|
||||||
assert.Equal(t, "x", build)
|
|
||||||
assert.Equal(t, runtime.Version(), goVer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfigPath_Windows(t *testing.T) {
|
func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
if runtime.GOOS != "windows" {
|
if runtime.GOOS != "windows" {
|
||||||
t.Skip("windows-specific HOME behavior varies; run on windows")
|
t.Skip("windows-specific HOME behavior varies; run on windows")
|
||||||
|
|
@ -112,17 +53,3 @@ func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
|
|
||||||
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetVersion(t *testing.T) {
|
|
||||||
assert.Equal(t, "dev", GetVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetConfigPath_WithEnv(t *testing.T) {
|
|
||||||
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
|
|
||||||
t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
|
|
||||||
|
|
||||||
got := GetConfigPath()
|
|
||||||
want := "/tmp/custom/config.json"
|
|
||||||
|
|
||||||
assert.Equal(t, want, got)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func statusCmd() {
|
func statusCmd() {
|
||||||
|
|
@ -18,8 +19,8 @@ func statusCmd() {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||||
fmt.Printf("Version: %s\n", internal.FormatVersion())
|
fmt.Printf("Version: %s\n", config.FormatVersion())
|
||||||
build, _ := internal.FormatBuildInfo()
|
build, _ := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf("Build: %s\n", build)
|
fmt.Printf("Build: %s\n", build)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewVersionCommand() *cobra.Command {
|
func NewVersionCommand() *cobra.Command {
|
||||||
|
|
@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
|
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
|
||||||
build, goVer := internal.FormatBuildInfo()
|
build, goVer := config.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf(" Build: %s\n", build)
|
fmt.Printf(" Build: %s\n", build)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,16 @@ import (
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPicoclawCommand() *cobra.Command {
|
func NewPicoclawCommand() *cobra.Command {
|
||||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "picoclaw",
|
Use: "picoclaw",
|
||||||
Short: short,
|
Short: short,
|
||||||
Example: "picoclaw list",
|
Example: "picoclaw version",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(
|
cmd.AddCommand(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewPicoclawCommand(t *testing.T) {
|
func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
|
||||||
require.NotNil(t, cmd)
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
|
||||||
|
|
||||||
assert.Equal(t, "picoclaw", cmd.Use)
|
assert.Equal(t, "picoclaw", cmd.Use)
|
||||||
assert.Equal(t, short, cmd.Short)
|
assert.Equal(t, short, cmd.Short)
|
||||||
|
|
|
||||||
|
|
@ -98,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,
|
||||||
|
|
@ -114,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",
|
||||||
|
|
@ -164,6 +182,33 @@
|
||||||
"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": {
|
||||||
|
|
@ -239,6 +284,9 @@
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"api_keys": [
|
||||||
|
"YOUR_BRAVE_API_KEY"
|
||||||
|
],
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"tavily": {
|
"tavily": {
|
||||||
|
|
@ -253,7 +301,10 @@
|
||||||
},
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "",
|
"api_key": "pplx-xxx",
|
||||||
|
"api_keys": [
|
||||||
|
"pplx-xxx"
|
||||||
|
],
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"searxng": {
|
"searxng": {
|
||||||
|
|
@ -276,6 +327,13 @@
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"discovery": {
|
||||||
|
"enabled": false,
|
||||||
|
"ttl": 5,
|
||||||
|
"max_search_results": 5,
|
||||||
|
"use_bm25": true,
|
||||||
|
"use_regex": false
|
||||||
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"context7": {
|
"context7": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
|
||||||
12
docker/Dockerfile.goreleaser.launcher
Normal file
12
docker/Dockerfile.goreleaser.launcher
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
|
||||||
|
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
|
||||||
|
|
||||||
|
ENTRYPOINT ["picoclaw-launcher"]
|
||||||
|
CMD ["-public", "-no-browser"]
|
||||||
|
|
@ -19,7 +19,7 @@ services:
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Gateway (Long-running Bot)
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
|
# docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
image: docker.io/sipeed/picoclaw:latest
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
|
|
@ -32,3 +32,21 @@ services:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/root/.picoclaw
|
- ./data:/root/.picoclaw
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# PicoClaw Launcher (Web Console + Gateway)
|
||||||
|
# docker compose -f docker/docker-compose.yml --profile launcher up
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
picoclaw-launcher:
|
||||||
|
image: docker.io/sipeed/picoclaw:launcher
|
||||||
|
container_name: picoclaw-launcher
|
||||||
|
restart: on-failure
|
||||||
|
profiles:
|
||||||
|
- launcher
|
||||||
|
environment:
|
||||||
|
- PICOCLAW_GATEWAY_HOST=0.0.0.0
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:18800:18800"
|
||||||
|
- "127.0.0.1:18790:18790"
|
||||||
|
volumes:
|
||||||
|
- ./data:/root/.picoclaw
|
||||||
|
|
|
||||||
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)
|
||||||
33
docs/debug.md
Normal file
33
docs/debug.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Debugging PicoClaw
|
||||||
|
|
||||||
|
PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates.
|
||||||
|
## Starting PicoClaw in Debug Mode
|
||||||
|
|
||||||
|
To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug
|
||||||
|
# or
|
||||||
|
picoclaw gateway -d
|
||||||
|
```
|
||||||
|
|
||||||
|
In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results.
|
||||||
|
|
||||||
|
## Disabling Log Truncation (Full Logs)
|
||||||
|
|
||||||
|
By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable.
|
||||||
|
|
||||||
|
If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag.
|
||||||
|
|
||||||
|
**Note:** This flag *only* works when combined with the `--debug` mode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway --debug --no-truncate
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
When this flag is active, the global truncation function is disabled. This is extremely useful for:
|
||||||
|
|
||||||
|
* Verifying the exact syntax of the messages sent to the provider.
|
||||||
|
* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.
|
||||||
|
* Debugging the session history saved in memory.
|
||||||
|
|
@ -7,11 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": { ... },
|
"web": {
|
||||||
"mcp": { ... },
|
...
|
||||||
"exec": { ... },
|
},
|
||||||
"cron": { ... },
|
"mcp": {
|
||||||
"skills": { ... }
|
...
|
||||||
|
},
|
||||||
|
"exec": {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
...
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -23,7 +33,7 @@ Web tools are used for web search and fetching.
|
||||||
### Brave
|
### Brave
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ------------- | ------ | ------- | ------------------------- |
|
|---------------|--------|---------|---------------------------|
|
||||||
| `enabled` | bool | false | Enable Brave search |
|
| `enabled` | bool | false | Enable Brave search |
|
||||||
| `api_key` | string | - | Brave Search API key |
|
| `api_key` | string | - | Brave Search API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
@ -31,14 +41,14 @@ Web tools are used for web search and fetching.
|
||||||
### DuckDuckGo
|
### DuckDuckGo
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ------------- | ---- | ------- | ------------------------- |
|
|---------------|------|---------|---------------------------|
|
||||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
||||||
### Perplexity
|
### Perplexity
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ------------- | ------ | ------- | ------------------------- |
|
|---------------|--------|---------|---------------------------|
|
||||||
| `enabled` | bool | false | Enable Perplexity search |
|
| `enabled` | bool | false | Enable Perplexity search |
|
||||||
| `api_key` | string | - | Perplexity API key |
|
| `api_key` | string | - | Perplexity API key |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `max_results` | int | 5 | Maximum number of results |
|
||||||
|
|
@ -48,7 +58,7 @@ Web tools are used for web search and fetching.
|
||||||
The exec tool is used to execute shell commands.
|
The exec tool is used to execute shell commands.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ---------------------- | ----- | ------- | ------------------------------------------ |
|
|------------------------|-------|---------|--------------------------------------------|
|
||||||
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
|
||||||
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
|
||||||
|
|
||||||
|
|
@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands:
|
||||||
"tools": {
|
"tools": {
|
||||||
"exec": {
|
"exec": {
|
||||||
"enable_deny_patterns": true,
|
"enable_deny_patterns": true,
|
||||||
"custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"]
|
"custom_deny_patterns": [
|
||||||
|
"\\brm\\s+-r\\b",
|
||||||
|
"\\bkillall\\s+python"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -92,24 +105,47 @@ By default, PicoClaw blocks the following dangerous commands:
|
||||||
The cron tool is used for scheduling periodic tasks.
|
The cron tool is used for scheduling periodic tasks.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ---------------------- | ---- | ------- | ---------------------------------------------- |
|
|------------------------|------|---------|------------------------------------------------|
|
||||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||||
|
|
||||||
## MCP Tool
|
## MCP Tool
|
||||||
|
|
||||||
The MCP tool enables integration with external Model Context Protocol servers.
|
The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
|
|
||||||
|
### Tool Discovery (Lazy Loading)
|
||||||
|
|
||||||
|
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
|
||||||
|
and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default.
|
||||||
|
|
||||||
|
Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex).
|
||||||
|
When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked"
|
||||||
|
and injected into the context for a configured number of turns (`ttl`).
|
||||||
|
|
||||||
### Global Config
|
### Global Config
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| --------- | ------ | ------- | ----------------------------------- |
|
|-------------|--------|---------|----------------------------------------------|
|
||||||
| `enabled` | bool | false | Enable MCP integration globally |
|
| `enabled` | bool | false | Enable MCP integration globally |
|
||||||
|
| `discovery` | object | `{}` | Configuration for Tool Discovery (see below) |
|
||||||
| `servers` | object | `{}` | Map of server name to server config |
|
| `servers` | object | `{}` | Map of server name to server config |
|
||||||
|
|
||||||
|
### Discovery Config (`discovery`)
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `enabled` | bool | false | If true, MCP tools are hidden and loaded on-demand via search. If false, all tools are loaded |
|
||||||
|
| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked |
|
||||||
|
| `max_search_results` | int | 5 | Maximum number of tools returned per search query |
|
||||||
|
| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search |
|
||||||
|
| `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) |
|
||||||
|
|
||||||
|
> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`),
|
||||||
|
> otherwise the application will fail to start.
|
||||||
|
|
||||||
### Per-Server Config
|
### Per-Server Config
|
||||||
|
|
||||||
| Config | Type | Required | Description |
|
| Config | Type | Required | Description |
|
||||||
| ---------- | ------ | -------- | ------------------------------------------ |
|
|------------|--------|----------|--------------------------------------------|
|
||||||
| `enabled` | bool | yes | Enable this MCP server |
|
| `enabled` | bool | yes | Enable this MCP server |
|
||||||
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
|
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
|
||||||
| `command` | string | stdio | Executable command for stdio transport |
|
| `command` | string | stdio | Executable command for stdio transport |
|
||||||
|
|
@ -140,7 +176,11 @@ The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
"filesystem": {
|
"filesystem": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem",
|
||||||
|
"/tmp"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +210,62 @@ The MCP tool enables integration with external Model Context Protocol servers.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 3) Massive MCP setup with Tool Discovery enabled
|
||||||
|
|
||||||
|
*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools
|
||||||
|
dynamically only when requested by the user.*
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"mcp": {
|
||||||
|
"enabled": true,
|
||||||
|
"discovery": {
|
||||||
|
"enabled": true,
|
||||||
|
"ttl": 5,
|
||||||
|
"max_search_results": 5,
|
||||||
|
"use_bm25": true,
|
||||||
|
"use_regex": false
|
||||||
|
},
|
||||||
|
"servers": {
|
||||||
|
"github": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-github"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-postgres",
|
||||||
|
"postgresql://user:password@localhost/dbname"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"slack": {
|
||||||
|
"enabled": true,
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-slack"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
|
||||||
|
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Skills Tool
|
## Skills Tool
|
||||||
|
|
||||||
The skills tool configures skill discovery and installation via registries like ClawHub.
|
The skills tool configures skill discovery and installation via registries like ClawHub.
|
||||||
|
|
@ -177,7 +273,7 @@ The skills tool configures skill discovery and installation via registries like
|
||||||
### Registries
|
### Registries
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
| ---------------------------------- | ------ | -------------------- | ----------------------- |
|
|------------------------------------|--------|----------------------|----------------------------------------------|
|
||||||
| `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.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
|
||||||
|
|
@ -217,4 +313,5 @@ For example:
|
||||||
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
|
||||||
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
|
||||||
|
|
||||||
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than environment variables.
|
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than
|
||||||
|
environment variables.
|
||||||
|
|
|
||||||
12
go.mod
12
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,18 +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/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
|
||||||
|
|
@ -48,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
|
||||||
|
|
@ -86,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=
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,19 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"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/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
workspace string
|
workspace string
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
|
toolDiscoveryBM25 bool
|
||||||
|
toolDiscoveryRegex bool
|
||||||
|
|
||||||
// Cache for system prompt to avoid rebuilding on every call.
|
// Cache for system prompt to avoid rebuilding on every call.
|
||||||
// This fixes issue #607: repeated reprocessing of the entire context.
|
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||||
|
|
@ -41,6 +45,12 @@ type ContextBuilder struct {
|
||||||
skillFilesAtCache map[string]time.Time
|
skillFilesAtCache map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
|
||||||
|
cb.toolDiscoveryBM25 = useBM25
|
||||||
|
cb.toolDiscoveryRegex = useRegex
|
||||||
|
return cb
|
||||||
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
|
||||||
return home
|
return home
|
||||||
|
|
@ -71,8 +81,11 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
||||||
|
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
|
toolDiscovery := cb.getDiscoveryRule()
|
||||||
|
version := config.FormatVersion()
|
||||||
|
|
||||||
return fmt.Sprintf(`# picoclaw 🦞
|
return fmt.Sprintf(
|
||||||
|
`# picoclaw 🦞 (%s)
|
||||||
|
|
||||||
You are picoclaw, a helpful AI assistant.
|
You are picoclaw, a helpful AI assistant.
|
||||||
|
|
||||||
|
|
@ -90,8 +103,29 @@ Your workspace is at: %s
|
||||||
|
|
||||||
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
|
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
|
||||||
|
|
||||||
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`,
|
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
|
||||||
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
|
|
||||||
|
%s`,
|
||||||
|
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) getDiscoveryRule() string {
|
||||||
|
if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolNames []string
|
||||||
|
if cb.toolDiscoveryBM25 {
|
||||||
|
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
|
||||||
|
}
|
||||||
|
if cb.toolDiscoveryRegex {
|
||||||
|
toolNames = append(toolNames, `"tool_search_tool_regex"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`,
|
||||||
|
strings.Join(toolNames, " or "),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||||
|
|
@ -505,10 +539,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log preview of system prompt (avoid logging huge content)
|
// Log preview of system prompt (avoid logging huge content)
|
||||||
preview := fullSystemPrompt
|
preview := utils.Truncate(fullSystemPrompt, 500)
|
||||||
if len(preview) > 500 {
|
|
||||||
preview = preview[:500] + "... (truncated)"
|
|
||||||
}
|
|
||||||
logger.DebugCF("agent", "System prompt preview",
|
logger.DebugCF("agent", "System prompt preview",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
|
|
@ -605,7 +636,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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -9,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
"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/session"
|
"github.com/sipeed/picoclaw/pkg/session"
|
||||||
|
|
@ -31,12 +33,20 @@ type AgentInstance struct {
|
||||||
SummarizeMessageThreshold int
|
SummarizeMessageThreshold int
|
||||||
SummarizeTokenPercent int
|
SummarizeTokenPercent int
|
||||||
Provider providers.LLMProvider
|
Provider providers.LLMProvider
|
||||||
Sessions *session.SessionManager
|
Sessions session.SessionStore
|
||||||
ContextBuilder *ContextBuilder
|
ContextBuilder *ContextBuilder
|
||||||
Tools *tools.ToolRegistry
|
Tools *tools.ToolRegistry
|
||||||
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.
|
||||||
|
|
@ -62,7 +72,8 @@ func NewAgentInstance(
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
|
|
||||||
if cfg.Tools.IsToolEnabled("read_file") {
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths))
|
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
||||||
|
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
|
||||||
}
|
}
|
||||||
if cfg.Tools.IsToolEnabled("write_file") {
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
|
@ -86,9 +97,13 @@ func NewAgentInstance(
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
sessions := initSessionStore(sessionsDir)
|
||||||
|
|
||||||
contextBuilder := NewContextBuilder(workspace)
|
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
||||||
|
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
|
||||||
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
||||||
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
|
||||||
|
)
|
||||||
|
|
||||||
agentID := routing.DefaultAgentID
|
agentID := routing.DefaultAgentID
|
||||||
agentName := ""
|
agentName := ""
|
||||||
|
|
@ -180,6 +195,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,
|
||||||
|
|
@ -194,12 +228,14 @@ func NewAgentInstance(
|
||||||
SummarizeMessageThreshold: summarizeMessageThreshold,
|
SummarizeMessageThreshold: summarizeMessageThreshold,
|
||||||
SummarizeTokenPercent: summarizeTokenPercent,
|
SummarizeTokenPercent: summarizeTokenPercent,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Sessions: sessionsManager,
|
Sessions: sessions,
|
||||||
ContextBuilder: contextBuilder,
|
ContextBuilder: contextBuilder,
|
||||||
Tools: toolsRegistry,
|
Tools: toolsRegistry,
|
||||||
Subagents: subagents,
|
Subagents: subagents,
|
||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
|
Router: router,
|
||||||
|
LightCandidates: lightCandidates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,6 +282,39 @@ func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||||
return compiled
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by the agent's session store.
|
||||||
|
func (a *AgentInstance) Close() error {
|
||||||
|
if a.Sessions != nil {
|
||||||
|
return a.Sessions.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initSessionStore creates the session persistence backend.
|
||||||
|
// It uses the JSONL store by default and auto-migrates legacy JSON sessions.
|
||||||
|
// Falls back to SessionManager if the JSONL store cannot be initialized or
|
||||||
|
// if migration fails (which indicates the store cannot write reliably).
|
||||||
|
func initSessionStore(dir string) session.SessionStore {
|
||||||
|
store, err := memory.NewJSONLStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("memory: init store: %v; using json sessions", err)
|
||||||
|
return session.NewSessionManager(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, merr := memory.MigrateFromJSON(context.Background(), dir, store); merr != nil {
|
||||||
|
// Migration failure means the store could not write data.
|
||||||
|
// Fall back to SessionManager to avoid a split state where
|
||||||
|
// some sessions are in JSONL and others remain in JSON.
|
||||||
|
log.Printf("memory: migration failed: %v; falling back to json sessions", merr)
|
||||||
|
store.Close()
|
||||||
|
return session.NewSessionManager(dir)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("memory: migrated %d session(s) to jsonl", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.NewJSONLBackend(store)
|
||||||
|
}
|
||||||
|
|
||||||
func expandHome(path string) string {
|
func expandHome(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return path
|
return path
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -107,19 +120,21 @@ func registerSharedTools(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web tools
|
|
||||||
if cfg.Tools.IsToolEnabled("web") {
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
|
TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
PerplexityAPIKeys: config.MergeAPIKeys(
|
||||||
|
cfg.Tools.Web.Perplexity.APIKey,
|
||||||
|
cfg.Tools.Web.Perplexity.APIKeys,
|
||||||
|
),
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
|
|
@ -170,6 +185,17 @@ func registerSharedTools(
|
||||||
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
|
||||||
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
skills_enabled := cfg.Tools.IsToolEnabled("skills")
|
||||||
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
|
||||||
|
|
@ -196,7 +222,7 @@ func registerSharedTools(
|
||||||
// Spawn tool with allowlist checker
|
// Spawn tool with allowlist checker
|
||||||
if cfg.Tools.IsToolEnabled("spawn") {
|
if cfg.Tools.IsToolEnabled("spawn") {
|
||||||
if cfg.Tools.IsToolEnabled("subagent") {
|
if cfg.Tools.IsToolEnabled("subagent") {
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
currentAgentID := agentID
|
currentAgentID := agentID
|
||||||
|
|
@ -259,7 +285,13 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||||
|
|
||||||
|
if al.cfg.Tools.MCP.Discovery.Enabled {
|
||||||
|
agent.Tools.RegisterHidden(mcpTool)
|
||||||
|
} else {
|
||||||
agent.Tools.Register(mcpTool)
|
agent.Tools.Register(mcpTool)
|
||||||
|
}
|
||||||
|
|
||||||
totalRegistrations++
|
totalRegistrations++
|
||||||
logger.DebugCF("agent", "Registered MCP tool",
|
logger.DebugCF("agent", "Registered MCP tool",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -278,6 +310,47 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
"total_registrations": totalRegistrations,
|
"total_registrations": totalRegistrations,
|
||||||
"agent_count": agentCount,
|
"agent_count": agentCount,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initializes Discovery Tools only if enabled by configuration
|
||||||
|
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
|
||||||
|
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
|
||||||
|
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
|
||||||
|
|
||||||
|
// Fail fast: If discovery is enabled but no search method is turned on
|
||||||
|
if !useBM25 && !useRegex {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := al.cfg.Tools.MCP.Discovery.TTL
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 5 // Default value
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
|
||||||
|
if maxSearchResults <= 0 {
|
||||||
|
maxSearchResults = 5 // Default value
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
|
||||||
|
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, agentID := range agentIDs {
|
||||||
|
agent, ok := al.registry.GetAgent(agentID)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if useRegex {
|
||||||
|
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
|
||||||
|
}
|
||||||
|
if useBM25 {
|
||||||
|
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,6 +429,11 @@ func (al *AgentLoop) Stop() {
|
||||||
al.running.Store(false)
|
al.running.Store(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by agent session stores. Call after Stop.
|
||||||
|
func (al *AgentLoop) Close() {
|
||||||
|
al.registry.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
|
||||||
for _, agentID := range al.registry.ListAgentIDs() {
|
for _, agentID := range al.registry.ListAgentIDs() {
|
||||||
if agent, ok := al.registry.GetAgent(agentID); ok {
|
if agent, ok := al.registry.GetAgent(agentID); ok {
|
||||||
|
|
@ -371,6 +449,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.
|
||||||
|
|
@ -549,27 +634,9 @@ 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 {
|
if routeErr != nil {
|
||||||
return response, nil
|
return "", routeErr
|
||||||
}
|
|
||||||
|
|
||||||
// Route to determine agent and session key
|
|
||||||
route := al.registry.ResolveRoute(routing.RouteInput{
|
|
||||||
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.
|
||||||
|
|
@ -579,20 +646,21 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
|
"scope_key": scopeKey,
|
||||||
"session_key": sessionKey,
|
"session_key": sessionKey,
|
||||||
"matched_by": route.MatchedBy,
|
"matched_by": route.MatchedBy,
|
||||||
|
"route_agent": route.AgentID,
|
||||||
|
"route_channel": route.Channel,
|
||||||
})
|
})
|
||||||
|
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
opts := processOptions{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
|
|
@ -601,7 +669,43 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, &opts); handled {
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return al.runAgentLoop(ctx, agent, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
|
@ -675,9 +779,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 {
|
||||||
|
|
@ -824,6 +927,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++
|
||||||
|
|
||||||
|
|
@ -842,7 +951,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,
|
||||||
|
|
@ -858,7 +967,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -879,10 +988,10 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
|
|
||||||
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(ctx, messages, providerToolDefs, model, llmOpts)
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts)
|
||||||
},
|
},
|
||||||
|
|
@ -900,7 +1009,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
return fbResult.Response, nil
|
return fbResult.Response, nil
|
||||||
}
|
}
|
||||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, llmOpts)
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry loop for context/token errors
|
// Retry loop for context/token errors
|
||||||
|
|
@ -999,9 +1108,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,
|
||||||
|
|
@ -1087,15 +1199,47 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create async callback for tools that implement AsyncExecutor
|
// 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(
|
||||||
|
|
@ -1128,7 +1272,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}
|
||||||
|
|
@ -1164,11 +1308,60 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
// Save tool result message to session
|
// Save tool result message to session
|
||||||
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tick down TTL of discovered tools after processing tool results.
|
||||||
|
// Only reached when tool calls were made (the loop continues);
|
||||||
|
// the break on no-tool-call responses skips this.
|
||||||
|
// NOTE: This is safe because processMessage is sequential per agent.
|
||||||
|
// If per-agent concurrency is added, TTL consistency between
|
||||||
|
// ToProviderDefs and Get must be re-evaluated.
|
||||||
|
agent.Tools.TickTTL()
|
||||||
|
logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{
|
||||||
|
"agent_id": agent.ID, "iteration": iteration,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalContent, iteration, nil
|
return finalContent, iteration, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectCandidates returns the model candidates and resolved model name to use
|
||||||
|
// for a conversation turn. When model routing is configured and the incoming
|
||||||
|
// message scores below the complexity threshold, it returns the light model
|
||||||
|
// candidates instead of the primary ones.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
_, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model)
|
||||||
|
if !usedLight {
|
||||||
|
logger.DebugCF("agent", "Model routing: primary model selected",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"score": score,
|
||||||
|
"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.
|
||||||
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
|
||||||
newHistory := agent.Sessions.GetHistory(sessionKey)
|
newHistory := agent.Sessions.GetHistory(sessionKey)
|
||||||
|
|
@ -1363,10 +1556,20 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxSummarizationMessages = 10
|
||||||
|
llmMaxRetries = 3
|
||||||
|
llmTemperature = 0.3
|
||||||
|
fallbackMaxContentLength = 200
|
||||||
|
)
|
||||||
|
|
||||||
// Multi-Part Summarization
|
// Multi-Part Summarization
|
||||||
var finalSummary string
|
var finalSummary string
|
||||||
if len(validMessages) > 10 {
|
if len(validMessages) > maxSummarizationMessages {
|
||||||
mid := len(validMessages) / 2
|
mid := len(validMessages) / 2
|
||||||
|
|
||||||
|
mid = al.findNearestUserMessage(validMessages, mid)
|
||||||
|
|
||||||
part1 := validMessages[:mid]
|
part1 := validMessages[:mid]
|
||||||
part2 := validMessages[mid:]
|
part2 := validMessages[mid:]
|
||||||
|
|
||||||
|
|
@ -1378,18 +1581,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||||
s1,
|
s1,
|
||||||
s2,
|
s2,
|
||||||
)
|
)
|
||||||
resp, err := agent.Provider.Chat(
|
|
||||||
ctx,
|
resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
|
||||||
[]providers.Message{{Role: "user", Content: mergePrompt}},
|
if err == nil && resp.Content != "" {
|
||||||
nil,
|
|
||||||
agent.Model,
|
|
||||||
map[string]any{
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"temperature": 0.3,
|
|
||||||
"prompt_cache_key": agent.ID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
finalSummary = resp.Content
|
finalSummary = resp.Content
|
||||||
} else {
|
} else {
|
||||||
finalSummary = s1 + " " + s2
|
finalSummary = s1 + " " + s2
|
||||||
|
|
@ -1409,6 +1603,68 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findNearestUserMessage finds the nearest user message to the given index.
|
||||||
|
// It searches backward first, then forward if no user message is found.
|
||||||
|
func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int {
|
||||||
|
originalMid := mid
|
||||||
|
|
||||||
|
for mid > 0 && messages[mid].Role != "user" {
|
||||||
|
mid--
|
||||||
|
}
|
||||||
|
|
||||||
|
if messages[mid].Role == "user" {
|
||||||
|
return mid
|
||||||
|
}
|
||||||
|
|
||||||
|
mid = originalMid
|
||||||
|
for mid < len(messages) && messages[mid].Role != "user" {
|
||||||
|
mid++
|
||||||
|
}
|
||||||
|
|
||||||
|
if mid < len(messages) {
|
||||||
|
return mid
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalMid
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryLLMCall calls the LLM with retry logic.
|
||||||
|
func (al *AgentLoop) retryLLMCall(
|
||||||
|
ctx context.Context,
|
||||||
|
agent *AgentInstance,
|
||||||
|
prompt string,
|
||||||
|
maxRetries int,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
const (
|
||||||
|
llmTemperature = 0.3
|
||||||
|
)
|
||||||
|
|
||||||
|
var resp *providers.LLMResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
resp, err = agent.Provider.Chat(
|
||||||
|
ctx,
|
||||||
|
[]providers.Message{{Role: "user", Content: prompt}},
|
||||||
|
nil,
|
||||||
|
agent.Model,
|
||||||
|
map[string]any{
|
||||||
|
"max_tokens": agent.MaxTokens,
|
||||||
|
"temperature": llmTemperature,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err == nil && resp != nil && resp.Content != "" {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if attempt < maxRetries-1 {
|
||||||
|
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
// summarizeBatch summarizes a batch of messages.
|
// summarizeBatch summarizes a batch of messages.
|
||||||
func (al *AgentLoop) summarizeBatch(
|
func (al *AgentLoop) summarizeBatch(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
@ -1416,6 +1672,13 @@ func (al *AgentLoop) summarizeBatch(
|
||||||
batch []providers.Message,
|
batch []providers.Message,
|
||||||
existingSummary string,
|
existingSummary string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
|
const (
|
||||||
|
llmMaxRetries = 3
|
||||||
|
llmTemperature = 0.3
|
||||||
|
fallbackMinContentLength = 200
|
||||||
|
fallbackMaxContentPercent = 10
|
||||||
|
)
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(
|
sb.WriteString(
|
||||||
"Provide a concise summary of this conversation segment, preserving core context and key points.\n",
|
"Provide a concise summary of this conversation segment, preserving core context and key points.\n",
|
||||||
|
|
@ -1431,21 +1694,40 @@ func (al *AgentLoop) summarizeBatch(
|
||||||
}
|
}
|
||||||
prompt := sb.String()
|
prompt := sb.String()
|
||||||
|
|
||||||
response, err := agent.Provider.Chat(
|
response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries)
|
||||||
ctx,
|
if err == nil && response.Content != "" {
|
||||||
[]providers.Message{{Role: "user", Content: prompt}},
|
return strings.TrimSpace(response.Content), nil
|
||||||
nil,
|
|
||||||
agent.Model,
|
|
||||||
map[string]any{
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"temperature": 0.3,
|
|
||||||
"prompt_cache_key": agent.ID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
return response.Content, nil
|
|
||||||
|
var fallback strings.Builder
|
||||||
|
fallback.WriteString("Conversation summary: ")
|
||||||
|
for i, m := range batch {
|
||||||
|
if i > 0 {
|
||||||
|
fallback.WriteString(" | ")
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(m.Content)
|
||||||
|
runes := []rune(content)
|
||||||
|
if len(runes) == 0 {
|
||||||
|
fallback.WriteString(fmt.Sprintf("%s: ", m.Role))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
keepLength := len(runes) * fallbackMaxContentPercent / 100
|
||||||
|
if keepLength < fallbackMinContentLength {
|
||||||
|
keepLength = fallbackMinContentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
if keepLength > len(runes) {
|
||||||
|
keepLength = len(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
content = string(runes[:keepLength])
|
||||||
|
if keepLength < len(runes) {
|
||||||
|
content += "..."
|
||||||
|
}
|
||||||
|
fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content))
|
||||||
|
}
|
||||||
|
return fallback.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// estimateTokens estimates the number of tokens in a message list.
|
// estimateTokens estimates the number of tokens in a message list.
|
||||||
|
|
@ -1460,94 +1742,102 @@ 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,
|
||||||
|
opts *processOptions,
|
||||||
|
) (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, opts)
|
||||||
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, opts *processOptions) *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 "", false
|
rt.ClearHistory = func() error {
|
||||||
|
if opts == nil {
|
||||||
|
return fmt.Errorf("process options not available")
|
||||||
|
}
|
||||||
|
if agent.Sessions == nil {
|
||||||
|
return fmt.Errorf("sessions not initialized for agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0))
|
||||||
|
agent.Sessions.SetSummary(opts.SessionKey, "")
|
||||||
|
agent.Sessions.Save(opts.SessionKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -1566,10 +1856,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -318,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,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,31 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases resources held by all registered agents.
|
||||||
|
func (r *AgentRegistry) Close() {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, agent := range r.agents {
|
||||||
|
if err := agent.Close(); err != nil {
|
||||||
|
logger.WarnCF("agent", "Failed to close agent",
|
||||||
|
map[string]any{"agent_id": agent.ID, "error": err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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()
|
||||||
|
|
|
||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -195,18 +196,30 @@ 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
|
||||||
|
var chosenEmoji string
|
||||||
|
if len(emojiList) == 0 {
|
||||||
|
// Default to "Pin" if no config
|
||||||
|
chosenEmoji = "Pin"
|
||||||
|
} else {
|
||||||
|
idx := rand.Intn(len(emojiList))
|
||||||
|
chosenEmoji = emojiList[idx]
|
||||||
|
}
|
||||||
|
|
||||||
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 +227,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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,7 +61,10 @@ var channelRateConfig = map[string]float64{
|
||||||
"telegram": 20,
|
"telegram": 20,
|
||||||
"discord": 1,
|
"discord": 1,
|
||||||
"slack": 1,
|
"slack": 1,
|
||||||
|
"matrix": 2,
|
||||||
"line": 10,
|
"line": 10,
|
||||||
|
"qq": 5,
|
||||||
|
"irc": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelWorker struct {
|
type channelWorker struct {
|
||||||
|
|
@ -243,6 +246,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 +277,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,10 @@ package qq
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tencent-connect/botgo"
|
"github.com/tencent-connect/botgo"
|
||||||
|
|
@ -20,6 +23,14 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dedupTTL = 5 * time.Minute
|
||||||
|
dedupInterval = 60 * time.Second
|
||||||
|
dedupMaxSize = 10000 // hard cap on dedup map entries
|
||||||
|
typingResend = 8 * time.Second
|
||||||
|
typingSeconds = 10
|
||||||
|
)
|
||||||
|
|
||||||
type QQChannel struct {
|
type QQChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config config.QQConfig
|
config config.QQConfig
|
||||||
|
|
@ -28,12 +39,28 @@ type QQChannel struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
sessionManager botgo.SessionManager
|
sessionManager botgo.SessionManager
|
||||||
processedIDs map[string]bool
|
|
||||||
mu sync.RWMutex
|
// Chat routing: track whether a chatID is group or direct.
|
||||||
|
chatType sync.Map // chatID → "group" | "direct"
|
||||||
|
|
||||||
|
// Passive reply: store last inbound message ID per chat.
|
||||||
|
lastMsgID sync.Map // chatID → string
|
||||||
|
|
||||||
|
// msg_seq: per-chat atomic counter for multi-part replies.
|
||||||
|
msgSeqCounters sync.Map // chatID → *atomic.Uint64
|
||||||
|
|
||||||
|
// Time-based dedup replacing the unbounded map.
|
||||||
|
dedup map[string]time.Time
|
||||||
|
muDedup sync.Mutex
|
||||||
|
|
||||||
|
// done is closed on Stop to shut down the dedup janitor.
|
||||||
|
done chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
|
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
|
||||||
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
|
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(cfg.MaxMessageLength),
|
||||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
)
|
)
|
||||||
|
|
@ -41,7 +68,8 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
|
||||||
return &QQChannel{
|
return &QQChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
processedIDs: make(map[string]bool),
|
dedup: make(map[string]time.Time),
|
||||||
|
done: make(chan struct{}),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,6 +80,10 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
|
|
||||||
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
|
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
|
||||||
|
|
||||||
|
// Reinitialize shutdown signal for clean restart.
|
||||||
|
c.done = make(chan struct{})
|
||||||
|
c.stopOnce = sync.Once{}
|
||||||
|
|
||||||
// create token source
|
// create token source
|
||||||
credentials := &token.QQBotCredentials{
|
credentials := &token.QQBotCredentials{
|
||||||
AppID: c.config.AppID,
|
AppID: c.config.AppID,
|
||||||
|
|
@ -99,6 +131,15 @@ func (c *QQChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// start dedup janitor goroutine
|
||||||
|
go c.dedupJanitor()
|
||||||
|
|
||||||
|
// Pre-register reasoning_channel_id as group chat if configured,
|
||||||
|
// so outbound-only destinations are routed correctly.
|
||||||
|
if c.config.ReasoningChannelID != "" {
|
||||||
|
c.chatType.Store(c.config.ReasoningChannelID, "group")
|
||||||
|
}
|
||||||
|
|
||||||
c.SetRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("qq", "QQ bot started successfully")
|
logger.InfoC("qq", "QQ bot started successfully")
|
||||||
|
|
||||||
|
|
@ -109,6 +150,9 @@ func (c *QQChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("qq", "Stopping QQ bot")
|
logger.InfoC("qq", "Stopping QQ bot")
|
||||||
c.SetRunning(false)
|
c.SetRunning(false)
|
||||||
|
|
||||||
|
// Signal the dedup janitor to stop (idempotent).
|
||||||
|
c.stopOnce.Do(func() { close(c.done) })
|
||||||
|
|
||||||
if c.cancel != nil {
|
if c.cancel != nil {
|
||||||
c.cancel()
|
c.cancel()
|
||||||
}
|
}
|
||||||
|
|
@ -116,20 +160,81 @@ func (c *QQChannel) Stop(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getChatKind returns the chat type for a given chatID ("group" or "direct").
|
||||||
|
// Unknown chatIDs default to "group" and log a warning, since QQ group IDs are
|
||||||
|
// more common as outbound-only destinations (e.g. reasoning_channel_id).
|
||||||
|
func (c *QQChannel) getChatKind(chatID string) string {
|
||||||
|
if v, ok := c.chatType.Load(chatID); ok {
|
||||||
|
if k, ok := v.(string); ok {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.DebugCF("qq", "Unknown chat type for chatID, defaulting to group", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
})
|
||||||
|
return "group"
|
||||||
|
}
|
||||||
|
|
||||||
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
// construct message
|
chatKind := c.getChatKind(msg.ChatID)
|
||||||
|
|
||||||
|
// Build message with content.
|
||||||
msgToCreate := &dto.MessageToCreate{
|
msgToCreate := &dto.MessageToCreate{
|
||||||
Content: msg.Content,
|
Content: msg.Content,
|
||||||
|
MsgType: dto.TextMsg,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Markdown message type if enabled in config.
|
||||||
|
if c.config.SendMarkdown {
|
||||||
|
msgToCreate.MsgType = dto.MarkdownMsg
|
||||||
|
msgToCreate.Markdown = &dto.Markdown{
|
||||||
|
Content: msg.Content,
|
||||||
|
}
|
||||||
|
// Clear plain content to avoid sending duplicate text.
|
||||||
|
msgToCreate.Content = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach passive reply msg_id and msg_seq if available.
|
||||||
|
if v, ok := c.lastMsgID.Load(msg.ChatID); ok {
|
||||||
|
if msgID, ok := v.(string); ok && msgID != "" {
|
||||||
|
msgToCreate.MsgID = msgID
|
||||||
|
|
||||||
|
// Increment msg_seq atomically for multi-part replies.
|
||||||
|
if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok {
|
||||||
|
if counter, ok := counterVal.(*atomic.Uint64); ok {
|
||||||
|
seq := counter.Add(1)
|
||||||
|
msgToCreate.MsgSeq = uint32(seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize URLs in group messages to avoid QQ's URL blacklist rejection.
|
||||||
|
if chatKind == "group" {
|
||||||
|
if msgToCreate.Content != "" {
|
||||||
|
msgToCreate.Content = sanitizeURLs(msgToCreate.Content)
|
||||||
|
}
|
||||||
|
if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" {
|
||||||
|
msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route to group or C2C.
|
||||||
|
var err error
|
||||||
|
if chatKind == "group" {
|
||||||
|
_, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate)
|
||||||
|
} else {
|
||||||
|
_, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// send C2C message
|
|
||||||
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
|
logger.ErrorCF("qq", "Failed to send message", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"chat_kind": chatKind,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return fmt.Errorf("qq send: %w", channels.ErrTemporary)
|
return fmt.Errorf("qq send: %w", channels.ErrTemporary)
|
||||||
|
|
@ -138,7 +243,150 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleC2CMessage handles QQ private messages
|
// StartTyping implements channels.TypingCapable.
|
||||||
|
// It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds.
|
||||||
|
// The returned stop function is idempotent and cancels the goroutine.
|
||||||
|
func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
// We need a stored msg_id for passive InputNotify; skip if none available.
|
||||||
|
v, ok := c.lastMsgID.Load(chatID)
|
||||||
|
if !ok {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
msgID, ok := v.(string)
|
||||||
|
if !ok || msgID == "" {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chatKind := c.getChatKind(chatID)
|
||||||
|
|
||||||
|
sendTyping := func(sendCtx context.Context) {
|
||||||
|
typingMsg := &dto.MessageToCreate{
|
||||||
|
MsgType: dto.InputNotifyMsg,
|
||||||
|
MsgID: msgID,
|
||||||
|
InputNotify: &dto.InputNotify{
|
||||||
|
InputType: 1,
|
||||||
|
InputSecond: typingSeconds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if chatKind == "group" {
|
||||||
|
_, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg)
|
||||||
|
} else {
|
||||||
|
_, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("qq", "Failed to send typing indicator", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send immediately.
|
||||||
|
sendTyping(c.ctx)
|
||||||
|
|
||||||
|
typingCtx, cancel := context.WithCancel(c.ctx)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(typingResend)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-typingCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
sendTyping(typingCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return cancel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
// QQ RichMediaMessage requires an HTTP/HTTPS URL — local file paths are not supported.
|
||||||
|
// If part.Ref is already an http(s) URL it is used directly; otherwise we try
|
||||||
|
// the media store, and skip with a warning if the resolved path is not an HTTP URL.
|
||||||
|
func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
chatKind := c.getChatKind(msg.ChatID)
|
||||||
|
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
// If the ref is already an HTTP(S) URL, use it directly.
|
||||||
|
mediaURL := part.Ref
|
||||||
|
if !isHTTPURL(mediaURL) {
|
||||||
|
// Try resolving through media store.
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := store.Resolve(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isHTTPURL(resolved) {
|
||||||
|
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, local files not supported", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"resolved": resolved,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaURL = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file.
|
||||||
|
var fileType uint64
|
||||||
|
switch part.Type {
|
||||||
|
case "image":
|
||||||
|
fileType = 1
|
||||||
|
case "video":
|
||||||
|
fileType = 2
|
||||||
|
case "audio":
|
||||||
|
fileType = 3
|
||||||
|
default:
|
||||||
|
fileType = 4 // file
|
||||||
|
}
|
||||||
|
|
||||||
|
richMedia := &dto.RichMediaMessage{
|
||||||
|
FileType: fileType,
|
||||||
|
URL: mediaURL,
|
||||||
|
SrvSendMsg: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendErr error
|
||||||
|
if chatKind == "group" {
|
||||||
|
_, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia)
|
||||||
|
} else {
|
||||||
|
_, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sendErr != nil {
|
||||||
|
logger.ErrorCF("qq", "Failed to send media", map[string]any{
|
||||||
|
"type": part.Type,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"error": sendErr.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("qq send media: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleC2CMessage handles QQ private messages.
|
||||||
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||||
// deduplication check
|
// deduplication check
|
||||||
|
|
@ -167,7 +415,13 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 转发到消息总线
|
// Store chat routing context.
|
||||||
|
c.chatType.Store(senderID, "direct")
|
||||||
|
c.lastMsgID.Store(senderID, data.ID)
|
||||||
|
|
||||||
|
// Reset msg_seq counter for new inbound message.
|
||||||
|
c.msgSeqCounters.Store(senderID, new(atomic.Uint64))
|
||||||
|
|
||||||
metadata := map[string]string{}
|
metadata := map[string]string{}
|
||||||
|
|
||||||
sender := bus.SenderInfo{
|
sender := bus.SenderInfo{
|
||||||
|
|
@ -195,7 +449,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGroupATMessage handles QQ group @ messages
|
// handleGroupATMessage handles QQ group @ messages.
|
||||||
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||||
// deduplication check
|
// deduplication check
|
||||||
|
|
@ -232,7 +486,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
"length": len(content),
|
"length": len(content),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 转发到消息总线(使用 GroupID 作为 ChatID)
|
// Store chat routing context using GroupID as chatID.
|
||||||
|
c.chatType.Store(data.GroupID, "group")
|
||||||
|
c.lastMsgID.Store(data.GroupID, data.ID)
|
||||||
|
|
||||||
|
// Reset msg_seq counter for new inbound message.
|
||||||
|
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"group_id": data.GroupID,
|
"group_id": data.GroupID,
|
||||||
}
|
}
|
||||||
|
|
@ -262,29 +522,102 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDuplicate 检查消息是否重复
|
// isDuplicate checks whether a message has been seen within the TTL window.
|
||||||
|
// It also enforces a hard cap on map size by evicting oldest entries.
|
||||||
func (c *QQChannel) isDuplicate(messageID string) bool {
|
func (c *QQChannel) isDuplicate(messageID string) bool {
|
||||||
c.mu.Lock()
|
c.muDedup.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.muDedup.Unlock()
|
||||||
|
|
||||||
if c.processedIDs[messageID] {
|
if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
c.processedIDs[messageID] = true
|
// Enforce hard cap: evict oldest entries when at capacity.
|
||||||
|
if len(c.dedup) >= dedupMaxSize {
|
||||||
// 简单清理:限制 map 大小
|
var oldestID string
|
||||||
if len(c.processedIDs) > 10000 {
|
var oldestTS time.Time
|
||||||
// 清空一半
|
for id, ts := range c.dedup {
|
||||||
count := 0
|
if oldestID == "" || ts.Before(oldestTS) {
|
||||||
for id := range c.processedIDs {
|
oldestID = id
|
||||||
if count >= 5000 {
|
oldestTS = ts
|
||||||
break
|
|
||||||
}
|
}
|
||||||
delete(c.processedIDs, id)
|
}
|
||||||
count++
|
if oldestID != "" {
|
||||||
|
delete(c.dedup, oldestID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.dedup[messageID] = time.Now()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dedupJanitor periodically evicts expired entries from the dedup map.
|
||||||
|
func (c *QQChannel) dedupJanitor() {
|
||||||
|
ticker := time.NewTicker(dedupInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// Collect expired keys under read-like scan.
|
||||||
|
c.muDedup.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
var expired []string
|
||||||
|
for id, ts := range c.dedup {
|
||||||
|
if now.Sub(ts) >= dedupTTL {
|
||||||
|
expired = append(expired, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range expired {
|
||||||
|
delete(c.dedup, id)
|
||||||
|
}
|
||||||
|
c.muDedup.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isHTTPURL returns true if s starts with http:// or https://.
|
||||||
|
func isHTTPURL(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlPattern matches URLs with explicit http(s):// scheme.
|
||||||
|
// Only scheme-prefixed URLs are matched to avoid false positives on bare text
|
||||||
|
// like version numbers (e.g., "1.2.3") or domain-like fragments.
|
||||||
|
var urlPattern = regexp.MustCompile(
|
||||||
|
`(?i)` +
|
||||||
|
`https?://` + // required scheme
|
||||||
|
`(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+` + // domain parts
|
||||||
|
`[a-zA-Z]{2,}` + // TLD
|
||||||
|
`(?:[/?#]\S*)?`, // optional path/query/fragment
|
||||||
|
)
|
||||||
|
|
||||||
|
// sanitizeURLs replaces dots in URL domains with "。" (fullwidth period)
|
||||||
|
// to prevent QQ's URL blacklist from rejecting the message.
|
||||||
|
func sanitizeURLs(text string) string {
|
||||||
|
return urlPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
// Split into scheme + rest (scheme is always present).
|
||||||
|
idx := strings.Index(match, "://")
|
||||||
|
scheme := match[:idx+3]
|
||||||
|
rest := match[idx+3:]
|
||||||
|
|
||||||
|
// Find where the domain ends (first / ? or #).
|
||||||
|
domainEnd := len(rest)
|
||||||
|
for i, ch := range rest {
|
||||||
|
if ch == '/' || ch == '?' || ch == '#' {
|
||||||
|
domainEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := rest[:domainEnd]
|
||||||
|
path := rest[domainEnd:]
|
||||||
|
|
||||||
|
// Replace dots in domain only.
|
||||||
|
domain = strings.ReplaceAll(domain, ".", "。")
|
||||||
|
|
||||||
|
return scheme + domain + path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
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"
|
||||||
|
|
@ -42,11 +42,13 @@ 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
|
||||||
|
|
@ -228,27 +168,65 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID, err := parseChatID(msg.ChatID)
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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, threadID, 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, threadID int, 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
|
||||||
|
tgMsg.MessageThreadID = threadID
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,13 +235,16 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
|
||||||
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
|
||||||
// The returned stop function is idempotent and cancels the goroutine.
|
// The returned stop function is idempotent and cancels the goroutine.
|
||||||
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
cid, err := parseChatID(chatID)
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return func() {}, err
|
return func() {}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||||
|
action.MessageThreadID = threadID
|
||||||
|
|
||||||
// Send the first typing action immediately
|
// Send the first typing action immediately
|
||||||
_ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
|
_ = c.bot.SendChatAction(ctx, action)
|
||||||
|
|
||||||
typingCtx, cancel := context.WithCancel(ctx)
|
typingCtx, cancel := context.WithCancel(ctx)
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -274,7 +255,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
||||||
case <-typingCtx.Done():
|
case <-typingCtx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
|
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||||
|
a.MessageThreadID = threadID
|
||||||
|
_ = c.bot.SendChatAction(typingCtx, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -284,7 +267,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
||||||
|
|
||||||
// EditMessage implements channels.MessageEditor.
|
// EditMessage implements channels.MessageEditor.
|
||||||
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
cid, err := parseChatID(chatID)
|
cid, _, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -313,12 +296,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
|
||||||
text = "Thinking... 💭"
|
text = "Thinking... 💭"
|
||||||
}
|
}
|
||||||
|
|
||||||
cid, err := parseChatID(chatID)
|
cid, threadID, err := parseTelegramChatID(chatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text))
|
phMsg := tu.Message(tu.ID(cid), text)
|
||||||
|
phMsg.MessageThreadID = threadID
|
||||||
|
pMsg, err := c.bot.SendMessage(ctx, phMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -332,7 +317,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID, err := parseChatID(msg.ChatID)
|
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||||
}
|
}
|
||||||
|
|
@ -365,6 +350,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
case "image":
|
case "image":
|
||||||
params := &telego.SendPhotoParams{
|
params := &telego.SendPhotoParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
Photo: telego.InputFile{File: file},
|
Photo: telego.InputFile{File: file},
|
||||||
Caption: part.Caption,
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
|
|
@ -372,6 +358,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
case "audio":
|
case "audio":
|
||||||
params := &telego.SendAudioParams{
|
params := &telego.SendAudioParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
Audio: telego.InputFile{File: file},
|
Audio: telego.InputFile{File: file},
|
||||||
Caption: part.Caption,
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
|
|
@ -379,6 +366,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
case "video":
|
case "video":
|
||||||
params := &telego.SendVideoParams{
|
params := &telego.SendVideoParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
Video: telego.InputFile{File: file},
|
Video: telego.InputFile{File: file},
|
||||||
Caption: part.Caption,
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
|
|
@ -386,6 +374,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
||||||
default: // "file" or unknown types
|
default: // "file" or unknown types
|
||||||
params := &telego.SendDocumentParams{
|
params := &telego.SendDocumentParams{
|
||||||
ChatID: tu.ID(chatID),
|
ChatID: tu.ID(chatID),
|
||||||
|
MessageThreadID: threadID,
|
||||||
Document: telego.InputFile{File: file},
|
Document: telego.InputFile{File: file},
|
||||||
Caption: part.Caption,
|
Caption: part.Caption,
|
||||||
}
|
}
|
||||||
|
|
@ -531,19 +520,28 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
content = cleaned
|
content = cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For forum topics, embed the thread ID as "chatID/threadID" so replies
|
||||||
|
// route to the correct topic and each topic gets its own session.
|
||||||
|
// Only forum groups (IsForum) are handled; regular group reply threads
|
||||||
|
// must share one session per group.
|
||||||
|
compositeChatID := fmt.Sprintf("%d", chatID)
|
||||||
|
threadID := message.MessageThreadID
|
||||||
|
if message.Chat.IsForum && threadID != 0 {
|
||||||
|
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
|
||||||
|
}
|
||||||
|
|
||||||
logger.DebugCF("telegram", "Received message", map[string]any{
|
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||||
"sender_id": sender.CanonicalID,
|
"sender_id": sender.CanonicalID,
|
||||||
"chat_id": fmt.Sprintf("%d", chatID),
|
"chat_id": compositeChatID,
|
||||||
|
"thread_id": threadID,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
|
|
||||||
|
|
||||||
peerKind := "direct"
|
peerKind := "direct"
|
||||||
peerID := fmt.Sprintf("%d", user.ID)
|
peerID := fmt.Sprintf("%d", user.ID)
|
||||||
if message.Chat.Type != "private" {
|
if message.Chat.Type != "private" {
|
||||||
peerKind = "group"
|
peerKind = "group"
|
||||||
peerID = fmt.Sprintf("%d", chatID)
|
peerID = compositeChatID
|
||||||
}
|
}
|
||||||
|
|
||||||
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
peer := bus.Peer{Kind: peerKind, ID: peerID}
|
||||||
|
|
@ -556,11 +554,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set parent_peer metadata for per-topic agent binding.
|
||||||
|
if message.Chat.IsForum && threadID != 0 {
|
||||||
|
metadata["parent_peer_kind"] = "topic"
|
||||||
|
metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID)
|
||||||
|
}
|
||||||
|
|
||||||
c.HandleMessage(c.ctx,
|
c.HandleMessage(c.ctx,
|
||||||
peer,
|
peer,
|
||||||
messageID,
|
messageID,
|
||||||
platformID,
|
platformID,
|
||||||
fmt.Sprintf("%d", chatID),
|
compositeChatID,
|
||||||
content,
|
content,
|
||||||
mediaPaths,
|
mediaPaths,
|
||||||
metadata,
|
metadata,
|
||||||
|
|
@ -608,10 +612,23 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
|
||||||
return c.downloadFileWithInfo(file, ext)
|
return c.downloadFileWithInfo(file, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseChatID(chatIDStr string) (int64, error) {
|
// parseTelegramChatID splits "chatID/threadID" into its components.
|
||||||
var id int64
|
// Returns threadID=0 when no "/" is present (non-forum messages).
|
||||||
_, err := fmt.Sscanf(chatIDStr, "%d", &id)
|
func parseTelegramChatID(chatID string) (int64, int, error) {
|
||||||
return id, err
|
idx := strings.Index(chatID, "/")
|
||||||
|
if idx == -1 {
|
||||||
|
cid, err := strconv.ParseInt(chatID, 10, 64)
|
||||||
|
return cid, 0, err
|
||||||
|
}
|
||||||
|
cid, err := strconv.ParseInt(chatID[:idx], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
tid, err := strconv.Atoi(chatID[idx+1:])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err)
|
||||||
|
}
|
||||||
|
return cid, tid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func markdownToTelegramHTML(text string) string {
|
func markdownToTelegramHTML(text string) string {
|
||||||
|
|
@ -721,34 +738,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()
|
||||||
}
|
|
||||||
|
|
||||||
for _, entity := range entities {
|
|
||||||
if entity.Type == "mention" {
|
|
||||||
// Extract the mention text from the message
|
|
||||||
text := message.Text
|
|
||||||
if text == "" {
|
|
||||||
text = message.Caption
|
|
||||||
}
|
}
|
||||||
runes := []rune(text)
|
runes := []rune(text)
|
||||||
end := entity.Offset + entity.Length
|
|
||||||
if end <= len(runes) {
|
for _, entity := range entities {
|
||||||
mention := string(runes[entity.Offset:end])
|
entityText, ok := telegramEntityText(runes, entity)
|
||||||
if strings.EqualFold(mention, "@"+botUsername) {
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch entity.Type {
|
||||||
|
case telego.EntityTypeMention:
|
||||||
|
if botUsername != "" && strings.EqualFold(entityText, "@"+botUsername) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
case telego.EntityTypeTextMention:
|
||||||
|
if botUsername != "" && entity.User != nil && strings.EqualFold(entity.User.Username, botUsername) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
case telego.EntityTypeBotCommand:
|
||||||
if entity.Type == "text_mention" && entity.User != nil {
|
if isBotCommandEntityForThisBot(entityText, botUsername) {
|
||||||
if entity.User.Username == botUsername {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -756,6 +773,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
462
pkg/channels/telegram/telegram_test.go
Normal file
462
pkg/channels/telegram/telegram_test.go
Normal file
|
|
@ -0,0 +1,462 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_Plain(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("12345")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(12345), cid)
|
||||||
|
assert.Equal(t, 0, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_NegativeGroup(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("-1001234567890")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(-1001234567890), cid)
|
||||||
|
assert.Equal(t, 0, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_WithThreadID(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("-1001234567890/42")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(-1001234567890), cid)
|
||||||
|
assert.Equal(t, 42, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_GeneralTopic(t *testing.T) {
|
||||||
|
cid, tid, err := parseTelegramChatID("-100123/1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(-100123), cid)
|
||||||
|
assert.Equal(t, 1, tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_Invalid(t *testing.T) {
|
||||||
|
_, _, err := parseTelegramChatID("not-a-number")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTelegramChatID_InvalidThreadID(t *testing.T) {
|
||||||
|
_, _, err := parseTelegramChatID("-100123/not-a-thread")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid thread ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSend_WithForumThreadID(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: "-1001234567890/42",
|
||||||
|
Content: "Hello from topic",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, caller.calls, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_ForumTopic_SetsMetadata(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: "hello from topic",
|
||||||
|
MessageID: 10,
|
||||||
|
MessageThreadID: 42,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: -1001234567890,
|
||||||
|
Type: "supergroup",
|
||||||
|
IsForum: true,
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 7,
|
||||||
|
FirstName: "Alice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
require.True(t, ok, "expected inbound message")
|
||||||
|
|
||||||
|
// Composite chatID should include thread ID
|
||||||
|
assert.Equal(t, "-1001234567890/42", inbound.ChatID)
|
||||||
|
|
||||||
|
// Peer ID should include thread ID for session key isolation
|
||||||
|
assert.Equal(t, "group", inbound.Peer.Kind)
|
||||||
|
assert.Equal(t, "-1001234567890/42", inbound.Peer.ID)
|
||||||
|
|
||||||
|
// Parent peer metadata should be set for agent binding
|
||||||
|
assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"])
|
||||||
|
assert.Equal(t, "42", inbound.Metadata["parent_peer_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_NoForum_NoThreadMetadata(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: "regular group message",
|
||||||
|
MessageID: 11,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: -100999,
|
||||||
|
Type: "group",
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 8,
|
||||||
|
FirstName: "Bob",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
// Plain chatID without thread suffix
|
||||||
|
assert.Equal(t, "-100999", inbound.ChatID)
|
||||||
|
|
||||||
|
// Peer ID should be raw chat ID (no thread suffix)
|
||||||
|
assert.Equal(t, "group", inbound.Peer.Kind)
|
||||||
|
assert.Equal(t, "-100999", inbound.Peer.ID)
|
||||||
|
|
||||||
|
// No parent peer metadata
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch := &TelegramChannel{
|
||||||
|
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||||
|
chatIDs: make(map[string]int64),
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// In regular groups, reply threads set MessageThreadID to the original
|
||||||
|
// message ID. This should NOT trigger per-thread session isolation.
|
||||||
|
msg := &telego.Message{
|
||||||
|
Text: "reply in thread",
|
||||||
|
MessageID: 20,
|
||||||
|
MessageThreadID: 15,
|
||||||
|
Chat: telego.Chat{
|
||||||
|
ID: -100999,
|
||||||
|
Type: "supergroup",
|
||||||
|
IsForum: false,
|
||||||
|
},
|
||||||
|
From: &telego.User{
|
||||||
|
ID: 9,
|
||||||
|
FirstName: "Carol",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inbound, ok := messageBus.ConsumeInbound(ctx)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
// chatID should NOT include thread suffix for non-forum groups
|
||||||
|
assert.Equal(t, "-100999", inbound.ChatID)
|
||||||
|
|
||||||
|
// Peer ID should be raw chat ID (shared session for whole group)
|
||||||
|
assert.Equal(t, "group", inbound.Peer.Kind)
|
||||||
|
assert.Equal(t, "-100999", inbound.Peer.ID)
|
||||||
|
|
||||||
|
// No parent peer metadata
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
|
||||||
|
assert.Empty(t, inbound.Metadata["parent_peer_id"])
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
pkg/commands/builtin.go
Normal file
17
pkg/commands/builtin.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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(),
|
||||||
|
clearCommand(),
|
||||||
|
}
|
||||||
|
}
|
||||||
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))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
20
pkg/commands/cmd_clear.go
Normal file
20
pkg/commands/cmd_clear.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func clearCommand() Definition {
|
||||||
|
return Definition{
|
||||||
|
Name: "clear",
|
||||||
|
Description: "Clear the chat history",
|
||||||
|
Usage: "/clear",
|
||||||
|
Handler: func(_ context.Context, req Request, rt *Runtime) error {
|
||||||
|
if rt == nil || rt.ClearHistory == nil {
|
||||||
|
return req.Reply(unavailableMsg)
|
||||||
|
}
|
||||||
|
if err := rt.ClearHistory(); err != nil {
|
||||||
|
return req.Reply("Failed to clear chat history: " + err.Error())
|
||||||
|
}
|
||||||
|
return req.Reply("Chat history cleared!")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue