diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..3d72ace94
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+# Ensure shell scripts always use LF line endings regardless of OS.
+*.sh text eol=lf
+docker/entrypoint.sh text eol=lf
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 9b89b69ae..def19c3e5 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,5 +16,5 @@ jobs:
with:
go-version-file: go.mod
- - name: Build
+ - name: Build core binaries
run: make build-all
diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml
new file mode 100644
index 000000000..4da3f79cb
--- /dev/null
+++ b/.github/workflows/create-tag.yml
@@ -0,0 +1,60 @@
+name: Create Tag
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Tag name (required, e.g. v0.2.0)"
+ required: true
+ type: string
+ commit:
+ description: "Target commit SHA (leave empty for latest main)"
+ required: false
+ type: string
+ default: ""
+
+jobs:
+ create-tag:
+ name: Create Git Tag
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ ref: main
+
+ - name: Validate commit exists
+ if: ${{ inputs.commit != '' }}
+ shell: bash
+ run: |
+ if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then
+ echo "::error::Commit '${{ inputs.commit }}' does not exist."
+ exit 1
+ fi
+
+ - name: Check tag does not already exist
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
+ echo "::error::Tag '${{ inputs.tag }}' already exists."
+ exit 1
+ fi
+
+ - name: Create and push tag
+ shell: bash
+ run: |
+ TARGET="${{ inputs.commit || 'HEAD' }}"
+ COMMIT_SHA=$(git rev-parse "$TARGET")
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}"
+ git push origin "${{ inputs.tag }}"
+ echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY"
+ echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml
index e03357566..626318619 100644
--- a/.github/workflows/create_dmg.yml
+++ b/.github/workflows/create_dmg.yml
@@ -17,29 +17,38 @@ jobs:
with:
ref: main
- # 1. 安装指定版本的 Go (可选,但推荐)
+ # 1. Install Go from go.mod
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- # 2. 安装 pnpm
- - name: Install pnpm
- run: brew install pnpm
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v6
+ with:
+ version: 10.33.0
+ run_install: false
- # 3. 运行你的 Makefile 编译二进制文件
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: web/frontend/pnpm-lock.yaml
+
+ # 3. Build the application bundle
- name: Build with Make
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
- # 4. 签名
+ # 4. Apply ad-hoc signing
- name: Ad-hoc Sign
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
- # 5. 安装打包工具
+ # 5. Install the DMG packaging tool
- name: Install create-dmg
run: brew install create-dmg
- # 6. 执行打包命令
+ # 6. Create the DMG
- name: Create DMG
run: |
mkdir -p dist
@@ -54,7 +63,7 @@ jobs:
"dist/picoclaw-${{ matrix.arch }}.dmg" \
"build/PicoClaw Launcher.app"
- # 7. 上传文件到 GitHub Artifacts (供你下载)
+ # 7. Upload the DMG as a GitHub artifact
- name: Upload DMG
uses: actions/upload-artifact@v7
with:
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index a5002fec5..d507234dc 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -47,13 +47,18 @@ jobs:
with:
go-version-file: go.mod
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v6
+ with:
+ version: 10.33.0
+ run_install: false
+
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
-
- - name: Setup pnpm
- run: corepack enable && corepack prepare pnpm@latest --activate
+ cache: pnpm
+ cache-dependency-path: web/frontend/pnpm-lock.yaml
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -69,15 +74,25 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
+ if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v4
+ env:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Install zip
+ run: sudo apt-get install -y zip
+
- name: Create local tag for GoReleaser
run: git tag "${{ steps.version.outputs.version }}"
+ - name: Lowercase owner for Docker tags
+ id: repo
+ run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
+
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
@@ -86,10 +101,11 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
+ REPO_OWNER: ${{ steps.repo.outputs.owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
+ INCLUDE_ANDROID_BUNDLE: "true"
NIGHTLY_BUILD: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
@@ -123,7 +139,7 @@ jobs:
# Collect release artifacts from goreleaser dist/
ASSETS=()
- for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
+ for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt build/picoclaw-android-universal.zip; do
[ -f "$f" ] && ASSETS+=("$f")
done
@@ -136,3 +152,153 @@ jobs:
--latest=false \
"${ASSETS[@]}"
+ build-macos-launcher:
+ name: Build macOS Launcher (${{ matrix.arch_name }})
+ runs-on: macos-latest
+ permissions:
+ contents: read
+ strategy:
+ matrix:
+ include:
+ - goarch: arm64
+ arch_name: arm64
+ - goarch: amd64
+ arch_name: x86_64
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup Go from go.mod
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v6
+ with:
+ version: 10.33.0
+ run_install: false
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: web/frontend/pnpm-lock.yaml
+
+ - name: Build frontend
+ run: |
+ cd web/frontend
+ CI=true pnpm install --frozen-lockfile
+ pnpm build:backend
+
+ - name: Compute version
+ 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
+ VERSION="v0.0.0-nightly.${DATE}.${SHA}"
+ else
+ VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
+ fi
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+
+ - name: Build picoclaw-launcher with CGO
+ env:
+ CGO_ENABLED: "1"
+ GOOS: darwin
+ GOARCH: ${{ matrix.goarch }}
+ run: |
+ SDK_PATH=$(xcrun --show-sdk-path)
+ export CGO_CFLAGS="-isysroot ${SDK_PATH} -mmacosx-version-min=11.0"
+ export CGO_LDFLAGS="-isysroot ${SDK_PATH}"
+
+ go generate ./...
+ go build -tags "goolm,stdjson" \
+ -ldflags "-s -w \
+ -X github.com/sipeed/picoclaw/pkg/config.Version=${{ steps.version.outputs.version }} \
+ -X github.com/sipeed/picoclaw/pkg/config.GitCommit=$(git rev-parse --short HEAD) \
+ -X github.com/sipeed/picoclaw/pkg/config.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ -o picoclaw-launcher-cgo \
+ ./web/backend
+
+ - name: Sign and notarize launcher binary
+ if: env.MACOS_SIGN_P12 != ''
+ env:
+ 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 }}
+ run: |
+ pip3 install rcodesign
+
+ echo "$MACOS_SIGN_P12" | base64 -d > cert.p12
+
+ rcodesign sign \
+ --p12-file cert.p12 \
+ --p12-password "$MACOS_SIGN_PASSWORD" \
+ picoclaw-launcher-cgo
+
+ echo "$MACOS_NOTARY_KEY" > notary-key.p8
+
+ rcodesign notary-submit \
+ --api-key-path notary-key.p8 \
+ --api-issuer "$MACOS_NOTARY_ISSUER_ID" \
+ --wait \
+ picoclaw-launcher-cgo
+
+ rm -f cert.p12 notary-key.p8
+
+ - name: Upload launcher artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: macos-launcher-${{ matrix.arch_name }}
+ path: picoclaw-launcher-cgo
+ retention-days: 1
+
+ patch-macos-archives:
+ name: Patch macOS Archives
+ needs: [nightly, build-macos-launcher]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ strategy:
+ matrix:
+ include:
+ - arch_name: arm64
+ - arch_name: x86_64
+ steps:
+ - name: Download launcher artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: macos-launcher-${{ matrix.arch_name }}
+
+ - name: Patch darwin release archive
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ ARCHIVE_NAME="picoclaw_Darwin_${{ matrix.arch_name }}.tar.gz"
+
+ gh release download nightly \
+ --repo "${{ github.repository }}" \
+ --pattern "${ARCHIVE_NAME}" \
+ --dir ./patch-tmp
+
+ mkdir -p ./patch-extracted
+ tar xzf "./patch-tmp/${ARCHIVE_NAME}" -C ./patch-extracted
+
+ cp picoclaw-launcher-cgo ./patch-extracted/picoclaw-launcher
+ chmod +x ./patch-extracted/picoclaw-launcher
+
+ tar czf "${ARCHIVE_NAME}" -C ./patch-extracted .
+
+ gh release upload nightly \
+ --repo "${{ github.repository }}" \
+ "${ARCHIVE_NAME}" --clobber
+
+ echo "✅ Patched ${ARCHIVE_NAME} with CGO launcher (systray enabled)"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2ce341770..9aa054943 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,10 +1,10 @@
-name: Create Tag and Release
+name: Release
on:
workflow_dispatch:
inputs:
tag:
- description: "Release tag (required, e.g. v0.2.0)"
+ description: "Existing tag to release (e.g. v0.2.0)"
required: true
type: string
prerelease:
@@ -24,35 +24,23 @@ on:
default: true
jobs:
- create-tag:
- name: Create Git Tag
- runs-on: ubuntu-latest
- permissions:
- contents: write
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - name: Create and push tag
- shell: bash
- env:
- RELEASE_TAG: ${{ inputs.tag }}
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
- git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
- git push origin "$RELEASE_TAG"
-
release:
name: GoReleaser Release
- needs: create-tag
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
+ - name: Verify tag exists
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ if ! gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then
+ echo "::error::Tag '${{ inputs.tag }}' does not exist. Create it first using the 'Create Tag' workflow."
+ exit 1
+ fi
+
- name: Checkout tag
uses: actions/checkout@v6
with:
@@ -65,13 +53,18 @@ jobs:
with:
go-version-file: go.mod
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v6
+ with:
+ version: 10.33.0
+ run_install: false
+
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
-
- - name: Setup pnpm
- run: corepack enable && corepack prepare pnpm@latest --activate
+ cache: pnpm
+ cache-dependency-path: web/frontend/pnpm-lock.yaml
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -87,12 +80,22 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
+ if: env.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v4
+ env:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Install zip
+ run: sudo apt-get install -y zip
+
+ - name: Lowercase owner for Docker tags
+ id: repo
+ run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
+
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
@@ -101,9 +104,10 @@ jobs:
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
+ REPO_OWNER: ${{ steps.repo.outputs.owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
+ INCLUDE_ANDROID_BUNDLE: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
@@ -119,9 +123,149 @@ jobs:
--draft=${{ inputs.draft }} \
--prerelease=${{ inputs.prerelease }}
+ build-macos-launcher:
+ name: Build macOS Launcher (${{ matrix.arch_name }})
+ runs-on: macos-latest
+ permissions:
+ contents: read
+ strategy:
+ matrix:
+ include:
+ - goarch: arm64
+ arch_name: arm64
+ - goarch: amd64
+ arch_name: x86_64
+ steps:
+ - name: Checkout tag
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ ref: ${{ inputs.tag }}
+
+ - name: Setup Go from go.mod
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v6
+ with:
+ version: 10.33.0
+ run_install: false
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: web/frontend/pnpm-lock.yaml
+
+ - name: Build frontend
+ run: |
+ cd web/frontend
+ CI=true pnpm install --frozen-lockfile
+ pnpm build:backend
+
+ - name: Build picoclaw-launcher with CGO
+ env:
+ CGO_ENABLED: "1"
+ GOOS: darwin
+ GOARCH: ${{ matrix.goarch }}
+ run: |
+ SDK_PATH=$(xcrun --show-sdk-path)
+ export CGO_CFLAGS="-isysroot ${SDK_PATH} -mmacosx-version-min=11.0"
+ export CGO_LDFLAGS="-isysroot ${SDK_PATH}"
+
+ go generate ./...
+ go build -tags "goolm,stdjson" \
+ -ldflags "-s -w \
+ -X github.com/sipeed/picoclaw/pkg/config.Version=${{ inputs.tag }} \
+ -X github.com/sipeed/picoclaw/pkg/config.GitCommit=$(git rev-parse --short HEAD) \
+ -X github.com/sipeed/picoclaw/pkg/config.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ -o picoclaw-launcher-cgo \
+ ./web/backend
+
+ - name: Sign and notarize launcher binary
+ if: env.MACOS_SIGN_P12 != ''
+ env:
+ 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 }}
+ run: |
+ pip3 install rcodesign
+
+ echo "$MACOS_SIGN_P12" | base64 -d > cert.p12
+
+ rcodesign sign \
+ --p12-file cert.p12 \
+ --p12-password "$MACOS_SIGN_PASSWORD" \
+ picoclaw-launcher-cgo
+
+ echo "$MACOS_NOTARY_KEY" > notary-key.p8
+
+ rcodesign notary-submit \
+ --api-key-path notary-key.p8 \
+ --api-issuer "$MACOS_NOTARY_ISSUER_ID" \
+ --wait \
+ picoclaw-launcher-cgo
+
+ rm -f cert.p12 notary-key.p8
+
+ - name: Upload launcher artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: macos-launcher-${{ matrix.arch_name }}
+ path: picoclaw-launcher-cgo
+ retention-days: 1
+
+ patch-macos-archives:
+ name: Patch macOS Archives
+ needs: [release, build-macos-launcher]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ strategy:
+ matrix:
+ include:
+ - arch_name: arm64
+ - arch_name: x86_64
+ steps:
+ - name: Download launcher artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: macos-launcher-${{ matrix.arch_name }}
+
+ - name: Patch darwin release archive
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ inputs.tag }}
+ run: |
+ ARCHIVE_NAME="picoclaw_Darwin_${{ matrix.arch_name }}.tar.gz"
+
+ gh release download "${TAG}" \
+ --repo "${{ github.repository }}" \
+ --pattern "${ARCHIVE_NAME}" \
+ --dir ./patch-tmp
+
+ mkdir -p ./patch-extracted
+ tar xzf "./patch-tmp/${ARCHIVE_NAME}" -C ./patch-extracted
+
+ cp picoclaw-launcher-cgo ./patch-extracted/picoclaw-launcher
+ chmod +x ./patch-extracted/picoclaw-launcher
+
+ tar czf "${ARCHIVE_NAME}" -C ./patch-extracted .
+
+ gh release upload "${TAG}" \
+ --repo "${{ github.repository }}" \
+ "${ARCHIVE_NAME}" --clobber
+
+ echo "Patched ${ARCHIVE_NAME} with CGO launcher (systray enabled)"
+
upload-tos:
name: Upload to TOS
- needs: release
+ needs: [release, patch-macos-archives]
if: ${{ inputs.upload_tos }}
uses: ./.github/workflows/upload-tos.yml
with:
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 9c26de34f..b330c60f5 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -9,11 +9,10 @@ git:
before:
hooks:
- - go mod tidy
- go generate ./...
- - sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
- - go install github.com/tc-hib/go-winres@latest
- - go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
+ - sh -c 'cd web/frontend && CI=true pnpm install --frozen-lockfile && pnpm build:backend'
+ - sh -c 'GOBIN="$(go env GOPATH)/bin"; mkdir -p "$GOBIN"; go install github.com/tc-hib/go-winres@v0.3.3 && "$GOBIN/go-winres" make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}'
+ - sh -c 'if [ "${INCLUDE_ANDROID_BUNDLE:-}" = "true" ]; then make build-android-bundle; fi'
builds:
- id: picoclaw
@@ -27,7 +26,7 @@ builds:
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
- - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
@@ -67,6 +66,10 @@ builds:
- stdjson
ldflags:
- -s -w
+ - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
+ - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
@@ -106,6 +109,10 @@ builds:
- stdjson
ldflags:
- -s -w
+ - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
+ - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
+ - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ with index .Env "GOVERSION" }}{{ . }}{{ else }}unknown{{ end }}
goos:
- linux
- windows
@@ -144,8 +151,8 @@ dockers_v2:
ids:
- picoclaw
images:
- - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
+ - "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw"
+ - '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}'
tags:
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
@@ -161,8 +168,8 @@ dockers_v2:
- picoclaw-launcher
- picoclaw-launcher-tui
images:
- - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
+ - "ghcr.io/{{ .Env.REPO_OWNER }}/picoclaw"
+ - '{{ with .Env.DOCKERHUB_IMAGE_NAME }}docker.io/{{ . }}{{ end }}'
tags:
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
@@ -217,7 +224,7 @@ nfpms:
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
{{- else }}{{ .Arch }}{{ end }}
vendor: picoclaw
- homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw
+ homepage: https://github.com/{{ .Env.REPO_OWNER }}/picoclaw
maintainer: picoclaw contributors
description: picoclaw - a tool for managing and running tasks
license: MIT
@@ -245,6 +252,8 @@ changelog:
release:
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
+ extra_files:
+ - glob: ./build/picoclaw-android-universal.zip
footer: >-
---
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ceff723d2..a78c41c36 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -35,6 +35,8 @@ We are committed to maintaining a welcoming and respectful community. Be kind, c
For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
+For documentation contributions, prefer the layout and naming conventions in [`docs/README.md`](docs/README.md). Run `make lint-docs` after adding or moving Markdown files to catch common consistency issues early.
+
---
## Getting Started
@@ -64,7 +66,7 @@ For substantial new features, please open an issue first to discuss the design b
```bash
make build # Build binary (runs go generate first)
make generate # Run go generate only
-make check # Full pre-commit check: deps + fmt + vet + test
+make check # Full pre-commit check: deps + fmt + vet + test + docs consistency checks
```
### Running Tests
@@ -81,9 +83,10 @@ go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
make fmt # Format code
make vet # Static analysis
make lint # Full linter run
+make lint-docs # Check common documentation layout and naming conventions
```
-All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
+All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early, including the common docs consistency checks from `make lint-docs`.
---
@@ -108,7 +111,7 @@ Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider
- Reference the related issue when relevant: `Fix session leak (#123)`.
- Keep commits focused. One logical change per commit is preferred.
- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
-- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
+- Refer to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
### Keeping Up to Date
diff --git a/Makefile b/Makefile
index f7ebc7411..c5d691c29 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: all build install uninstall clean help test
+.PHONY: all build install uninstall clean help test build-all lint-docs
# Build variables
BINARY_NAME=picoclaw
@@ -205,11 +205,44 @@ build-linux-mipsle: generate
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
+## build-android-arm64: Build core for Android ARM64
+build-android-arm64: generate
+ @echo "Building for android/arm64..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
+ @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-android-arm64"
+
+## build-launcher-android-arm64: Build launcher for Android ARM64
+build-launcher-android-arm64:
+ @echo "Building picoclaw-launcher for android/arm64..."
+ @mkdir -p $(BUILD_DIR)
+ @$(MAKE) -C web build-android-arm64 \
+ OUTPUT_ANDROID_ARM64="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-android-arm64" \
+ GO='$(GO)' \
+ LDFLAGS='$(LDFLAGS)'
+ @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-android-arm64"
+
+## build-android-bundle: Build core and launcher for all Android architectures and package as universal zip
+build-android-bundle: generate
+ @echo "Building core for all Android architectures..."
+ @mkdir -p $(BUILD_DIR)
+ GOOS=android GOARCH=arm64 $(GO) build -tags stdjson -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 ./$(CMD_DIR)
+ @echo "Building launcher for Android arm64..."
+ @$(MAKE) build-launcher-android-arm64
+ @echo "Staging JNI libs..."
+ @rm -rf $(BUILD_DIR)/android-staging
+ @mkdir -p $(BUILD_DIR)/android-staging/arm64-v8a
+ @cp $(BUILD_DIR)/$(BINARY_NAME)-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw.so
+ @cp $(BUILD_DIR)/picoclaw-launcher-android-arm64 $(BUILD_DIR)/android-staging/arm64-v8a/libpicoclaw-web.so
+ @cd $(BUILD_DIR)/android-staging && zip -r ../picoclaw-android-universal.zip .
+ @rm -rf $(BUILD_DIR)/android-staging
+ @echo "All Android builds complete: $(BUILD_DIR)/picoclaw-android-universal.zip"
+
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
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)"
-## build-all: Build picoclaw for all platforms
+## build-all: Build the picoclaw core binary for all Makefile-managed platforms
build-all: generate
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
@@ -226,7 +259,7 @@ build-all: generate
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
- @echo "All builds complete"
+ @echo "Core builds complete"
## install: Install picoclaw to system and copy builtin skills
install: build
@@ -275,9 +308,14 @@ test: generate
fmt:
@$(GOLANGCI_LINT) fmt
+## lint-docs: Check common documentation layout and naming conventions
+lint-docs:
+ @./scripts/lint-docs.sh
+
## lint: Run linters
lint:
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
+ @./scripts/lint-docs.sh
## fix: Fix linting issues
fix:
@@ -293,8 +331,8 @@ update-deps:
@$(GO) get -u ./...
@$(GO) mod tidy
-## check: Run vet, fmt, and verify dependencies
-check: deps fmt vet test
+## check: Run deps, fmt, vet, tests, and docs consistency checks
+check: deps fmt vet test lint-docs
## run: Build and run picoclaw
run: build
diff --git a/README.md b/README.md
index eb0d389d2..5aac4bbc9 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
@@ -164,22 +164,32 @@ Alternatively, download the binary for your platform from the [GitHub Releases](
### Build from source (for development)
+Prerequisites:
+
+- Go 1.25+
+- Node.js 22+ and pnpm 10.33.0+ for Web UI / launcher builds
+
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
-# Build core binary
+# Install frontend dependencies
+(cd web/frontend && pnpm install --frozen-lockfile)
+
+# Build the core binary for the current platform
make build
-# Build Web UI Launcher (required for WebUI mode)
+# Build the Web UI Launcher (required for WebUI mode)
make build-launcher
-# Build for multiple platforms
+# Build core binaries for all Makefile-managed platforms
make build-all
-# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
+# Build for Raspberry Pi Zero 2 W
+# 32-bit: make build-linux-arm
+# 64-bit: make build-linux-arm64
make build-pi-zero
# Build and install
@@ -215,7 +225,7 @@ picoclaw-launcher
-**Getting started:**
+**Getting started:**
Use the TUI menus to: **1)** Configure a Provider -> **2)** Configure a Channel -> **3)** Start the Gateway -> **4)** Chat!
For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io).
+
### 📱 Android
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
@@ -368,8 +379,8 @@ This creates `~/.picoclaw/config.json` and the workspace directory.
```
> See `config/config.example.json` in the repo for a complete configuration template with all available options.
->
-> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security_configuration.md` for more details.
+>
+> Please note: config.example.json format is version 0, with sensitive codes in it, and will be auto migrated to version 1+, then, the config.json will only store insensitive data, the sensitive codes will be stored in .security.yml, if you need manually modify the codes, please see `docs/security/security_configuration.md` for more details.
**3. Chat**
@@ -448,7 +459,7 @@ PicoClaw supports 30+ LLM providers through the `model_list` configuration. Use
}
```
-For full provider configuration details, see [Providers & Models](docs/providers.md).
+For full provider configuration details, see [Providers & Models](docs/guides/providers.md).
@@ -460,8 +471,8 @@ Talk to your PicoClaw through 18+ messaging platforms:
|---------|-------|----------|------|
| **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) |
| **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) |
-| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/chat-apps.md#whatsapp) |
-| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) |
+| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/guides/chat-apps.md#whatsapp) |
+| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/guides/chat-apps.md#weixin) |
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
@@ -470,7 +481,7 @@ Talk to your PicoClaw through 18+ messaging platforms:
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
| **WeCom** | Easy (QR login or manual) | WebSocket | [Guide](docs/channels/wecom/README.md) |
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
-| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/chat-apps.md#irc) |
+| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) |
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
| **Pico** | Easy (enable) | Native protocol | Built-in |
@@ -478,9 +489,9 @@ Talk to your PicoClaw through 18+ messaging platforms:
> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
-> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/configuration.md#gateway-log-level) for details.
+> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/guides/configuration.md#gateway-log-level) for details.
-For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
+For detailed channel setup instructions, see [Chat Apps Configuration](docs/guides/chat-apps.md).
## 🔧 Tools
@@ -500,7 +511,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
### ⚙️ Other Tools
-PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/tools_configuration.md) for details.
+PicoClaw includes built-in tools for file operations, code execution, scheduling, and more. See [Tools Configuration](docs/reference/tools_configuration.md) for details.
## 🎯 Skills
@@ -513,7 +524,7 @@ picoclaw skills search "web scraping"
picoclaw skills install
+
+
+
+
+
+
-
+
-
+
+
-> **[Liste de compatibilité matérielle](docs/fr/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
+> **[Liste de compatibilité matérielle](../guides/hardware-compatibility.fr.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
-
+
Recherche Web & Apprentissage






-
+
-
+
-
+
-
+
![]() |
- ![]() |
- ![]() |
- ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
@@ -446,7 +455,7 @@ PicoClaw supporte plus de 30 providers LLM via la configuration `model_list`. Ut
}
```
-Pour les détails complets de configuration des providers, voir [Providers & Models](docs/fr/providers.md).
+Pour les détails complets de configuration des providers, voir [Providers & Models](../guides/providers.fr.md).
@@ -456,28 +465,28 @@ Parlez à votre PicoClaw via plus de 17 plateformes de messagerie :
| Channel | Configuration | Protocole | Docs |
|---------|---------------|-----------|------|
-| **Telegram** | Facile (token bot) | Long polling | [Guide](docs/channels/telegram/README.fr.md) |
-| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](docs/channels/discord/README.fr.md) |
-| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](docs/fr/chat-apps.md#whatsapp) |
-| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](docs/fr/chat-apps.md#weixin) |
-| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.fr.md) |
-| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](docs/channels/slack/README.fr.md) |
-| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.fr.md) |
-| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](docs/channels/dingtalk/README.fr.md) |
-| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.fr.md) |
-| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](docs/channels/line/README.fr.md) |
-| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](docs/channels/wecom/README.md) |
-| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](docs/fr/chat-apps.md#irc) |
-| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](docs/channels/onebot/README.fr.md) |
-| **MaixCam** | Facile (activer) | Socket TCP | [Guide](docs/channels/maixcam/README.fr.md) |
+| **Telegram** | Facile (token bot) | Long polling | [Guide](../channels/telegram/README.fr.md) |
+| **Discord** | Facile (token bot + intents) | WebSocket | [Guide](../channels/discord/README.fr.md) |
+| **WhatsApp** | Facile (scan QR ou URL bridge) | Natif / Bridge | [Guide](../guides/chat-apps.fr.md#whatsapp) |
+| **Weixin** | Facile (scan QR natif) | iLink API | [Guide](../guides/chat-apps.fr.md#weixin) |
+| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guide](../channels/qq/README.fr.md) |
+| **Slack** | Facile (token bot + app) | Socket Mode | [Guide](../channels/slack/README.fr.md) |
+| **Matrix** | Moyen (homeserver + token) | Sync API | [Guide](../channels/matrix/README.fr.md) |
+| **DingTalk** | Moyen (identifiants client) | Stream | [Guide](../channels/dingtalk/README.fr.md) |
+| **Feishu / Lark** | Moyen (App ID + Secret) | WebSocket/SDK | [Guide](../channels/feishu/README.fr.md) |
+| **LINE** | Moyen (identifiants + webhook) | Webhook | [Guide](../channels/line/README.fr.md) |
+| **WeCom** | Facile (QR login ou manuel) | WebSocket | [Guide](../channels/wecom/README.fr.md) |
+| **IRC** | Moyen (serveur + pseudo) | Protocole IRC | [Guide](../guides/chat-apps.fr.md#irc) |
+| **OneBot** | Moyen (URL WebSocket) | OneBot v11 | [Guide](../channels/onebot/README.fr.md) |
+| **MaixCam** | Facile (activer) | Socket TCP | [Guide](../channels/maixcam/README.fr.md) |
| **Pico** | Facile (activer) | Protocole natif | Intégré |
| **Pico Client** | Facile (URL WebSocket) | WebSocket | Intégré |
> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé.
-> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](docs/fr/configuration.md#niveau-de-log-du-gateway) pour plus de détails.
+> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](../guides/configuration.fr.md#niveau-de-log-du-gateway) pour plus de détails.
-Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md).
+Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](../guides/chat-apps.fr.md).
## 🔧 Outils
@@ -497,7 +506,7 @@ PicoClaw peut effectuer des recherches sur le web pour fournir des informations
### ⚙️ Autres outils
-PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](docs/fr/tools_configuration.md) pour les détails.
+PicoClaw inclut des outils intégrés pour les opérations sur fichiers, l'exécution de code, la planification et plus encore. Voir [Configuration des outils](../reference/tools_configuration.fr.md) pour les détails.
## 🎯 Skills
@@ -527,7 +536,7 @@ Ajoutez à votre `config.json` :
}
```
-Pour plus de détails, voir [Configuration des outils - Skills](docs/fr/tools_configuration.md#skills-tool).
+Pour plus de détails, voir [Configuration des outils - Skills](../reference/tools_configuration.fr.md#skills-tool).
## 🔗 MCP (Model Context Protocol)
@@ -550,9 +559,9 @@ PicoClaw supporte nativement [MCP](https://modelcontextprotocol.io/) — connect
}
```
-Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](docs/fr/tools_configuration.md#mcp-tool).
+Pour la configuration MCP complète (transports stdio, SSE, HTTP, Tool Discovery), voir [Configuration des outils - MCP](../reference/tools_configuration.fr.md#mcp-tool).
-##
-
-
-
+
diff --git a/README.id.md b/docs/project/README.id.md
similarity index 82%
rename from README.id.md
rename to docs/project/README.id.md
index 5aa7b58f5..244e6e49a 100644
--- a/README.id.md
+++ b/docs/project/README.id.md
@@ -1,5 +1,5 @@
+
-
+
-
+
+
-> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
+> **[Daftar Kompatibilitas Hardware](../guides/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
-
+
Pencarian Web & Pembelajaran






-
+
-
+
-
+
-
+
![]() |
- ![]() |
- ![]() |
- ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
@@ -442,7 +450,7 @@ PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan fo
}
```
-Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md).
+Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](../guides/providers.md).
@@ -452,28 +460,28 @@ Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
| Channel | Pengaturan | Protocol | Dokumentasi |
|---------|------------|----------|-------------|
-| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) |
-| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
-| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) |
-| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
-| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
-| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
-| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
-| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
-| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
-| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
-| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) |
-| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) |
-| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
-| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
+| **Telegram** | Mudah (bot token) | Long polling | [Panduan](../channels/telegram/README.md) |
+| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](../channels/discord/README.md) |
+| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](../guides/chat-apps.md#whatsapp) |
+| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](../guides/chat-apps.md#weixin) |
+| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) |
+| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](../channels/slack/README.md) |
+| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) |
+| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](../channels/dingtalk/README.md) |
+| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) |
+| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](../channels/line/README.md) |
+| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) |
+| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](../guides/chat-apps.md#irc) |
+| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](../channels/onebot/README.md) |
+| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) |
| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan |
| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan |
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
-> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk detail.
+> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.md#gateway-log-level) untuk detail.
-Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
+Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](../guides/chat-apps.md).
## 🔧 Tools
@@ -493,7 +501,7 @@ PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `t
### ⚙️ Tools Lainnya
-PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail.
+PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](../reference/tools_configuration.md) untuk detail.
## 🎯 Skills
@@ -523,7 +531,7 @@ Tambahkan ke `config.json` Anda:
}
```
-Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool).
+Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](../reference/tools_configuration.md#skills-tool).
## 🔗 MCP (Model Context Protocol)
@@ -546,9 +554,9 @@ PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hub
}
```
-Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool).
+Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](../reference/tools_configuration.md#mcp-tool).
-##
+
diff --git a/README.it.md b/docs/project/README.it.md
similarity index 81%
rename from README.it.md
rename to docs/project/README.it.md
index 57dd014b3..eb2f7c95b 100644
--- a/README.it.md
+++ b/docs/project/README.it.md
@@ -1,5 +1,5 @@
+
-
+
-
+
+
-> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
+> **[Lista di Compatibilità Hardware](../guides/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
-
+
Ricerca Web & Apprendimento






-
+
-
+
-
+
-
+
![]() |
- ![]() |
- ![]() |
- ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
@@ -442,7 +450,7 @@ PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa i
}
```
-Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md).
+Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](../guides/providers.md).
@@ -452,28 +460,28 @@ Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
| Channel | Configurazione | Protocollo | Docs |
|---------|----------------|------------|------|
-| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) |
-| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) |
-| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) |
-| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) |
-| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) |
-| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) |
-| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) |
-| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) |
-| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) |
-| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) |
-| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](docs/channels/wecom/README.md) |
-| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) |
-| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) |
-| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) |
+| **Telegram** | Facile (bot token) | Long polling | [Guida](../channels/telegram/README.md) |
+| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](../channels/discord/README.md) |
+| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](../guides/chat-apps.md#whatsapp) |
+| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](../guides/chat-apps.md#weixin) |
+| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](../channels/qq/README.md) |
+| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](../channels/slack/README.md) |
+| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](../channels/matrix/README.md) |
+| **DingTalk** | Medio (credenziali client) | Stream | [Guida](../channels/dingtalk/README.md) |
+| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](../channels/feishu/README.md) |
+| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](../channels/line/README.md) |
+| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](../channels/wecom/README.md) |
+| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](../guides/chat-apps.md#irc) |
+| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](../channels/onebot/README.md) |
+| **MaixCam** | Facile (abilita) | TCP socket | [Guida](../channels/maixcam/README.md) |
| **Pico** | Facile (abilita) | Protocollo nativo | Integrato |
| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato |
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
-> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](docs/configuration.md#gateway-log-level) per i dettagli.
+> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](../guides/configuration.md#gateway-log-level) per i dettagli.
-Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
+Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](../guides/chat-apps.md).
## 🔧 Strumenti
@@ -493,7 +501,7 @@ PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in
### ⚙️ Altri Strumenti
-PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli.
+PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](../reference/tools_configuration.md) per i dettagli.
## 🎯 Skill
@@ -523,7 +531,7 @@ Aggiungi al tuo `config.json`:
}
```
-Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool).
+Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](../reference/tools_configuration.md#skills-tool).
## 🔗 MCP (Model Context Protocol)
@@ -546,9 +554,9 @@ PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connet
}
```
-Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool).
+Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool).
-##
+
diff --git a/README.ja.md b/docs/project/README.ja.md
similarity index 83%
rename from README.ja.md
rename to docs/project/README.ja.md
index 64bff9ee9..66d06ba5e 100644
--- a/README.ja.md
+++ b/docs/project/README.ja.md
@@ -1,5 +1,5 @@
+
-
+
-
+
+
-> **[ハードウェア互換性リスト](docs/ja/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
+> **[ハードウェア互換性リスト](../guides/hardware-compatibility.ja.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
-
+
Web 検索&学習






-
+
-
+
-
+
-
+
![]() |
- ![]() |
- ![]() |
- ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
@@ -442,7 +451,7 @@ PicoClaw は `model_list` 設定を通じて 30 以上の LLM Provider をサポ
}
```
-Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.md) を参照してください。
+Provider の完全な設定詳細は [Provider とモデル](../guides/providers.ja.md) を参照してください。
@@ -452,28 +461,28 @@ Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.m
| Channel | セットアップ | Protocol | ドキュメント |
|---------|------------|----------|------------|
-| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](docs/channels/telegram/README.ja.md) |
-| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](docs/channels/discord/README.ja.md) |
-| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](docs/ja/chat-apps.md#whatsapp) |
-| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](docs/ja/chat-apps.md#weixin) |
-| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](docs/channels/qq/README.ja.md) |
-| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](docs/channels/slack/README.ja.md) |
-| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](docs/channels/matrix/README.ja.md) |
-| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](docs/channels/dingtalk/README.ja.md) |
-| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](docs/channels/feishu/README.ja.md) |
-| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](docs/channels/line/README.ja.md) |
-| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](docs/channels/wecom/README.md) |
-| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](docs/ja/chat-apps.md#irc) |
-| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](docs/channels/onebot/README.ja.md) |
-| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](docs/channels/maixcam/README.ja.md) |
+| **Telegram** | 簡単(bot トークン) | Long polling | [ガイド](../channels/telegram/README.ja.md) |
+| **Discord** | 簡単(bot トークン + intents) | WebSocket | [ガイド](../channels/discord/README.ja.md) |
+| **WhatsApp** | 簡単(QR スキャンまたは bridge URL) | Native / Bridge | [ガイド](../guides/chat-apps.ja.md#whatsapp) |
+| **微信 (Weixin)** | 簡単(QR スキャン) | iLink API | [ガイド](../guides/chat-apps.ja.md#weixin) |
+| **QQ** | 簡単(AppID + AppSecret) | WebSocket | [ガイド](../channels/qq/README.ja.md) |
+| **Slack** | 簡単(bot + app トークン) | Socket Mode | [ガイド](../channels/slack/README.ja.md) |
+| **Matrix** | 中級(homeserver + トークン) | Sync API | [ガイド](../channels/matrix/README.ja.md) |
+| **DingTalk** | 中級(クライアント認証情報) | Stream | [ガイド](../channels/dingtalk/README.ja.md) |
+| **Feishu / Lark** | 中級(App ID + Secret) | WebSocket/SDK | [ガイド](../channels/feishu/README.ja.md) |
+| **LINE** | 中級(認証情報 + webhook) | Webhook | [ガイド](../channels/line/README.ja.md) |
+| **WeCom** | 簡単(QR ログインまたは手動) | WebSocket | [ガイド](../channels/wecom/README.ja.md) |
+| **IRC** | 中級(サーバー + nick) | IRC protocol | [ガイド](../guides/chat-apps.ja.md#irc) |
+| **OneBot** | 中級(WebSocket URL) | OneBot v11 | [ガイド](../channels/onebot/README.ja.md) |
+| **MaixCam** | 簡単(有効化) | TCP socket | [ガイド](../channels/maixcam/README.ja.md) |
| **Pico** | 簡単(有効化) | Native protocol | 内蔵 |
| **Pico Client** | 簡単(WebSocket URL) | WebSocket | 内蔵 |
> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`)を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。
-> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](docs/ja/configuration.md#gateway-ログレベル)を参照してください。
+> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug`、`info`、`warn`、`error`、`fatal`。`PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](../guides/configuration.ja.md#gateway-ログレベル)を参照してください。
-Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。
+Channel の詳細なセットアップ手順は [チャットアプリ設定](../guides/chat-apps.ja.md) を参照してください。
## 🔧 ツール
@@ -493,7 +502,7 @@ PicoClaw は最新情報を提供するために Web を検索できます。`to
### ⚙️ その他のツール
-PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](docs/ja/tools_configuration.md) を参照してください。
+PicoClaw にはファイル操作、コード実行、スケジューリングなどの組み込みツールが含まれています。詳細は [ツール設定](../reference/tools_configuration.ja.md) を参照してください。
## 🎯 Skill
@@ -523,7 +532,7 @@ picoclaw skills install
+
diff --git a/README.ko.md b/docs/project/README.ko.md
similarity index 82%
rename from README.ko.md
rename to docs/project/README.ko.md
index 341c09812..cfc985688 100644
--- a/README.ko.md
+++ b/docs/project/README.ko.md
@@ -1,5 +1,5 @@
+
-
+
-
+
+
-> **[하드웨어 호환 목록](docs/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요!
+> **[하드웨어 호환 목록](../guides/hardware-compatibility.md)** — 테스트된 모든 보드를 확인하세요. $5 RISC-V 보드부터 Raspberry Pi, Android 스마트폰까지 포함됩니다. 사용 중인 보드가 없나요? PR을 보내주세요!
-
+
웹 검색 및 학습






-
+
-
+
-
+
-
+
![]() |
- ![]() |
- ![]() |
- ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
런처 UI 없이 `picoclaw` 코어 바이너리만 있는 최소 환경에서는 명령줄과 JSON 설정 파일만으로도 모든 설정을 마칠 수 있습니다.
@@ -369,7 +377,7 @@ picoclaw onboard
> 사용 가능한 모든 옵션이 포함된 전체 설정 템플릿은 저장소의 `config/config.example.json`을 참고하세요.
>
-> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `docs/security_configuration.md`를 참고하세요.
+> 참고: `config.example.json` 형식은 버전 0이며 민감 정보가 포함되어 있습니다. 실행 시 자동으로 버전 1+로 마이그레이션되며, 이후 `config.json`에는 비민감 정보만 저장되고 민감 정보는 `.security.yml`에 저장됩니다. 민감 정보를 직접 수정해야 한다면 `../security/security_configuration.md`를 참고하세요.
**3. 채팅**
@@ -447,7 +455,7 @@ PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를
}
```
-프로바이더 전체 설정은 [프로바이더와 모델](docs/providers.md)을 참고하세요.
+프로바이더 전체 설정은 [프로바이더와 모델](../guides/providers.md)을 참고하세요.
@@ -457,29 +465,29 @@ PicoClaw는 `model_list` 설정을 통해 30개 이상의 LLM 프로바이더를
| 채널 | 설정 | 프로토콜 | 문서 |
|---------|------|----------|------|
-| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](docs/channels/telegram/README.md) |
-| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](docs/channels/discord/README.md) |
-| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](docs/chat-apps.md#whatsapp) |
-| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](docs/chat-apps.md#weixin) |
-| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](docs/channels/qq/README.md) |
-| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](docs/channels/slack/README.md) |
-| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](docs/channels/matrix/README.md) |
-| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](docs/channels/dingtalk/README.md) |
-| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](docs/channels/feishu/README.md) |
-| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](docs/channels/line/README.md) |
-| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](docs/channels/wecom/README.md) |
-| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](docs/channels/vk/README.md) |
-| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](docs/chat-apps.md#irc) |
-| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](docs/channels/onebot/README.md) |
-| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](docs/channels/maixcam/README.md) |
+| **Telegram** | 쉬움(봇 토큰) | Long polling | [가이드](../channels/telegram/README.md) |
+| **Discord** | 쉬움(봇 토큰 + intents) | WebSocket | [가이드](../channels/discord/README.md) |
+| **WhatsApp** | 쉬움(QR 스캔 또는 브리지 URL) | Native / Bridge | [가이드](../guides/chat-apps.md#whatsapp) |
+| **Weixin** | 쉬움(네이티브 QR 스캔) | iLink API | [가이드](../guides/chat-apps.md#weixin) |
+| **QQ** | 쉬움(AppID + AppSecret) | WebSocket | [가이드](../channels/qq/README.md) |
+| **Slack** | 쉬움(봇 + 앱 토큰) | Socket Mode | [가이드](../channels/slack/README.md) |
+| **Matrix** | 중간(homeserver + 토큰) | Sync API | [가이드](../channels/matrix/README.md) |
+| **DingTalk** | 중간(클라이언트 자격 증명) | Stream | [가이드](../channels/dingtalk/README.md) |
+| **Feishu / Lark** | 중간(App ID + Secret) | WebSocket/SDK | [가이드](../channels/feishu/README.md) |
+| **LINE** | 중간(인증 정보 + webhook) | Webhook | [가이드](../channels/line/README.md) |
+| **WeCom** | 쉬움(QR 로그인 또는 수동 설정) | WebSocket | [가이드](../channels/wecom/README.md) |
+| **VK** | 쉬움(그룹 토큰) | Long Poll | [가이드](../channels/vk/README.md) |
+| **IRC** | 중간(서버 + 닉네임) | IRC protocol | [가이드](../guides/chat-apps.md#irc) |
+| **OneBot** | 중간(WebSocket URL) | OneBot v11 | [가이드](../channels/onebot/README.md) |
+| **MaixCam** | 쉬움(활성화) | TCP socket | [가이드](../channels/maixcam/README.md) |
| **Pico** | 쉬움(활성화) | 네이티브 프로토콜 | 내장 |
| **Pico Client** | 쉬움(WebSocket URL) | WebSocket | 내장 |
> webhook 기반 채널은 모두 하나의 게이트웨이 HTTP 서버(`gateway.host`:`gateway.port`, 기본값 `127.0.0.1:18790`)를 공유합니다. Feishu는 WebSocket/SDK 모드를 사용하며 이 공용 HTTP 서버를 사용하지 않습니다.
-> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](docs/configuration.md#gateway-log-level)를 참고하세요.
+> 로그 상세도는 `gateway.log_level`(기본값: `warn`)로 제어됩니다. 지원 값은 `debug`, `info`, `warn`, `error`, `fatal`입니다. `PICOCLAW_LOG_LEVEL` 환경 변수로도 설정할 수 있습니다. 자세한 내용은 [설정 문서](../guides/configuration.md#gateway-log-level)를 참고하세요.
-자세한 채널 설정 방법은 [채팅 앱 설정 가이드](docs/chat-apps.md)를 참고하세요.
+자세한 채널 설정 방법은 [채팅 앱 설정 가이드](../guides/chat-apps.md)를 참고하세요.
## 🔧 도구
@@ -499,7 +507,7 @@ PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있
### ⚙️ 기타 도구
-PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](docs/tools_configuration.md)을 참고하세요.
+PicoClaw에는 파일 작업, 코드 실행, 스케줄링 등을 위한 내장 도구가 포함되어 있습니다. 자세한 내용은 [도구 설정](../reference/tools_configuration.md)을 참고하세요.
## 🎯 스킬
@@ -529,7 +537,7 @@ picoclaw skills install
+
diff --git a/README.my.md b/docs/project/README.ms.md
similarity index 84%
rename from README.my.md
rename to docs/project/README.ms.md
index f8e602f83..f8c9e95e7 100644
--- a/README.my.md
+++ b/docs/project/README.ms.md
@@ -1,5 +1,5 @@
+
-
+
-
+
+
-> **[Senarai Keserasian Perkakasan](docs/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android.
+> **[Senarai Keserasian Perkakasan](../guides/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android.
-
+
Carian Web & Pembelajaran






-
+
-
+
-
+
-
+
![]() |
- ![]() |
- ![]() |
- ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON.
@@ -441,7 +449,7 @@ PicoClaw menyokong 30+ penyedia LLM melalui konfigurasi `model_list`. Gunakan fo
}
```
-Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](docs/providers.md).
+Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](../guides/providers.md).
@@ -452,28 +460,28 @@ Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan:
| Saluran | Persediaan | Protokol | Dok |
|---------|-----------|----------|-----|
-| **Telegram** | Mudah (token bot) | Long polling | [Panduan](docs/channels/telegram/README.md) |
-| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
-| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](docs/chat-apps.md#whatsapp) |
-| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
-| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
-| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
-| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
-| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
-| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
-| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
-| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) |
-| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](docs/chat-apps.md#irc) |
-| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
-| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
+| **Telegram** | Mudah (token bot) | Long polling | [Panduan](../channels/telegram/README.md) |
+| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](../channels/discord/README.md) |
+| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](../guides/chat-apps.ms.md#whatsapp) |
+| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](../guides/chat-apps.ms.md#weixin) |
+| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](../channels/qq/README.md) |
+| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](../channels/slack/README.md) |
+| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](../channels/matrix/README.md) |
+| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](../channels/dingtalk/README.md) |
+| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](../channels/feishu/README.md) |
+| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](../channels/line/README.md) |
+| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](../channels/wecom/README.md) |
+| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](../guides/chat-apps.ms.md#irc) |
+| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](../channels/onebot/README.md) |
+| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](../channels/maixcam/README.md) |
| **Pico** | Mudah (aktifkan) | Protokol natif | Terbina dalam |
| **Pico Client** | Mudah (URL WebSocket) | WebSocket | Terbina dalam |
> Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi.
-> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk butiran.
+> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](../guides/configuration.ms.md#gateway-log-level) untuk butiran.
-Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md).
+Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](../guides/chat-apps.ms.md).
## 🔧 Alat
@@ -493,7 +501,7 @@ PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan da
### ⚙️ Alat Lain
-PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](docs/tools_configuration.md) untuk butiran.
+PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](../reference/tools_configuration.md) untuk butiran.
## 🎯 Kemahiran
@@ -523,7 +531,7 @@ Tambah ke `config.json` anda:
}
```
-Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](docs/tools_configuration.md#skills-tool).
+Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](../reference/tools_configuration.md#skills-tool).
## 🔗 MCP (Protokol Konteks Model)
@@ -546,9 +554,9 @@ PicoClaw menyokong [MCP](https://modelcontextprotocol.io/) secara natif — samb
}
```
-Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](docs/tools_configuration.md#mcp-tool).
+Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](../reference/tools_configuration.md#mcp-tool).
-##
+
diff --git a/README.pt-br.md b/docs/project/README.pt-br.md
similarity index 81%
rename from README.pt-br.md
rename to docs/project/README.pt-br.md
index 65d23d1d1..56d4ddd63 100644
--- a/README.pt-br.md
+++ b/docs/project/README.pt-br.md
@@ -1,5 +1,5 @@
+
-
+
-
+
+
-> **[Lista de Compatibilidade de Hardware](docs/pt-br/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR!
+> **[Lista de Compatibilidade de Hardware](../guides/hardware-compatibility.pt-br.md)** — Veja todas as placas testadas, de RISC-V de $5 ao Raspberry Pi e celulares Android. Sua placa não está listada? Envie um PR!
-
+
Busca na Web e Aprendizado






-
+
-
+
-
+
-
+
![]() |
- ![]() |
- ![]() |
- ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
@@ -442,7 +451,7 @@ O PicoClaw suporta mais de 30 providers de LLM através da configuração `model
}
```
-Para detalhes completos de configuração de providers, veja [Providers & Models](docs/pt-br/providers.md).
+Para detalhes completos de configuração de providers, veja [Providers & Models](../guides/providers.pt-br.md).
@@ -452,28 +461,28 @@ Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens:
| Channel | Configuração | Protocolo | Docs |
|---------|--------------|-----------|------|
-| **Telegram** | Fácil (bot token) | Long polling | [Guia](docs/channels/telegram/README.pt-br.md) |
-| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](docs/channels/discord/README.pt-br.md) |
-| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](docs/pt-br/chat-apps.md#whatsapp) |
-| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](docs/pt-br/chat-apps.md#weixin) |
-| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](docs/channels/qq/README.pt-br.md) |
-| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](docs/channels/slack/README.pt-br.md) |
-| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](docs/channels/matrix/README.pt-br.md) |
-| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](docs/channels/dingtalk/README.pt-br.md) |
-| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](docs/channels/feishu/README.pt-br.md) |
-| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](docs/channels/line/README.pt-br.md) |
-| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](docs/channels/wecom/README.md) |
-| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](docs/pt-br/chat-apps.md#irc) |
-| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](docs/channels/onebot/README.pt-br.md) |
-| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](docs/channels/maixcam/README.pt-br.md) |
+| **Telegram** | Fácil (bot token) | Long polling | [Guia](../channels/telegram/README.pt-br.md) |
+| **Discord** | Fácil (bot token + intents) | WebSocket | [Guia](../channels/discord/README.pt-br.md) |
+| **WhatsApp** | Fácil (QR scan ou bridge URL) | Nativo / Bridge | [Guia](../guides/chat-apps.pt-br.md#whatsapp) |
+| **Weixin** | Fácil (scan QR nativo) | iLink API | [Guia](../guides/chat-apps.pt-br.md#weixin) |
+| **QQ** | Fácil (AppID + AppSecret) | WebSocket | [Guia](../channels/qq/README.pt-br.md) |
+| **Slack** | Fácil (bot + app token) | Socket Mode | [Guia](../channels/slack/README.pt-br.md) |
+| **Matrix** | Médio (homeserver + token) | Sync API | [Guia](../channels/matrix/README.pt-br.md) |
+| **DingTalk** | Médio (credenciais do cliente) | Stream | [Guia](../channels/dingtalk/README.pt-br.md) |
+| **Feishu / Lark** | Médio (App ID + Secret) | WebSocket/SDK | [Guia](../channels/feishu/README.pt-br.md) |
+| **LINE** | Médio (credenciais + webhook) | Webhook | [Guia](../channels/line/README.pt-br.md) |
+| **WeCom** | Fácil (login QR ou manual) | WebSocket | [Guia](../channels/wecom/README.pt-br.md) |
+| **IRC** | Médio (servidor + nick) | Protocolo IRC | [Guia](../guides/chat-apps.pt-br.md#irc) |
+| **OneBot** | Médio (WebSocket URL) | OneBot v11 | [Guia](../channels/onebot/README.pt-br.md) |
+| **MaixCam** | Fácil (habilitar) | TCP socket | [Guia](../channels/maixcam/README.pt-br.md) |
| **Pico** | Fácil (habilitar) | Protocolo nativo | Integrado |
| **Pico Client** | Fácil (WebSocket URL) | WebSocket | Integrado |
> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
-> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](docs/pt-br/configuration.md#nível-de-log-do-gateway) para detalhes.
+> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](../guides/configuration.pt-br.md#nível-de-log-do-gateway) para detalhes.
-Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
+Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](../guides/chat-apps.pt-br.md).
## 🔧 Ferramentas
@@ -493,7 +502,7 @@ O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Config
### ⚙️ Outras Ferramentas
-O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) para detalhes.
+O PicoClaw inclui ferramentas integradas para operações de arquivo, execução de código, agendamento e mais. Veja [Configuração de Ferramentas](../reference/tools_configuration.pt-br.md) para detalhes.
## 🎯 Skills
@@ -523,7 +532,7 @@ Adicione ao seu `config.json`:
}
```
-Para mais detalhes, veja [Configuração de Ferramentas - Skills](docs/pt-br/tools_configuration.md#skills-tool).
+Para mais detalhes, veja [Configuração de Ferramentas - Skills](../reference/tools_configuration.pt-br.md#skills-tool).
## 🔗 MCP (Model Context Protocol)
@@ -546,9 +555,9 @@ O PicoClaw suporta nativamente o [MCP](https://modelcontextprotocol.io/) — con
}
```
-Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](docs/pt-br/tools_configuration.md#mcp-tool).
+Para configuração completa de MCP (transportes stdio, SSE, HTTP, Tool Discovery), veja [Configuração de Ferramentas - MCP](../reference/tools_configuration.pt-br.md#mcp-tool).
-##
+
diff --git a/README.vi.md b/docs/project/README.vi.md
similarity index 83%
rename from README.vi.md
rename to docs/project/README.vi.md
index 1d70d0615..52a56796b 100644
--- a/README.vi.md
+++ b/docs/project/README.vi.md
@@ -1,5 +1,5 @@
+
-
+
-
+
+
-> **[Danh sách Tương thích Phần cứng](docs/vi/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR!
+> **[Danh sách Tương thích Phần cứng](../guides/hardware-compatibility.vi.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi đến điện thoại Android. Board của bạn chưa có trong danh sách? Gửi PR!
-
+
Tìm kiếm Web & Học tập






-
+
-
+
-
+
-
+
![]() |
- ![]() |
- ![]() |
- ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
@@ -442,7 +451,7 @@ PicoClaw hỗ trợ 30+ Provider LLM thông qua cấu hình `model_list`. Sử d
}
```
-Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](docs/vi/providers.md).
+Để biết chi tiết cấu hình provider đầy đủ, xem [Providers & Models](../guides/providers.vi.md).
@@ -452,28 +461,28 @@ Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin:
| Channel | Thiết lập | Protocol | Tài liệu |
|---------|-----------|----------|----------|
-| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](docs/channels/telegram/README.vi.md) |
-| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](docs/channels/discord/README.vi.md) |
-| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](docs/vi/chat-apps.md#whatsapp) |
-| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](docs/vi/chat-apps.md#weixin) |
-| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](docs/channels/qq/README.vi.md) |
-| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](docs/channels/slack/README.vi.md) |
-| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](docs/channels/matrix/README.vi.md) |
-| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](docs/channels/dingtalk/README.vi.md) |
-| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](docs/channels/feishu/README.vi.md) |
-| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](docs/channels/line/README.vi.md) |
-| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](docs/channels/wecom/README.md) |
-| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](docs/vi/chat-apps.md#irc) |
-| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](docs/channels/onebot/README.vi.md) |
-| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](docs/channels/maixcam/README.vi.md) |
+| **Telegram** | Dễ (bot token) | Long polling | [Hướng dẫn](../channels/telegram/README.vi.md) |
+| **Discord** | Dễ (bot token + intents) | WebSocket | [Hướng dẫn](../channels/discord/README.vi.md) |
+| **WhatsApp** | Dễ (quét QR hoặc bridge URL) | Native / Bridge | [Hướng dẫn](../guides/chat-apps.vi.md#whatsapp) |
+| **Weixin** | Dễ (quét QR gốc) | iLink API | [Hướng dẫn](../guides/chat-apps.vi.md#weixin) |
+| **QQ** | Dễ (AppID + AppSecret) | WebSocket | [Hướng dẫn](../channels/qq/README.vi.md) |
+| **Slack** | Dễ (bot + app token) | Socket Mode | [Hướng dẫn](../channels/slack/README.vi.md) |
+| **Matrix** | Trung bình (homeserver + token) | Sync API | [Hướng dẫn](../channels/matrix/README.vi.md) |
+| **DingTalk** | Trung bình (client credentials) | Stream | [Hướng dẫn](../channels/dingtalk/README.vi.md) |
+| **Feishu / Lark** | Trung bình (App ID + Secret) | WebSocket/SDK | [Hướng dẫn](../channels/feishu/README.vi.md) |
+| **LINE** | Trung bình (credentials + webhook) | Webhook | [Hướng dẫn](../channels/line/README.vi.md) |
+| **WeCom** | Dễ (đăng nhập QR hoặc thủ công) | WebSocket | [Hướng dẫn](../channels/wecom/README.vi.md) |
+| **IRC** | Trung bình (server + nick) | IRC protocol | [Hướng dẫn](../guides/chat-apps.vi.md#irc) |
+| **OneBot** | Trung bình (WebSocket URL) | OneBot v11 | [Hướng dẫn](../channels/onebot/README.vi.md) |
+| **MaixCam** | Dễ (bật) | TCP socket | [Hướng dẫn](../channels/maixcam/README.vi.md) |
| **Pico** | Dễ (bật) | Native protocol | Tích hợp sẵn |
| **Pico Client** | Dễ (WebSocket URL) | WebSocket | Tích hợp sẵn |
> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
-> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](docs/vi/configuration.md#mức-log-của-gateway) để biết thêm chi tiết.
+> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](../guides/configuration.vi.md#mức-log-của-gateway) để biết thêm chi tiết.
-Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md).
+Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](../guides/chat-apps.vi.md).
## 🔧 Tools
@@ -493,7 +502,7 @@ PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. C
### ⚙️ Các Tools Khác
-PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](docs/vi/tools_configuration.md) để biết chi tiết.
+PicoClaw bao gồm các tool tích hợp sẵn cho thao tác tệp, thực thi mã, lên lịch và nhiều hơn nữa. Xem [Cấu hình Tools](../reference/tools_configuration.vi.md) để biết chi tiết.
## 🎯 Skills
@@ -523,7 +532,7 @@ Thêm vào `config.json` của bạn:
}
```
-Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](docs/vi/tools_configuration.md#skills-tool).
+Để biết thêm chi tiết, xem [Cấu hình Tools - Skills](../reference/tools_configuration.vi.md#skills-tool).
## 🔗 MCP (Model Context Protocol)
@@ -546,9 +555,9 @@ PicoClaw hỗ trợ [MCP](https://modelcontextprotocol.io/) gốc — kết nố
}
```
-Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](docs/vi/tools_configuration.md#mcp-tool).
+Để biết cấu hình MCP đầy đủ (stdio, SSE, HTTP transports, Tool Discovery), xem [Cấu hình Tools - MCP](../reference/tools_configuration.vi.md#mcp-tool).
-##
+
diff --git a/README.zh.md b/docs/project/README.zh.md
similarity index 80%
rename from README.zh.md
rename to docs/project/README.zh.md
index e61ff7e28..a4fc892bd 100644
--- a/README.zh.md
+++ b/docs/project/README.zh.md
@@ -1,5 +1,5 @@
+
-
+
-
+
+
-> 📋 **[硬件兼容列表](docs/zh/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
+> 📋 **[硬件兼容列表](../guides/hardware-compatibility.zh.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
-
+
🔎 网络搜索与学习






-
+
-
+
-
+
-
+
![]() |
- ![]() |
- ![]() |
- ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+ ![]() |
+
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
@@ -442,7 +451,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模
}
```
-完整 Provider 配置详情请参阅 [Providers & Models](docs/zh/providers.md)。
+完整 Provider 配置详情请参阅 [Providers & Models](../guides/providers.zh.md)。
@@ -452,29 +461,29 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模
| Channel | 配置难度 | 协议 | 文档 |
|---------|----------|------|------|
-| **Telegram** | 简单(bot token) | 长轮询 | [指南](docs/channels/telegram/README.zh.md) |
-| **Discord** | 简单(bot token + intents) | WebSocket | [指南](docs/channels/discord/README.zh.md) |
-| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](docs/zh/chat-apps.md#whatsapp) |
-| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](docs/zh/chat-apps.md#weixin) |
-| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](docs/channels/qq/README.zh.md) |
-| **Slack** | 简单(bot + app token) | Socket Mode | [指南](docs/channels/slack/README.zh.md) |
-| **Matrix** | 中等(homeserver + token) | Sync API | [指南](docs/channels/matrix/README.zh.md) |
-| **钉钉** | 中等(client credentials) | Stream | [指南](docs/channels/dingtalk/README.zh.md) |
-| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](docs/channels/feishu/README.zh.md) |
-| **LINE** | 中等(credentials + webhook) | Webhook | [指南](docs/channels/line/README.zh.md) |
-| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](docs/channels/wecom/README.zh.md) |
-| **VK** | 简单(群组 token) | Long Poll | [指南](docs/channels/vk/README.md) |
-| **IRC** | 中等(server + nick) | IRC 协议 | [指南](docs/zh/chat-apps.md#irc) |
-| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](docs/channels/onebot/README.zh.md) |
-| **MaixCam** | 简单(启用即可) | TCP socket | [指南](docs/channels/maixcam/README.zh.md) |
+| **Telegram** | 简单(bot token) | 长轮询 | [指南](../channels/telegram/README.zh.md) |
+| **Discord** | 简单(bot token + intents) | WebSocket | [指南](../channels/discord/README.zh.md) |
+| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](../guides/chat-apps.zh.md#whatsapp) |
+| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](../guides/chat-apps.zh.md#weixin) |
+| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](../channels/qq/README.zh.md) |
+| **Slack** | 简单(bot + app token) | Socket Mode | [指南](../channels/slack/README.zh.md) |
+| **Matrix** | 中等(homeserver + token) | Sync API | [指南](../channels/matrix/README.zh.md) |
+| **钉钉** | 中等(client credentials) | Stream | [指南](../channels/dingtalk/README.zh.md) |
+| **飞书 / Lark** | 中等(App ID + Secret) | WebSocket/SDK | [指南](../channels/feishu/README.zh.md) |
+| **LINE** | 中等(credentials + webhook) | Webhook | [指南](../channels/line/README.zh.md) |
+| **企业微信** | 简单(扫码登录或手动配置) | WebSocket | [指南](../channels/wecom/README.zh.md) |
+| **VK** | 简单(群组 token) | Long Poll | [指南](../channels/vk/README.md) |
+| **IRC** | 中等(server + nick) | IRC 协议 | [指南](../guides/chat-apps.zh.md#irc) |
+| **OneBot** | 中等(WebSocket URL) | OneBot v11 | [指南](../channels/onebot/README.zh.md) |
+| **MaixCam** | 简单(启用即可) | TCP socket | [指南](../channels/maixcam/README.zh.md) |
| **Pico** | 简单(启用即可) | 原生协议 | 内置 |
| **Pico Client** | 简单(WebSocket URL) | WebSocket | 内置 |
> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。
-> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](docs/zh/configuration.md#gateway-日志等级)。
+> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug`、`info`、`warn`、`error`、`fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](../guides/configuration.zh.md#gateway-日志等级)。
-详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。
+详细 Channel 配置说明请参阅 [聊天应用配置](../guides/chat-apps.zh.md)。
## 🔧 Tools
@@ -494,7 +503,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置:
### ⚙️ 其他工具
-PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](docs/zh/tools_configuration.md)。
+PicoClaw 内置文件操作、代码执行、定时任务等工具。详情请参阅 [工具配置](../reference/tools_configuration.zh.md)。
## 🎯 Skills
@@ -507,7 +516,7 @@ picoclaw skills search "web scraping"
picoclaw skills install
-
-
-
-
+
diff --git a/docs/reference/README.md b/docs/reference/README.md
new file mode 100644
index 000000000..eec5c09b4
--- /dev/null
+++ b/docs/reference/README.md
@@ -0,0 +1,8 @@
+# Reference
+
+Reference docs for precise configuration, runtime behavior, and tool semantics.
+
+- [Tools Configuration](tools_configuration.md): per-tool configuration, execution policies, MCP, and Skills.
+- [Scheduled Tasks and Cron Jobs](cron.md): schedule types, delivery modes, command gates, and storage.
+- [Config Schema Versioning Guide](config-versioning.md): config schema migration and compatibility notes.
+- [Dynamic Rate Limiting](rate-limiting.md): request throttling behavior for LLM providers.
diff --git a/docs/config-versioning.md b/docs/reference/config-versioning.md
similarity index 69%
rename from docs/config-versioning.md
rename to docs/reference/config-versioning.md
index b5cdaf990..36f327e8c 100644
--- a/docs/config-versioning.md
+++ b/docs/reference/config-versioning.md
@@ -20,6 +20,16 @@ PicoClaw uses a schema versioning system for `config.json` to ensure smooth upgr
- V0 configs now migrate directly to CurrentVersion (V2) instead of going through V1
- `makeBackup()` now uses date-only suffix (e.g., `config.json.20260330.bak`) and also backs up `.security.yml`
+### Version 3
+- **Introduction**: Enhanced type safety and improved error handling
+- **Changes**:
+ - Added comma-ok type assertions in channel configuration decoding to prevent potential panics
+ - Improved error logging for Weixin channel configuration decoding
+ - Enhanced security configuration documentation and examples
+ - **Auto-migration**: V2 configs are automatically migrated to V3 on load with no user action required
+ - **Backup**: Before migration, the system creates a date-stamped backup (e.g., `config.json.20260413.bak`) in the same directory
+ - **Downgrade risk**: Once migrated to V3, the config cannot be safely loaded by older V2-only versions. To downgrade, restore from the auto-created backup file.
+
## How It Works
### Automatic Migration
@@ -39,7 +49,7 @@ The `version` field in `config.json` indicates the schema version:
```json
{
- "version": 2,
+ "version": 3,
"agents": {...},
...
}
@@ -164,6 +174,52 @@ func TestMigrateV2ToV3(t *testing.T) {
7. **Test Thoroughly**: Test with real user config files
8. **Update Defaults**: Keep `defaults.go` in sync with the latest schema
+## V2→V3 Migration Guide
+
+### What Changed?
+
+Version 3 introduces improved type safety and error handling:
+
+- **Type-safe channel decoding**: All channel type assertions now use comma-ok pattern (`val, ok := v.(*Settings)`) to prevent panics if Type and Settings are mismatched
+- **Enhanced error logging**: Weixin channel now logs errors on `GetDecoded()` failure for consistency with other channels
+- **Documentation fixes**: Corrected stray quotes in JSON configuration examples
+
+### Auto-Migration Behavior
+
+When you run PicoClaw with a V2 config file:
+
+1. **Detection**: PicoClaw reads the `version` field and detects V2
+2. **Backup**: Before any changes, creates `config.json.YYYYMMDD.bak` (e.g., `config.json.20260413.bak`)
+3. **Migration**: Applies V2→V3 structural changes (primarily internal type safety improvements)
+4. **Save**: Writes the updated config with `"version": 3`
+5. **Continue**: Starts normally with the V3 config
+
+**No user action required** — the migration happens automatically on first load.
+
+### Backup Location
+
+Backups are created in the same directory as your config file:
+
+- **Default**: `~/.picoclaw/config.json.20260413.bak`
+- **Custom path**: If using `PICOCLAW_CONFIG`, backup is created next to that file
+- **Security file**: `.security.yml` is also backed up as `.security.yml.YYYYMMDD.bak`
+
+### Downgrade Risk
+
+⚠️ **Important**: Once migrated to V3, the config **cannot** be safely loaded by older PicoClaw versions that only support V2.
+
+**To downgrade:**
+
+1. Stop PicoClaw
+2. Restore the backup:
+ ```bash
+ cp ~/.picoclaw/config.json.20260413.bak ~/.picoclaw/config.json
+ cp ~/.picoclaw/.security.yml.20260413.bak ~/.picoclaw/.security.yml # if it exists
+ ```
+3. Use a PicoClaw version that supports V2 configs
+
+**Alternative**: Manually edit `config.json` and change `"version": 3` to `"version": 2`. This works because V3 changes are primarily code-level safety improvements, not structural schema changes.
+
## Example Migration
### Scenario: Adding a new field with default value
@@ -171,7 +227,7 @@ func TestMigrateV2ToV3(t *testing.T) {
Old config (version 2):
```json
{
- "version": 2,
+ "version": 3,
"model_list": [
{
"model_name": "gpt-5.4",
diff --git a/docs/cron.md b/docs/reference/cron.md
similarity index 100%
rename from docs/cron.md
rename to docs/reference/cron.md
diff --git a/docs/rate-limiting.md b/docs/reference/rate-limiting.md
similarity index 95%
rename from docs/rate-limiting.md
rename to docs/reference/rate-limiting.md
index b54c757f8..d491c9c56 100644
--- a/docs/rate-limiting.md
+++ b/docs/reference/rate-limiting.md
@@ -39,20 +39,23 @@ Set `rpm` on any model in `model_list`:
```yaml
model_list:
- model_name: gpt-4o-free
- model: openai/gpt-4o
+ provider: openai
+ model: gpt-4o
api_base: https://api.openai.com/v1
rpm: 3 # max 3 requests per minute
api_keys:
- sk-...
- model_name: claude-haiku
- model: anthropic/claude-haiku-4-5
+ provider: anthropic
+ model: claude-haiku-4-5
rpm: 60 # 60 rpm (Anthropic free tier)
api_keys:
- sk-ant-...
- model_name: local-llm
- model: openai/llama3
+ provider: ollama
+ model: llama3
api_base: http://localhost:11434/v1
# no rpm → unrestricted
```
@@ -68,7 +71,8 @@ When a model has fallbacks configured, each candidate is rate-limited **independ
```yaml
model_list:
- model_name: gpt4-with-fallback
- model: openai/gpt-4o
+ provider: openai
+ model: gpt-4o
rpm: 5
fallbacks:
- gpt-4o-mini # must also be in model_list; its own rpm applies
diff --git a/docs/fr/tools_configuration.md b/docs/reference/tools_configuration.fr.md
similarity index 99%
rename from docs/fr/tools_configuration.md
rename to docs/reference/tools_configuration.fr.md
index 1324d49e5..109c9cd6f 100644
--- a/docs/fr/tools_configuration.md
+++ b/docs/reference/tools_configuration.fr.md
@@ -1,6 +1,6 @@
# 🔧 Configuration des Outils
-> Retour au [README](../../README.fr.md)
+> Retour au [README](../project/README.fr.md)
La configuration des outils de PicoClaw se trouve dans le champ `tools` de `config.json`.
@@ -207,6 +207,7 @@ L'outil cron est utilisé pour planifier des tâches périodiques.
|------------------------|------|------------|----------------------------------------------------|
| `exec_timeout_minutes` | int | 5 | Délai d'expiration en minutes, 0 signifie sans limite |
+
## Outil MCP
L'outil MCP permet l'intégration avec des serveurs Model Context Protocol externes.
@@ -345,6 +346,7 @@ Au lieu de charger tous les outils, le LLM reçoit un outil de recherche léger
},
"slack": {
"enabled": true,
+ "type": "slack",
"command": "npx",
"args": [
"-y",
@@ -361,6 +363,7 @@ Au lieu de charger tous les outils, le LLM reçoit un outil de recherche léger
}
```
+
## Outil Skills
L'outil skills configure la découverte et l'installation de compétences via des registres comme ClawHub.
diff --git a/docs/ja/tools_configuration.md b/docs/reference/tools_configuration.ja.md
similarity index 99%
rename from docs/ja/tools_configuration.md
rename to docs/reference/tools_configuration.ja.md
index c946bf088..a331c869e 100644
--- a/docs/ja/tools_configuration.md
+++ b/docs/reference/tools_configuration.ja.md
@@ -1,6 +1,6 @@
# 🔧 ツール設定
-> [README](../../README.ja.md) に戻る
+> [README](../project/README.ja.md) に戻る
PicoClaw のツール設定は `config.json` の `tools` フィールドにあります。
@@ -207,6 +207,7 @@ Cron ツールは定期タスクのスケジューリングに使用されます
|------------------------|-----|------------|-----------------------------------------|
| `exec_timeout_minutes` | int | 5 | 実行タイムアウト(分)、0 は無制限 |
+
## MCP ツール
MCP ツールは外部の Model Context Protocol サーバーとの統合を可能にします。
@@ -345,6 +346,7 @@ MCP ツールは外部の Model Context Protocol サーバーとの統合を可
},
"slack": {
"enabled": true,
+ "type": "slack",
"command": "npx",
"args": [
"-y",
@@ -361,6 +363,7 @@ MCP ツールは外部の Model Context Protocol サーバーとの統合を可
}
```
+
## Skills ツール
Skills ツールは ClawHub などのレジストリを通じたスキルの発見とインストールを設定します。
diff --git a/docs/tools_configuration.md b/docs/reference/tools_configuration.md
similarity index 93%
rename from docs/tools_configuration.md
rename to docs/reference/tools_configuration.md
index adee9244a..fa33f0bb4 100644
--- a/docs/tools_configuration.md
+++ b/docs/reference/tools_configuration.md
@@ -30,7 +30,7 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
Before tool results are sent to the LLM, PicoClaw can filter sensitive values (API keys, tokens, secrets) from the output. This prevents the LLM from seeing its own credentials.
-See [Sensitive Data Filtering](../sensitive_data_filtering.md) for full documentation.
+See [Sensitive Data Filtering](../security/sensitive_data_filtering.md) for full documentation.
| Config | Type | Default | Description |
|--------|------|---------|-------------|
@@ -397,6 +397,7 @@ dynamically only when requested by the user.*
},
"slack": {
"enabled": true,
+ "type": "slack",
"command": "npx",
"args": [
"-y",
@@ -459,7 +460,7 @@ default (deferred). `aws` explicitly opts in to deferred mode even though it is
## 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 and GitHub.
### Registries
@@ -474,13 +475,20 @@ The skills tool configures skill discovery and installation via registries like
| `registries.clawhub.timeout` | int | 0 | Request timeout in seconds (0 = default) |
| `registries.clawhub.max_zip_size` | int | 0 | Max skill zip size in bytes (0 = default) |
| `registries.clawhub.max_response_size` | int | 0 | Max API response size in bytes (0 = default) |
+| `registries.github.enabled` | bool | true | Enable GitHub installs via registry config |
+| `registries.github.base_url` | string | `https://github.com` | GitHub or GitHub Enterprise base URL |
+| `registries.github.auth_token` | string | `""` | GitHub personal access token |
+| `registries.github.proxy` | string | `""` | HTTP proxy for GitHub API requests |
-### GitHub Integration
+### Legacy GitHub Config
-| Config | Type | Default | Description |
-|------------------|--------|---------|--------------------------------------|
-| `github.proxy` | string | `""` | HTTP proxy for GitHub API requests |
-| `github.token` | string | `""` | GitHub personal access token |
+`github.*` is deprecated. Use `registries.github.*` instead. The legacy fields are still supported for compatibility and will be removed later.
+
+| Config | Type | Default | Description |
+|--------------------|--------|----------------------|--------------------------------|
+| `github.base_url` | string | `https://github.com` | Deprecated GitHub base URL |
+| `github.proxy` | string | `""` | Deprecated GitHub proxy |
+| `github.token` | string | `""` | Deprecated GitHub token |
### Search Settings
@@ -500,10 +508,23 @@ The skills tool configures skill discovery and installation via registries like
"clawhub": {
"enabled": true,
"base_url": "https://clawhub.ai",
- "auth_token": ""
+ "auth_token": "",
+ "search_path": "",
+ "skills_path": "",
+ "download_path": "",
+ "timeout": 0,
+ "max_zip_size": 0,
+ "max_response_size": 0
+ },
+ "github": {
+ "enabled": true,
+ "base_url": "https://github.com",
+ "auth_token": "",
+ "proxy": ""
}
},
"github": {
+ "base_url": "https://github.com",
"proxy": "",
"token": ""
},
diff --git a/docs/pt-br/tools_configuration.md b/docs/reference/tools_configuration.pt-br.md
similarity index 99%
rename from docs/pt-br/tools_configuration.md
rename to docs/reference/tools_configuration.pt-br.md
index feec3c3d8..3dae0f908 100644
--- a/docs/pt-br/tools_configuration.md
+++ b/docs/reference/tools_configuration.pt-br.md
@@ -1,6 +1,6 @@
# 🔧 Configuração de Ferramentas
-> Voltar ao [README](../../README.pt-br.md)
+> Voltar ao [README](../project/README.pt-br.md)
A configuração de ferramentas do PicoClaw está localizada no campo `tools` do `config.json`.
@@ -207,6 +207,7 @@ A ferramenta cron é usada para agendar tarefas periódicas.
|------------------------|------|--------|-----------------------------------------------------|
| `exec_timeout_minutes` | int | 5 | Tempo limite de execução em minutos, 0 significa sem limite |
+
## Ferramenta MCP
A ferramenta MCP permite a integração com servidores Model Context Protocol externos.
@@ -345,6 +346,7 @@ Em vez de carregar todas as ferramentas, o LLM recebe uma ferramenta de pesquisa
},
"slack": {
"enabled": true,
+ "type": "slack",
"command": "npx",
"args": [
"-y",
@@ -361,6 +363,7 @@ Em vez de carregar todas as ferramentas, o LLM recebe uma ferramenta de pesquisa
}
```
+
## Ferramenta Skills
A ferramenta skills configura a descoberta e instalação de habilidades via registros como o ClawHub.
diff --git a/docs/vi/tools_configuration.md b/docs/reference/tools_configuration.vi.md
similarity index 99%
rename from docs/vi/tools_configuration.md
rename to docs/reference/tools_configuration.vi.md
index 55e7699eb..7d65ca377 100644
--- a/docs/vi/tools_configuration.md
+++ b/docs/reference/tools_configuration.vi.md
@@ -1,6 +1,6 @@
# 🔧 Cấu Hình Công Cụ
-> Quay lại [README](../../README.vi.md)
+> Quay lại [README](../project/README.vi.md)
Cấu hình công cụ của PicoClaw nằm trong trường `tools` của `config.json`.
@@ -207,6 +207,7 @@ Công cụ cron được sử dụng để lên lịch các tác vụ định k
|--------------------------|------|----------|-----------------------------------------------------|
| `exec_timeout_minutes` | int | 5 | Thời gian chờ thực thi tính bằng phút, 0 nghĩa là không giới hạn |
+
## Công cụ MCP
Công cụ MCP cho phép tích hợp với các máy chủ Model Context Protocol bên ngoài.
@@ -345,6 +346,7 @@ Thay vì tải tất cả các công cụ, LLM được cung cấp một công c
},
"slack": {
"enabled": true,
+ "type": "slack",
"command": "npx",
"args": [
"-y",
@@ -361,6 +363,7 @@ Thay vì tải tất cả các công cụ, LLM được cung cấp một công c
}
```
+
## Công cụ Skills
Công cụ skills cấu hình khám phá và cài đặt kỹ năng thông qua các registry như ClawHub.
diff --git a/docs/zh/tools_configuration.md b/docs/reference/tools_configuration.zh.md
similarity index 93%
rename from docs/zh/tools_configuration.md
rename to docs/reference/tools_configuration.zh.md
index 63ac5000b..3937a6254 100644
--- a/docs/zh/tools_configuration.md
+++ b/docs/reference/tools_configuration.zh.md
@@ -1,6 +1,6 @@
# 🔧 工具配置
-> 返回 [README](../../README.zh.md)
+> 返回 [README](../project/README.zh.md)
PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。
@@ -32,7 +32,7 @@ PicoClaw 的工具配置位于 `config.json` 的 `tools` 字段中。
在将工具结果发送给 LLM 之前,PicoClaw 可以从输出中过滤敏感值(API 密钥、令牌、密码)。这可以防止 LLM 看到自己的凭据。
-详细说明请参阅[敏感数据过滤](../sensitive_data_filtering.md)。
+详细说明请参阅[敏感数据过滤](../security/sensitive_data_filtering.zh.md)。
| 配置项 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
@@ -234,6 +234,7 @@ Cron 工具用于调度周期性任务。
| `exec_timeout_minutes` | int | 5 | 执行超时时间(分钟),0 表示无限制 |
| `allow_command` | bool | false | 允许 cron 任务执行 shell 命令 |
+
## MCP 工具
MCP 工具支持与外部 Model Context Protocol 服务器集成。
@@ -372,6 +373,7 @@ LLM 不会加载所有工具,而是获得一个轻量级搜索工具(使用
},
"slack": {
"enabled": true,
+ "type": "slack",
"command": "npx",
"args": [
"-y",
@@ -388,6 +390,7 @@ LLM 不会加载所有工具,而是获得一个轻量级搜索工具(使用
}
```
+
## Skills 工具
Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。
@@ -461,3 +464,29 @@ Skills 工具配置通过 ClawHub 等注册表进行技能发现和安装。
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
注意:嵌套的映射式配置(例如 `tools.mcp.servers.You can close this window.
") - resultCh <- callbackResult{code: code} - }) - - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port)) - if err != nil { - return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) - } - - server := &http.Server{Handler: mux} - go server.Serve(listener) - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - server.Shutdown(ctx) - }() - fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) - if err := OpenBrowser(authURL); err != nil { + if opts.NoBrowser { + fmt.Println("Browser auto-open disabled. Open the URL manually to continue.") + } else if err := openBrowserFunc(authURL); err != nil { fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) } fmt.Printf( "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", - cfg.Port, + callbackPort, ) fmt.Println( "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.", @@ -142,11 +145,16 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { fmt.Println("Waiting for authentication (browser or manual paste)...") // Start manual input in a goroutine - manualCh := make(chan string) + manualCh := make(chan string, 1) + manualDone := make(chan struct{}) + defer close(manualDone) go func() { - reader := bufio.NewReader(os.Stdin) + reader := bufio.NewReader(browserLoginInput) input, _ := reader.ReadString('\n') - manualCh <- strings.TrimSpace(input) + select { + case manualCh <- strings.TrimSpace(input): + case <-manualDone: + } }() select { @@ -176,6 +184,49 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { } } +func oauthCallbackRedirectURI(port int) string { + return fmt.Sprintf("http://localhost:%d/auth/callback", port) +} + +func oauthCallbackHandler(state string, resultCh chan<- callbackResult) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("state") != state { + resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} + http.Error(w, "State mismatch", http.StatusBadRequest) + return + } + + code := r.URL.Query().Get("code") + if code == "" { + errMsg := r.URL.Query().Get("error") + resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} + http.Error(w, "No authorization code received", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "You can close this window.
") + resultCh <- callbackResult{code: code} + }) + return mux +} + +func listenOAuthCallback(port int) (net.Listener, int, error) { + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, 0, err + } + + tcpAddr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + _ = listener.Close() + return nil, 0, fmt.Errorf("unexpected listener address type %T", listener.Addr()) + } + + return listener, tcpAddr.Port, nil +} + type callbackResult struct { code string err error diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 230ac7c2a..b318934f9 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -3,6 +3,7 @@ package auth import ( "encoding/base64" "encoding/json" + "net" "net/http" "net/http/httptest" "net/url" @@ -373,3 +374,118 @@ func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) { t.Fatal("expected error for invalid interval") } } + +func TestLoginBrowserWithOptionsNoBrowserDoesNotRequireCallbackPort(t *testing.T) { + server := newMockOAuthTokenServer() + defer server.Close() + reservedListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen() error: %v", err) + } + defer reservedListener.Close() + + reservedPort := reservedListener.Addr().(*net.TCPAddr).Port + origOpenBrowserFunc := openBrowserFunc + origBrowserLoginInput := browserLoginInput + t.Cleanup(func() { + openBrowserFunc = origOpenBrowserFunc + browserLoginInput = origBrowserLoginInput + }) + + var openCalls int + openBrowserFunc = func(string) error { + openCalls++ + return nil + } + browserLoginInput = strings.NewReader("manual-code\n") + + cfg := OAuthProviderConfig{ + Issuer: server.URL, + ClientID: "test-client", + Scopes: "openid", + Port: reservedPort, + } + + cred, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{NoBrowser: true}) + if err != nil { + t.Fatalf("LoginBrowserWithOptions() error: %v", err) + } + + if openCalls != 0 { + t.Fatalf("openBrowserFunc call count = %d, want 0", openCalls) + } + if cred.AccessToken != "mock-access-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token") + } +} + +func TestLoginBrowserWithOptionsAutoOpensByDefault(t *testing.T) { + server := newMockOAuthTokenServer() + defer server.Close() + + origOpenBrowserFunc := openBrowserFunc + origBrowserLoginInput := browserLoginInput + t.Cleanup(func() { + openBrowserFunc = origOpenBrowserFunc + browserLoginInput = origBrowserLoginInput + }) + + var ( + openCalls int + browserURL string + ) + openBrowserFunc = func(url string) error { + openCalls++ + browserURL = url + return nil + } + browserLoginInput = strings.NewReader("manual-code\n") + + cfg := OAuthProviderConfig{ + Issuer: server.URL, + ClientID: "test-client", + Scopes: "openid", + Port: 0, + } + + _, err := LoginBrowserWithOptions(cfg, LoginBrowserOptions{}) + if err != nil { + t.Fatalf("LoginBrowserWithOptions() error: %v", err) + } + + if openCalls != 1 { + t.Fatalf("openBrowserFunc call count = %d, want 1", openCalls) + } + + parsedBrowserURL, err := url.Parse(browserURL) + if err != nil { + t.Fatalf("url.Parse(browserURL) error: %v", err) + } + + redirectURI, err := url.Parse(parsedBrowserURL.Query().Get("redirect_uri")) + if err != nil { + t.Fatalf("url.Parse(redirectURI) error: %v", err) + } + if redirectURI.Port() == "" { + t.Fatal("redirectURI port is empty") + } + if redirectURI.Port() == "0" { + t.Fatalf("redirectURI port = %q, want dynamically assigned port", redirectURI.Port()) + } +} + +func newMockOAuthTokenServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/token" { + http.Error(w, "not found", http.StatusNotFound) + return + } + + resp := map[string]any{ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + } + _ = json.NewEncoder(w).Encode(resp) + })) +} diff --git a/pkg/auth/store.go b/pkg/auth/store.go index dfea11df4..0e6567a03 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -25,6 +26,11 @@ type AuthStore struct { Credentials map[string]*AuthCredential `json:"credentials"` } +const ( + providerGoogleAntigravity = "google-antigravity" + providerAntigravityAlias = "antigravity" +) + func (c *AuthCredential) IsExpired() bool { if c.ExpiresAt.IsZero() { return false @@ -43,6 +49,125 @@ func authFilePath() string { return filepath.Join(config.GetHome(), "auth.json") } +func canonicalProvider(provider string) string { + normalized := strings.ToLower(strings.TrimSpace(provider)) + switch normalized { + case providerAntigravityAlias: + return providerGoogleAntigravity + default: + return normalized + } +} + +func cloneCredential(cred *AuthCredential) *AuthCredential { + if cred == nil { + return nil + } + cp := *cred + return &cp +} + +func mergeCredentials(primary, secondary *AuthCredential) *AuthCredential { + if primary == nil { + return cloneCredential(secondary) + } + + merged := *primary + if secondary == nil { + return &merged + } + if merged.AccessToken == "" { + merged.AccessToken = secondary.AccessToken + } + if merged.RefreshToken == "" { + merged.RefreshToken = secondary.RefreshToken + } + if merged.AccountID == "" { + merged.AccountID = secondary.AccountID + } + if merged.ExpiresAt.IsZero() { + merged.ExpiresAt = secondary.ExpiresAt + } + if merged.Provider == "" { + merged.Provider = secondary.Provider + } + if merged.AuthMethod == "" { + merged.AuthMethod = secondary.AuthMethod + } + if merged.Email == "" { + merged.Email = secondary.Email + } + if merged.ProjectID == "" { + merged.ProjectID = secondary.ProjectID + } + + return &merged +} + +func shouldPreferCredential( + candidate *AuthCredential, + candidateCanonical bool, + current *AuthCredential, + currentCanonical bool, +) bool { + if candidate == nil { + return false + } + if current == nil { + return true + } + + switch { + case candidate.ExpiresAt.After(current.ExpiresAt): + return true + case current.ExpiresAt.After(candidate.ExpiresAt): + return false + case candidateCanonical != currentCanonical: + return candidateCanonical + default: + return false + } +} + +func normalizeStore(store *AuthStore) { + if store == nil { + return + } + if store.Credentials == nil { + store.Credentials = make(map[string]*AuthCredential) + return + } + + normalized := make(map[string]*AuthCredential, len(store.Credentials)) + canonicalFlags := make(map[string]bool, len(store.Credentials)) + + for provider, cred := range store.Credentials { + normalizedProvider := strings.ToLower(strings.TrimSpace(provider)) + canonical := canonicalProvider(provider) + normalizedCred := cloneCredential(cred) + if normalizedCred != nil { + normalizedCred.Provider = canonicalProvider(normalizedCred.Provider) + if normalizedCred.Provider == "" { + normalizedCred.Provider = canonical + } + } + + current := normalized[canonical] + currentCanonical := canonicalFlags[canonical] + candidateCanonical := normalizedProvider == canonical + + if shouldPreferCredential(normalizedCred, candidateCanonical, current, currentCanonical) { + normalized[canonical] = mergeCredentials(normalizedCred, current) + canonicalFlags[canonical] = candidateCanonical + continue + } + + normalized[canonical] = mergeCredentials(current, normalizedCred) + } + + store.Credentials = normalized +} + func LoadStore() (*AuthStore, error) { path := authFilePath() data, err := os.ReadFile(path) @@ -57,9 +182,7 @@ func LoadStore() (*AuthStore, error) { if err := json.Unmarshal(data, &store); err != nil { return nil, err } - if store.Credentials == nil { - store.Credentials = make(map[string]*AuthCredential) - } + normalizeStore(&store) return &store, nil } @@ -79,7 +202,7 @@ func GetCredential(provider string) (*AuthCredential, error) { if err != nil { return nil, err } - cred, ok := store.Credentials[provider] + cred, ok := store.Credentials[canonicalProvider(provider)] if !ok { return nil, nil } @@ -91,7 +214,17 @@ func SetCredential(provider string, cred *AuthCredential) error { if err != nil { return err } - store.Credentials[provider] = cred + + canonical := canonicalProvider(provider) + normalized := cloneCredential(cred) + if normalized != nil { + normalized.Provider = canonicalProvider(normalized.Provider) + if normalized.Provider == "" { + normalized.Provider = canonical + } + } + + store.Credentials[canonical] = normalized return SaveStore(store) } @@ -100,7 +233,7 @@ func DeleteCredential(provider string) error { if err != nil { return err } - delete(store.Credentials, provider) + delete(store.Credentials, canonicalProvider(provider)) return SaveStore(store) } diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go index f6793cfce..578ed4ead 100644 --- a/pkg/auth/store_test.go +++ b/pkg/auth/store_test.go @@ -1,12 +1,24 @@ package auth import ( + "encoding/json" "os" "path/filepath" + "runtime" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) +func setTestAuthHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestAuthCredentialIsExpired(t *testing.T) { tests := []struct { name string @@ -51,10 +63,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) { } func TestStoreRoundtrip(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "test-access-token", @@ -88,10 +97,7 @@ func TestStoreRoundtrip(t *testing.T) { } func TestStoreFilePermissions(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + tmpDir := setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "secret-token", @@ -108,16 +114,16 @@ func TestStoreFilePermissions(t *testing.T) { t.Fatalf("Stat() error: %v", err) } perm := info.Mode().Perm() + if runtime.GOOS == "windows" { + return + } if perm != 0o600 { t.Errorf("file permissions = %o, want 0600", perm) } } func TestStoreMultiProvider(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} @@ -147,10 +153,7 @@ func TestStoreMultiProvider(t *testing.T) { } func TestDeleteCredential(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} if err := SetCredential("openai", cred); err != nil { @@ -171,10 +174,7 @@ func TestDeleteCredential(t *testing.T) { } func TestLoadStoreEmpty(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) store, err := LoadStore() if err != nil { @@ -187,3 +187,319 @@ func TestLoadStoreEmpty(t *testing.T) { t.Errorf("expected empty credentials, got %d", len(store.Credentials)) } } + +func TestGetCredentialCanonicalizesLegacyAntigravityProvider(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "project-1", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cred, err := GetCredential("google-antigravity") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if cred == nil { + t.Fatal("GetCredential() returned nil") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } +} + +func TestLoadStoreMergesAntigravityAliasesPreferringNewerExpiry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + refreshedExpiry := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + "google-antigravity": map[string]any{ + "access_token": "fresh-token", + "expires_at": refreshedExpiry.Format(time.RFC3339), + "provider": "google-antigravity", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestLoadStorePrefersCanonicalKeyWhenExpiryMatchesAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + " Google-Antigravity ": map[string]any{ + "access_token": "fresh-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": " Google-Antigravity ", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } +} + +func TestSetCredentialReplacesLegacyAntigravityEntry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC).Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = SetCredential("google-antigravity", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + }) + if err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestDeleteCredentialRemovesLegacyAntigravityAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err = DeleteCredential(" google-antigravity ") + if err != nil { + t.Fatalf("DeleteCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 0 { + t.Fatalf("credential count = %d, want 0", len(loaded.Credentials)) + } +} + +func TestSetCredentialCanonicalizesTrimmedMixedCaseProvider(t *testing.T) { + setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC) + if err := SetCredential(" AnTiGrAvItY ", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: expiresAt, + Provider: " AnTiGrAvItY ", + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } + + got, err := GetCredential(" GoOgLe-AnTiGrAvItY ") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if got == nil { + t.Fatal("GetCredential() returned nil") + } + if got.Provider != "google-antigravity" { + t.Fatalf("GetCredential provider = %q, want %q", got.Provider, "google-antigravity") + } +} diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index a9c74ef90..9a05d4f95 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -12,6 +12,12 @@ import ( // ErrBusClosed is returned when publishing to a closed MessageBus. var ErrBusClosed = errors.New("message bus closed") +var ( + ErrMissingInboundContext = errors.New("inbound message context is required") + ErrMissingOutboundContext = errors.New("outbound message context is required") + ErrMissingOutboundMediaContext = errors.New("outbound media context is required") +) + const defaultBusBufferSize = 64 // StreamDelegate is implemented by the channel Manager to provide streaming @@ -49,7 +55,7 @@ func NewMessageBus() *MessageBus { inbound: make(chan InboundMessage, defaultBusBufferSize), outbound: make(chan OutboundMessage, defaultBusBufferSize), outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize), - audioChunks: make(chan AudioChunk, defaultBusBufferSize*4), // Audio chunks need more buffer + audioChunks: make(chan AudioChunk, defaultBusBufferSize*4), // Audio chunks need more buffer. voiceControls: make(chan VoiceControl, defaultBusBufferSize), done: make(chan struct{}), } @@ -84,6 +90,10 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error } func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { + msg = NormalizeInboundMessage(msg) + if msg.Context.isZero() { + return ErrMissingInboundContext + } return publish(ctx, mb, mb.inbound, msg) } @@ -92,6 +102,10 @@ func (mb *MessageBus) InboundChan() <-chan InboundMessage { } func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { + msg = NormalizeOutboundMessage(msg) + if msg.Context.isZero() { + return ErrMissingOutboundContext + } return publish(ctx, mb, mb.outbound, msg) } @@ -100,6 +114,10 @@ func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { } func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { + msg = NormalizeOutboundMediaMessage(msg) + if msg.Context.isZero() { + return ErrMissingOutboundMediaContext + } return publish(ctx, mb, mb.outboundMedia, msg) } diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go index 9b6324ca6..5145d4759 100644 --- a/pkg/bus/bus_test.go +++ b/pkg/bus/bus_test.go @@ -14,10 +14,13 @@ func TestPublishConsume(t *testing.T) { ctx := context.Background() msg := InboundMessage{ - Channel: "test", - SenderID: "user1", - ChatID: "chat1", - Content: "hello", + Context: InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "hello", } if err := mb.PublishInbound(ctx, msg); err != nil { @@ -34,6 +37,138 @@ func TestPublishConsume(t *testing.T) { if got.Channel != "test" { t.Fatalf("expected channel 'test', got %q", got.Channel) } + if got.Context.Channel != "test" { + t.Fatalf("expected context channel 'test', got %q", got.Context.Channel) + } + if got.Context.ChatID != "chat1" { + t.Fatalf("expected context chat ID 'chat1', got %q", got.Context.ChatID) + } + if got.Context.SenderID != "user1" { + t.Fatalf("expected context sender ID 'user1', got %q", got.Context.SenderID) + } +} + +func TestPublishInbound_NormalizesContext(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := InboundMessage{ + Context: InboundContext{ + Channel: "slack", + Account: "workspace-a", + ChatID: "C456/1712", + ChatType: "group", + TopicID: "1712", + SpaceID: "T001", + SpaceType: "team", + SenderID: "U123", + MessageID: "1712.01", + ReplyToMessageID: "1700.01", + Mentioned: true, + }, + Content: "hello", + } + + if err := mb.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + + got := <-mb.InboundChan() + if got.Context.Channel != "slack" { + t.Fatalf("expected context channel slack, got %q", got.Context.Channel) + } + if got.Context.Account != "workspace-a" { + t.Fatalf("expected context account workspace-a, got %q", got.Context.Account) + } + if got.Context.ChatType != "group" { + t.Fatalf("expected context chat type group, got %q", got.Context.ChatType) + } + if got.Context.TopicID != "1712" { + t.Fatalf("expected topic 1712, got %q", got.Context.TopicID) + } + if got.Context.SpaceType != "team" || got.Context.SpaceID != "T001" { + t.Fatalf("expected team space T001, got %q/%q", got.Context.SpaceType, got.Context.SpaceID) + } + if !got.Context.Mentioned { + t.Fatal("expected mentioned=true in context") + } + if got.Context.ReplyToMessageID != "1700.01" { + t.Fatalf("expected reply_to_message_id 1700.01, got %q", got.Context.ReplyToMessageID) + } +} + +func TestPublishInbound_MirrorsContextIntoConvenienceFields(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := InboundMessage{ + Context: InboundContext{ + Channel: "telegram", + Account: "bot-a", + ChatID: "-1001", + ChatType: "group", + TopicID: "42", + SpaceID: "guild-9", + SpaceType: "guild", + SenderID: "user-1", + MessageID: "777", + Mentioned: true, + ReplyToMessageID: "666", + }, + Content: "hi", + } + + if err := mb.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + + got := <-mb.InboundChan() + if got.Channel != "telegram" { + t.Fatalf("expected legacy channel telegram, got %q", got.Channel) + } + if got.ChatID != "-1001" { + t.Fatalf("expected legacy chat ID -1001, got %q", got.ChatID) + } + if got.SenderID != "user-1" { + t.Fatalf("expected legacy sender ID user-1, got %q", got.SenderID) + } + if got.MessageID != "777" { + t.Fatalf("expected legacy message ID 777, got %q", got.MessageID) + } + if got.Context.Account != "bot-a" || got.Context.SpaceID != "guild-9" || got.Context.TopicID != "42" { + t.Fatalf("unexpected normalized context: %+v", got.Context) + } +} + +func TestPublishInbound_BackfillsContextFromLegacyFields(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := InboundMessage{ + Channel: "pico", + ChatID: "session-1", + SenderID: "user-1", + MessageID: "msg-1", + Content: "hello", + } + + if err := mb.PublishInbound(context.Background(), msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + + got := <-mb.InboundChan() + if got.Context.Channel != "pico" { + t.Fatalf("expected context channel pico, got %q", got.Context.Channel) + } + if got.Context.ChatID != "session-1" { + t.Fatalf("expected context chat ID session-1, got %q", got.Context.ChatID) + } + if got.Context.SenderID != "user-1" { + t.Fatalf("expected context sender ID user-1, got %q", got.Context.SenderID) + } + if got.Context.MessageID != "msg-1" { + t.Fatalf("expected context message ID msg-1, got %q", got.Context.MessageID) + } } func TestPublishOutboundSubscribe(t *testing.T) { @@ -43,8 +178,10 @@ func TestPublishOutboundSubscribe(t *testing.T) { ctx := context.Background() msg := OutboundMessage{ - Channel: "telegram", - ChatID: "123", + Context: InboundContext{ + Channel: "telegram", + ChatID: "123", + }, Content: "world", } @@ -59,6 +196,222 @@ func TestPublishOutboundSubscribe(t *testing.T) { if got.Content != "world" { t.Fatalf("expected content 'world', got %q", got.Content) } + if got.Context.Channel != "telegram" || got.Context.ChatID != "123" { + t.Fatalf("expected normalized outbound context, got %+v", got.Context) + } +} + +func TestPublishOutbound_MirrorsContextToLegacyFields(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := OutboundMessage{ + Context: InboundContext{ + Channel: "telegram", + ChatID: "chat-42", + ReplyToMessageID: "msg-9", + }, + AgentID: "main", + SessionKey: "sk_v1_123", + Scope: &OutboundScope{ + Version: 1, + AgentID: "main", + Channel: "telegram", + Account: "bot-a", + Dimensions: []string{"chat", "sender"}, + Values: map[string]string{ + "chat": "direct:chat-42", + "sender": "user-1", + }, + }, + Content: "reply", + } + + if err := mb.PublishOutbound(context.Background(), msg); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + + got := <-mb.OutboundChan() + if got.Channel != "telegram" { + t.Fatalf("expected legacy channel telegram, got %q", got.Channel) + } + if got.ChatID != "chat-42" { + t.Fatalf("expected legacy chat ID chat-42, got %q", got.ChatID) + } + if got.ReplyToMessageID != "msg-9" { + t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID) + } + if got.AgentID != "main" || got.SessionKey != "sk_v1_123" { + t.Fatalf("unexpected outbound turn metadata: agent=%q session=%q", got.AgentID, got.SessionKey) + } + if got.Scope == nil || got.Scope.AgentID != "main" || got.Scope.Values["chat"] != "direct:chat-42" { + t.Fatalf("unexpected outbound scope: %+v", got.Scope) + } + if got.Context.Channel != "telegram" || got.Context.ChatID != "chat-42" { + t.Fatalf("unexpected outbound context: %+v", got.Context) + } +} + +func TestPublishOutbound_PreservesExplicitReplyToMessageID(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := OutboundMessage{ + Context: InboundContext{ + Channel: "telegram", + ChatID: "chat-42", + }, + ReplyToMessageID: "msg-9", + Content: "reply", + } + + if err := mb.PublishOutbound(context.Background(), msg); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + + got := <-mb.OutboundChan() + if got.ReplyToMessageID != "msg-9" { + t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID) + } + if got.Context.ReplyToMessageID != "msg-9" { + t.Fatalf("expected context reply_to_message_id msg-9, got %q", got.Context.ReplyToMessageID) + } +} + +func TestPublishOutbound_PreservesExplicitReplyToMessageIDWhenContextReplyIsBlank(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := OutboundMessage{ + Context: InboundContext{ + Channel: "telegram", + ChatID: "chat-42", + ReplyToMessageID: " ", + }, + ReplyToMessageID: "msg-9", + Content: "reply", + } + + if err := mb.PublishOutbound(context.Background(), msg); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + + got := <-mb.OutboundChan() + if got.ReplyToMessageID != "msg-9" { + t.Fatalf("expected mirrored reply_to_message_id msg-9, got %q", got.ReplyToMessageID) + } + if got.Context.ReplyToMessageID != "msg-9" { + t.Fatalf("expected context reply_to_message_id msg-9, got %q", got.Context.ReplyToMessageID) + } +} + +func TestPublishOutboundMedia_MirrorsContextToLegacyFields(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + msg := OutboundMediaMessage{ + Context: InboundContext{ + Channel: "slack", + ChatID: "C001", + }, + AgentID: "support", + SessionKey: "sk_v1_media", + Scope: &OutboundScope{ + Version: 1, + AgentID: "support", + Channel: "slack", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "channel:c001", + }, + }, + Parts: []MediaPart{{Type: "image", Ref: "media://1"}}, + } + + if err := mb.PublishOutboundMedia(context.Background(), msg); err != nil { + t.Fatalf("PublishOutboundMedia failed: %v", err) + } + + got := <-mb.OutboundMediaChan() + if got.Channel != "slack" { + t.Fatalf("expected legacy channel slack, got %q", got.Channel) + } + if got.ChatID != "C001" { + t.Fatalf("expected legacy chat ID C001, got %q", got.ChatID) + } + if got.AgentID != "support" || got.SessionKey != "sk_v1_media" { + t.Fatalf("unexpected outbound media turn metadata: agent=%q session=%q", got.AgentID, got.SessionKey) + } + if got.Scope == nil || got.Scope.Values["chat"] != "channel:c001" { + t.Fatalf("unexpected outbound media scope: %+v", got.Scope) + } + if got.Context.Channel != "slack" || got.Context.ChatID != "C001" { + t.Fatalf("unexpected outbound media context: %+v", got.Context) + } +} + +func TestPublishAudioChunkSubscribe(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + chunk := AudioChunk{ + SessionID: "voice-1", + SpeakerID: "speaker-1", + ChatID: "chat-1", + Channel: "discord", + Sequence: 7, + Format: "opus", + Data: []byte{0x01, 0x02}, + } + + if err := mb.PublishAudioChunk(context.Background(), chunk); err != nil { + t.Fatalf("PublishAudioChunk failed: %v", err) + } + + got, ok := <-mb.AudioChunksChan() + if !ok { + t.Fatal("AudioChunksChan returned ok=false") + } + if got.SessionID != "voice-1" || got.Sequence != 7 { + t.Fatalf("unexpected audio chunk: %+v", got) + } +} + +func TestPublishVoiceControlSubscribe(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctrl := VoiceControl{ + SessionID: "voice-1", + ChatID: "chat-1", + Type: "command", + Action: "start", + } + + if err := mb.PublishVoiceControl(context.Background(), ctrl); err != nil { + t.Fatalf("PublishVoiceControl failed: %v", err) + } + + got, ok := <-mb.VoiceControlsChan() + if !ok { + t.Fatal("VoiceControlsChan returned ok=false") + } + if got.Type != "command" || got.Action != "start" { + t.Fatalf("unexpected voice control: %+v", got) + } +} + +func TestNewOutboundContext_NormalizesReplyAddress(t *testing.T) { + ctx := NewOutboundContext(" telegram ", " chat-42 ", " msg-9 ") + if ctx.Channel != "telegram" { + t.Fatalf("expected channel telegram, got %q", ctx.Channel) + } + if ctx.ChatID != "chat-42" { + t.Fatalf("expected chat_id chat-42, got %q", ctx.ChatID) + } + if ctx.ReplyToMessageID != "msg-9" { + t.Fatalf("expected reply_to_message_id msg-9, got %q", ctx.ReplyToMessageID) + } } func TestPublishInbound_ContextCancel(t *testing.T) { @@ -68,7 +421,15 @@ func TestPublishInbound_ContextCancel(t *testing.T) { // Fill the buffer ctx := context.Background() for i := range defaultBusBufferSize { - if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { + if err := mb.PublishInbound(ctx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-fill", + ChatType: "direct", + SenderID: "user-fill", + }, + Content: "fill", + }); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } } @@ -77,7 +438,15 @@ func TestPublishInbound_ContextCancel(t *testing.T) { cancelCtx, cancel := context.WithCancel(context.Background()) cancel() - err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"}) + err := mb.PublishInbound(cancelCtx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-overflow", + ChatType: "direct", + SenderID: "user-overflow", + }, + Content: "overflow", + }) if err == nil { t.Fatal("expected error from canceled context, got nil") } @@ -90,7 +459,15 @@ func TestPublishInbound_BusClosed(t *testing.T) { mb := NewMessageBus() mb.Close() - err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) + err := mb.PublishInbound(context.Background(), InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "test", + }) if err != ErrBusClosed { t.Fatalf("expected ErrBusClosed, got %v", err) } @@ -100,7 +477,13 @@ func TestPublishOutbound_BusClosed(t *testing.T) { mb := NewMessageBus() mb.Close() - err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"}) + err := mb.PublishOutbound(context.Background(), OutboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat1", + }, + Content: "test", + }) if err != ErrBusClosed { t.Fatalf("expected ErrBusClosed, got %v", err) } @@ -112,14 +495,30 @@ func TestConsumeInbound_ContextCancel(t *testing.T) { defer mb.Close() for i := range defaultBusBufferSize { - if err := mb.PublishInbound(context.Background(), InboundMessage{Content: "fill"}); err != nil { + if err := mb.PublishInbound(context.Background(), InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-fill", + ChatType: "direct", + SenderID: "user-fill", + }, + Content: "fill", + }); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } } ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() - mb.PublishInbound(ctx, InboundMessage{Content: "ContextCancel"}) + mb.PublishInbound(ctx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-cancel", + ChatType: "direct", + SenderID: "user-cancel", + }, + Content: "ContextCancel", + }) select { case <-ctx.Done(): @@ -213,7 +612,15 @@ func TestPublishInbound_FullBuffer(t *testing.T) { // Fill the buffer for i := range defaultBusBufferSize { - if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { + if err := mb.PublishInbound(ctx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-fill", + ChatType: "direct", + SenderID: "user-fill", + }, + Content: "fill", + }); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } } @@ -222,7 +629,15 @@ func TestPublishInbound_FullBuffer(t *testing.T) { timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() - err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"}) + err := mb.PublishInbound(timeoutCtx, InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat-overflow", + ChatType: "direct", + SenderID: "user-overflow", + }, + Content: "overflow", + }) if err == nil { t.Fatal("expected error when buffer is full and context times out") } @@ -240,7 +655,15 @@ func TestCloseIdempotent(t *testing.T) { mb.Close() // After close, publish should return ErrBusClosed - err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) + err := mb.PublishInbound(context.Background(), InboundMessage{ + Context: InboundContext{ + Channel: "test", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + }, + Content: "test", + }) if err != ErrBusClosed { t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err) } diff --git a/pkg/bus/inbound_context.go b/pkg/bus/inbound_context.go new file mode 100644 index 000000000..d6be80565 --- /dev/null +++ b/pkg/bus/inbound_context.go @@ -0,0 +1,81 @@ +package bus + +import "strings" + +// NormalizeInboundMessage ensures the inbound context is normalized and keeps +// convenience mirrors in sync for runtime consumers. +func NormalizeInboundMessage(msg InboundMessage) InboundMessage { + if msg.Context.Channel == "" { + msg.Context.Channel = msg.Channel + } + if msg.Context.ChatID == "" { + msg.Context.ChatID = msg.ChatID + } + if msg.Context.SenderID == "" { + msg.Context.SenderID = msg.SenderID + } + if msg.Context.MessageID == "" { + msg.Context.MessageID = msg.MessageID + } + msg.Context = normalizeInboundContext(msg.Context) + msg.Channel = msg.Context.Channel + msg.SenderID = msg.Context.SenderID + msg.ChatID = msg.Context.ChatID + if msg.MessageID == "" { + msg.MessageID = msg.Context.MessageID + } + if msg.Context.MessageID == "" { + msg.Context.MessageID = msg.MessageID + } + return msg +} + +func (ctx InboundContext) isZero() bool { + return ctx.Channel == "" && + ctx.Account == "" && + ctx.ChatID == "" && + ctx.ChatType == "" && + ctx.TopicID == "" && + ctx.SpaceID == "" && + ctx.SpaceType == "" && + ctx.SenderID == "" && + ctx.MessageID == "" && + !ctx.Mentioned && + ctx.ReplyToMessageID == "" && + ctx.ReplyToSenderID == "" && + len(ctx.ReplyHandles) == 0 && + len(ctx.Raw) == 0 +} + +func normalizeInboundContext(ctx InboundContext) InboundContext { + ctx.Channel = strings.TrimSpace(ctx.Channel) + ctx.Account = strings.TrimSpace(ctx.Account) + ctx.ChatID = strings.TrimSpace(ctx.ChatID) + ctx.ChatType = normalizeKind(ctx.ChatType) + ctx.TopicID = strings.TrimSpace(ctx.TopicID) + ctx.SpaceID = strings.TrimSpace(ctx.SpaceID) + ctx.SpaceType = normalizeKind(ctx.SpaceType) + ctx.SenderID = strings.TrimSpace(ctx.SenderID) + ctx.MessageID = strings.TrimSpace(ctx.MessageID) + ctx.ReplyToMessageID = strings.TrimSpace(ctx.ReplyToMessageID) + ctx.ReplyToSenderID = strings.TrimSpace(ctx.ReplyToSenderID) + ctx.ReplyHandles = cloneStringMap(ctx.ReplyHandles) + ctx.Raw = cloneStringMap(ctx.Raw) + return ctx +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func normalizeKind(kind string) string { + return strings.ToLower(strings.TrimSpace(kind)) +} diff --git a/pkg/bus/outbound_context.go b/pkg/bus/outbound_context.go new file mode 100644 index 000000000..cbbbc99c7 --- /dev/null +++ b/pkg/bus/outbound_context.go @@ -0,0 +1,84 @@ +package bus + +import "strings" + +// NewOutboundContext builds the minimal normalized addressing context required +// to deliver an outbound text message or reply. +func NewOutboundContext(channel, chatID, replyToMessageID string) InboundContext { + return normalizeInboundContext(InboundContext{ + Channel: strings.TrimSpace(channel), + ChatID: strings.TrimSpace(chatID), + ReplyToMessageID: strings.TrimSpace(replyToMessageID), + }) +} + +// NormalizeOutboundMessage ensures Context is normalized and keeps convenience +// mirrors in sync for runtime consumers. +func NormalizeOutboundMessage(msg OutboundMessage) OutboundMessage { + msg.Channel = strings.TrimSpace(msg.Channel) + msg.ChatID = strings.TrimSpace(msg.ChatID) + msg.ReplyToMessageID = strings.TrimSpace(msg.ReplyToMessageID) + if msg.Context.Channel == "" { + msg.Context.Channel = msg.Channel + } + if msg.Context.ChatID == "" { + msg.Context.ChatID = msg.ChatID + } + if msg.Context.ReplyToMessageID == "" { + msg.Context.ReplyToMessageID = msg.ReplyToMessageID + } + msg.Context = normalizeInboundContext(msg.Context) + if msg.Channel == "" { + msg.Channel = msg.Context.Channel + } + if msg.ChatID == "" { + msg.ChatID = msg.Context.ChatID + } + if msg.ReplyToMessageID == "" { + msg.ReplyToMessageID = msg.Context.ReplyToMessageID + } + if msg.Context.ReplyToMessageID == "" { + msg.Context.ReplyToMessageID = msg.ReplyToMessageID + } + msg.Scope = cloneOutboundScope(msg.Scope) + return msg +} + +// NormalizeOutboundMediaMessage ensures media outbound messages also carry a +// normalized context while keeping convenience mirrors in sync. +func NormalizeOutboundMediaMessage(msg OutboundMediaMessage) OutboundMediaMessage { + msg.Channel = strings.TrimSpace(msg.Channel) + msg.ChatID = strings.TrimSpace(msg.ChatID) + if msg.Context.Channel == "" { + msg.Context.Channel = msg.Channel + } + if msg.Context.ChatID == "" { + msg.Context.ChatID = msg.ChatID + } + msg.Context = normalizeInboundContext(msg.Context) + if msg.Channel == "" { + msg.Channel = msg.Context.Channel + } + if msg.ChatID == "" { + msg.ChatID = msg.Context.ChatID + } + msg.Scope = cloneOutboundScope(msg.Scope) + return msg +} + +func cloneOutboundScope(scope *OutboundScope) *OutboundScope { + if scope == nil { + return nil + } + cloned := *scope + if len(scope.Dimensions) > 0 { + cloned.Dimensions = append([]string(nil), scope.Dimensions...) + } + if len(scope.Values) > 0 { + cloned.Values = make(map[string]string, len(scope.Values)) + for key, value := range scope.Values { + cloned.Values[key] = value + } + } + return &cloned +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 27cf61b5f..953e69d9c 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -1,11 +1,5 @@ package bus -// Peer identifies the routing peer for a message (direct, group, channel, etc.) -type Peer struct { - Kind string `json:"kind"` // "direct" | "group" | "channel" | "" - ID string `json:"id"` -} - // SenderInfo provides structured sender identity information. type SenderInfo struct { Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ... @@ -15,26 +9,77 @@ type SenderInfo struct { DisplayName string `json:"display_name,omitempty"` // display name } +// InboundContext captures the normalized, platform-agnostic facts about an +// inbound message. This is the source of truth for routing and session +// allocation. +type InboundContext struct { + Channel string `json:"channel"` + Account string `json:"account,omitempty"` + + ChatID string `json:"chat_id"` + ChatType string `json:"chat_type,omitempty"` // direct / group / channel + TopicID string `json:"topic_id,omitempty"` + + SpaceID string `json:"space_id,omitempty"` + SpaceType string `json:"space_type,omitempty"` // guild / team / workspace / tenant + + SenderID string `json:"sender_id"` + MessageID string `json:"message_id,omitempty"` + + Mentioned bool `json:"mentioned,omitempty"` + + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ReplyToSenderID string `json:"reply_to_sender_id,omitempty"` + + ReplyHandles map[string]string `json:"reply_handles,omitempty"` + Raw map[string]string `json:"raw,omitempty"` +} + type InboundMessage struct { - Channel string `json:"channel"` - SenderID string `json:"sender_id"` - Sender SenderInfo `json:"sender"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - Media []string `json:"media,omitempty"` - Peer Peer `json:"peer"` // routing peer - MessageID string `json:"message_id,omitempty"` // platform message ID - MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope - SessionKey string `json:"session_key"` - Metadata map[string]string `json:"metadata,omitempty"` + Context InboundContext `json:"context"` + Sender SenderInfo `json:"sender"` + Content string `json:"content"` + Media []string `json:"media,omitempty"` + MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope + SessionKey string `json:"session_key"` + + // Convenience mirrors derived from Context for runtime consumers. + Channel string `json:"channel"` + SenderID string `json:"sender_id"` + ChatID string `json:"chat_id"` + MessageID string `json:"message_id,omitempty"` // platform message ID +} + +// OutboundScope captures the structured session scope associated with an +// outbound turn result without depending on the session package. +type OutboundScope struct { + Version int `json:"version,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Channel string `json:"channel,omitempty"` + Account string `json:"account,omitempty"` + Dimensions []string `json:"dimensions,omitempty"` + Values map[string]string `json:"values,omitempty"` +} + +// ContextUsage describes how much of the model's context window the current +// session consumes, and how far it is from triggering compression. +type ContextUsage struct { + UsedTokens int `json:"used_tokens"` + TotalTokens int `json:"total_tokens"` // model context window + CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression + UsedPercent int `json:"used_percent"` // 0-100 } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - ReplyToMessageID string `json:"reply_to_message_id,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Context InboundContext `json:"context"` + AgentID string `json:"agent_id,omitempty"` + SessionKey string `json:"session_key,omitempty"` + Scope *OutboundScope `json:"scope,omitempty"` + Content string `json:"content"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ContextUsage *ContextUsage `json:"context_usage,omitempty"` } // MediaPart describes a single media attachment to send. @@ -48,9 +93,13 @@ type MediaPart struct { // OutboundMediaMessage carries media attachments from Agent to channels via the bus. type OutboundMediaMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Parts []MediaPart `json:"parts"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Context InboundContext `json:"context"` + AgentID string `json:"agent_id,omitempty"` + SessionKey string `json:"session_key,omitempty"` + Scope *OutboundScope `json:"scope,omitempty"` + Parts []MediaPart `json:"parts"` } // AudioChunk represents a chunk of streaming voice data. diff --git a/pkg/channels/README.md b/pkg/channels/README.md index c4d12ef59..1cab1a4a6 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -327,8 +327,13 @@ import ( ) func init() { - channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewTelegramChannel(cfg, b) + channels.RegisterFactory(config.ChannelTelegram, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.TelegramSettings) + if !ok { return nil, channels.ErrSendFailed } + return NewTelegramChannel(bc, c, b) }) } ``` @@ -427,8 +432,13 @@ import ( ) func init() { - channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMatrixChannel(cfg, b) + channels.RegisterFactory(config.ChannelMatrix, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.MatrixSettings) + if !ok { return nil, channels.ErrSendFailed } + return NewMatrixChannel(bc, c, b) }) } ``` @@ -773,41 +783,59 @@ When the Agent finishes processing a message, Manager's `preSend` automatically: ### 3.5 Register Configuration and Gateway Integration -#### Add configuration in `pkg/config/config.go` +#### Add configuration entry + +Channels now use a unified map-based configuration (`map[string]*config.Channel`). +Each channel entry stores common fields (`enabled`, `type`, `allow_from`, etc.) at +the top level, with channel-specific settings in the `settings` sub-key: + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "type": "matrix", + "allow_from": ["@user:example.com"], + "settings": { + "home_server": "https://matrix.org", + "user_id": "@bot:example.com", + "access_token": "enc://..." + } + } + } +} +``` + +Secure fields (tokens, passwords, API keys) go into `.security.yml`: + +```yaml +channels: + matrix: + access_token: "your-matrix-access-token" +``` + +Channel types must be registered in `channelSettingsFactory` in +`pkg/config/config_channel.go`: ```go -type ChannelsConfig struct { +var channelSettingsFactory = map[string]any{ // ... existing channels - Matrix MatrixChannelConfig `json:"matrix"` -} - -type MatrixChannelConfig struct { - Enabled bool `json:"enabled"` - HomeServer string `json:"home_server"` - Token string `json:"token"` - AllowFrom []string `json:"allow_from"` - GroupTrigger GroupTriggerConfig `json:"group_trigger"` - Placeholder PlaceholderConfig `json:"placeholder"` - ReasoningChannelID string `json:"reasoning_channel_id"` + ChannelMatrix: (MatrixSettings{}), } ``` -#### Add entry in Manager.initChannels() +#### No Manager changes needed -```go -// In the initChannels() method of pkg/channels/manager.go -if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { - m.initChannel("matrix", "Matrix") -} -``` +The Manager uses `InitChannelList()` to validate types and decode settings, +then looks up factories by `bc.Type`. No per-channel entry needed in Manager — +just register the factory and the config entry. -> **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), branch in initChannels based on config: +> **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), +> register both types in `channelSettingsFactory` and branch on config: > ```go -> if cfg.UseNative { -> m.initChannel("whatsapp_native", "WhatsApp Native") -> } else { -> m.initChannel("whatsapp", "WhatsApp") -> } +> // In config_channel.go: +> ChannelWhatsApp: (WhatsAppSettings{}), +> ChannelWhatsAppNative: (WhatsAppSettings{}), > ``` #### Add blank import in Gateway @@ -947,10 +975,29 @@ channels.WithReasoningChannelID(id) // Set reasoning chain routing target **File**: `pkg/channels/registry.go` ```go -type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) +type ChannelFactory func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (Channel, error) -func RegisterFactory(name string, f ChannelFactory) // Called in sub-package init() -func getFactory(name string) (ChannelFactory, bool) // Called internally by Manager +func RegisterFactory(name string, f ChannelFactory) // Called in sub-package init() +func getFactory(name string) (ChannelFactory, bool) // Called internally by Manager +func GetRegisteredFactoryNames() []string // Returns all registered factory names +``` + +For convenience, `RegisterSafeFactory[S any]` provides automatic type-safe settings decoding: + +```go +// Instead of manual GetDecoded() + type assertion: +channels.RegisterFactory(config.ChannelTelegram, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.TelegramSettings) + if !ok { return nil, ErrSendFailed } + return NewTelegramChannel(bc, c, b) + }) + +// You can use RegisterSafeFactory (same safety, less boilerplate): +channels.RegisterSafeFactory(config.ChannelTelegram, NewTelegramChannel) ``` The factory registry is protected by `sync.RWMutex` and registrations occur during `init()` phase (completed at process startup). Manager looks up factories by name in `initChannel()` and calls them. diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index 3edc5cb6b..c44859c20 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -327,8 +327,13 @@ import ( ) func init() { - channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewTelegramChannel(cfg, b) + channels.RegisterFactory(config.ChannelTelegram, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.TelegramSettings) + if !ok { return nil, channels.ErrSendFailed } + return NewTelegramChannel(bc, c, b) }) } ``` @@ -427,8 +432,13 @@ import ( ) func init() { - channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMatrixChannel(cfg, b) + channels.RegisterFactory(config.ChannelMatrix, func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.MatrixSettings) + if !ok { return nil, channels.ErrSendFailed } + return NewMatrixChannel(bc, c, b) }) } ``` @@ -772,41 +782,58 @@ if c.owner != nil && c.placeholderRecorder != nil { ### 3.5 注册配置和 Gateway 接入 -#### 在 `pkg/config/config.go` 中添加配置 +#### 添加配置入口 + +Channels 现在使用统一的 map 类型配置(`map[string]*config.Channel`)。 +每个 channel 条目将通用字段(`enabled`、`type`、`allow_from` 等)放在顶层, +channel 特定的设置放在 `settings` 子键中: + +```json +{ + "channels": { + "matrix": { + "enabled": true, + "type": "matrix", + "allow_from": ["@user:example.com"], + "settings": { + "home_server": "https://matrix.org", + "user_id": "@bot:example.com", + "access_token": "enc://..." + } + } + } +} +``` + +安全字段(token、密码、API 密钥)放入 `.security.yml`: + +```yaml +channels: + matrix: + access_token: "your-matrix-access-token" +``` + +Channel 类型必须在 `pkg/config/config_channel.go` 的 `channelSettingsFactory` 中注册: ```go -type ChannelsConfig struct { +var channelSettingsFactory = map[string]any{ // ... 现有 channels - Matrix MatrixChannelConfig `json:"matrix"` -} - -type MatrixChannelConfig struct { - Enabled bool `json:"enabled"` - HomeServer string `json:"home_server"` - Token string `json:"token"` - AllowFrom []string `json:"allow_from"` - GroupTrigger GroupTriggerConfig `json:"group_trigger"` - Placeholder PlaceholderConfig `json:"placeholder"` - ReasoningChannelID string `json:"reasoning_channel_id"` + ChannelMatrix: (MatrixSettings{}), } ``` -#### 在 Manager.initChannels() 中添加入口 +#### 无需修改 Manager -```go -// pkg/channels/manager.go 的 initChannels() 方法中 -if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { - m.initChannel("matrix", "Matrix") -} -``` +Manager 使用 `InitChannelList()` 来验证类型和解码设置, +然后通过 `bc.Type` 查找工厂。不需要在 Manager 中添加每个 channel 的条目—— +只需注册工厂和配置条目即可。 -> **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native),需要在 initChannels 中根据配置分支: +> **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native), +> 在 `channelSettingsFactory` 中注册两种类型,并根据配置分支: > ```go -> if cfg.UseNative { -> m.initChannel("whatsapp_native", "WhatsApp Native") -> } else { -> m.initChannel("whatsapp", "WhatsApp") -> } +> // 在 config_channel.go 中: +> ChannelWhatsApp: (WhatsAppSettings{}), +> ChannelWhatsAppNative: (WhatsAppSettings{}), > ``` #### 在 Gateway 中添加 blank import @@ -946,10 +973,29 @@ channels.WithReasoningChannelID(id) // 设置思维链路由目标 channe **文件**:`pkg/channels/registry.go` ```go -type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) +type ChannelFactory func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (Channel, error) -func RegisterFactory(name string, f ChannelFactory) // 子包 init() 中调用 -func getFactory(name string) (ChannelFactory, bool) // Manager 内部调用 +func RegisterFactory(name string, f ChannelFactory) // 子包 init() 中调用 +func getFactory(name string) (ChannelFactory, bool) // Manager 内部调用 +func GetRegisteredFactoryNames() []string // 返回所有已注册的工厂名称 +``` + +为方便使用,`RegisterSafeFactory[S any]` 提供自动类型安全的设置解码: + +```go +// 不使用 RegisterSafeFactory(手动 GetDecoded() + 类型断言): +channels.RegisterFactory(config.ChannelTelegram, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { return nil, err } + c, ok := decoded.(*config.TelegramSettings) + if !ok { return nil, ErrSendFailed } + return NewTelegramChannel(bc, c, b) + }) + +// 使用 RegisterSafeFactory(同等安全,减少样板代码): +channels.RegisterSafeFactory(config.ChannelTelegram, NewTelegramChannel) ``` 工厂注册表使用 `sync.RWMutex` 保护,在 `init()` 阶段注册(进程启动时完成)。Manager 在 `initChannel()` 中通过名字查找工厂并调用它。 diff --git a/pkg/channels/base.go b/pkg/channels/base.go index bd4ced849..3585fb075 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -103,6 +103,16 @@ func NewBaseChannel( allowList []string, opts ...BaseChannelOption, ) *BaseChannel { + isEmpty := true + for _, s := range allowList { + if s != "" { + isEmpty = false + break + } + } + if isEmpty { + allowList = []string{} + } bc := &BaseChannel{ config: config, bus: bus, @@ -177,6 +187,12 @@ func (c *BaseChannel) Name() string { return c.name } +// SetName updates the channel name. Used by the manager after channel creation +// to ensure the name matches the config key (which may differ from the type). +func (c *BaseChannel) SetName(name string) { + c.name = name +} + func (c *BaseChannel) ReasoningChannelID() string { return c.reasoningChannelID } @@ -244,12 +260,11 @@ func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool { return false } -func (c *BaseChannel) HandleMessage( +func (c *BaseChannel) HandleMessageWithContext( ctx context.Context, - peer bus.Peer, - messageID, senderID, chatID, content string, + deliveryChatID, content string, media []string, - metadata map[string]string, + inboundCtx bus.InboundContext, senderOpts ...bus.SenderInfo, ) { // Use SenderInfo-based allow check when available, else fall back to string @@ -257,6 +272,7 @@ func (c *BaseChannel) HandleMessage( if len(senderOpts) > 0 { sender = senderOpts[0] } + senderID := strings.TrimSpace(inboundCtx.SenderID) if sender.CanonicalID != "" || sender.PlatformID != "" { if !c.IsAllowedSender(sender) { return @@ -273,20 +289,28 @@ func (c *BaseChannel) HandleMessage( resolvedSenderID = sender.CanonicalID } - scope := BuildMediaScope(c.name, chatID, messageID) + if resolvedSenderID == "" { + resolvedSenderID = senderID + } + + inboundCtx.Channel = c.name + if inboundCtx.ChatID == "" { + inboundCtx.ChatID = deliveryChatID + } + if inboundCtx.SenderID == "" { + inboundCtx.SenderID = resolvedSenderID + } + + scope := BuildMediaScope(c.name, deliveryChatID, inboundCtx.MessageID) msg := bus.InboundMessage{ - Channel: c.name, - SenderID: resolvedSenderID, + Context: inboundCtx, Sender: sender, - ChatID: chatID, Content: content, Media: media, - Peer: peer, - MessageID: messageID, MediaScope: scope, - Metadata: metadata, } + msg = bus.NormalizeInboundMessage(msg) // Auto-trigger typing indicator, message reaction, and placeholder before publishing. // Each capability is independent — all three may fire for the same message. @@ -297,14 +321,14 @@ func (c *BaseChannel) HandleMessage( if c.owner != nil && c.placeholderRecorder != nil { // Typing if tc, ok := c.owner.(TypingCapable); ok { - if stop, err := tc.StartTyping(ctx, chatID); err == nil { - c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) + if stop, err := tc.StartTyping(ctx, deliveryChatID); err == nil { + c.placeholderRecorder.RecordTypingStop(c.name, deliveryChatID, stop) } } // Reaction - if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { - if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { - c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) + if rc, ok := c.owner.(ReactionCapable); ok && msg.MessageID != "" { + if undo, err := rc.ReactToMessage(ctx, deliveryChatID, msg.MessageID); err == nil { + c.placeholderRecorder.RecordReactionUndo(c.name, deliveryChatID, undo) } } // Placeholder — independent pipeline. @@ -313,8 +337,8 @@ func (c *BaseChannel) HandleMessage( // "Thinking…" only once the voice has been processed. if !audioAnnotationRe.MatchString(content) { if pc, ok := c.owner.(PlaceholderCapable); ok { - if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { - c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) + if phID, err := pc.SendPlaceholder(ctx, deliveryChatID); err == nil && phID != "" { + c.placeholderRecorder.RecordPlaceholder(c.name, deliveryChatID, phID) } } } @@ -323,12 +347,24 @@ func (c *BaseChannel) HandleMessage( if err := c.bus.PublishInbound(ctx, msg); err != nil { logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{ "channel": c.name, - "chat_id": chatID, + "chat_id": deliveryChatID, "error": err.Error(), }) } } +// HandleInboundContext publishes a normalized inbound message using only the +// structured context. +func (c *BaseChannel) HandleInboundContext( + ctx context.Context, + deliveryChatID, content string, + media []string, + inboundCtx bus.InboundContext, + senderOpts ...bus.SenderInfo, +) { + c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...) +} + func (c *BaseChannel) SetRunning(running bool) { c.running.Store(running) } diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go index 6132b8bf9..04500f775 100644 --- a/pkg/channels/base_test.go +++ b/pkg/channels/base_test.go @@ -1,6 +1,7 @@ package channels import ( + "context" "testing" "github.com/sipeed/picoclaw/pkg/bus" @@ -263,3 +264,58 @@ func TestIsAllowedSender(t *testing.T) { }) } } + +func TestHandleInboundContext_PublishesNormalizedContext(t *testing.T) { + tests := []struct { + name string + inbound bus.InboundContext + wantChat string + wantSender string + }{ + { + name: "direct uses sender as peer", + inbound: bus.InboundContext{ + Channel: "test", + ChatID: "chat-1", + ChatType: "direct", + SenderID: "user-1", + MessageID: "msg-1", + }, + wantChat: "chat-1", + wantSender: "user-1", + }, + { + name: "group uses chat as peer", + inbound: bus.InboundContext{ + Channel: "test", + ChatID: "group-1", + ChatType: "group", + SenderID: "user-2", + MessageID: "msg-2", + }, + wantChat: "group-1", + wantSender: "user-2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + ch := NewBaseChannel("test", nil, msgBus, nil) + ch.HandleInboundContext(context.Background(), tt.inbound.ChatID, "hello", nil, tt.inbound) + + msg := <-msgBus.InboundChan() + if msg.ChatID != tt.wantChat { + t.Fatalf("ChatID = %q, want %q", msg.ChatID, tt.wantChat) + } + if msg.SenderID != tt.wantSender { + t.Fatalf("SenderID = %q, want %q", msg.SenderID, tt.wantSender) + } + if msg.Context.ChatType != tt.inbound.ChatType { + t.Fatalf("ChatType = %q, want %q", msg.Context.ChatType, tt.inbound.ChatType) + } + }) + } +} diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 04ccec8a2..9cd461bc8 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -25,7 +25,7 @@ import ( // It uses WebSocket for receiving messages via stream mode and API for sending type DingTalkChannel struct { *channels.BaseChannel - config config.DingTalkConfig + config *config.DingTalkSettings clientID string clientSecret string streamClient *client.StreamClient @@ -36,7 +36,11 @@ type DingTalkChannel struct { } // NewDingTalkChannel creates a new DingTalk channel instance -func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) { +func NewDingTalkChannel( + bc *config.Channel, + cfg *config.DingTalkSettings, + messageBus *bus.MessageBus, +) (*DingTalkChannel, error) { if cfg.ClientID == "" || cfg.ClientSecret.String() == "" { return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } @@ -44,10 +48,10 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( // Set the logger for the Stream SDK dinglog.SetLogger(logger.NewLogger("dingtalk")) - base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, + base := channels.NewBaseChannel("dingtalk", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(20000), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &DingTalkChannel{ @@ -181,16 +185,15 @@ func (c *DingTalkChannel) onChatBotMessageReceived( "session_webhook": data.SessionWebhook, } - var peer bus.Peer + var ( + chatType string + isMentioned bool + ) if data.ConversationType == "1" { - peerID := senderID - if peerID == "" { - peerID = chatID - } - peer = bus.Peer{Kind: "direct", ID: peerID} + chatType = "direct" } else { - peer = bus.Peer{Kind: "group", ID: data.ConversationId} - isMentioned := data.IsInAtList + chatType = "group" + isMentioned = data.IsInAtList if isMentioned { content = stripLeadingAtMentions(content) } @@ -228,8 +231,21 @@ func (c *DingTalkChannel) onChatBotMessageReceived( return nil, nil } - // Handle the message through the base channel - c.HandleMessage(ctx, peer, "", resolvedSenderID, chatID, content, nil, metadata, sender) + inboundCtx := bus.InboundContext{ + Channel: "dingtalk", + ChatID: chatID, + ChatType: chatType, + SenderID: resolvedSenderID, + Mentioned: isMentioned, + Raw: metadata, + } + if data.SessionWebhook != "" { + inboundCtx.ReplyHandles = map[string]string{ + "session_webhook": data.SessionWebhook, + } + } + + c.HandleInboundContext(ctx, chatID, content, nil, inboundCtx, sender) // Return nil to indicate we've handled the message asynchronously // The response will be sent through the message bus diff --git a/pkg/channels/dingtalk/dingtalk_test.go b/pkg/channels/dingtalk/dingtalk_test.go index 437616456..6dfc44730 100644 --- a/pkg/channels/dingtalk/dingtalk_test.go +++ b/pkg/channels/dingtalk/dingtalk_test.go @@ -11,7 +11,11 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) -func newTestDingTalkChannel(t *testing.T, cfg config.DingTalkConfig) (*DingTalkChannel, *bus.MessageBus) { +func newTestDingTalkChannel( + t *testing.T, + cfg config.DingTalkSettings, + bc *config.Channel, +) (*DingTalkChannel, *bus.MessageBus) { t.Helper() if cfg.ClientID == "" { @@ -22,7 +26,10 @@ func newTestDingTalkChannel(t *testing.T, cfg config.DingTalkConfig) (*DingTalkC } msgBus := bus.NewMessageBus() - ch, err := NewDingTalkChannel(cfg, msgBus) + if bc == nil { + bc = &config.Channel{Type: config.ChannelDingTalk, Enabled: true} + } + ch, err := NewDingTalkChannel(bc, &cfg, msgBus) if err != nil { t.Fatalf("new channel: %v", err) } @@ -41,9 +48,12 @@ func mustReceiveInbound(t *testing.T, msgBus *bus.MessageBus) bus.InboundMessage } func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention(t *testing.T) { - ch, msgBus := newTestDingTalkChannel(t, config.DingTalkConfig{ + bc := &config.Channel{ + Type: config.ChannelDingTalk, + Enabled: true, GroupTrigger: config.GroupTriggerConfig{MentionOnly: true}, - }) + } + ch, msgBus := newTestDingTalkChannel(t, config.DingTalkSettings{}, bc) _, err := ch.onChatBotMessageReceived(context.Background(), &chatbot.BotCallbackDataModel{ Text: chatbot.BotCallbackDataTextModel{Content: " @bot /help "}, @@ -65,8 +75,8 @@ func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention if inbound.ChatID != "group-abc" { t.Fatalf("chat_id=%q", inbound.ChatID) } - if inbound.Peer.Kind != "group" || inbound.Peer.ID != "group-abc" { - t.Fatalf("peer=%+v", inbound.Peer) + if inbound.Context.ChatType != "group" { + t.Fatalf("chat_type=%q", inbound.Context.ChatType) } if inbound.Content != "/help" { t.Fatalf("content=%q", inbound.Content) @@ -74,7 +84,7 @@ func TestOnChatBotMessageReceived_GroupMentionOnlyUsesIsInAtListAndStripsMention } func TestOnChatBotMessageReceived_DirectFallbackSenderIDUsesConversationID(t *testing.T) { - ch, msgBus := newTestDingTalkChannel(t, config.DingTalkConfig{}) + ch, msgBus := newTestDingTalkChannel(t, config.DingTalkSettings{}, nil) _, err := ch.onChatBotMessageReceived(context.Background(), &chatbot.BotCallbackDataModel{ Text: chatbot.BotCallbackDataTextModel{Content: "ping"}, @@ -93,12 +103,15 @@ func TestOnChatBotMessageReceived_DirectFallbackSenderIDUsesConversationID(t *te if inbound.ChatID != "conv-direct-42" { t.Fatalf("chat_id=%q", inbound.ChatID) } - if inbound.Peer.Kind != "direct" || inbound.Peer.ID != "openid-user-42" { - t.Fatalf("peer=%+v", inbound.Peer) + if inbound.Context.ChatType != "direct" { + t.Fatalf("chat_type=%q", inbound.Context.ChatType) } - if inbound.SenderID != "dingtalk:openid-user-42" { + if inbound.SenderID != "openid-user-42" { t.Fatalf("sender_id=%q", inbound.SenderID) } + if inbound.Sender.CanonicalID != "dingtalk:openid-user-42" { + t.Fatalf("sender canonical_id=%q", inbound.Sender.CanonicalID) + } if _, ok := ch.sessionWebhooks.Load("conv-direct-42"); !ok { t.Fatal("expected session webhook keyed by conversation_id") diff --git a/pkg/channels/dingtalk/init.go b/pkg/channels/dingtalk/init.go index 5f49bce8c..ab92c75b4 100644 --- a/pkg/channels/dingtalk/init.go +++ b/pkg/channels/dingtalk/init.go @@ -7,7 +7,26 @@ import ( ) func init() { - channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewDingTalkChannel(cfg.Channels.DingTalk, b) - }) + channels.RegisterFactory( + config.ChannelDingTalk, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.DingTalkSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewDingTalkChannel(bc, c, b) + if err != nil { + return nil, err + } + if channelName != config.ChannelDingTalk { + ch.SetName(channelName) + } + return ch, nil + }, + ) } diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 01b1b4053..514b9b3b1 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -38,15 +38,19 @@ var ( type DiscordChannel struct { *channels.BaseChannel + bc *config.Channel session *discordgo.Session - config config.DiscordConfig + config *config.DiscordSettings ctx context.Context cancel context.CancelFunc typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal - botUserID string // stored for mention checking + progress *channels.ToolFeedbackAnimator + botUserID string // stored for mention checking bus *bus.MessageBus tts tts.TTSProvider + playTTSFn func(context.Context, *discordgo.VoiceConnection, string, uint64) + ttsVoiceFn func(string) (*discordgo.VoiceConnection, bool) voiceMu sync.RWMutex voiceSSRC map[string]map[uint32]string // guildID -> ssrc -> userID @@ -56,7 +60,11 @@ type DiscordChannel struct { ttsPlayID uint64 } -func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { +func NewDiscordChannel( + bc *config.Channel, + cfg *config.DiscordSettings, + bus *bus.MessageBus, +) (*DiscordChannel, error) { discordgo.Logger = logger.NewLogger("discord"). WithLevels(map[int]logger.LogLevel{ discordgo.LogError: logger.ERROR, @@ -73,21 +81,26 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC if err := applyDiscordProxy(session, cfg.Proxy); err != nil { return nil, err } - base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, + base := channels.NewBaseChannel("discord", cfg, bus, bc.AllowFrom, channels.WithMaxMessageLength(2000), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &DiscordChannel{ + ch := &DiscordChannel{ BaseChannel: base, + bc: bc, session: session, config: cfg, ctx: context.Background(), typingStop: make(map[string]chan struct{}), bus: bus, voiceSSRC: make(map[string]map[uint32]string), - }, nil + } + ch.playTTSFn = ch.playTTS + ch.ttsVoiceFn = ch.voiceConnectionForTTS + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *DiscordChannel) Start(ctx context.Context) error { @@ -136,6 +149,9 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) @@ -158,32 +174,88 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]s return nil, nil } - if c.tts != nil { - if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" { - if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil { - // Cancel any previous TTS playback - c.ttsMu.Lock() - if c.cancelTTS != nil { - c.cancelTTS() - } - ttsCtx, ttsCancel := context.WithCancel(c.ctx) - c.ttsPlayID++ - playID := c.ttsPlayID - c.cancelTTS = ttsCancel - c.ttsMu.Unlock() - - go c.playTTS(ttsCtx, vc, msg.Content, playID) + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, channelID, msg.Content); handled { + if err != nil { + return nil, err } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) + c.maybeStartTTS(channelID, msg.Content, isToolFeedback) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil } } - msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + content := msg.Content + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + msgID, err := c.sendChunk(ctx, channelID, content, msg.ReplyToMessageID) if err != nil { return nil, err } + if isToolFeedback { + c.RecordToolFeedbackMessage(channelID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } return []string{msgID}, nil } +func (c *DiscordChannel) maybeStartTTS(channelID, content string, isToolFeedback bool) { + if c.tts == nil || isToolFeedback { + return + } + + voiceFn := c.ttsVoiceFn + if voiceFn == nil { + voiceFn = c.voiceConnectionForTTS + } + vc, ok := voiceFn(channelID) + if !ok || vc == nil { + return + } + + // Cancel any previous TTS playback. + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + } + ttsCtx, ttsCancel := context.WithCancel(c.ctx) + c.ttsPlayID++ + playID := c.ttsPlayID + c.cancelTTS = ttsCancel + playFn := c.playTTSFn + c.ttsMu.Unlock() + + if playFn == nil { + playFn = c.playTTS + } + go playFn(ttsCtx, vc, content, playID) +} + +func (c *DiscordChannel) voiceConnectionForTTS(channelID string) (*discordgo.VoiceConnection, bool) { + if c.session == nil || c.session.State == nil { + return nil, false + } + + ch, err := c.session.State.Channel(channelID) + if err != nil || ch == nil || ch.GuildID == "" { + return nil, false + } + + vc, ok := c.session.VoiceConnections[ch.GuildID] + if !ok || vc == nil { + return nil, false + } + return vc, true +} + // SendMedia implements the channels.MediaSender interface. func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { @@ -194,6 +266,7 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if channelID == "" { return nil, fmt.Errorf("channel ID is empty") } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) store := c.GetMediaStore() if store == nil { @@ -275,6 +348,9 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if r.err != nil { return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary) } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } return []string{r.id}, nil case <-sendCtx.Done(): // Close all file readers @@ -289,19 +365,24 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes // EditMessage implements channels.MessageEditor. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - _, err := c.session.ChannelMessageEdit(chatID, messageID, content) + _, err := c.session.ChannelMessageEdit(chatID, messageID, content, discordgo.WithContext(ctx)) return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *DiscordChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + return c.session.ChannelMessageDelete(chatID, messageID, discordgo.WithContext(ctx)) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message that will later be edited to the actual // response via EditMessage (channels.MessageEditor). func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - if !c.config.Placeholder.Enabled { + if !c.bc.Placeholder.Enabled { return "", nil } - text := c.config.Placeholder.GetRandomText() + text := c.bc.Placeholder.GetRandomText() msg, err := c.session.ChannelMessageSend(chatID, text) if err != nil { @@ -311,6 +392,81 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *DiscordChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *DiscordChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *DiscordChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *DiscordChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *DiscordChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *DiscordChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *DiscordChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *DiscordChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) @@ -402,8 +558,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag // In guild (group) channels, apply unified group trigger filtering // DMs (GuildID is empty) always get a response + isMentioned := false if m.GuildID != "" { - isMentioned := false for _, mention := range m.Mentions { if mention.ID == c.botUserID { isMentioned = true @@ -500,14 +656,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag }) peerKind := "channel" - peerID := m.ChannelID if m.GuildID == "" { peerKind = "direct" - peerID = senderID } - peer := bus.Peer{Kind: peerKind, ID: peerID} - metadata := map[string]string{ "user_id": senderID, "username": m.Author.Username, @@ -516,8 +668,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + ChatID: m.ChannelID, + ChatType: peerKind, + SenderID: senderID, + MessageID: m.ID, + Mentioned: isMentioned, + Raw: metadata, + } + if m.GuildID != "" { + inboundCtx.SpaceID = m.GuildID + inboundCtx.SpaceType = "guild" + } + if m.MessageReference != nil { + inboundCtx.ReplyToMessageID = m.MessageReference.MessageID + } - c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender) + c.HandleInboundContext(c.ctx, m.ChannelID, content, mediaPaths, inboundCtx, sender) } // startTyping starts a continuous typing indicator loop for the given chatID. diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go index 0cd5328f4..d42b0bc52 100644 --- a/pkg/channels/discord/discord_test.go +++ b/pkg/channels/discord/discord_test.go @@ -1,13 +1,37 @@ package discord import ( + "context" + "io" "net/http" + "net/http/httptest" "net/url" + "reflect" + "sync" "testing" + "time" "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" ) +type stubTTSProvider struct{} + +func (stubTTSProvider) Name() string { return "stub-tts" } + +func (stubTTSProvider) Synthesize(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(&noopReader{}), nil +} + +type noopReader struct{} + +func (*noopReader) Read(p []byte) (int, error) { + return 0, io.EOF +} + func TestApplyDiscordProxy_CustomProxy(t *testing.T) { session, err := discordgo.New("Bot test-token") if err != nil { @@ -89,3 +113,224 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } + +func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback message to be cleared") + } + + mu.Lock() + defer mu.Unlock() + wantRequests := []string{ + "PATCH /channels/chat-1/messages/prog-1", + } + if !reflect.DeepEqual(requests, wantRequests) { + t.Fatalf("requests = %v, want %v", requests, wantRequests) + } +} + +func TestEditMessage_UsesContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + return + case <-time.After(time.Second): + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"msg-1"}`) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + err = ch.EditMessage(ctx, "chat-1", "msg-1", "still running") + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected EditMessage() to fail when context times out") + } + if elapsed >= 500*time.Millisecond { + t.Fatalf("EditMessage() ignored context timeout, elapsed=%v", elapsed) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &DiscordChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if got, want := msgIDs, []string{"msg-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want %v", got, want) + } +} + +func TestSend_NonToolFeedbackFinalizerStillStartsTTS(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ttsStarted := make(chan string, 1) + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + tts: tts.TTSProvider(stubTTSProvider{}), + } + ch.ttsVoiceFn = func(string) (*discordgo.VoiceConnection, bool) { + return &discordgo.VoiceConnection{}, true + } + ch.playTTSFn = func(_ context.Context, _ *discordgo.VoiceConnection, text string, _ uint64) { + ttsStarted <- text + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + + select { + case got := <-ttsStarted: + if got != "final reply" { + t.Fatalf("TTS content = %q, want final reply", got) + } + case <-time.After(2 * time.Second): + t.Fatal("expected TTS to start for finalized tracked tool feedback reply") + } +} diff --git a/pkg/channels/discord/init.go b/pkg/channels/discord/init.go index 8381dc9e9..c8dbe1081 100644 --- a/pkg/channels/discord/init.go +++ b/pkg/channels/discord/init.go @@ -8,11 +8,23 @@ import ( ) func init() { - channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - ch, err := NewDiscordChannel(cfg.Channels.Discord, b) - if err == nil { - ch.tts = tts.DetectTTS(cfg) - } - return ch, err - }) + channels.RegisterFactory( + config.ChannelDiscord, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.DiscordSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewDiscordChannel(bc, c, b) + if err == nil { + ch.tts = tts.DetectTTS(cfg) + } + return ch, err + }, + ) } diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index f3fe2a6cb..04c7acc15 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -19,7 +19,7 @@ type FeishuChannel struct { var errUnsupported = errors.New("feishu channel is not supported on 32-bit architectures") // NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported -func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { +func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.MessageBus) (*FeishuChannel, error) { return nil, errors.New( "feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config", ) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index c12827729..8f3ae39d9 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -38,7 +38,8 @@ const errCodeTenantTokenInvalid = 99991663 type FeishuChannel struct { *channels.BaseChannel - config config.FeishuConfig + bc *config.Channel + config *config.FeishuSettings client *lark.Client wsClient *larkws.Client tokenCache *tokenCache // custom cache that supports invalidation @@ -48,6 +49,9 @@ type FeishuChannel struct { mu sync.Mutex cancel context.CancelFunc + + progress *channels.ToolFeedbackAnimator + deleteMessageFn func(context.Context, string, string) error } type cachedMessage struct { @@ -55,10 +59,10 @@ type cachedMessage struct { expiry time.Time } -func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { - base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom, - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), +func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.MessageBus) (*FeishuChannel, error) { + base := channels.NewBaseChannel("feishu", cfg, bus, bc.AllowFrom, + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) tc := newTokenCache() @@ -68,10 +72,13 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan } ch := &FeishuChannel{ BaseChannel: base, + bc: bc, config: cfg, tokenCache: tc, client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...), } + ch.deleteMessageFn = ch.deleteMessageAPI + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) ch.SetOwner(ch) return ch, nil } @@ -130,6 +137,9 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } c.wsClient = nil c.mu.Unlock() + if c.progress != nil { + c.progress.StopAll() + } c.SetRunning(false) logger.InfoC("feishu", "Feishu channel stopped") @@ -147,17 +157,55 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { + if err != nil { + // Feishu can fall back to plain text for a previous progress + // message, and those messages cannot be patched through the card + // edit API. Drop the stale tracker and recreate the progress + // message so later tool feedback is not blocked. + c.resetTrackedToolFeedbackAfterEditFailure(ctx, msg.ChatID) + } else { + return []string{msgID}, nil + } + } + } else { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + // Build interactive card with markdown content - cardContent, err := buildMarkdownCard(msg.Content) + sendContent := msg.Content + if isToolFeedback { + sendContent = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + cardContent, err := buildMarkdownCard(sendContent) if err != nil { // If card build fails, fall back to plain text - return nil, c.sendText(ctx, msg.ChatID, msg.Content) + msgID, sendErr := c.sendText(ctx, msg.ChatID, sendContent) + if sendErr != nil { + return nil, sendErr + } + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // First attempt: try sending as interactive card - err = c.sendCard(ctx, msg.ChatID, cardContent) + msgID, err := c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { - return nil, nil + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // Check if error is due to card table limit (error code 11310) @@ -172,9 +220,14 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st }) // Second attempt: fall back to plain text message - textErr := c.sendText(ctx, msg.ChatID, msg.Content) + msgID, textErr := c.sendText(ctx, msg.ChatID, sendContent) if textErr == nil { - return nil, nil + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // If text also fails, return the text error return nil, textErr @@ -208,17 +261,42 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont return nil } +// DeleteMessage implements channels.MessageDeleter. +func (c *FeishuChannel) DeleteMessage(ctx context.Context, chatID, messageID string) error { + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.deleteMessageAPI + } + return deleteFn(ctx, chatID, messageID) +} + +func (c *FeishuChannel) deleteMessageAPI(ctx context.Context, chatID, messageID string) error { + req := larkim.NewDeleteMessageReqBuilder(). + MessageId(messageID). + Build() + + resp, err := c.client.Im.V1.Message.Delete(ctx, req) + if err != nil { + return fmt.Errorf("feishu delete: %w", err) + } + if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) + return fmt.Errorf("feishu delete api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + return nil +} + // SendPlaceholder implements channels.PlaceholderCapable. // Sends an interactive card with placeholder text and returns its message ID. func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { - if !c.config.Placeholder.Enabled { + if !c.bc.Placeholder.Enabled { logger.DebugCF("feishu", "Placeholder disabled, skipping", map[string]any{ "chat_id": chatID, }) return "", nil } - text := c.config.Placeholder.GetRandomText() + text := c.bc.Placeholder.GetRandomText() cardContent, err := buildMarkdownCard(text) if err != nil { @@ -249,6 +327,93 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", nil } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *FeishuChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *FeishuChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *FeishuChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *FeishuChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *FeishuChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *FeishuChannel) resetTrackedToolFeedbackAfterEditFailure(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *FeishuChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.deleteMessageAPI + } + _ = deleteFn(ctx, chatID, messageID) +} + +func (c *FeishuChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *FeishuChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + // ReactToMessage implements channels.ReactionCapable. // 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) { @@ -321,6 +486,7 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) if msg.ChatID == "" { return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) @@ -337,6 +503,10 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return nil, nil } @@ -443,17 +613,23 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. // Append media tags to content (like Telegram does) content = appendMediaTags(content, messageType, mediaRefs) + if content == "" { + content = "[empty message]" + } chatType := stringValue(message.ChatType) metadata := buildInboundMetadata(message, sender) - var peer bus.Peer + var ( + inboundChatType string + isMentioned bool + ) if chatType == "p2p" { - peer = bus.Peer{Kind: "direct", ID: senderID} + inboundChatType = "direct" } else { - peer = bus.Peer{Kind: "group", ID: chatID} + inboundChatType = "group" // Check if bot was mentioned - isMentioned := c.isBotMentioned(message) + isMentioned = c.isBotMentioned(message) // Strip mention placeholders from content before group trigger check if len(message.Mentions) > 0 { @@ -488,7 +664,21 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. "thread_id": stringValue(message.ThreadId), }) - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, senderInfo) + inboundCtx := bus.InboundContext{ + Channel: "feishu", + ChatID: chatID, + ChatType: inboundChatType, + SenderID: senderID, + MessageID: messageID, + Mentioned: isMentioned, + Raw: metadata, + } + if sender != nil && sender.TenantKey != nil && *sender.TenantKey != "" { + inboundCtx.SpaceType = "tenant" + inboundCtx.SpaceID = *sender.TenantKey + } + + c.HandleInboundContext(ctx, chatID, content, mediaRefs, inboundCtx, senderInfo) return nil } @@ -779,7 +969,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { } // sendCard sends an interactive card message to a chat. -func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { +func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) { req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). @@ -791,23 +981,26 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send card: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu send card: %w", channels.ErrTemporary) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) - return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return "", fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu card message sent", map[string]any{ "chat_id": chatID, }) - return nil + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil } // sendText sends a plain text message to a chat (fallback when card fails). -func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error { +func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (string, error) { content, _ := json.Marshal(map[string]string{"text": text}) req := larkim.NewCreateMessageReqBuilder(). @@ -821,18 +1014,21 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send text: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu send text: %w", channels.ErrTemporary) } if !resp.Success() { - return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return "", fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{ "chat_id": chatID, }) - return nil + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil } // sendImage uploads an image and sends it as a message. diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 9010abf69..48fdf0f74 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -3,9 +3,13 @@ package feishu import ( + "context" + "errors" "testing" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/sipeed/picoclaw/pkg/channels" ) func TestExtractContent(t *testing.T) { @@ -279,3 +283,110 @@ func TestExtractFeishuSenderID(t *testing.T) { }) } } + +func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after successful edit") + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_EditFailureKeepsTrackedMessage(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(context.Context, string, string, string) error { + return errors.New("edit failed") + }, + ) + if handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to report unhandled on edit failure") + } + if len(msgIDs) != 0 { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if msgID, ok := ch.currentToolFeedbackMessage("chat-1"); !ok || msgID != "msg-1" { + t.Fatalf("expected tracked tool feedback to remain after failed edit, got (%q, %v)", msgID, ok) + } +} + +func TestResetTrackedToolFeedbackAfterEditFailure_DismissesTrackedMessage(t *testing.T) { + var ( + deletedChatID string + deletedMsgID string + ) + + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + deleteMessageFn: func(_ context.Context, chatID, messageID string) error { + deletedChatID = chatID + deletedMsgID = messageID + return nil + }, + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + ch.resetTrackedToolFeedbackAfterEditFailure(context.Background(), "chat-1") + + if deletedChatID != "chat-1" || deletedMsgID != "msg-1" { + t.Fatalf("unexpected delete target: chat=%q msg=%q", deletedChatID, deletedMsgID) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after edit failure reset") + } +} diff --git a/pkg/channels/feishu/init.go b/pkg/channels/feishu/init.go index 7e5a62dae..c4982bef1 100644 --- a/pkg/channels/feishu/init.go +++ b/pkg/channels/feishu/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewFeishuChannel(cfg.Channels.Feishu, b) - }) + channels.RegisterFactory( + config.ChannelFeishu, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.FeishuSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewFeishuChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/irc/handler.go b/pkg/channels/irc/handler.go index b92359da4..73df9c43c 100644 --- a/pkg/channels/irc/handler.go +++ b/pkg/channels/irc/handler.go @@ -51,14 +51,11 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.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{ @@ -73,9 +70,11 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) { return } + isMentioned := false + // For channel messages, check group trigger (mention detection) if !isDM { - isMentioned := isBotMentioned(content, currentNick) + isMentioned = isBotMentioned(content, currentNick) if isMentioned { content = stripBotMention(content, currentNick) } @@ -100,7 +99,21 @@ func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) { metadata["channel"] = target } - c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender) + inboundCtx := bus.InboundContext{ + Channel: "irc", + ChatID: chatID, + SenderID: nick, + MessageID: messageID, + Mentioned: isMentioned, + Raw: metadata, + } + if isDM { + inboundCtx.ChatType = "direct" + } else { + inboundCtx.ChatType = "group" + } + + c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender) } // nickMentionedAt returns the byte index where botNick is mentioned in content diff --git a/pkg/channels/irc/init.go b/pkg/channels/irc/init.go index 221d41b62..3f206cbc7 100644 --- a/pkg/channels/irc/init.go +++ b/pkg/channels/irc/init.go @@ -7,10 +7,29 @@ import ( ) 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) - }) + channels.RegisterFactory( + config.ChannelIRC, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + if bc == nil || !bc.Enabled { + return nil, nil + } + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.IRCSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewIRCChannel(bc, c, b) + if err != nil { + return nil, err + } + if channelName != config.ChannelIRC { + ch.SetName(channelName) + } + return ch, nil + }, + ) } diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go index e8a70923f..fa60e9b6d 100644 --- a/pkg/channels/irc/irc.go +++ b/pkg/channels/irc/irc.go @@ -18,14 +18,15 @@ import ( // IRCChannel implements the Channel interface for IRC servers. type IRCChannel struct { *channels.BaseChannel - config config.IRCConfig + bc *config.Channel + config *config.IRCSettings 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) { +func NewIRCChannel(bc *config.Channel, cfg *config.IRCSettings, messageBus *bus.MessageBus) (*IRCChannel, error) { if cfg.Server == "" { return nil, fmt.Errorf("irc server is required") } @@ -33,14 +34,15 @@ func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChanne return nil, fmt.Errorf("irc nick is required") } - base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom, + base := channels.NewBaseChannel("irc", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(400), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &IRCChannel{ BaseChannel: base, + bc: bc, config: cfg, }, nil } @@ -166,7 +168,7 @@ func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]strin func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { noop := func() {} - if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil { + if !c.bc.Typing.Enabled || !c.IsRunning() || c.conn == nil { return noop, nil } diff --git a/pkg/channels/irc/irc_test.go b/pkg/channels/irc/irc_test.go index 168252a4d..e459e71fc 100644 --- a/pkg/channels/irc/irc_test.go +++ b/pkg/channels/irc/irc_test.go @@ -11,28 +11,31 @@ 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) + bc := &config.Channel{Type: config.ChannelIRC, Enabled: true} + cfg := &config.IRCSettings{Nick: "bot"} + _, err := NewIRCChannel(bc, 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) + bc := &config.Channel{Type: config.ChannelIRC, Enabled: true} + cfg := &config.IRCSettings{Server: "irc.example.com:6667"} + _, err := NewIRCChannel(bc, 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{ + bc := &config.Channel{Type: config.ChannelIRC, Enabled: true} + cfg := &config.IRCSettings{ Server: "irc.example.com:6667", Nick: "testbot", Channels: []string{"#test"}, } - ch, err := NewIRCChannel(cfg, msgBus) + ch, err := NewIRCChannel(bc, cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/pkg/channels/line/init.go b/pkg/channels/line/init.go index 9265575cc..6d829cd40 100644 --- a/pkg/channels/line/init.go +++ b/pkg/channels/line/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewLINEChannel(cfg.Channels.LINE, b) - }) + channels.RegisterFactory( + config.ChannelLINE, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.LINESettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewLINEChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 230983935..760506a31 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -48,7 +48,7 @@ type replyTokenEntry struct { // and REST API for sending messages. type LINEChannel struct { *channels.BaseChannel - config config.LINEConfig + config *config.LINESettings infoClient *http.Client // for bot info lookups (short timeout) apiClient *http.Client // for messaging API calls botUserID string // Bot's user ID @@ -61,15 +61,19 @@ type LINEChannel struct { } // NewLINEChannel creates a new LINE channel instance. -func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) { +func NewLINEChannel( + bc *config.Channel, + cfg *config.LINESettings, + messageBus *bus.MessageBus, +) (*LINEChannel, error) { if cfg.ChannelSecret.String() == "" || cfg.ChannelAccessToken.String() == "" { return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } - base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, + base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(5000), - channels.WithGroupTrigger(cfg.GroupTrigger), - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + channels.WithGroupTrigger(bc.GroupTrigger), + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &LINEChannel{ @@ -350,8 +354,9 @@ func (c *LINEChannel) processEvent(event lineEvent) { } // In group chats, apply unified group trigger filtering + isMentioned := false if isGroup { - isMentioned := c.isBotMentioned(msg) + isMentioned = c.isBotMentioned(msg) respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{ @@ -367,13 +372,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { "source_type": event.Source.Type, } - var peer bus.Peer - if isGroup { - peer = bus.Peer{Kind: "group", ID: chatID} - } else { - peer = bus.Peer{Kind: "direct", ID: senderID} - } - logger.DebugCF("line", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, @@ -392,7 +390,25 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) + inboundCtx := bus.InboundContext{ + Channel: c.Name(), + ChatID: chatID, + ChatType: map[bool]string{true: "group", false: "direct"}[isGroup], + SenderID: senderID, + MessageID: msg.ID, + Mentioned: isMentioned, + Raw: metadata, + } + if event.ReplyToken != "" { + inboundCtx.ReplyHandles = map[string]string{ + "reply_token": event.ReplyToken, + } + if msg.QuoteToken != "" { + inboundCtx.ReplyHandles["quote_token"] = msg.QuoteToken + } + } + + c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender) } // isBotMentioned checks if the bot is mentioned in the message. diff --git a/pkg/channels/line/line_test.go b/pkg/channels/line/line_test.go index 00770f1c7..c5f4e9be2 100644 --- a/pkg/channels/line/line_test.go +++ b/pkg/channels/line/line_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestWebhookRejectsOversizedBody(t *testing.T) { @@ -66,7 +68,9 @@ func TestWebhookRejectsNonPostMethod(t *testing.T) { } func TestWebhookRejectsInvalidSignature(t *testing.T) { - ch := &LINEChannel{} + ch := &LINEChannel{ + config: &config.LINESettings{}, + } body := `{"events":[]}` req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) diff --git a/pkg/channels/maixcam/init.go b/pkg/channels/maixcam/init.go index 5a269b22b..f2f7b910b 100644 --- a/pkg/channels/maixcam/init.go +++ b/pkg/channels/maixcam/init.go @@ -7,7 +7,19 @@ import ( ) func init() { - channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { - return NewMaixCamChannel(cfg.Channels.MaixCam, b) - }) + channels.RegisterFactory( + config.ChannelMaixCam, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.MaixCamSettings) + if !ok { + return nil, channels.ErrSendFailed + } + return NewMaixCamChannel(bc, c, b) + }, + ) } diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index bbbf2da56..b81206c59 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -17,7 +17,7 @@ import ( type MaixCamChannel struct { *channels.BaseChannel - config config.MaixCamConfig + config *config.MaixCamSettings listener net.Listener ctx context.Context cancel context.CancelFunc @@ -32,13 +32,17 @@ type MaixCamMessage struct { Data map[string]any `json:"data"` } -func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { +func NewMaixCamChannel( + bc *config.Channel, + cfg *config.MaixCamSettings, + bus *bus.MessageBus, +) (*MaixCamChannel, error) { base := channels.NewBaseChannel( "maixcam", cfg, bus, - cfg.AllowFrom, - channels.WithReasoningChannelID(cfg.ReasoningChannelID), + bc.AllowFrom, + channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &MaixCamChannel{ @@ -196,17 +200,15 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { return } - c.HandleMessage( - c.ctx, - bus.Peer{Kind: "channel", ID: "default"}, - "", - senderID, - chatID, - content, - []string{}, - metadata, - sender, - ) + inboundCtx := bus.InboundContext{ + Channel: "maixcam", + ChatID: chatID, + ChatType: "channel", + SenderID: senderID, + Raw: metadata, + } + + c.HandleInboundContext(c.ctx, chatID, content, nil, inboundCtx, sender) } func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index c4326fda0..7974a39e4 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -11,8 +11,10 @@ import ( "errors" "fmt" "math" + "net" "net/http" "sort" + "strings" "sync" "time" @@ -24,6 +26,7 @@ import ( "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" ) const ( @@ -86,6 +89,7 @@ type Manager struct { dispatchTask *asyncTask mux *dynamicServeMux httpServer *http.Server + httpListeners []net.Listener mu sync.RWMutex placeholders sync.Map // "channel:chatID" → placeholderID (string) typingStops sync.Map // "channel:chatID" → func() @@ -94,10 +98,112 @@ type Manager struct { channelHashes map[string]string // channel name → config hash } +type toolFeedbackMessageTracker interface { + RecordToolFeedbackMessage(chatID, messageID, content string) + ClearToolFeedbackMessage(chatID string) +} + +type toolFeedbackMessageCleaner interface { + DismissToolFeedbackMessage(ctx context.Context, chatID string) +} + +type toolFeedbackMessageTargetResolver interface { + ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string +} + +type toolFeedbackMessageContentPreparer interface { + PrepareToolFeedbackMessageContent(content string) string +} + type asyncTask struct { cancel context.CancelFunc } +func outboundMessageChannel(msg bus.OutboundMessage) string { + return msg.Context.Channel +} + +func outboundMessageChatID(msg bus.OutboundMessage) string { + return msg.ChatID +} + +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func outboundMediaChannel(msg bus.OutboundMediaMessage) string { + return msg.Context.Channel +} + +func outboundMediaChatID(msg bus.OutboundMediaMessage) string { + return msg.ChatID +} + +func trackedToolFeedbackMessageChatID(ch Channel, chatID string, outboundCtx *bus.InboundContext) string { + if resolver, ok := ch.(toolFeedbackMessageTargetResolver); ok { + if resolved := strings.TrimSpace(resolver.ToolFeedbackMessageChatID(chatID, outboundCtx)); resolved != "" { + return resolved + } + } + return strings.TrimSpace(chatID) +} + +func dismissTrackedToolFeedbackMessage( + ctx context.Context, + ch Channel, + chatID string, + outboundCtx *bus.InboundContext, +) { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx) + if trackedChatID == "" { + return + } + if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok { + cleaner.DismissToolFeedbackMessage(ctx, trackedChatID) + return + } + if tracker, ok := ch.(toolFeedbackMessageTracker); ok { + tracker.ClearToolFeedbackMessage(trackedChatID) + } +} + +func clearTrackedToolFeedbackMessage( + ch Channel, + chatID string, + outboundCtx *bus.InboundContext, +) { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx) + if trackedChatID == "" { + return + } + if tracker, ok := ch.(toolFeedbackMessageTracker); ok { + tracker.ClearToolFeedbackMessage(trackedChatID) + } +} + +func prepareToolFeedbackMessageContent(ch Channel, content string) string { + prepared := strings.TrimSpace(content) + if prepared == "" { + return "" + } + if preparer, ok := ch.(toolFeedbackMessageContentPreparer); ok { + if candidate := strings.TrimSpace(preparer.PrepareToolFeedbackMessageContent(prepared)); candidate != "" { + return candidate + } + } + return prepared +} + +func (m *Manager) toolFeedbackSeparateMessagesEnabled() bool { + if m == nil || m.config == nil { + return false + } + return m.config.Agents.Defaults.IsToolFeedbackSeparateMessagesEnabled() +} + // RecordPlaceholder registers a placeholder message for later editing. // Implements PlaceholderRecorder. func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { @@ -161,7 +267,8 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { // preSend handles typing stop, reaction undo, and placeholder editing before sending a message. // Returns the delivered message IDs and true when delivery completed before a normal Send. func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) ([]string, bool) { - key := name + ":" + msg.ChatID + chatID := outboundMessageChatID(msg) + key := name + ":" + chatID // 1. Stop typing if v, loaded := m.typingStops.LoadAndDelete(key); loaded { @@ -177,26 +284,68 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - // 3. If a stream already finalized this message, delete the placeholder and skip send + isToolFeedback := outboundMessageIsToolFeedback(msg) + separateToolFeedbackMessages := m.toolFeedbackSeparateMessagesEnabled() + + // 3. If a stream already finalized this chat, stale tool feedback must be + // dropped without consuming the final-response marker. Streaming finalization + // bypasses the worker queue, so older queued feedback can arrive before the + // normal final outbound message that cleans up the marker and placeholder. + if isToolFeedback { + if _, loaded := m.streamActive.Load(key); loaded { + return nil, true + } + } + + // 4. If a stream already finalized this message, delete the placeholder and skip send if _, loaded := m.streamActive.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { // Prefer deleting the placeholder (cleaner UX than editing to same content) if deleter, ok := ch.(MessageDeleter); ok { - deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // best effort + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort } else if editor, ok := ch.(MessageEditor); ok { - editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content) // fallback + editor.EditMessage(ctx, chatID, entry.id, msg.Content) // fallback } } } + if !isToolFeedback { + if separateToolFeedbackMessages { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } else { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) + } + } return nil, true } - // 4. Try editing placeholder + if separateToolFeedbackMessages { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } + + // 5. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { + if isToolFeedback && separateToolFeedbackMessages { + if deleter, ok := ch.(MessageDeleter); ok { + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort + } + return nil, false + } if editor, ok := ch.(MessageEditor); ok { - if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil { + content := msg.Content + trackedContent := msg.Content + if isToolFeedback { + trackedContent = prepareToolFeedbackMessageContent(ch, msg.Content) + content = InitialAnimatedToolFeedbackContent(trackedContent) + } + if err := editor.EditMessage(ctx, chatID, entry.id, content); err == nil { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, &msg.Context) + if tracker, ok := ch.(toolFeedbackMessageTracker); ok && isToolFeedback { + tracker.RecordToolFeedbackMessage(trackedChatID, entry.id, trackedContent) + } else if !isToolFeedback { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) + } return []string{entry.id}, true } // edit failed → fall through to normal Send @@ -212,7 +361,8 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess // delivery never edits the placeholder because there is no text payload to // replace it with; it only attempts to delete the placeholder when possible. func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.OutboundMediaMessage, ch Channel) { - key := name + ":" + msg.ChatID + chatID := outboundMediaChatID(msg) + key := name + ":" + chatID // 1. Stop typing if v, loaded := m.typingStops.LoadAndDelete(key); loaded { @@ -231,11 +381,15 @@ func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.Outboun // 3. Clear any finalized stream marker for this chat before media delivery. m.streamActive.LoadAndDelete(key) + if m.toolFeedbackSeparateMessagesEnabled() { + clearTrackedToolFeedbackMessage(ch, chatID, &msg.Context) + } + // 4. Delete placeholder if present. if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if deleter, ok := ch.(MessageDeleter); ok { - deleter.DeleteMessage(ctx, msg.ChatID, entry.id) // best effort + deleter.DeleteMessage(ctx, chatID, entry.id) // best effort } } } @@ -292,41 +446,70 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) ( // Mark streamActive on Finalize so preSend knows to clean up the placeholder key := channelName + ":" + chatID return &finalizeHookStreamer{ - Streamer: streamer, - onFinalize: func() { m.streamActive.Store(key, true) }, + Streamer: streamer, + onFinalize: func(finalizeCtx context.Context) { + if m.toolFeedbackSeparateMessagesEnabled() { + clearTrackedToolFeedbackMessage( + ch, + chatID, + &bus.InboundContext{ + Channel: channelName, + ChatID: chatID, + }, + ) + } else { + dismissTrackedToolFeedbackMessage( + finalizeCtx, + ch, + chatID, + &bus.InboundContext{ + Channel: channelName, + ChatID: chatID, + }, + ) + } + m.streamActive.Store(key, true) + }, }, true } // finalizeHookStreamer wraps a Streamer to run a hook on Finalize. type finalizeHookStreamer struct { Streamer - onFinalize func() + onFinalize func(context.Context) } func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) error { if err := s.Streamer.Finalize(ctx, content); err != nil { return err } - s.onFinalize() + if s.onFinalize != nil { + s.onFinalize(ctx) + } return nil } -// initChannel is a helper that looks up a factory by name and creates the channel. -func (m *Manager) initChannel(name, displayName string) { - f, ok := getFactory(name) +// initChannel is a helper that looks up a factory by type name and creates the channel. +// typeName is the channel type used for factory lookup (e.g., "telegram"). +// channelName is the config map key used as the channel's runtime name (e.g., "my_telegram"). +func (m *Manager) initChannel(typeName, channelName string) { + f, ok := getFactory(typeName) if !ok { logger.WarnCF("channels", "Factory not registered", map[string]any{ - "channel": displayName, + "channel": channelName, + "type": typeName, }) return } logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{ - "channel": displayName, + "channel": channelName, + "type": typeName, }) - ch, err := f(m.config, m.bus) + ch, err := f(channelName, typeName, m.config, m.bus) if err != nil { logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{ - "channel": displayName, + "channel": channelName, + "type": typeName, "error": err.Error(), }) } else { @@ -344,103 +527,100 @@ func (m *Manager) initChannel(name, displayName string) { if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok { setter.SetOwner(ch) } - m.channels[name] = ch + m.channels[channelName] = ch logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ - "channel": displayName, + "channel": channelName, + "type": typeName, }) } } +func (m *Manager) getChannelConfigAndEnabled(channelName string) (*config.Channel, bool) { + bc, ok := m.config.Channels[channelName] + if !ok || bc == nil { + return nil, false + } + if !bc.Enabled { + return bc, false + } + + // Use Type to determine the config struct for validation. + // The map key (channelName) is the config key, which may differ from the type. + channelType := bc.Type + if channelType == "" { + channelType = channelName + } + + // Settings have already been decoded by InitChannelList, so we just need to + // type-assert and check the relevant fields. + decoded, err := bc.GetDecoded() + if err != nil { + return bc, false + } + //nolint:revive + switch settings := decoded.(type) { + case *config.WhatsAppSettings: + if channelType == config.ChannelWhatsApp { + return bc, settings.BridgeURL != "" + } + return bc, channelType == config.ChannelWhatsAppNative && settings.UseNative + case *config.MatrixSettings: + return bc, settings.Homeserver != "" && settings.UserID != "" && settings.AccessToken.String() != "" + case *config.WeComSettings: + return bc, settings.BotID != "" && settings.Secret.String() != "" + case *config.PicoClientSettings: + return bc, settings.URL != "" + case *config.DingTalkSettings: + return bc, settings.ClientID != "" + case *config.SlackSettings: + return bc, settings.BotToken.String() != "" + case *config.WeixinSettings: + return bc, settings.Token.String() != "" + case *config.PicoSettings: + return bc, settings.Token.String() != "" + case *config.IRCSettings: + return bc, settings.Server != "" + case *config.LINESettings: + return bc, settings.ChannelAccessToken.String() != "" + case *config.OneBotSettings: + return bc, settings.WSUrl != "" + case *config.QQSettings: + return bc, settings.AppSecret.String() != "" + case *config.TelegramSettings: + return bc, settings.Token.String() != "" + case *config.FeishuSettings: + return bc, settings.AppSecret.String() != "" + case *config.MaixCamSettings: + return bc, true + case *config.TeamsWebhookSettings: + return bc, true + case *config.DiscordSettings: + return bc, settings.Token.String() != "" + case *config.VKSettings: + return bc, settings.GroupID != 0 && settings.Token.String() != "" + } + + return bc, bc.Enabled +} + +// initChannels initializes all enabled channels based on the configuration. +// It iterates config entries and uses bc.Type to look up the appropriate factory. func (m *Manager) initChannels(channels *config.ChannelsConfig) error { logger.InfoC("channels", "Initializing channel manager") - if channels.Telegram.Enabled && channels.Telegram.Token.String() != "" { - m.initChannel("telegram", "Telegram") - } - - if channels.WhatsApp.Enabled { - waCfg := channels.WhatsApp - if waCfg.UseNative { - m.initChannel("whatsapp_native", "WhatsApp Native") - } else if waCfg.BridgeURL != "" { - m.initChannel("whatsapp", "WhatsApp") + for name, bc := range *channels { + if !bc.Enabled { + continue } - } - - if channels.Feishu.Enabled { - m.initChannel("feishu", "Feishu") - } - - if channels.Discord.Enabled && channels.Discord.Token.String() != "" { - m.initChannel("discord", "Discord") - } - - if channels.MaixCam.Enabled { - m.initChannel("maixcam", "MaixCam") - } - - if channels.QQ.Enabled { - m.initChannel("qq", "QQ") - } - - if channels.DingTalk.Enabled && channels.DingTalk.ClientID != "" { - m.initChannel("dingtalk", "DingTalk") - } - - if channels.Slack.Enabled && channels.Slack.BotToken.String() != "" { - m.initChannel("slack", "Slack") - } - - if channels.Matrix.Enabled && - m.config.Channels.Matrix.Homeserver != "" && - m.config.Channels.Matrix.UserID != "" && - m.config.Channels.Matrix.AccessToken.String() != "" { - m.initChannel("matrix", "Matrix") - } - - if channels.LINE.Enabled && channels.LINE.ChannelAccessToken.String() != "" { - m.initChannel("line", "LINE") - } - - if channels.OneBot.Enabled && channels.OneBot.WSUrl != "" { - m.initChannel("onebot", "OneBot") - } - - if channels.WeCom.Enabled && channels.WeCom.BotID != "" && channels.WeCom.Secret.String() != "" { - m.initChannel("wecom", "WeCom") - } - - if channels.Weixin.Enabled && channels.Weixin.Token.String() != "" { - m.initChannel("weixin", "Weixin") - } - - if channels.Pico.Enabled && channels.Pico.Token.String() != "" { - m.initChannel("pico", "Pico") - } - - if channels.PicoClient.Enabled && channels.PicoClient.URL != "" { - m.initChannel("pico_client", "Pico Client") - } - - if channels.IRC.Enabled && channels.IRC.Server != "" { - m.initChannel("irc", "IRC") - } - - if channels.VK.Enabled && channels.VK.Token.String() != "" && channels.VK.GroupID != 0 { - m.initChannel("vk", "VK") - } - - if channels.TeamsWebhook.Enabled && len(channels.TeamsWebhook.Webhooks) > 0 { - hasValidTarget := false - for _, target := range channels.TeamsWebhook.Webhooks { - if target.WebhookURL.String() != "" { - hasValidTarget = true - break - } + _, ready := m.getChannelConfigAndEnabled(name) + if !ready { + continue } - if hasValidTarget { - m.initChannel("teams_webhook", "Teams Webhook") + typeName := bc.Type + if typeName == "" { + typeName = name } + m.initChannel(typeName, name) } logger.InfoCF("channels", "Channel initialization completed", map[string]any{ @@ -454,6 +634,12 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { // It registers health endpoints from the health server and discovers channels // that implement WebhookHandler and/or HealthChecker to register their handlers. func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { + m.SetupHTTPServerListeners(nil, addr, healthServer) +} + +// SetupHTTPServerListeners creates a shared HTTP server on pre-opened listeners. +// When listeners is empty it falls back to Addr-based ListenAndServe behavior. +func (m *Manager) SetupHTTPServerListeners(listeners []net.Listener, addr string, healthServer *health.Server) { m.mux = newDynamicServeMux() // Register health endpoints @@ -470,6 +656,7 @@ func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, } + m.httpListeners = append([]net.Listener(nil), listeners...) } // registerHTTPHandlersLocked registers webhook and health-check handlers for @@ -548,7 +735,13 @@ func (m *Manager) StartAll(ctx context.Context) error { continue } // Lazily create worker only after channel starts successfully - w := newChannelWorker(name, channel) + channelType := name + if m.config != nil { + if bc := m.config.Channels.Get(name); bc != nil && bc.Type != "" { + channelType = bc.Type + } + } + w := newChannelWorker(name, channel, channelType) m.workers[name] = w go m.runWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w) @@ -593,16 +786,33 @@ func (m *Manager) StartAll(ctx context.Context) error { // Start shared HTTP server if configured if m.httpServer != nil { - go func() { - logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ - "addr": m.httpServer.Addr, - }) - if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.FatalCF("channels", "Shared HTTP server error", map[string]any{ - "error": err.Error(), - }) + if len(m.httpListeners) > 0 { + for _, listener := range m.httpListeners { + ln := listener + go func() { + logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ + "addr": ln.Addr().String(), + }) + if err := m.httpServer.Serve(ln); err != nil && err != http.ErrServerClosed { + logger.FatalCF("channels", "Shared HTTP server error", map[string]any{ + "addr": ln.Addr().String(), + "error": err.Error(), + }) + } + }() } - }() + } else { + go func() { + logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ + "addr": m.httpServer.Addr, + }) + if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.FatalCF("channels", "Shared HTTP server error", map[string]any{ + "error": err.Error(), + }) + } + }() + } } logger.InfoCF("channels", "Channel startup completed", map[string]any{ @@ -629,6 +839,7 @@ func (m *Manager) StopAll(ctx context.Context) error { }) } m.httpServer = nil + m.httpListeners = nil } // Cancel dispatcher @@ -678,10 +889,10 @@ func (m *Manager) StopAll(ctx context.Context) error { } // newChannelWorker creates a channelWorker with a rate limiter configured -// for the given channel name. -func newChannelWorker(name string, ch Channel) *channelWorker { +// for the given channel type. channelType is used for rate limit lookup. +func newChannelWorker(name string, ch Channel, channelType string) *channelWorker { rateVal := float64(defaultRateLimit) - if r, ok := channelRateConfig[name]; ok { + if r, ok := channelRateConfig[channelType]; ok { rateVal = r } burst := int(math.Max(1, math.Ceil(rateVal/2))) @@ -716,18 +927,21 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) // Collect all message chunks to send var chunks []string - // Step 1: Try marker-based splitting if enabled - if m.config != nil && m.config.Agents.Defaults.SplitOnMarker { + // Step 1: Try marker-based splitting if enabled. + // Tool feedback must stay a single message, so it skips marker splitting. + if m.config != nil && m.config.Agents.Defaults.SplitOnMarker && !outboundMessageIsToolFeedback(msg) { if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 { for _, chunk := range markerChunks { - chunks = append(chunks, splitByLength(chunk, maxLen)...) + chunkMsg := msg + chunkMsg.Content = chunk + chunks = append(chunks, splitOutboundMessageContent(chunkMsg, maxLen)...) } } } // Step 2: Fallback to length-based splitting if no chunks from marker if len(chunks) == 0 { - chunks = splitByLength(msg.Content, maxLen) + chunks = splitOutboundMessageContent(msg, maxLen) } // Step 3: Send all chunks @@ -742,12 +956,25 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) } } -// splitByLength splits content by maxLen if needed, otherwise returns single chunk. -func splitByLength(content string, maxLen int) []string { - if maxLen > 0 && len([]rune(content)) > maxLen { - return SplitMessage(content, maxLen) +// splitOutboundMessageContent splits regular outbound content by maxLen, but +// keeps tool feedback in a single message by truncating the explanation body. +func splitOutboundMessageContent(msg bus.OutboundMessage, maxLen int) []string { + if maxLen > 0 { + if outboundMessageIsToolFeedback(msg) { + animationSafeLen := maxLen - MaxToolFeedbackAnimationFrameLength() + if animationSafeLen <= 0 { + animationSafeLen = maxLen + } + if len([]rune(msg.Content)) > animationSafeLen { + return []string{utils.FitToolFeedbackMessage(msg.Content, animationSafeLen)} + } + return []string{msg.Content} + } + if len([]rune(msg.Content)) > maxLen { + return SplitMessage(msg.Content, maxLen) + } } - return []string{content} + return []string{msg.Content} } // sendWithRetry sends a message through the channel with rate limiting and @@ -812,7 +1039,7 @@ func (m *Manager) sendWithRetry( // All retries exhausted or permanent failure logger.ErrorCF("channels", "Send failed", map[string]any{ "channel": name, - "chat_id": msg.ChatID, + "chat_id": outboundMessageChatID(msg), "error": lastErr.Error(), "retries": maxRetries, }) @@ -874,7 +1101,7 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { dispatchLoop( ctx, m, m.bus.OutboundChan(), - func(msg bus.OutboundMessage) string { return msg.Channel }, + func(msg bus.OutboundMessage) string { return outboundMessageChannel(msg) }, func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool { select { case w.queue <- msg: @@ -894,7 +1121,7 @@ func (m *Manager) dispatchOutboundMedia(ctx context.Context) { dispatchLoop( ctx, m, m.bus.OutboundMediaChan(), - func(msg bus.OutboundMediaMessage) string { return msg.Channel }, + func(msg bus.OutboundMediaMessage) string { return outboundMediaChannel(msg) }, func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool { select { case w.mediaQueue <- msg: @@ -993,7 +1220,7 @@ func (m *Manager) sendMediaWithRetry( // All retries exhausted or permanent failure logger.ErrorCF("channels", "SendMedia failed", map[string]any{ "channel": name, - "chat_id": msg.ChatID, + "chat_id": outboundMediaChatID(msg), "error": lastErr.Error(), "retries": maxRetries, }) @@ -1137,7 +1364,13 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error { continue } // Lazily create worker only after channel starts successfully - w := newChannelWorker(name, channel) + channelType := name + if m.config != nil { + if bc := m.config.Channels.Get(name); bc != nil && bc.Type != "" { + channelType = bc.Type + } + } + w := newChannelWorker(name, channel, channelType) m.workers[name] = w go m.runWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w) @@ -1186,30 +1419,36 @@ func (m *Manager) UnregisterChannel(name string) { // delivered (or all retries are exhausted), which preserves ordering when // a subsequent operation depends on the message having been sent. func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { + msg = bus.NormalizeOutboundMessage(msg) + channelName := outboundMessageChannel(msg) + m.mu.RLock() - _, exists := m.channels[msg.Channel] - w, wExists := m.workers[msg.Channel] + _, exists := m.channels[channelName] + w, wExists := m.workers[channelName] m.mu.RUnlock() if !exists { - return fmt.Errorf("channel %s not found", msg.Channel) + return fmt.Errorf("channel %s not found", channelName) } if !wExists || w == nil { - return fmt.Errorf("channel %s has no active worker", msg.Channel) + return fmt.Errorf("channel %s has no active worker", channelName) } maxLen := 0 if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } - if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - for _, chunk := range SplitMessage(msg.Content, maxLen) { + if chunks := splitOutboundMessageContent(msg, maxLen); len(chunks) > 1 { + for _, chunk := range chunks { chunkMsg := msg chunkMsg.Content = chunk - m.sendWithRetry(ctx, msg.Channel, w, chunkMsg) + m.sendWithRetry(ctx, channelName, w, chunkMsg) } } else { - m.sendWithRetry(ctx, msg.Channel, w, msg) + if len(chunks) == 1 { + msg.Content = chunks[0] + } + m.sendWithRetry(ctx, channelName, w, msg) } return nil } @@ -1219,19 +1458,22 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro // retries are exhausted), which preserves ordering when later agent behavior // depends on actual media delivery. func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + msg = bus.NormalizeOutboundMediaMessage(msg) + channelName := outboundMediaChannel(msg) + m.mu.RLock() - _, exists := m.channels[msg.Channel] - w, wExists := m.workers[msg.Channel] + _, exists := m.channels[channelName] + w, wExists := m.workers[channelName] m.mu.RUnlock() if !exists { - return fmt.Errorf("channel %s not found", msg.Channel) + return fmt.Errorf("channel %s not found", channelName) } if !wExists || w == nil { - return fmt.Errorf("channel %s has no active worker", msg.Channel) + return fmt.Errorf("channel %s has no active worker", channelName) } - _, err := m.sendMediaWithRetry(ctx, msg.Channel, w, msg) + _, err := m.sendMediaWithRetry(ctx, channelName, w, msg) return err } @@ -1246,10 +1488,10 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten } msg := bus.OutboundMessage{ - Channel: channelName, - ChatID: chatID, + Context: bus.NewOutboundContext(channelName, chatID, ""), Content: content, } + msg = bus.NormalizeOutboundMessage(msg) if wExists && w != nil { select { diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go index b54facda4..1f5978e7d 100644 --- a/pkg/channels/manager_channel.go +++ b/pkg/channels/manager_channel.go @@ -6,7 +6,6 @@ import ( "encoding/json" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" ) func toChannelHashes(cfg *config.Config) map[string]string { @@ -21,7 +20,7 @@ func toChannelHashes(cfg *config.Config) map[string]string { if !value["enabled"].(bool) { continue } - hiddenValues(key, value, ch) + hiddenValues(key, value, ch.Get(key)) valueBytes, _ := json.Marshal(value) hash := md5.Sum(valueBytes) result[key] = hex.EncodeToString(hash[:]) @@ -30,43 +29,77 @@ func toChannelHashes(cfg *config.Config) map[string]string { return result } -func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) { +func hiddenValues(key string, value map[string]any, ch *config.Channel) { + v, err := ch.GetDecoded() + if err != nil { + return + } switch key { case "pico": - value["token"] = ch.Pico.Token.String() + if settings, ok := v.(*config.PicoSettings); ok { + value["token"] = settings.Token.String() + } case "telegram": - value["token"] = ch.Telegram.Token.String() + if settings, ok := v.(*config.TelegramSettings); ok { + value["token"] = settings.Token.String() + } case "discord": - value["token"] = ch.Discord.Token.String() + if settings, ok := v.(*config.DiscordSettings); ok { + value["token"] = settings.Token.String() + } case "slack": - value["bot_token"] = ch.Slack.BotToken.String() - value["app_token"] = ch.Slack.AppToken.String() + if settings, ok := v.(*config.SlackSettings); ok { + value["bot_token"] = settings.BotToken.String() + value["app_token"] = settings.AppToken.String() + } case "matrix": - value["token"] = ch.Matrix.AccessToken.String() + if settings, ok := v.(*config.MatrixSettings); ok { + value["token"] = settings.AccessToken.String() + } case "onebot": - value["token"] = ch.OneBot.AccessToken.String() + if settings, ok := v.(*config.OneBotSettings); ok { + value["token"] = settings.AccessToken.String() + } case "line": - value["token"] = ch.LINE.ChannelAccessToken.String() - value["secret"] = ch.LINE.ChannelSecret.String() + if settings, ok := v.(*config.LINESettings); ok { + value["token"] = settings.ChannelAccessToken.String() + value["secret"] = settings.ChannelSecret.String() + } case "wecom": - value["secret"] = ch.WeCom.Secret.String() + if settings, ok := v.(*config.WeComSettings); ok { + value["secret"] = settings.Secret.String() + } case "dingtalk": - value["secret"] = ch.DingTalk.ClientSecret.String() + if settings, ok := v.(*config.DingTalkSettings); ok { + value["secret"] = settings.ClientSecret.String() + } case "qq": - value["secret"] = ch.QQ.AppSecret.String() + if settings, ok := v.(*config.QQSettings); ok { + value["secret"] = settings.AppSecret.String() + } case "irc": - value["password"] = ch.IRC.Password.String() - value["serv_password"] = ch.IRC.NickServPassword.String() - value["sasl_password"] = ch.IRC.SASLPassword.String() + if settings, ok := v.(*config.IRCSettings); ok { + value["password"] = settings.Password.String() + value["serv_password"] = settings.NickServPassword.String() + value["sasl_password"] = settings.SASLPassword.String() + } case "feishu": - value["app_secret"] = ch.Feishu.AppSecret.String() - value["encrypt_key"] = ch.Feishu.EncryptKey.String() - value["verification_token"] = ch.Feishu.VerificationToken.String() + if settings, ok := v.(*config.FeishuSettings); ok { + value["app_secret"] = settings.AppSecret.String() + value["encrypt_key"] = settings.EncryptKey.String() + value["verification_token"] = settings.VerificationToken.String() + } case "teams_webhook": // Expose webhook URLs for hash computation (they contain secrets) + vv := value["webhooks"] webhooks := make(map[string]string) - for name, target := range ch.TeamsWebhook.Webhooks { - webhooks[name] = target.WebhookURL.String() + if vv != nil { + webhooks = vv.(map[string]string) + } + if settings, ok := v.(*config.TeamsWebhookSettings); ok { + for name, target := range settings.Webhooks { + webhooks[name] = target.WebhookURL.String() + } } value["webhooks"] = webhooks } @@ -92,94 +125,13 @@ func compareChannels(old, news map[string]string) (added, removed []string) { } func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig, error) { - result := &config.ChannelsConfig{} - ch := cfg.Channels - // should not be error - marshal, _ := json.Marshal(ch) - var channelConfig map[string]map[string]any - _ = json.Unmarshal(marshal, &channelConfig) - temp := make(map[string]map[string]any, 0) - - for key, value := range channelConfig { - found := false - for _, s := range list { - if key == s { - found = true - break - } - } - if !found || !value["enabled"].(bool) { + result := make(config.ChannelsConfig) + for _, name := range list { + bc, ok := cfg.Channels[name] + if !ok || !bc.Enabled { continue } - temp[key] = value - } - - marshal, err := json.Marshal(temp) - if err != nil { - logger.Errorf("marshal error: %v", err) - return nil, err - } - err = json.Unmarshal(marshal, result) - if err != nil { - logger.Errorf("unmarshal error: %v", err) - return nil, err - } - - updateKeys(result, &ch) - - return result, nil -} - -func updateKeys(newcfg, old *config.ChannelsConfig) { - if newcfg.Pico.Enabled { - newcfg.Pico.Token = old.Pico.Token - } - if newcfg.Telegram.Enabled { - newcfg.Telegram.Token = old.Telegram.Token - } - if newcfg.Discord.Enabled { - newcfg.Discord.Token = old.Discord.Token - } - if newcfg.Slack.Enabled { - newcfg.Slack.BotToken = old.Slack.BotToken - newcfg.Slack.AppToken = old.Slack.AppToken - } - if newcfg.Matrix.Enabled { - newcfg.Matrix.AccessToken = old.Matrix.AccessToken - } - if newcfg.OneBot.Enabled { - newcfg.OneBot.AccessToken = old.OneBot.AccessToken - } - if newcfg.LINE.Enabled { - newcfg.LINE.ChannelAccessToken = old.LINE.ChannelAccessToken - newcfg.LINE.ChannelSecret = old.LINE.ChannelSecret - } - if newcfg.WeCom.Enabled { - newcfg.WeCom.Secret = old.WeCom.Secret - } - if newcfg.DingTalk.Enabled { - newcfg.DingTalk.ClientSecret = old.DingTalk.ClientSecret - } - if newcfg.QQ.Enabled { - newcfg.QQ.AppSecret = old.QQ.AppSecret - } - if newcfg.IRC.Enabled { - newcfg.IRC.Password = old.IRC.Password - newcfg.IRC.NickServPassword = old.IRC.NickServPassword - newcfg.IRC.SASLPassword = old.IRC.SASLPassword - } - if newcfg.Feishu.Enabled { - newcfg.Feishu.AppSecret = old.Feishu.AppSecret - newcfg.Feishu.EncryptKey = old.Feishu.EncryptKey - newcfg.Feishu.VerificationToken = old.Feishu.VerificationToken - } - if newcfg.TeamsWebhook.Enabled { - // Copy SecureString webhook URLs from old config - for name, oldTarget := range old.TeamsWebhook.Webhooks { - if newTarget, ok := newcfg.TeamsWebhook.Webhooks[name]; ok { - newTarget.WebhookURL = oldTarget.WebhookURL - newcfg.TeamsWebhook.Webhooks[name] = newTarget - } - } + result[name] = bc } + return &result, nil } diff --git a/pkg/channels/manager_channel_test.go b/pkg/channels/manager_channel_test.go index 3de1e2b3f..b991e58d6 100644 --- a/pkg/channels/manager_channel_test.go +++ b/pkg/channels/manager_channel_test.go @@ -1,6 +1,7 @@ package channels import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -15,37 +16,138 @@ func TestToChannelHashes(t *testing.T) { results := toChannelHashes(cfg) assert.Equal(t, 0, len(results)) logger.Debugf("results: %v", results) + + // Add dingtalk channel via map cfg2 := config.DefaultConfig() - cfg2.Channels.DingTalk.Enabled = true + cfg2.Channels["dingtalk"] = &config.Channel{ + Enabled: true, + Type: config.ChannelDingTalk, + Settings: config.RawNode(`{"enabled":true}`), + } results2 := toChannelHashes(cfg2) assert.Equal(t, 1, len(results2)) logger.Debugf("results2: %v", results2) added, removed := compareChannels(results, results2) assert.EqualValues(t, []string{"dingtalk"}, added) assert.EqualValues(t, []string(nil), removed) + + // Add telegram channel cfg3 := config.DefaultConfig() - cfg3.Channels.Telegram.Enabled = true + cfg3.Channels["telegram"] = &config.Channel{ + Enabled: true, + Type: config.ChannelTelegram, + Settings: config.RawNode(`{"enabled":true,"token":"test-token"}`), + } results3 := toChannelHashes(cfg3) assert.Equal(t, 1, len(results3)) logger.Debugf("results3: %v", results3) added, removed = compareChannels(results2, results3) assert.EqualValues(t, []string{"dingtalk"}, removed) assert.EqualValues(t, []string{"telegram"}, added) - cfg3.Channels.Telegram.SetToken("114314") + + // Modify telegram channel — hash should change + cfg3.Channels["telegram"] = &config.Channel{ + Enabled: true, + Type: config.ChannelTelegram, + Settings: config.RawNode(`{"enabled":true,"token":"114314"}`), + } results4 := toChannelHashes(cfg3) assert.Equal(t, 1, len(results4)) logger.Debugf("results4: %v", results4) added, removed = compareChannels(results3, results4) assert.EqualValues(t, []string{"telegram"}, removed) assert.EqualValues(t, []string{"telegram"}, added) + + // toChannelConfig with telegram cc, err := toChannelConfig(cfg3, added) assert.NoError(t, err) - logger.Debugf("cc: %#v", cc.Telegram) - assert.Equal(t, "114314", cc.Telegram.Token.String()) - assert.Equal(t, true, cc.Telegram.Enabled) + bc := cc.Get("telegram") + assert.NotNil(t, bc) + var tc config.TelegramSettings + bc.Decode(&tc) + assert.Equal(t, "114314", tc.Token.String()) + assert.Equal(t, true, bc.Enabled) + + // toChannelConfig with dingtalk (no telegram) cc, err = toChannelConfig(cfg2, added) assert.NoError(t, err) - logger.Debugf("cc: %#v", cc.Telegram) - assert.Equal(t, "", cc.Telegram.Token.String()) - assert.Equal(t, false, cc.Telegram.Enabled) + bc = cc.Get("telegram") + assert.Nil(t, bc) +} + +func TestToChannelHashes_SerializationStability(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["test"] = &config.Channel{ + Enabled: true, + Settings: config.RawNode(`{"enabled":true,"key":"value"}`), + } + h1 := toChannelHashes(cfg) + + // Same config should produce same hash + cfg2 := config.DefaultConfig() + cfg2.Channels["test"] = &config.Channel{ + Enabled: true, + Settings: config.RawNode(`{"enabled":true,"key":"value"}`), + } + h2 := toChannelHashes(cfg2) + assert.Equal(t, h1["test"], h2["test"]) +} + +func TestCompareChannels_NoChanges(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["a"] = &config.Channel{Enabled: true, Settings: config.RawNode(`{}`)} + cfg.Channels["b"] = &config.Channel{Enabled: true, Settings: config.RawNode(`{}`)} + h := toChannelHashes(cfg) + + added, removed := compareChannels(h, h) + assert.EqualValues(t, []string(nil), added) + assert.EqualValues(t, []string(nil), removed) +} + +func TestToChannelConfig_EmptyList(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["test"] = &config.Channel{Enabled: true, Settings: config.RawNode(`{}`)} + + cc, err := toChannelConfig(cfg, []string{}) + assert.NoError(t, err) + assert.Equal(t, 0, len(*cc)) +} + +func TestToChannelHashes_NonEnabledSkipped(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["test"] = &config.Channel{Enabled: false, Settings: config.RawNode(`{"enabled":false}`)} + + h := toChannelHashes(cfg) + assert.Equal(t, 0, len(h)) +} + +func TestToChannelHashes_InvalidJSON(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels["test"] = &config.Channel{ + Enabled: true, + Settings: config.RawNode(`invalid-json`), + } + + // Should not panic, just skip the invalid entry + h := toChannelHashes(cfg) + assert.Equal(t, 0, len(h)) +} + +func TestToChannelHashes_RealWorldChannel(t *testing.T) { + cfg := config.DefaultConfig() + + // Simulate a telegram channel config + telegramSettings, _ := json.Marshal(map[string]any{ + "enabled": true, + "token": "123456:ABC-DEF", + }) + cfg.Channels["telegram"] = &config.Channel{ + Enabled: true, + Type: config.ChannelTelegram, + Settings: config.RawNode(telegramSettings), + } + + h := toChannelHashes(cfg) + assert.Equal(t, 1, len(h)) + assert.Contains(t, h, "telegram") } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 937b32d2c..a5d7c2838 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -13,6 +13,8 @@ import ( "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/utils" ) // mockChannel is a test double that delegates Send to a configurable function. @@ -76,8 +78,9 @@ func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM type mockDeletingMediaChannel struct { mockMediaChannel - deleteCalls int - lastDeleted struct { + deleteCalls int + dismissedChatID string + lastDeleted struct { chatID string messageID string } @@ -94,6 +97,48 @@ func (m *mockDeletingMediaChannel) DeleteMessage( return nil } +func (m *mockDeletingMediaChannel) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +type mockStreamer struct { + finalizeFn func(context.Context, string) error +} + +func (m *mockStreamer) Update(context.Context, string) error { return nil } + +func (m *mockStreamer) Finalize(ctx context.Context, content string) error { + if m.finalizeFn != nil { + return m.finalizeFn(ctx, content) + } + return nil +} + +func (m *mockStreamer) Cancel(context.Context) {} + +type mockStreamingChannel struct { + mockMessageEditor + streamer Streamer + resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string +} + +func (m *mockStreamingChannel) BeginStream(context.Context, string) (Streamer, error) { + if m.streamer == nil { + return nil, errors.New("missing streamer") + } + return m.streamer, nil +} + +func (m *mockStreamingChannel) ToolFeedbackMessageChatID( + chatID string, + outboundCtx *bus.InboundContext, +) string { + if m.resolveChatIDFn != nil { + return m.resolveChatIDFn(chatID, outboundCtx) + } + return chatID +} + // newTestManager creates a minimal Manager suitable for unit tests. func newTestManager() *Manager { return &Manager{ @@ -175,11 +220,11 @@ func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) { pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second) defer pubCancel() - if err := m.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + if err := m.bus.PublishOutbound(pubCtx, testOutboundMessage(bus.OutboundMessage{ Channel: "good", ChatID: "chat-1", Content: "hello", - }); err != nil { + })); err != nil { t.Fatalf("PublishOutbound() error = %v", err) } @@ -197,6 +242,20 @@ func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) { } } +func testOutboundMessage(msg bus.OutboundMessage) bus.OutboundMessage { + if msg.Context.Channel == "" && msg.Context.ChatID == "" { + msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, msg.ReplyToMessageID) + } + return bus.NormalizeOutboundMessage(msg) +} + +func testOutboundMediaMessage(msg bus.OutboundMediaMessage) bus.OutboundMediaMessage { + if msg.Context.Channel == "" && msg.Context.ChatID == "" { + msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, "") + } + return bus.NormalizeOutboundMediaMessage(msg) +} + func TestSendWithRetry_Success(t *testing.T) { m := newTestManager() var callCount int @@ -212,7 +271,7 @@ func TestSendWithRetry_Success(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -239,7 +298,7 @@ func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -263,7 +322,7 @@ func TestSendWithRetry_PermanentFailure(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -287,7 +346,7 @@ func TestSendWithRetry_NotRunning(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -314,7 +373,7 @@ func TestSendWithRetry_RateLimitRetry(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) start := time.Now() m.sendWithRetry(ctx, "test", w, msg) @@ -344,7 +403,7 @@ func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -370,11 +429,11 @@ func TestSendMedia_Success(t *testing.T) { m.channels["test"] = ch m.workers["test"] = w - err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ + err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{ Channel: "test", ChatID: "chat1", Parts: []bus.MediaPart{{Ref: "media://abc"}}, - }) + })) if err != nil { t.Fatalf("SendMedia() error = %v", err) } @@ -397,11 +456,11 @@ func TestSendMedia_PropagatesFailure(t *testing.T) { m.channels["test"] = ch m.workers["test"] = w - err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ + err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{ Channel: "test", ChatID: "chat1", Parts: []bus.MediaPart{{Ref: "media://abc"}}, - }) + })) if err == nil { t.Fatal("expected SendMedia to return error") } @@ -424,11 +483,11 @@ func TestSendMedia_UnsupportedChannelReturnsError(t *testing.T) { m.channels["test"] = ch m.workers["test"] = w - err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ + err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{ Channel: "test", ChatID: "chat1", Parts: []bus.MediaPart{{Ref: "media://abc"}}, - }) + })) if err == nil { t.Fatal("expected SendMedia to return error for unsupported channel") } @@ -454,11 +513,11 @@ func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) { m.workers["test"] = w m.RecordPlaceholder("test", "chat1", "placeholder-1") - err := m.SendMedia(context.Background(), bus.OutboundMediaMessage{ + err := m.SendMedia(context.Background(), testOutboundMediaMessage(bus.OutboundMediaMessage{ Channel: "test", ChatID: "chat1", Parts: []bus.MediaPart{{Ref: "media://abc"}}, - }) + })) if err != nil { t.Fatalf("SendMedia() error = %v", err) } @@ -491,7 +550,7 @@ func TestSendWithRetry_UnknownError(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) m.sendWithRetry(ctx, "test", w, msg) @@ -515,7 +574,7 @@ func TestSendWithRetry_ContextCancelled(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) // Cancel context after first Send attempt returns ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error { @@ -561,7 +620,7 @@ func TestWorkerRateLimiter(t *testing.T) { // Enqueue 4 messages for i := range 4 { - w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)} + w.queue <- testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)}) } // Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin) @@ -586,7 +645,7 @@ func TestWorkerRateLimiter(t *testing.T) { func TestNewChannelWorker_DefaultRate(t *testing.T) { ch := &mockChannel{} - w := newChannelWorker("unknown_channel", ch) + w := newChannelWorker("unknown_channel", ch, "unknown_channel") if w.limiter == nil { t.Fatal("expected limiter to be non-nil") @@ -599,10 +658,10 @@ func TestNewChannelWorker_DefaultRate(t *testing.T) { func TestNewChannelWorker_ConfiguredRate(t *testing.T) { ch := &mockChannel{} - for name, expectedRate := range channelRateConfig { - w := newChannelWorker(name, ch) + for channelType, expectedRate := range channelRateConfig { + w := newChannelWorker(channelType, ch, channelType) if w.limiter.Limit() != rate.Limit(expectedRate) { - t.Fatalf("channel %s: expected rate %v, got %v", name, expectedRate, w.limiter.Limit()) + t.Fatalf("channel %s: expected rate %v, got %v", channelType, expectedRate, w.limiter.Limit()) } } } @@ -637,7 +696,7 @@ func TestRunWorker_MessageSplitting(t *testing.T) { go m.runWorker(ctx, "test", w) // Send a message that should be split - w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"} + w.queue <- testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"}) time.Sleep(100 * time.Millisecond) @@ -678,7 +737,7 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) { } ctx := context.Background() - msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}) start := time.Now() m.sendWithRetry(ctx, "test", w, msg) @@ -701,13 +760,86 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) { // mockMessageEditor is a channel that supports MessageEditor. type mockMessageEditor struct { mockChannel - editFn func(ctx context.Context, chatID, messageID, content string) error + editFn func(ctx context.Context, chatID, messageID, content string) error + finalizeFn func(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) + finalizeCalled bool + recordedChatID string + recordedMessageID string + recordedContent string + clearedChatID string + dismissedChatID string } func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error { return m.editFn(ctx, chatID, messageID, content) } +func (m *mockMessageEditor) RecordToolFeedbackMessage(chatID, messageID, content string) { + m.recordedChatID = chatID + m.recordedMessageID = messageID + m.recordedContent = content +} + +func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) { + m.clearedChatID = chatID +} + +func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +func (m *mockMessageEditor) FinalizeToolFeedbackMessage( + ctx context.Context, + msg bus.OutboundMessage, +) ([]string, bool) { + m.finalizeCalled = true + if m.finalizeFn == nil { + return nil, false + } + return m.finalizeFn(ctx, msg) +} + +type mockResolvedToolFeedbackEditor struct { + mockMessageEditor + resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string +} + +type mockDeletingMessageEditor struct { + mockMessageEditor + deleteCalls int + deletedChatID string + deletedMessageID string +} + +func (m *mockDeletingMessageEditor) DeleteMessage(_ context.Context, chatID, messageID string) error { + m.deleteCalls++ + m.deletedChatID = chatID + m.deletedMessageID = messageID + return nil +} + +func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID( + chatID string, + outboundCtx *bus.InboundContext, +) string { + if m.resolveChatIDFn != nil { + return m.resolveChatIDFn(chatID, outboundCtx) + } + return chatID +} + +type mockPreparedToolFeedbackEditor struct { + mockMessageEditor + prepareFn func(content string) string +} + +func (m *mockPreparedToolFeedbackEditor) PrepareToolFeedbackMessageContent(content string) string { + if m.prepareFn != nil { + return m.prepareFn(content) + } + return content +} + func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m := newTestManager() var sendCalled bool @@ -738,7 +870,7 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { // Register placeholder m.RecordPlaceholder("test", "123", "456") - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + msg := testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}) _, edited := m.preSend(context.Background(), "test", msg, ch) if !edited { @@ -752,6 +884,709 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { } } +func TestPreSend_ToolFeedbackPlaceholderEditRecordsTrackedMessage(t *testing.T) { + m := newTestManager() + + ch := &mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "456" || content != "hello" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedChatID != "123" || ch.recordedMessageID != "456" { + t.Fatalf("expected tracked message 123/456, got %q/%q", ch.recordedChatID, ch.recordedMessageID) + } +} + +func TestPreSend_ToolFeedbackPlaceholderEditUsesResolvedTrackedChatID(t *testing.T) { + m := newTestManager() + + ch := &mockResolvedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "-100123" || messageID != "456" || content != "hello" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if chatID != "-100123" { + t.Fatalf("expected raw chat ID, got %q", chatID) + } + if outboundCtx == nil || outboundCtx.TopicID != "42" { + t.Fatalf("expected topic-aware outbound context, got %+v", outboundCtx) + } + return chatID + "/" + outboundCtx.TopicID + }, + } + + m.RecordPlaceholder("test", "-100123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "-100123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "-100123", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedChatID != "-100123/42" || ch.recordedMessageID != "456" { + t.Fatalf("expected resolved tracked message -100123/42/456, got %q/%q", + ch.recordedChatID, ch.recordedMessageID) + } +} + +func TestPreSend_ToolFeedbackPlaceholderEditUsesPreparedContent(t *testing.T) { + m := newTestManager() + + const rawContent = "🔧 `read_file`\n" + "%s", escaped))
@@ -92,6 +108,11 @@ type codeBlockMatch struct {
codes []string
}
+type rawURLMatch struct {
+ text string
+ urls []string
+}
+
func extractCodeBlocks(text string) codeBlockMatch {
matches := reCodeBlock.FindAllStringSubmatch(text, -1)
@@ -110,6 +131,24 @@ func extractCodeBlocks(text string) codeBlockMatch {
return codeBlockMatch{text: text, codes: codes}
}
+func extractRawURLs(text string) rawURLMatch {
+ matches := reRawURL.FindAllString(text, -1)
+
+ urls := make([]string, 0, len(matches))
+ for _, match := range matches {
+ urls = append(urls, match)
+ }
+
+ i := 0
+ text = reRawURL.ReplaceAllStringFunc(text, func(string) string {
+ placeholder := fmt.Sprintf("\x00RU%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return rawURLMatch{text: text, urls: urls}
+}
+
type inlineCodeMatch struct {
text string
codes []string
@@ -139,3 +178,7 @@ func escapeHTML(text string) string {
text = strings.ReplaceAll(text, ">", ">")
return text
}
+
+func escapeHTMLAttr(text string) string {
+ return html.EscapeString(text)
+}
diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go
index 7754ee076..a05b39877 100644
--- a/pkg/channels/telegram/parser_markdown_to_html_test.go
+++ b/pkg/channels/telegram/parser_markdown_to_html_test.go
@@ -32,6 +32,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
input: "[click here](https://example.com/path)",
expected: `click here`,
},
+ {
+ name: "raw oauth url with underscores survives",
+ input: "Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256",
+ expected: `Apri https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A8001%2Foauth2callback&code_challenge=abc_def&code_challenge_method=S256`,
+ },
{
name: "link with underscores in URL is not corrupted by italic regex",
// Google Flights URLs use URL-safe base64 with underscores in the tfs param.
@@ -45,6 +50,11 @@ func Test_markdownToTelegramHTML(t *testing.T) {
input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)",
expected: `first and second`,
},
+ {
+ name: "markdown link query params are escaped in href",
+ input: "[oauth](https://example.com/cb?response_type=code&client_id=test-client)",
+ expected: `oauth`,
+ },
{
name: "link label with HTML special chars is escaped",
input: "[a & b](https://example.com)",
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 2d59de4dc..cebebfed6 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -45,20 +45,27 @@ var (
type TelegramChannel struct {
*channels.BaseChannel
- bot *telego.Bot
- bh *th.BotHandler
- config *config.Config
- chatIDs map[string]int64
- ctx context.Context
- cancel context.CancelFunc
+ bot *telego.Bot
+ bh *th.BotHandler
+ bc *config.Channel
+ chatIDs map[string]int64
+ ctx context.Context
+ cancel context.CancelFunc
+ tgCfg *config.TelegramSettings
+ progress *channels.ToolFeedbackAnimator
- registerFunc func(context.Context, []commands.Definition) error
- commandRegCancel context.CancelFunc
+ registerFunc func(context.Context, []commands.Definition) error
+ commandRegDelayFn func(int) time.Duration
+ commandRegCancel context.CancelFunc
}
-func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
+func NewTelegramChannel(
+ bc *config.Channel,
+ telegramCfg *config.TelegramSettings,
+ bus *bus.MessageBus,
+) (*TelegramChannel, error) {
+ channelName := bc.Name()
var opts []telego.BotOption
- telegramCfg := cfg.Channels.Telegram
if telegramCfg.Proxy != "" {
proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
@@ -90,21 +97,24 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
}
base := channels.NewBaseChannel(
- "telegram",
+ channelName,
telegramCfg,
bus,
- telegramCfg.AllowFrom,
+ bc.AllowFrom,
channels.WithMaxMessageLength(4000),
- channels.WithGroupTrigger(telegramCfg.GroupTrigger),
- channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
+ channels.WithGroupTrigger(bc.GroupTrigger),
+ channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
- return &TelegramChannel{
+ ch := &TelegramChannel{
BaseChannel: base,
bot: bot,
- config: cfg,
+ bc: bc,
chatIDs: make(map[string]int64),
- }, nil
+ tgCfg: telegramCfg,
+ }
+ ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
+ return ch, nil
}
func (c *TelegramChannel) Start(ctx context.Context) error {
@@ -162,6 +172,9 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
if c.cancel != nil {
c.cancel()
}
+ if c.progress != nil {
+ c.progress.StopAll()
+ }
if c.commandRegCancel != nil {
c.commandRegCancel()
}
@@ -174,9 +187,9 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
return nil, channels.ErrNotRunning
}
- useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
+ useMarkdownV2 := c.tgCfg.UseMarkdownV2
- chatID, threadID, err := parseTelegramChatID(msg.ChatID)
+ chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context)
if err != nil {
return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
@@ -185,12 +198,36 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
return nil, nil
}
+ isToolFeedback := outboundMessageIsToolFeedback(msg)
+ toolFeedbackContent := msg.Content
+ if isToolFeedback {
+ toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096)
+ }
+ trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context)
+ if isToolFeedback {
+ if msgID, handled, err := c.progress.Update(ctx, trackedChatID, toolFeedbackContent); handled {
+ if err != nil {
+ return nil, err
+ }
+ return []string{msgID}, nil
+ }
+ }
+ trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID)
+ if !isToolFeedback {
+ if msgIDs, handled := c.finalizeToolFeedbackMessageForChat(ctx, trackedChatID, msg); handled {
+ return msgIDs, nil
+ }
+ }
+
// 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.
replyToID := msg.ReplyToMessageID
var messageIDs []string
queue := []string{msg.Content}
+ if isToolFeedback {
+ queue = []string{channels.InitialAnimatedToolFeedbackContent(toolFeedbackContent)}
+ }
for len(queue) > 0 {
chunk := queue[0]
queue = queue[1:]
@@ -198,6 +235,13 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
content := parseContent(chunk, useMarkdownV2)
if len([]rune(content)) > 4096 {
+ if isToolFeedback {
+ fittedChunk := fitToolFeedbackForTelegram(chunk, useMarkdownV2, 4096)
+ if fittedChunk != "" && fittedChunk != chunk {
+ queue = append([]string{fittedChunk}, queue...)
+ continue
+ }
+ }
runeChunk := []rune(chunk)
ratio := float64(len(runeChunk)) / float64(len([]rune(content)))
smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
@@ -264,6 +308,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
replyToID = ""
}
+ if isToolFeedback && len(messageIDs) > 0 {
+ c.RecordToolFeedbackMessage(trackedChatID, messageIDs[0], toolFeedbackContent)
+ } else if !isToolFeedback && hasTrackedMsg {
+ c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID)
+ }
+
return messageIDs, nil
}
@@ -360,7 +410,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
// EditMessage implements channels.MessageEditor.
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
- useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
+ useMarkdownV2 := c.tgCfg.UseMarkdownV2
cid, _, err := parseTelegramChatID(chatID)
if err != nil {
return err
@@ -431,11 +481,94 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess
})
}
+func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
+ if len(msg.Context.Raw) == 0 {
+ return false
+ }
+ return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
+}
+
+func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
+ if c.progress == nil {
+ return "", false
+ }
+ return c.progress.Current(chatID)
+}
+
+func (c *TelegramChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) {
+ if c.progress == nil {
+ return "", "", false
+ }
+ return c.progress.Take(chatID)
+}
+
+func (c *TelegramChannel) RecordToolFeedbackMessage(chatID, messageID, content string) {
+ if c.progress == nil {
+ return
+ }
+ c.progress.Record(chatID, messageID, content)
+}
+
+func (c *TelegramChannel) ClearToolFeedbackMessage(chatID string) {
+ if c.progress == nil {
+ return
+ }
+ c.progress.Clear(chatID)
+}
+
+func (c *TelegramChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) {
+ msgID, ok := c.currentToolFeedbackMessage(chatID)
+ if !ok {
+ return
+ }
+ c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID)
+}
+
+func (c *TelegramChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) {
+ if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" {
+ return
+ }
+ c.ClearToolFeedbackMessage(chatID)
+ _ = c.DeleteMessage(ctx, chatID, messageID)
+}
+
+func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage(
+ ctx context.Context,
+ chatID string,
+ content string,
+ editFn func(context.Context, string, string, string) error,
+) ([]string, bool) {
+ msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID)
+ if !ok || editFn == nil {
+ return nil, false
+ }
+ if err := editFn(ctx, chatID, msgID, content); err != nil {
+ c.RecordToolFeedbackMessage(chatID, msgID, baseContent)
+ return nil, false
+ }
+ return []string{msgID}, true
+}
+
+func (c *TelegramChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) {
+ if outboundMessageIsToolFeedback(msg) {
+ return nil, false
+ }
+ return c.finalizeToolFeedbackMessageForChat(ctx, telegramToolFeedbackChatKey(msg.ChatID, &msg.Context), msg)
+}
+
+func (c *TelegramChannel) finalizeToolFeedbackMessageForChat(
+ ctx context.Context,
+ chatID string,
+ msg bus.OutboundMessage,
+) ([]string, bool) {
+ return c.finalizeTrackedToolFeedbackMessage(ctx, chatID, msg.Content, c.EditMessage)
+}
+
// SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
// edited to the actual response via EditMessage (channels.MessageEditor).
func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
- phCfg := c.config.Channels.Telegram.Placeholder
+ phCfg := c.bc.Placeholder
if !phCfg.Enabled {
return "", nil
}
@@ -462,8 +595,10 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
+ trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context)
+ trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID)
- chatID, threadID, err := parseTelegramChatID(msg.ChatID)
+ chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context)
if err != nil {
return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
@@ -570,6 +705,10 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
}
}
+ if hasTrackedMsg {
+ c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID)
+ }
+
return messageIDs, nil
}
@@ -691,8 +830,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
}
// In group chats, apply unified group trigger filtering
+ isMentioned := false
if message.Chat.Type != "private" {
- isMentioned := c.isBotMentioned(message)
+ isMentioned = c.isBotMentioned(message)
if isMentioned {
content = c.stripBotMention(content)
}
@@ -738,13 +878,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
})
peerKind := "direct"
- peerID := fmt.Sprintf("%d", user.ID)
if message.Chat.Type != "private" {
peerKind = "group"
- peerID = compositeChatID
}
-
- peer := bus.Peer{Kind: peerKind, ID: peerID}
messageID := fmt.Sprintf("%d", message.MessageID)
metadata := map[string]string{
@@ -753,24 +889,29 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
- if message.ReplyToMessage != nil {
- metadata["reply_to_message_id"] = fmt.Sprintf("%d", message.ReplyToMessage.MessageID)
- }
- // Set parent_peer metadata for per-topic agent binding.
+ inboundCtx := bus.InboundContext{
+ Channel: c.Name(),
+ ChatID: fmt.Sprintf("%d", chatID),
+ ChatType: peerKind,
+ SenderID: platformID,
+ MessageID: messageID,
+ Mentioned: isMentioned,
+ Raw: metadata,
+ }
if message.Chat.IsForum && threadID != 0 {
- metadata["parent_peer_kind"] = "topic"
- metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID)
+ inboundCtx.TopicID = fmt.Sprintf("%d", threadID)
+ }
+ if message.ReplyToMessage != nil {
+ inboundCtx.ReplyToMessageID = fmt.Sprintf("%d", message.ReplyToMessage.MessageID)
}
- c.HandleMessage(c.ctx,
- peer,
- messageID,
- platformID,
+ c.HandleMessageWithContext(
+ c.ctx,
compositeChatID,
content,
mediaPaths,
- metadata,
+ inboundCtx,
sender,
)
return nil
@@ -939,6 +1080,60 @@ func parseContent(text string, useMarkdownV2 bool) string {
return markdownToTelegramHTML(text)
}
+func fitToolFeedbackForTelegram(content string, useMarkdownV2 bool, maxParsedLen int) string {
+ content = strings.TrimSpace(content)
+ if content == "" || maxParsedLen <= 0 {
+ return ""
+ }
+ animationSafeLen := maxParsedLen - channels.MaxToolFeedbackAnimationFrameLength()
+ if animationSafeLen <= 0 {
+ animationSafeLen = maxParsedLen
+ }
+ if len([]rune(parseContent(content, useMarkdownV2))) <= animationSafeLen {
+ return content
+ }
+
+ low := 1
+ high := len([]rune(content))
+ best := utils.Truncate(content, 1)
+
+ for low <= high {
+ mid := (low + high) / 2
+ candidate := utils.FitToolFeedbackMessage(content, mid)
+ if candidate == "" {
+ high = mid - 1
+ continue
+ }
+ if len([]rune(parseContent(candidate, useMarkdownV2))) <= animationSafeLen {
+ best = candidate
+ low = mid + 1
+ continue
+ }
+ high = mid - 1
+ }
+
+ return best
+}
+
+func (c *TelegramChannel) PrepareToolFeedbackMessageContent(content string) string {
+ if c == nil || c.tgCfg == nil {
+ return strings.TrimSpace(content)
+ }
+ return fitToolFeedbackForTelegram(content, c.tgCfg.UseMarkdownV2, 4096)
+}
+
+func telegramToolFeedbackChatKey(chatID string, outboundCtx *bus.InboundContext) string {
+ resolvedChatID, threadID, err := resolveTelegramOutboundTarget(chatID, outboundCtx)
+ if err != nil || threadID == 0 {
+ return strings.TrimSpace(chatID)
+ }
+ return fmt.Sprintf("%d/%d", resolvedChatID, threadID)
+}
+
+func (c *TelegramChannel) ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string {
+ return telegramToolFeedbackChatKey(chatID, outboundCtx)
+}
+
// parseTelegramChatID splits "chatID/threadID" into its components.
// Returns threadID=0 when no "/" is present (non-forum messages).
func parseTelegramChatID(chatID string) (int64, int, error) {
@@ -958,6 +1153,28 @@ func parseTelegramChatID(chatID string) (int64, int, error) {
return cid, tid, nil
}
+func resolveTelegramOutboundTarget(chatID string, outboundCtx *bus.InboundContext) (int64, int, error) {
+ targetChatID := strings.TrimSpace(chatID)
+ if targetChatID == "" && outboundCtx != nil {
+ targetChatID = strings.TrimSpace(outboundCtx.ChatID)
+ }
+ resolvedChatID, resolvedThreadID, err := parseTelegramChatID(targetChatID)
+ if err != nil {
+ return 0, 0, err
+ }
+ if resolvedThreadID != 0 || outboundCtx == nil {
+ return resolvedChatID, resolvedThreadID, nil
+ }
+ topicID := strings.TrimSpace(outboundCtx.TopicID)
+ if topicID == "" {
+ return resolvedChatID, resolvedThreadID, nil
+ }
+ if threadID, convErr := strconv.Atoi(topicID); convErr == nil {
+ return resolvedChatID, threadID, nil
+ }
+ return resolvedChatID, resolvedThreadID, nil
+}
+
func logParseFailed(err error, useMarkdownV2 bool) {
parsingName := "HTML"
if useMarkdownV2 {
@@ -1063,19 +1280,20 @@ func (c *TelegramChannel) stripBotMention(content string) string {
// BeginStream implements channels.StreamingCapable.
func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) {
- if !c.config.Channels.Telegram.Streaming.Enabled {
+ if !c.tgCfg.Streaming.Enabled {
return nil, fmt.Errorf("streaming disabled in config")
}
- cid, _, err := parseTelegramChatID(chatID)
+ cid, threadID, err := parseTelegramChatID(chatID)
if err != nil {
return nil, err
}
- streamCfg := c.config.Channels.Telegram.Streaming
+ streamCfg := c.tgCfg.Streaming
return &telegramStreamer{
bot: c.bot,
chatID: cid,
+ threadID: threadID,
draftID: cryptoRandInt(),
throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second,
minGrowth: streamCfg.MinGrowthChars,
@@ -1088,6 +1306,7 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann
type telegramStreamer struct {
bot *telego.Bot
chatID int64
+ threadID int
draftID int
throttleInterval time.Duration
minGrowth int
@@ -1115,10 +1334,11 @@ func (s *telegramStreamer) Update(ctx context.Context, content string) error {
htmlContent := markdownToTelegramHTML(content)
err := s.bot.SendMessageDraft(ctx, &telego.SendMessageDraftParams{
- ChatID: s.chatID,
- DraftID: s.draftID,
- Text: htmlContent,
- ParseMode: telego.ModeHTML,
+ ChatID: s.chatID,
+ MessageThreadID: s.threadID,
+ DraftID: s.draftID,
+ Text: htmlContent,
+ ParseMode: telego.ModeHTML,
})
if err != nil {
// First error → degrade silently (e.g. no forum mode)
@@ -1137,6 +1357,7 @@ func (s *telegramStreamer) Update(ctx context.Context, content string) error {
func (s *telegramStreamer) Finalize(ctx context.Context, content string) error {
htmlContent := markdownToTelegramHTML(content)
tgMsg := tu.Message(tu.ID(s.chatID), htmlContent)
+ tgMsg.MessageThreadID = s.threadID
tgMsg.ParseMode = telego.ModeHTML
if _, err := s.bot.SendMessage(ctx, tgMsg); err != nil {
diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go
index 614b2ca7f..20b2004a9 100644
--- a/pkg/channels/telegram/telegram_group_command_filter_test.go
+++ b/pkg/channels/telegram/telegram_group_command_filter_test.go
@@ -108,7 +108,7 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {
t.Fatalf("handleMessage error: %v", err)
}
- ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond)
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
select {
case <-ctx.Done():
diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go
index 4f7a2600b..69c76b430 100644
--- a/pkg/channels/telegram/telegram_test.go
+++ b/pkg/channels/telegram/telegram_test.go
@@ -98,8 +98,12 @@ func (s *multipartRecordingConstructor) MultipartRequest(
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
func successResponse(t *testing.T) *ta.Response {
+ return successResponseWithMessageID(t, 1)
+}
+
+func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response {
t.Helper()
- msg := &telego.Message{MessageID: 1}
+ msg := &telego.Message{MessageID: messageID}
b, err := json.Marshal(msg)
require.NoError(t, err)
return &ta.Response{Ok: true, Result: b}
@@ -140,7 +144,9 @@ func newTestChannelWithConstructor(
BaseChannel: base,
bot: bot,
chatIDs: make(map[string]int64),
- config: config.DefaultConfig(),
+ bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true},
+ tgCfg: &config.TelegramSettings{},
+ progress: channels.NewToolFeedbackAnimator(nil),
}
}
@@ -265,6 +271,176 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) {
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
}
+func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) {
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ switch {
+ case strings.Contains(url, "editMessageText"):
+ return successResponseWithMessageID(t, 1), nil
+ default:
+ t.Fatalf("unexpected API call: %s", url)
+ return nil, nil
+ }
+ },
+ }
+ ch := newTestChannel(t, caller)
+ ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`")
+
+ ids, err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "12345",
+ Content: "final reply",
+ })
+
+ assert.NoError(t, err)
+ assert.Equal(t, []string{"1"}, ids)
+ require.Len(t, caller.calls, 1)
+ assert.Contains(t, caller.calls[0].URL, "editMessageText")
+ _, ok := ch.currentToolFeedbackMessage("12345")
+ assert.False(t, ok, "tracked tool feedback should be cleared after final reply")
+}
+
+func TestSend_ToolFeedbackTrackingIsTopicScoped(t *testing.T) {
+ nextMessageID := 0
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ nextMessageID++
+ return successResponseWithMessageID(t, nextMessageID), nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "-1001234567890",
+ Content: "🔧 `read_file`",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ ChatID: "-1001234567890",
+ TopicID: "42",
+ Raw: map[string]string{
+ "message_kind": "tool_feedback",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ _, ok := ch.currentToolFeedbackMessage("-1001234567890")
+ assert.False(t, ok, "base chat should not track topic-specific tool feedback")
+
+ msgID, ok := ch.currentToolFeedbackMessage("-1001234567890/42")
+ require.True(t, ok, "topic chat should track tool feedback")
+ assert.Equal(t, "1", msgID)
+}
+
+func TestSend_TopicReplyDoesNotFinalizeDifferentTopicToolFeedback(t *testing.T) {
+ nextMessageID := 0
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ nextMessageID++
+ return successResponseWithMessageID(t, nextMessageID), nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "-1001234567890",
+ Content: "🔧 `read_file`",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ ChatID: "-1001234567890",
+ TopicID: "42",
+ Raw: map[string]string{
+ "message_kind": "tool_feedback",
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ ids, err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "-1001234567890",
+ Content: "final reply in another topic",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ ChatID: "-1001234567890",
+ TopicID: "43",
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, caller.calls, 2)
+ assert.Equal(t, []string{"2"}, ids)
+ assert.Contains(t, caller.calls[1].URL, "sendMessage")
+ assert.NotContains(t, caller.calls[1].URL, "editMessageText")
+
+ _, ok := ch.currentToolFeedbackMessage("-1001234567890/42")
+ assert.True(t, ok, "tool feedback in the original topic should remain tracked")
+}
+
+func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) {
+ ch := newTestChannel(t, &stubCaller{
+ callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) {
+ t.Fatal("unexpected API call")
+ return nil, nil
+ },
+ })
+ ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`")
+
+ msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage(
+ context.Background(),
+ "12345",
+ "final reply",
+ func(_ context.Context, chatID, messageID, content string) error {
+ _, ok := ch.currentToolFeedbackMessage(chatID)
+ assert.False(t, ok, "tracked tool feedback should be stopped before edit")
+ assert.Equal(t, "12345", chatID)
+ assert.Equal(t, "1", messageID)
+ assert.Equal(t, "final reply", content)
+ return nil
+ },
+ )
+
+ assert.True(t, handled)
+ assert.Equal(t, []string{"1"}, msgIDs)
+}
+
+func TestSend_ToolFeedbackStaysSingleMessageAfterHTMLExpansion(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: "🔧 `read_file`\n" + strings.Repeat("<", 2000),
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ ChatID: "12345",
+ Raw: map[string]string{
+ "message_kind": "tool_feedback",
+ },
+ },
+ })
+
+ assert.NoError(t, err)
+ assert.Len(t, caller.calls, 1, "tool feedback should stay a single Telegram message after HTML escaping")
+}
+
+func TestFitToolFeedbackForTelegram_ReservesAnimationFrame(t *testing.T) {
+ content := "🔧 `read_file`\n" + strings.Repeat("a", 4096)
+
+ fitted := fitToolFeedbackForTelegram(content, false, 4096)
+ animated := strings.Replace(
+ fitted,
+ "`\n",
+ strings.Repeat(".", channels.MaxToolFeedbackAnimationFrameLength())+"`\n",
+ 1,
+ )
+
+ if got := len([]rune(parseContent(animated, false))); got > 4096 {
+ t.Fatalf("animated parsed length = %d, want <= 4096", got)
+ }
+}
+
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
@@ -527,6 +703,90 @@ func TestSend_WithForumThreadID(t *testing.T) {
assert.Len(t, caller.calls, 1)
}
+func TestSend_UsesContextTopicIDWhenChatIDDoesNotIncludeThread(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",
+ Content: "Hello from topic context",
+ Context: bus.InboundContext{
+ Channel: "telegram",
+ ChatID: "-1001234567890",
+ TopicID: "42",
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, caller.calls, 1)
+
+ var params struct {
+ ChatID int64 `json:"chat_id"`
+ MessageThreadID int `json:"message_thread_id"`
+ Text string `json:"text"`
+ }
+ require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms))
+ assert.Equal(t, int64(-1001234567890), params.ChatID)
+ assert.Equal(t, 42, params.MessageThreadID)
+ assert.Equal(t, "Hello from topic context", params.Text)
+}
+
+func TestBeginStream_UpdateUsesForumThreadID(t *testing.T) {
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ return &ta.Response{Ok: true, Result: []byte("true")}, nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+ ch.tgCfg.Streaming.Enabled = true
+
+ streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42")
+ require.NoError(t, err)
+ require.NoError(t, streamer.Update(context.Background(), "partial"))
+ require.Len(t, caller.calls, 1)
+ assert.Contains(t, caller.calls[0].URL, "sendMessageDraft")
+
+ var params struct {
+ ChatID int64 `json:"chat_id"`
+ MessageThreadID int `json:"message_thread_id"`
+ Text string `json:"text"`
+ }
+ require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms))
+ assert.Equal(t, int64(-1001234567890), params.ChatID)
+ assert.Equal(t, 42, params.MessageThreadID)
+ assert.Equal(t, "partial", params.Text)
+}
+
+func TestBeginStream_FinalizeUsesForumThreadID(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)
+ ch.tgCfg.Streaming.Enabled = true
+
+ streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42")
+ require.NoError(t, err)
+ require.NoError(t, streamer.Finalize(context.Background(), "final"))
+ require.Len(t, caller.calls, 1)
+ assert.Contains(t, caller.calls[0].URL, "sendMessage")
+
+ var params struct {
+ ChatID int64 `json:"chat_id"`
+ MessageThreadID int `json:"message_thread_id"`
+ Text string `json:"text"`
+ }
+ require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms))
+ assert.Equal(t, int64(-1001234567890), params.ChatID)
+ assert.Equal(t, 42, params.MessageThreadID)
+ assert.Equal(t, "final", params.Text)
+}
+
func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
@@ -556,16 +816,10 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
inbound, ok := <-messageBus.InboundChan()
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"])
+ // ChatID remains the parent chat; TopicID isolates the sub-conversation.
+ assert.Equal(t, "-1001234567890", inbound.ChatID)
+ assert.Equal(t, "group", inbound.Context.ChatType)
+ assert.Equal(t, "42", inbound.Context.TopicID)
}
func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
@@ -598,13 +852,8 @@ func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
// 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"])
+ assert.Equal(t, "group", inbound.Context.ChatType)
+ assert.Empty(t, inbound.Context.TopicID)
}
func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
@@ -641,13 +890,8 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
// 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"])
+ assert.Equal(t, "group", inbound.Context.ChatType)
+ assert.Empty(t, inbound.Context.TopicID)
}
func assertHandleMessageQuotedUserReply(
@@ -700,7 +944,7 @@ func assertHandleMessageQuotedUserReply(
inbound, ok := <-messageBus.InboundChan()
require.True(t, ok)
- assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Metadata["reply_to_message_id"])
+ assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Context.ReplyToMessageID)
assert.Equal(t, expectedContent, inbound.Content)
}
@@ -786,7 +1030,7 @@ func TestHandleMessage_ReplyToOwnBotMessage_UsesAssistantRole(t *testing.T) {
inbound, ok := <-messageBus.InboundChan()
require.True(t, ok)
- assert.Equal(t, "101", inbound.Metadata["reply_to_message_id"])
+ assert.Equal(t, "101", inbound.Context.ReplyToMessageID)
assert.Equal(
t,
"[quoted assistant message from afjcjsbx_picoclaw_bot]: Fatto! Ho creato il file notizie_2026_03_28.md\n\nti ricordi questo file?",
diff --git a/pkg/channels/tool_feedback_animator.go b/pkg/channels/tool_feedback_animator.go
new file mode 100644
index 000000000..b424612bf
--- /dev/null
+++ b/pkg/channels/tool_feedback_animator.go
@@ -0,0 +1,240 @@
+package channels
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "time"
+)
+
+const toolFeedbackAnimationInterval = 3 * time.Second
+
+const initialToolFeedbackAnimationFrame = ""
+
+var toolFeedbackAnimationFrames = []string{"..", "."}
+
+// MaxToolFeedbackAnimationFrameLength returns the largest frame suffix length
+// so callers can reserve room before sending messages to length-limited APIs.
+func MaxToolFeedbackAnimationFrameLength() int {
+ maxLen := len([]rune(initialToolFeedbackAnimationFrame))
+ for _, frame := range toolFeedbackAnimationFrames {
+ if frameLen := len([]rune(frame)); frameLen > maxLen {
+ maxLen = frameLen
+ }
+ }
+ return maxLen
+}
+
+type toolFeedbackAnimationState struct {
+ messageID string
+ baseContent string
+ stop chan struct{}
+ done chan struct{}
+}
+
+type ToolFeedbackAnimator struct {
+ mu sync.Mutex
+ editFn func(ctx context.Context, chatID, messageID, content string) error
+ entries map[string]*toolFeedbackAnimationState
+}
+
+func NewToolFeedbackAnimator(
+ editFn func(ctx context.Context, chatID, messageID, content string) error,
+) *ToolFeedbackAnimator {
+ return &ToolFeedbackAnimator{
+ editFn: editFn,
+ entries: make(map[string]*toolFeedbackAnimationState),
+ }
+}
+
+func (a *ToolFeedbackAnimator) Current(chatID string) (string, bool) {
+ if a == nil || strings.TrimSpace(chatID) == "" {
+ return "", false
+ }
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ entry, ok := a.entries[chatID]
+ if !ok || strings.TrimSpace(entry.messageID) == "" {
+ return "", false
+ }
+ return entry.messageID, true
+}
+
+func (a *ToolFeedbackAnimator) Record(chatID, messageID, content string) {
+ if a == nil {
+ return
+ }
+ chatID = strings.TrimSpace(chatID)
+ messageID = strings.TrimSpace(messageID)
+ content = strings.TrimSpace(content)
+ if chatID == "" || messageID == "" || content == "" {
+ return
+ }
+
+ entry := &toolFeedbackAnimationState{
+ messageID: messageID,
+ baseContent: content,
+ stop: make(chan struct{}),
+ done: make(chan struct{}),
+ }
+
+ var previous *toolFeedbackAnimationState
+ a.mu.Lock()
+ if old, ok := a.entries[chatID]; ok {
+ previous = old
+ }
+ a.entries[chatID] = entry
+ a.mu.Unlock()
+
+ stopToolFeedbackAnimation(previous)
+ go a.run(chatID, entry)
+}
+
+func (a *ToolFeedbackAnimator) Clear(chatID string) {
+ if a == nil || strings.TrimSpace(chatID) == "" {
+ return
+ }
+ entry := a.detach(chatID)
+ stopToolFeedbackAnimation(entry)
+}
+
+func (a *ToolFeedbackAnimator) Take(chatID string) (string, string, bool) {
+ if a == nil || strings.TrimSpace(chatID) == "" {
+ return "", "", false
+ }
+ entry := a.detach(chatID)
+ if entry == nil || strings.TrimSpace(entry.messageID) == "" {
+ return "", "", false
+ }
+ stopToolFeedbackAnimation(entry)
+ return entry.messageID, entry.baseContent, true
+}
+
+// Update edits an existing tracked feedback message. If the edit fails, the
+// previous feedback state is restored so callers can retry without orphaning
+// the old progress message.
+func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content string) (string, bool, error) {
+ if a == nil || a.editFn == nil {
+ return "", false, nil
+ }
+ msgID, baseContent, ok := a.Take(chatID)
+ if !ok {
+ return "", false, nil
+ }
+
+ animatedContent := InitialAnimatedToolFeedbackContent(content)
+ if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil {
+ a.Record(chatID, msgID, baseContent)
+ return "", true, err
+ }
+
+ a.Record(chatID, msgID, content)
+ return msgID, true, nil
+}
+
+func (a *ToolFeedbackAnimator) StopAll() {
+ if a == nil {
+ return
+ }
+ a.mu.Lock()
+ entries := make([]*toolFeedbackAnimationState, 0, len(a.entries))
+ for chatID, entry := range a.entries {
+ entries = append(entries, entry)
+ delete(a.entries, chatID)
+ }
+ a.mu.Unlock()
+
+ for _, entry := range entries {
+ stopToolFeedbackAnimation(entry)
+ }
+}
+
+func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState {
+ if a == nil || strings.TrimSpace(chatID) == "" {
+ return nil
+ }
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ entry := a.entries[chatID]
+ delete(a.entries, chatID)
+ return entry
+}
+
+func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) {
+ defer close(entry.done)
+
+ ticker := time.NewTicker(toolFeedbackAnimationInterval)
+ defer ticker.Stop()
+
+ frameIdx := 1
+
+ for {
+ select {
+ case <-entry.stop:
+ return
+ case <-ticker.C:
+ if a.editFn == nil {
+ continue
+ }
+ frame := toolFeedbackAnimationFrames[frameIdx%len(toolFeedbackAnimationFrames)]
+ content := formatAnimatedToolFeedbackContent(entry.baseContent, frame)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ _ = a.editFn(ctx, chatID, entry.messageID, content)
+ cancel()
+ frameIdx++
+ }
+ }
+}
+
+func InitialAnimatedToolFeedbackContent(baseContent string) string {
+ return formatAnimatedToolFeedbackContent(baseContent, initialToolFeedbackAnimationFrame)
+}
+
+func formatAnimatedToolFeedbackContent(baseContent, frame string) string {
+ baseContent = strings.TrimSpace(baseContent)
+ frame = strings.TrimSpace(frame)
+ if baseContent == "" {
+ return ""
+ }
+ if frame == "" {
+ return baseContent
+ }
+ lineBreak := strings.IndexByte(baseContent, '\n')
+ if lineBreak < 0 {
+ return appendToolFeedbackFrame(baseContent, frame)
+ }
+ return appendToolFeedbackFrame(baseContent[:lineBreak], frame) + baseContent[lineBreak:]
+}
+
+func appendToolFeedbackFrame(firstLine, frame string) string {
+ firstLine = strings.TrimSpace(firstLine)
+ frame = strings.TrimSpace(frame)
+ if firstLine == "" {
+ return ""
+ }
+ if frame == "" {
+ return firstLine
+ }
+
+ openTick := strings.IndexByte(firstLine, '`')
+ if openTick >= 0 {
+ if closeOffset := strings.IndexByte(firstLine[openTick+1:], '`'); closeOffset >= 0 {
+ closeTick := openTick + 1 + closeOffset
+ return firstLine[:closeTick] + frame + firstLine[closeTick:]
+ }
+ }
+
+ return firstLine + frame
+}
+
+func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) {
+ if entry == nil {
+ return
+ }
+ select {
+ case <-entry.stop:
+ default:
+ close(entry.stop)
+ }
+ <-entry.done
+}
diff --git a/pkg/channels/tool_feedback_animator_test.go b/pkg/channels/tool_feedback_animator_test.go
new file mode 100644
index 000000000..a23284548
--- /dev/null
+++ b/pkg/channels/tool_feedback_animator_test.go
@@ -0,0 +1,121 @@
+package channels
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestFormatAnimatedToolFeedbackContent(t *testing.T) {
+ got := formatAnimatedToolFeedbackContent("🔧 `read_file`\nReading config file", "running..")
+ want := "🔧 `read_filerunning..`\nReading config file"
+ if got != want {
+ t.Fatalf("formatAnimatedToolFeedbackContent() = %q, want %q", got, want)
+ }
+}
+
+func TestInitialAnimatedToolFeedbackContent(t *testing.T) {
+ got := InitialAnimatedToolFeedbackContent("🔧 `exec`\nRunning command")
+ want := "🔧 `exec`\nRunning command"
+ if got != want {
+ t.Fatalf("InitialAnimatedToolFeedbackContent() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatAnimatedToolFeedbackContent_WithoutCodeSpan(t *testing.T) {
+ got := formatAnimatedToolFeedbackContent("hello", "running..")
+ want := "hellorunning.."
+ if got != want {
+ t.Fatalf("formatAnimatedToolFeedbackContent() without code span = %q, want %q", got, want)
+ }
+}
+
+func TestToolFeedbackAnimator_RecordCurrentAndClear(t *testing.T) {
+ animator := NewToolFeedbackAnimator(nil)
+ animator.Record("chat-1", "msg-1", "🔧 `read_file`")
+
+ msgID, ok := animator.Current("chat-1")
+ if !ok || msgID != "msg-1" {
+ t.Fatalf("Current() = (%q, %v), want (msg-1, true)", msgID, ok)
+ }
+
+ animator.Clear("chat-1")
+
+ msgID, ok = animator.Current("chat-1")
+ if ok || msgID != "" {
+ t.Fatalf("Current() after Clear = (%q, %v), want (\"\", false)", msgID, ok)
+ }
+}
+
+func TestToolFeedbackAnimator_TakeStopsTrackingAndReturnsState(t *testing.T) {
+ animator := NewToolFeedbackAnimator(nil)
+ animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config")
+
+ msgID, baseContent, ok := animator.Take("chat-1")
+ if !ok {
+ t.Fatal("Take() = not found, want tracked message")
+ }
+ if msgID != "msg-1" {
+ t.Fatalf("Take() msgID = %q, want msg-1", msgID)
+ }
+ if baseContent != "🔧 `read_file`\nChecking config" {
+ t.Fatalf("Take() baseContent = %q", baseContent)
+ }
+ if _, ok := animator.Current("chat-1"); ok {
+ t.Fatal("expected tracked message to be removed after Take()")
+ }
+}
+
+func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) {
+ var animator *ToolFeedbackAnimator
+ animator = NewToolFeedbackAnimator(func(_ context.Context, chatID, messageID, content string) error {
+ if _, ok := animator.Current(chatID); ok {
+ t.Fatal("expected tracked tool feedback to be stopped before edit")
+ }
+ if messageID != "msg-1" {
+ t.Fatalf("messageID = %q, want msg-1", messageID)
+ }
+ if content != "🔧 `write_file`\nUpdating config" {
+ t.Fatalf("content = %q, want updated animated content", content)
+ }
+ return nil
+ })
+ defer animator.StopAll()
+
+ animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config")
+
+ msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config")
+ if err != nil {
+ t.Fatalf("Update() error = %v", err)
+ }
+ if !handled {
+ t.Fatal("Update() handled = false, want true")
+ }
+ if msgID != "msg-1" {
+ t.Fatalf("Update() msgID = %q, want msg-1", msgID)
+ }
+}
+
+func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) {
+ editErr := errors.New("edit failed")
+ animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error {
+ return editErr
+ })
+ defer animator.StopAll()
+
+ animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config")
+
+ msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config")
+ if !handled {
+ t.Fatal("Update() handled = false, want true")
+ }
+ if !errors.Is(err, editErr) {
+ t.Fatalf("Update() error = %v, want editErr", err)
+ }
+ if msgID != "" {
+ t.Fatalf("Update() msgID = %q, want empty on failed edit", msgID)
+ }
+ if currentID, ok := animator.Current("chat-1"); !ok || currentID != "msg-1" {
+ t.Fatalf("Current() after failed Update = (%q, %v), want (msg-1, true)", currentID, ok)
+ }
+}
diff --git a/pkg/channels/vk/init.go b/pkg/channels/vk/init.go
index 6a5927a32..deca297d5 100644
--- a/pkg/channels/vk/init.go
+++ b/pkg/channels/vk/init.go
@@ -7,7 +7,14 @@ import (
)
func init() {
- channels.RegisterFactory("vk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewVKChannel(cfg, b)
- })
+ channels.RegisterFactory(
+ config.ChannelVK,
+ func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ bc := cfg.Channels[channelName]
+ if bc == nil {
+ return nil, channels.ErrSendFailed
+ }
+ return NewVKChannel(channelName, bc, b)
+ },
+ )
}
diff --git a/pkg/channels/vk/vk.go b/pkg/channels/vk/vk.go
index 92fbcf4ad..b27431ba0 100644
--- a/pkg/channels/vk/vk.go
+++ b/pkg/channels/vk/vk.go
@@ -21,41 +21,54 @@ import (
type VKChannel struct {
*channels.BaseChannel
- vk *api.VK
- lp *longpoll.LongPoll
- config *config.Config
- ctx context.Context
- cancel context.CancelFunc
+ vk *api.VK
+ lp *longpoll.LongPoll
+ channelName string
+ bc *config.Channel
+ ctx context.Context
+ cancel context.CancelFunc
}
-func NewVKChannel(cfg *config.Config, bus *bus.MessageBus) (*VKChannel, error) {
- vkCfg := cfg.Channels.VK
+func NewVKChannel(channelName string, bc *config.Channel, bus *bus.MessageBus) (*VKChannel, error) {
+ var vkCfg config.VKSettings
+ if err := bc.Decode(&vkCfg); err != nil {
+ return nil, err
+ }
vk := api.NewVK(vkCfg.Token.String())
base := channels.NewBaseChannel(
- "vk",
- vkCfg,
+ channelName,
+ &vkCfg,
bus,
- vkCfg.AllowFrom,
+ bc.AllowFrom,
channels.WithMaxMessageLength(4000),
- channels.WithGroupTrigger(vkCfg.GroupTrigger),
- channels.WithReasoningChannelID(vkCfg.ReasoningChannelID),
+ channels.WithGroupTrigger(bc.GroupTrigger),
+ channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &VKChannel{
BaseChannel: base,
vk: vk,
- config: cfg,
+ channelName: channelName,
+ bc: bc,
}, nil
}
+func (c *VKChannel) getVKCfg() *config.VKSettings {
+ var v config.VKSettings
+ if err := c.bc.Decode(&v); err != nil {
+ return nil
+ }
+ return &v
+}
+
func (c *VKChannel) Start(ctx context.Context) error {
logger.InfoC("vk", "Starting VK bot (Long Poll mode)...")
c.ctx, c.cancel = context.WithCancel(ctx)
- groupID := c.config.Channels.VK.GroupID
+ groupID := c.getVKCfg().GroupID
if groupID == 0 {
c.cancel()
return fmt.Errorf("group_id is required for VK bot")
@@ -143,7 +156,7 @@ func (c *VKChannel) handleMessage(msg object.MessagesMessage) {
return
}
- groupTrigger := c.config.Channels.VK.GroupTrigger
+ groupTrigger := c.bc.GroupTrigger
isGroupChat := peerID != fromID
if isGroupChat {
@@ -159,14 +172,11 @@ func (c *VKChannel) handleMessage(msg object.MessagesMessage) {
_ = groupTrigger
}
- peerKind := "direct"
- peerIDStr := userID
+ chatType := "direct"
if isGroupChat {
- peerKind = "group"
- peerIDStr = chatID
+ chatType = "group"
}
- peer := bus.Peer{Kind: peerKind, ID: peerIDStr}
messageID := strconv.Itoa(msg.ConversationMessageID)
metadata := map[string]string{
@@ -174,16 +184,15 @@ func (c *VKChannel) handleMessage(msg object.MessagesMessage) {
"is_group": fmt.Sprintf("%t", isGroupChat),
}
- c.HandleMessage(c.ctx,
- peer,
- messageID,
- userID,
- chatID,
- text,
- nil,
- metadata,
- sender,
- )
+ c.HandleInboundContext(c.ctx, chatID, text, nil, bus.InboundContext{
+ Channel: "vk",
+ ChatID: chatID,
+ ChatType: chatType,
+ SenderID: userID,
+ MessageID: messageID,
+ Mentioned: isGroupChat && c.isMentioned(msg),
+ Raw: metadata,
+ }, sender)
}
func (c *VKChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
diff --git a/pkg/channels/vk/vk_test.go b/pkg/channels/vk/vk_test.go
index c7e62ab31..9583cbf44 100644
--- a/pkg/channels/vk/vk_test.go
+++ b/pkg/channels/vk/vk_test.go
@@ -1,6 +1,7 @@
package vk
import (
+ "encoding/json"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -8,19 +9,23 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
)
+func makeVKTestBaseChannel(vkCfg config.VKSettings) *config.Channel {
+ settings, _ := json.Marshal(vkCfg)
+ return &config.Channel{
+ Enabled: true,
+ Type: config.ChannelVK,
+ Settings: settings,
+ }
+}
+
func TestNewVKChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("missing group_id", func(t *testing.T) {
- cfg := &config.Config{
- Channels: config.ChannelsConfig{
- VK: config.VKConfig{
- Enabled: true,
- Token: *config.NewSecureString("test_token"),
- },
- },
- }
- ch, err := NewVKChannel(cfg, msgBus)
+ bc := makeVKTestBaseChannel(config.VKSettings{
+ Token: *config.NewSecureString("test_token"),
+ })
+ ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error during creation: %v", err)
}
@@ -33,16 +38,11 @@ func TestNewVKChannel(t *testing.T) {
})
t.Run("valid config with group_id", func(t *testing.T) {
- cfg := &config.Config{
- Channels: config.ChannelsConfig{
- VK: config.VKConfig{
- Enabled: true,
- Token: *config.NewSecureString("test_token"),
- GroupID: 123456789,
- },
- },
- }
- ch, err := NewVKChannel(cfg, msgBus)
+ bc := makeVKTestBaseChannel(config.VKSettings{
+ Token: *config.NewSecureString("test_token"),
+ GroupID: 123456789,
+ })
+ ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -55,17 +55,18 @@ func TestNewVKChannel(t *testing.T) {
})
t.Run("with allow_from", func(t *testing.T) {
- cfg := &config.Config{
- Channels: config.ChannelsConfig{
- VK: config.VKConfig{
- Enabled: true,
- Token: *config.NewSecureString("test_token"),
- GroupID: 123456789,
- AllowFrom: []string{"123456789"},
- },
- },
+ vkCfg := config.VKSettings{
+ Token: *config.NewSecureString("test_token"),
+ GroupID: 123456789,
}
- ch, err := NewVKChannel(cfg, msgBus)
+ settings, _ := json.Marshal(vkCfg)
+ bc := &config.Channel{
+ Enabled: true,
+ Type: "vk",
+ AllowFrom: []string{"123456789"},
+ Settings: settings,
+ }
+ ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -78,20 +79,21 @@ func TestNewVKChannel(t *testing.T) {
})
t.Run("with group_trigger", func(t *testing.T) {
- cfg := &config.Config{
- Channels: config.ChannelsConfig{
- VK: config.VKConfig{
- Enabled: true,
- Token: *config.NewSecureString("test_token"),
- GroupID: 123456789,
- GroupTrigger: config.GroupTriggerConfig{
- MentionOnly: false,
- Prefixes: []string{"/bot", "!bot"},
- },
- },
- },
+ vkCfg := config.VKSettings{
+ Token: *config.NewSecureString("test_token"),
+ GroupID: 123456789,
}
- ch, err := NewVKChannel(cfg, msgBus)
+ settings, _ := json.Marshal(vkCfg)
+ bc := &config.Channel{
+ Enabled: true,
+ Type: "vk",
+ GroupTrigger: config.GroupTriggerConfig{
+ MentionOnly: false,
+ Prefixes: []string{"/bot", "!bot"},
+ },
+ Settings: settings,
+ }
+ ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -103,16 +105,11 @@ func TestNewVKChannel(t *testing.T) {
func TestVKChannel_MaxMessageLength(t *testing.T) {
msgBus := bus.NewMessageBus()
- cfg := &config.Config{
- Channels: config.ChannelsConfig{
- VK: config.VKConfig{
- Enabled: true,
- Token: *config.NewSecureString("test_token"),
- GroupID: 123456789,
- },
- },
- }
- ch, err := NewVKChannel(cfg, msgBus)
+ bc := makeVKTestBaseChannel(config.VKSettings{
+ Token: *config.NewSecureString("test_token"),
+ GroupID: 123456789,
+ })
+ ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -236,16 +233,11 @@ func TestVKChannel_ProcessAttachments(t *testing.T) {
func TestVKChannel_VoiceCapabilities(t *testing.T) {
msgBus := bus.NewMessageBus()
- cfg := &config.Config{
- Channels: config.ChannelsConfig{
- VK: config.VKConfig{
- Enabled: true,
- Token: *config.NewSecureString("test_token"),
- GroupID: 123456789,
- },
- },
- }
- ch, err := NewVKChannel(cfg, msgBus)
+ bc := makeVKTestBaseChannel(config.VKSettings{
+ Token: *config.NewSecureString("test_token"),
+ GroupID: 123456789,
+ })
+ ch, err := NewVKChannel("vk", bc, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
diff --git a/pkg/channels/wecom/init.go b/pkg/channels/wecom/init.go
index 3aad84d42..78e51d18e 100644
--- a/pkg/channels/wecom/init.go
+++ b/pkg/channels/wecom/init.go
@@ -7,7 +7,19 @@ import (
)
func init() {
- channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewChannel(cfg.Channels.WeCom, b)
- })
+ channels.RegisterFactory(
+ config.ChannelWeCom,
+ func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ bc := cfg.Channels[channelName]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ return nil, err
+ }
+ c, ok := decoded.(*config.WeComSettings)
+ if !ok {
+ return nil, channels.ErrSendFailed
+ }
+ return NewChannel(bc, c, b)
+ },
+ )
}
diff --git a/pkg/channels/wecom/wecom.go b/pkg/channels/wecom/wecom.go
index 9689d5171..a0a23feda 100644
--- a/pkg/channels/wecom/wecom.go
+++ b/pkg/channels/wecom/wecom.go
@@ -34,7 +34,7 @@ const (
type WeComChannel struct {
*channels.BaseChannel
- config config.WeComConfig
+ config *config.WeComSettings
ctx context.Context
cancel context.CancelFunc
@@ -108,7 +108,7 @@ func (s *recentMessageSet) Mark(id string) bool {
return true
}
-func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChannel, error) {
+func NewChannel(bc *config.Channel, cfg *config.WeComSettings, messageBus *bus.MessageBus) (*WeComChannel, error) {
if cfg.BotID == "" || cfg.Secret.String() == "" {
return nil, fmt.Errorf("wecom bot_id and secret are required")
}
@@ -120,8 +120,8 @@ func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChann
"wecom",
cfg,
messageBus,
- cfg.AllowFrom,
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ bc.AllowFrom,
+ channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
ch := &WeComChannel{
@@ -570,7 +570,6 @@ func (c *WeComChannel) dispatchIncoming(reqID string, msg wecomIncomingMessage)
return err
}
- peer := bus.Peer{Kind: peerKind, ID: actualChatID}
metadata := map[string]string{
"channel": "wecom",
"req_id": reqID,
@@ -583,7 +582,20 @@ func (c *WeComChannel) dispatchIncoming(reqID string, msg wecomIncomingMessage)
metadata["quote_text"] = quoteText
}
- c.HandleMessage(c.ctx, peer, msg.MsgID, senderID, actualChatID, content, mediaRefs, metadata, sender)
+ inboundCtx := bus.InboundContext{
+ Channel: c.Name(),
+ Account: strings.TrimSpace(msg.AIBotID),
+ ChatID: actualChatID,
+ ChatType: peerKind,
+ SenderID: senderID,
+ MessageID: msg.MsgID,
+ ReplyHandles: map[string]string{
+ "req_id": reqID,
+ },
+ Raw: metadata,
+ }
+
+ c.HandleInboundContext(c.ctx, actualChatID, content, mediaRefs, inboundCtx, sender)
return nil
}
diff --git a/pkg/channels/wecom/wecom_test.go b/pkg/channels/wecom/wecom_test.go
index b3a87e246..85a2f6ef7 100644
--- a/pkg/channels/wecom/wecom_test.go
+++ b/pkg/channels/wecom/wecom_test.go
@@ -50,11 +50,11 @@ func TestDispatchIncoming_UsesActualChatIDAndStoresReqIDRoute(t *testing.T) {
if inbound.MessageID != "msg-1" {
t.Fatalf("inbound MessageID = %q, want msg-1", inbound.MessageID)
}
- if inbound.Peer.ID != "chat-1" {
- t.Fatalf("inbound Peer.ID = %q, want chat-1", inbound.Peer.ID)
+ if inbound.Context.ChatType != "direct" {
+ t.Fatalf("inbound Context.ChatType = %q, want direct", inbound.Context.ChatType)
}
- if inbound.Metadata["req_id"] != "req-1" {
- t.Fatalf("inbound req_id = %q, want req-1", inbound.Metadata["req_id"])
+ if inbound.Context.ReplyHandles["req_id"] != "req-1" {
+ t.Fatalf("inbound req_id = %q, want req-1", inbound.Context.ReplyHandles["req_id"])
}
default:
t.Fatal("expected inbound message to be published")
@@ -605,9 +605,10 @@ func TestSendMedia_SendsActiveFile(t *testing.T) {
func newTestWeComChannel(t *testing.T, messageBus *bus.MessageBus) *WeComChannel {
t.Helper()
- cfg := config.WeComConfig{BotID: "bot-1"}
+ cfg := &config.WeComSettings{BotID: "bot-1"}
cfg.SetSecret("secret-1")
- ch, err := NewChannel(cfg, messageBus)
+ bc := &config.Channel{Type: config.ChannelWeCom, Enabled: true}
+ ch, err := NewChannel(bc, cfg, messageBus)
if err != nil {
t.Fatalf("NewChannel() error = %v", err)
}
diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go
index 8fbdd00dd..0f8257895 100644
--- a/pkg/channels/weixin/state.go
+++ b/pkg/channels/weixin/state.go
@@ -44,7 +44,7 @@ func picoclawHomeDir() string {
return config.GetHome()
}
-func genWeixinAccountKey(cfg config.WeixinConfig) string {
+func genWeixinAccountKey(cfg *config.WeixinSettings) string {
token := strings.TrimSpace(cfg.Token.String())
if token == "" {
return "default"
@@ -53,11 +53,11 @@ func genWeixinAccountKey(cfg config.WeixinConfig) string {
return hex.EncodeToString(sum[:8])
}
-func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
+func buildWeixinSyncBufPath(cfg *config.WeixinSettings) string {
return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", genWeixinAccountKey(cfg)+".json")
}
-func buildWeixinContextTokensPath(cfg config.WeixinConfig) string {
+func buildWeixinContextTokensPath(cfg *config.WeixinSettings) string {
return filepath.Join(picoclawHomeDir(), "channels", "weixin", "context-tokens", genWeixinAccountKey(cfg)+".json")
}
diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go
index a0d0c96b5..2897d2422 100644
--- a/pkg/channels/weixin/weixin.go
+++ b/pkg/channels/weixin/weixin.go
@@ -20,7 +20,7 @@ import (
type WeixinChannel struct {
*channels.BaseChannel
api *ApiClient
- config config.WeixinConfig
+ config *config.WeixinSettings
ctx context.Context
cancel context.CancelFunc
bus *bus.MessageBus
@@ -36,25 +36,48 @@ type WeixinChannel struct {
}
func init() {
- channels.RegisterFactory("weixin", func(cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error) {
- return NewWeixinChannel(cfg.Channels.Weixin, bus)
- })
+ channels.RegisterFactory(
+ config.ChannelWeixin,
+ func(channelName, channelType string, cfg *config.Config, bus *bus.MessageBus) (channels.Channel, error) {
+ bc := cfg.Channels[channelName]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ return nil, err
+ }
+ weixinCfg, ok := decoded.(*config.WeixinSettings)
+ if !ok {
+ return nil, channels.ErrSendFailed
+ }
+ ch, err := NewWeixinChannel(bc, weixinCfg, bus)
+ if err != nil {
+ return nil, err
+ }
+ if channelName != config.ChannelWeixin {
+ ch.SetName(channelName)
+ }
+ return ch, nil
+ },
+ )
}
// NewWeixinChannel creates a new WeixinChannel from config.
-func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) {
+func NewWeixinChannel(
+ bc *config.Channel,
+ cfg *config.WeixinSettings,
+ messageBus *bus.MessageBus,
+) (*WeixinChannel, error) {
api, err := NewApiClient(cfg.BaseURL, cfg.Token.String(), cfg.Proxy)
if err != nil {
return nil, fmt.Errorf("weixin: failed to create API client: %w", err)
}
base := channels.NewBaseChannel(
- "weixin",
+ bc.Name(),
cfg,
messageBus,
- cfg.AllowFrom,
+ bc.AllowFrom,
channels.WithMaxMessageLength(4000),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &WeixinChannel{
@@ -334,8 +357,6 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
return
}
- peer := bus.Peer{Kind: "direct", ID: fromUserID}
-
metadata := map[string]string{
"from_user_id": fromUserID,
"context_token": msg.ContextToken,
@@ -354,7 +375,21 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
c.persistContextTokens()
}
- c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, mediaRefs, metadata, sender)
+ inboundCtx := bus.InboundContext{
+ Channel: "weixin",
+ ChatID: fromUserID,
+ ChatType: "direct",
+ SenderID: fromUserID,
+ MessageID: messageID,
+ Raw: metadata,
+ }
+ if msg.ContextToken != "" {
+ inboundCtx.ReplyHandles = map[string]string{
+ "context_token": msg.ContextToken,
+ }
+ }
+
+ c.HandleInboundContext(ctx, fromUserID, content, mediaRefs, inboundCtx, sender)
}
// Send implements channels.Channel by sending a text message to the WeChat user.
diff --git a/pkg/channels/weixin/weixin_test.go b/pkg/channels/weixin/weixin_test.go
index b41b930db..aea2cbb0c 100644
--- a/pkg/channels/weixin/weixin_test.go
+++ b/pkg/channels/weixin/weixin_test.go
@@ -66,7 +66,7 @@ func TestDownloadAndDecryptCDNBuffer(t *testing.T) {
}, nil
})},
},
- config: config.WeixinConfig{
+ config: &config.WeixinSettings{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
@@ -105,7 +105,7 @@ func TestDownloadAndDecryptCDNBufferUsesFullURLWhenProvided(t *testing.T) {
return nil, nil
})},
},
- config: config.WeixinConfig{
+ config: &config.WeixinSettings{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
@@ -155,7 +155,7 @@ func TestDownloadAndDecryptCDNBufferFallsBackToConstructedURLWhenFullURLFails(t
}, nil
})},
},
- config: config.WeixinConfig{
+ config: &config.WeixinSettings{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
@@ -224,7 +224,7 @@ func TestUploadBufferToCDN(t *testing.T) {
}, nil
})},
},
- config: config.WeixinConfig{
+ config: &config.WeixinSettings{
CDNBaseURL: "https://cdn.example.com",
},
typingCache: make(map[string]typingTicketCacheEntry),
@@ -259,7 +259,7 @@ func TestBuildWeixinSyncBufPathUsesPicoclawHome(t *testing.T) {
home := t.TempDir()
t.Setenv(config.EnvHome, home)
- wxCfg := config.WeixinConfig{
+ wxCfg := &config.WeixinSettings{
BaseURL: "https://ilinkai.weixin.qq.com/",
}
wxCfg.SetToken("token-123")
diff --git a/pkg/channels/whatsapp/init.go b/pkg/channels/whatsapp/init.go
index d9c2669c3..a9558d185 100644
--- a/pkg/channels/whatsapp/init.go
+++ b/pkg/channels/whatsapp/init.go
@@ -7,7 +7,19 @@ import (
)
func init() {
- channels.RegisterFactory("whatsapp", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- return NewWhatsAppChannel(cfg.Channels.WhatsApp, b)
- })
+ channels.RegisterFactory(
+ config.ChannelWhatsApp,
+ func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ bc := cfg.Channels[channelName]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ return nil, err
+ }
+ c, ok := decoded.(*config.WhatsAppSettings)
+ if !ok {
+ return nil, channels.ErrSendFailed
+ }
+ return NewWhatsAppChannel(bc, c, b)
+ },
+ )
}
diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go
index 98622fe37..4c338b5f4 100644
--- a/pkg/channels/whatsapp/whatsapp.go
+++ b/pkg/channels/whatsapp/whatsapp.go
@@ -20,7 +20,7 @@ import (
type WhatsAppChannel struct {
*channels.BaseChannel
conn *websocket.Conn
- config config.WhatsAppConfig
+ config *config.WhatsAppSettings
url string
ctx context.Context
cancel context.CancelFunc
@@ -28,14 +28,18 @@ type WhatsAppChannel struct {
connected bool
}
-func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
+func NewWhatsAppChannel(
+ bc *config.Channel,
+ cfg *config.WhatsAppSettings,
+ bus *bus.MessageBus,
+) (*WhatsAppChannel, error) {
base := channels.NewBaseChannel(
"whatsapp",
cfg,
bus,
- cfg.AllowFrom,
+ bc.AllowFrom,
channels.WithMaxMessageLength(65536),
- channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
return &WhatsAppChannel{
@@ -223,13 +227,6 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
metadata["user_name"] = userName
}
- var peer bus.Peer
- if chatID == senderID {
- peer = bus.Peer{Kind: "direct", ID: senderID}
- } else {
- peer = bus.Peer{Kind: "group", ID: chatID}
- }
-
logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{
"sender": senderID,
"preview": utils.Truncate(content, 50),
@@ -248,5 +245,18 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
return
}
- c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
+ inboundCtx := bus.InboundContext{
+ Channel: "whatsapp",
+ ChatID: chatID,
+ SenderID: senderID,
+ MessageID: messageID,
+ Raw: metadata,
+ }
+ if chatID == senderID {
+ inboundCtx.ChatType = "direct"
+ } else {
+ inboundCtx.ChatType = "group"
+ }
+
+ c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender)
}
diff --git a/pkg/channels/whatsapp/whatsapp_command_test.go b/pkg/channels/whatsapp/whatsapp_command_test.go
index 2d85d74f8..17ba0d2f9 100644
--- a/pkg/channels/whatsapp/whatsapp_command_test.go
+++ b/pkg/channels/whatsapp/whatsapp_command_test.go
@@ -12,7 +12,7 @@ import (
func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &WhatsAppChannel{
- BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil),
+ BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppSettings{}, messageBus, nil),
ctx: context.Background(),
}
diff --git a/pkg/channels/whatsapp_native/init.go b/pkg/channels/whatsapp_native/init.go
index df13e8539..f1be82ec9 100644
--- a/pkg/channels/whatsapp_native/init.go
+++ b/pkg/channels/whatsapp_native/init.go
@@ -9,12 +9,27 @@ import (
)
func init() {
- channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
- waCfg := cfg.Channels.WhatsApp
- storePath := waCfg.SessionStorePath
- if storePath == "" {
- storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp")
- }
- return NewWhatsAppNativeChannel(waCfg, b, storePath)
- })
+ channels.RegisterFactory(
+ config.ChannelWhatsAppNative,
+ func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ bc := cfg.Channels[channelName]
+ decoded, err := bc.GetDecoded()
+ if err != nil {
+ return nil, err
+ }
+ c, ok := decoded.(*config.WhatsAppSettings)
+ if !ok {
+ return nil, channels.ErrSendFailed
+ }
+ storePath := c.SessionStorePath
+ if storePath == "" {
+ storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp")
+ }
+ ch, err := NewWhatsAppNativeChannel(bc, channelName, c, b, storePath)
+ if err != nil {
+ return nil, err
+ }
+ return ch, nil
+ },
+ )
}
diff --git a/pkg/channels/whatsapp_native/whatsapp_command_test.go b/pkg/channels/whatsapp_native/whatsapp_command_test.go
index e51bec392..4d269af66 100644
--- a/pkg/channels/whatsapp_native/whatsapp_command_test.go
+++ b/pkg/channels/whatsapp_native/whatsapp_command_test.go
@@ -20,7 +20,7 @@ import (
func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &WhatsAppNativeChannel{
- BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil),
+ BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppSettings{}, messageBus, nil),
runCtx: context.Background(),
}
diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go
index d0a74a405..de4ecfd44 100644
--- a/pkg/channels/whatsapp_native/whatsapp_native.go
+++ b/pkg/channels/whatsapp_native/whatsapp_native.go
@@ -48,7 +48,7 @@ const (
// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge).
type WhatsAppNativeChannel struct {
*channels.BaseChannel
- config config.WhatsAppConfig
+ config *config.WhatsAppSettings
storePath string
client *whatsmeow.Client
container *sqlstore.Container
@@ -64,11 +64,13 @@ type WhatsAppNativeChannel struct {
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp).
func NewWhatsAppNativeChannel(
- cfg config.WhatsAppConfig,
+ bc *config.Channel,
+ name string,
+ cfg *config.WhatsAppSettings,
bus *bus.MessageBus,
storePath string,
) (channels.Channel, error) {
- base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536))
+ base := channels.NewBaseChannel(name, cfg, bus, bc.AllowFrom, channels.WithMaxMessageLength(65536))
if storePath == "" {
storePath = "whatsapp"
}
@@ -375,7 +377,6 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
if evt.Info.Chat.Server == types.GroupServer {
peerKind = "group"
}
- peer := bus.Peer{Kind: peerKind, ID: chatID}
messageID := evt.Info.ID
sender := bus.SenderInfo{
Platform: "whatsapp",
@@ -393,7 +394,17 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
"WhatsApp message received",
map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)},
)
- c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
+
+ inboundCtx := bus.InboundContext{
+ Channel: "whatsapp",
+ ChatID: chatID,
+ SenderID: senderID,
+ MessageID: messageID,
+ ChatType: peerKind,
+ Raw: metadata,
+ }
+
+ c.HandleInboundContext(c.runCtx, chatID, content, mediaPaths, inboundCtx, sender)
}
func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
diff --git a/pkg/channels/whatsapp_native/whatsapp_native_stub.go b/pkg/channels/whatsapp_native/whatsapp_native_stub.go
index 984af23e7..d058d8bba 100644
--- a/pkg/channels/whatsapp_native/whatsapp_native_stub.go
+++ b/pkg/channels/whatsapp_native/whatsapp_native_stub.go
@@ -13,9 +13,16 @@ import (
// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native.
// Build with: go build -tags whatsapp_native ./cmd/...
func NewWhatsAppNativeChannel(
- cfg config.WhatsAppConfig,
+ bc *config.Channel,
+ name string,
+ cfg *config.WhatsAppSettings,
bus *bus.MessageBus,
storePath string,
) (channels.Channel, error) {
+ _ = bc
+ _ = name
+ _ = cfg
+ _ = bus
+ _ = storePath
return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native")
}
diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go
index 39e76f752..a7e401bb8 100644
--- a/pkg/commands/builtin.go
+++ b/pkg/commands/builtin.go
@@ -11,9 +11,11 @@ func BuiltinDefinitions() []Definition {
showCommand(),
listCommand(),
useCommand(),
+ btwCommand(),
switchCommand(),
checkCommand(),
clearCommand(),
+ contextCommand(),
subagentsCommand(),
reloadCommand(),
}
diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go
index 5fd8dd9bc..efd27fa00 100644
--- a/pkg/commands/builtin_test.go
+++ b/pkg/commands/builtin_test.go
@@ -36,10 +36,10 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
t.Fatalf("/help handler error: %v", err)
}
// Now uses auto-generated EffectiveUsage which includes agents
- if !strings.Contains(reply, "/show [model|channel|agents]") {
+ if !strings.Contains(reply, "/show [model|channel|agents|mcp