diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 27782ced2..be1c10c52 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,25 +24,6 @@ jobs: with: version: v2.10.1 - # TODO: Remove once linter is properly configured - vet: - name: Vet - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - name: Run go generate - run: go generate ./... - - - name: Run go vet - run: go vet ./... - test: name: Tests runs-on: ubuntu-latest diff --git a/.golangci.yaml b/.golangci.yaml index 6dafb6b56..d45d69e67 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -47,7 +47,6 @@ linters: - godox - goprintffuncname - gosec - - govet - ineffassign - lll - maintidx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..88227f493 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,302 @@ +# Contributing to PicoClaw + +Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing. + +PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Ways to Contribute](#ways-to-contribute) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [Making Changes](#making-changes) +- [AI-Assisted Contributions](#ai-assisted-contributions) +- [Pull Request Process](#pull-request-process) +- [Branch Strategy](#branch-strategy) +- [Code Review](#code-review) +- [Communication](#communication) + +--- + +## Code of Conduct + +We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated. + +--- + +## Ways to Contribute + +- **Bug reports** — Open an issue using the bug report template. +- **Feature requests** — Open an issue using the feature request template; discuss before implementing. +- **Code** — Fix bugs or implement features. See the workflow below. +- **Documentation** — Improve READMEs, docs, inline comments, or translations. +- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results. + +For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction. + +--- + +## Getting Started + +1. **Fork** the repository on GitHub. +2. **Clone** your fork locally: + ```bash + git clone https://github.com//picoclaw.git + cd picoclaw + ``` +3. Add the upstream remote: + ```bash + git remote add upstream https://github.com/sipeed/picoclaw.git + ``` + +--- + +## Development Setup + +### Prerequisites + +- Go 1.25 or later +- `make` + +### Build + +```bash +make build # Build binary (runs go generate first) +make generate # Run go generate only +make check # Full pre-commit check: deps + fmt + vet + test +``` + +### Running Tests + +```bash +make test # Run all tests +go test -run TestName -v ./pkg/session/ # Run a single test +go test -bench=. -benchmem -run='^$' ./... # Run benchmarks +``` + +### Code Style + +```bash +make fmt # Format code +make vet # Static analysis +make lint # Full linter run +``` + +All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early. + +--- + +## Making Changes + +### Branching + +Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch: + +```bash +git checkout main +git pull upstream main +git checkout -b your-feature-branch +``` + +Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`. + +### Commits + +- Write clear, concise commit messages in English. +- Use the imperative mood: "Add retry logic" not "Added retry logic". +- Reference the related issue when relevant: `Fix session leak (#123)`. +- Keep commits focused. One logical change per commit is preferred. +- For minor cleanups or typo fixes, squash them into a single commit before opening a PR. +- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/ + +### Keeping Up to Date + +Rebase your branch onto upstream `main` before opening a PR: + +```bash +git fetch upstream +git rebase upstream/main +``` + +--- + +## AI-Assisted Contributions + +PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools. + +### Disclosure Is Required + +Every PR must disclose AI involvement using the PR template's **🤖 AI Code Generation** section. There are three levels: + +| Level | Description | +|---|---| +| 🤖 Fully AI-generated | AI wrote the code; contributor reviewed and validated it | +| 🛠️ Mostly AI-generated | AI produced the draft; contributor made significant modifications | +| 👨‍💻 Mostly Human-written | Contributor led; AI provided suggestions or none at all | + +Honest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution. + +### You Are Responsible for What You Submit + +Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must: + +- **Read and understand** every line of the generated code. +- **Test it** in a real environment (see the Test Environment section of the PR template). +- **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully. +- **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax. + +PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review. + +### AI-Generated Code Quality Standards + +AI-generated contributions are held to the **same quality bar** as human-written code: + +- It must pass all CI checks (`make check`). +- It must be idiomatic Go and consistent with the existing codebase style. +- It must not introduce unnecessary abstractions, dead code, or over-engineering. +- It must include or update tests where appropriate. + +### Security Review + +AI-generated code requires extra security scrutiny. Pay special attention to: + +- File path handling and sandbox escapes (see commit `244eb0b` for a real example) +- External input validation in channel handlers and tool implementations +- Credential or secret handling +- Command execution (`exec.Command`, shell invocations) + +If you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help. + +--- + +## Pull Request Process + +### Before Opening a PR + +- [ ] Run `make check` and ensure it passes locally. +- [ ] Fill in the PR template completely, including the AI disclosure section. +- [ ] Link any related issue(s) in the PR description. +- [ ] Keep the PR focused. Avoid bundling unrelated changes together. + +### PR Template Sections + +The PR template asks for: + +- **Description** — What does this change do and why? +- **Type of Change** — Bug fix, feature, docs, or refactor. +- **AI Code Generation** — Disclosure of AI involvement (required). +- **Related Issue** — Link to the issue this addresses. +- **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs). +- **Test Environment** — Hardware, OS, model/provider, and channels used for testing. +- **Evidence** — Optional logs or screenshots demonstrating the change works. +- **Checklist** — Self-review confirmation. + +### PR Size + +Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs. + +--- + +## Branch Strategy + +### Long-Lived Branches + +- **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging. +- **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`. + +### Requirements to Merge into `main` + +A PR can only be merged when all of the following are satisfied: + +1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green. +2. **Reviewer approval** — At least one maintainer has approved the PR. +3. **No unresolved review comments** — All review threads must be resolved. +4. **PR template is complete** — Including AI disclosure and test environment. + +### Who Can Merge + +Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access. + +### Merge Strategy + +We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.: + +``` +feat: Add Ollama provider support (#491) +``` + +If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion. + +### Release Branches + +When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point: + +- **New features are not backported.** The release branch receives no new functionality after it is cut. +- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release. + +If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers. + +Release branches have stricter protections than `main` and are never directly pushed to under any circumstances. + +--- + +## Code Review + +### For Contributors + +- Respond to review comments within a reasonable time. If you need more time, say so. +- When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested"). +- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too. +- Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge. + +### For Reviewers + +Review for: + +1. **Correctness** — Does the code do what it claims? Are there edge cases? +2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers. +3. **Architecture** — Is the approach consistent with the existing design? +4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity? +5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful? + +Be constructive and specific. "This could have a race condition if two goroutines call this concurrently — consider using a mutex here" is better than "this looks wrong". + + +### Reviewer List +Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table. + +|Function| Reviewer| +|--- |--- | +|Provider|@yinwm | +|Channel |@yinwm | +|Agent |@lxowalle| +|Tools |@lxowalle| +|SKill || +|MCP || +|Optimization|@lxowalle| +|Security|| +|AI CI |@imguoguo| +|UX || +|Document|| + +--- + +## Communication + +- **GitHub Issues** — Bug reports, feature requests, design discussions. +- **GitHub Discussions** — General questions, ideas, community conversation. +- **Pull Request comments** — Code-specific feedback. +- **Wechat&Discord** — We will invite you when you have at least one merged PR + +When in doubt, open an issue before writing code. It costs little and prevents wasted effort. + +--- + +## A Note on the Project's AI-Driven Origin + +PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome. + +We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict. + +Thank you for contributing! diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md new file mode 100644 index 000000000..01a1abfd5 --- /dev/null +++ b/CONTRIBUTING.zh.md @@ -0,0 +1,303 @@ +# 参与贡献 PicoClaw + +感谢你对 PicoClaw 的关注!本项目是一个社区驱动的开源项目,目标是构建 轻量灵活,人人可用 的个人AI助手。我们欢迎一切形式的贡献:Bug 修复、新功能、文档、翻译和测试。 + +PicoClaw 本身在很大程度上是借助 AI 辅助开发的——我们拥抱这种方式,并围绕它构建了贡献流程。 + +## 目录 + +- [行为准则](#行为准则) +- [贡献方式](#贡献方式) +- [快速开始](#快速开始) +- [开发环境配置](#开发环境配置) +- [提交修改](#提交修改) +- [AI 辅助贡献](#ai-辅助贡献) +- [Pull Request 流程](#pull-request-流程) +- [分支策略](#分支策略) +- [代码审查](#代码审查) +- [沟通渠道](#沟通渠道) + +--- + +## 行为准则 + +我们致力于维护一个友好、互相尊重的社区环境。请保持善意、建设性的态度,并善意地理解他人。任何形式的骚扰或歧视均不被接受。 + +--- + +## 贡献方式 + +- **Bug 反馈** — 使用 Bug 报告模板提交 Issue。 +- **功能建议** — 使用功能请求模板提交 Issue,建议在开始实现前先进行讨论。 +- **代码贡献** — 修复 Bug 或实现新功能,参见下方工作流程。 +- **文档改进** — 完善 README、文档、代码注释或翻译。 +- **测试与验证** — 在新硬件、新渠道或新 LLM 提供商上运行 PicoClaw 并反馈结果。 + +对于较大的新功能,请先提交 Issue 讨论设计方案,再动手写代码。这能避免无效投入,也确保与项目方向保持一致。 + +--- + +## 快速开始 + +1. 在 GitHub 上 **Fork** 本仓库。 +2. 将你的 Fork **克隆**到本地: + ```bash + git clone https://github.com/<你的用户名>/picoclaw.git + cd picoclaw + ``` +3. 添加上游远程仓库: + ```bash + git remote add upstream https://github.com/sipeed/picoclaw.git + ``` + +--- + +## 开发环境配置 + +### 前置依赖 + +- Go 1.25 或更高版本 +- `make` + +### 构建 + +```bash +make build # 构建二进制文件(会先执行 go generate) +make generate # 仅执行 go generate +make check # 完整的提交前检查:deps + fmt + vet + test +``` + +### 运行测试 + +```bash +make test # 运行所有测试 +go test -run TestName -v ./pkg/session/ # 运行单个测试 +go test -bench=. -benchmem -run='^$' ./... # 运行基准测试 +``` + +### 代码风格 + +```bash +make fmt # 格式化代码 +make vet # 静态分析 +make lint # 完整的 lint 检查 +``` + +所有 CI 检查通过后 PR 才能被合并。推送代码前请先在本地运行 `make check`,提前发现问题。 + +--- + +## 提交修改 + +### 分支管理 + +始终从 `main` 分支切出,并在 PR 中以 `main` 为目标分支。不要直接向 `main` 或任何 `release/*` 分支推送代码: + +```bash +git checkout main +git pull upstream main +git checkout -b 你的功能分支名 +``` + +请使用描述性的分支名,例如:`fix/telegram-timeout`、`feat/ollama-provider`、`docs/contributing-guide`。 + +### Commit 规范 + +- 使用英文撰写清晰、简洁的 commit 信息。 +- 使用祈使句:写 "Add retry logic",而不是 "Added retry logic"。 +- 有关联 Issue 时请引用:`Fix session leak (#123)`。 +- 保持 commit 专注,每个 commit 只做一件事。 +- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。 +- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写 + +### 保持与上游同步 + +提 PR 前,请将你的分支变基到上游 `main`: + +```bash +git fetch upstream +git rebase upstream/main +``` + +--- + +## AI 辅助贡献 + +PicoClaw 在很大程度上借助 AI 辅助开发,我们完全拥抱这种开发方式。但贡献者必须清楚地了解自己在使用 AI 工具时所承担的责任。 + +### 必须披露 AI 使用情况 + +每个 PR 都必须通过 PR 模板中的 **🤖 AI 代码生成** 部分披露 AI 参与情况,共分三个级别: + +| 级别 | 说明 | +|---|---| +| 🤖 完全由 AI 生成 | AI 编写代码,贡献者负责审查和验证 | +| 🛠️ 主要由 AI 生成 | AI 起草,贡献者做了较大修改 | +| 👨‍💻 主要由人工编写 | 贡献者主导,AI 仅提供辅助或未使用 AI | + +我们期望你诚实填写。三种级别均可接受,没有任何歧视——重要的是贡献的质量。 + +### 你对提交的代码负全责 + +使用 AI 生成代码并不能减轻你作为贡献者的责任。在提交含有 AI 生成代码的 PR 之前,你必须: + +- **逐行阅读并理解**生成的代码。 +- **在真实环境中测试**(参见 PR 模板中的测试环境部分)。 +- **检查安全问题** — AI 模型可能生成存在安全隐患的代码(如路径穿越、注入攻击、凭据泄露等),请仔细审查。 +- **验证正确性** — AI 生成的逻辑可能听起来合理但实际上是错误的,请验证行为,而不仅仅是语法。 + +如果明显可以看出贡献者没有阅读或测试 AI 生成的代码,该 PR 将被直接关闭,不予审查。 + +### AI 生成代码的质量标准 + +AI 生成的代码与人工编写的代码遵循**相同的质量要求**: + +- 必须通过所有 CI 检查(`make check`)。 +- 必须符合 Go 惯用写法,并与现有代码库的风格保持一致。 +- 不得引入不必要的抽象、死代码或过度设计。 +- 须在适当的地方包含或更新测试。 + +### 安全审查 + +AI 生成的代码需要格外仔细的安全审查。请特别关注以下方面: + +- 文件路径处理与沙箱逃逸(项目历史中的 commit `244eb0b` 就是真实案例) +- channel 处理器和 tool 实现中的外部输入校验 +- 凭据或密钥的处理 +- 命令执行(`exec.Command`、shell 调用等) + +如果你不确定某段 AI 生成代码是否安全,请在 PR 中说明——审查者会帮助判断。 + +--- + +## Pull Request 流程 + +### 提 PR 前的检查 + +- [ ] 在本地运行 `make check` 并确认通过。 +- [ ] 完整填写 PR 模板,包括 AI 披露部分。 +- [ ] 在 PR 描述中关联相关 Issue。 +- [ ] 保持 PR 专注,避免将不相关的修改混在一起。 + +### PR 模板各部分说明 + +PR 模板要求填写: + +- **描述** — 这个改动做了什么,为什么要做? +- **变更类型** — Bug 修复、新功能、文档或重构。 +- **AI 代码生成** — AI 参与情况披露(必填)。 +- **关联 Issue** — 此 PR 解决的 Issue 链接。 +- **技术背景** — 参考链接和设计理由(纯文档类 PR 可跳过)。 +- **测试环境** — 用于测试的硬件、操作系统、模型/提供商和渠道。 +- **验证证据** — 可选的日志或截图,用于证明改动有效。 +- **检查清单** — 自我审查确认。 + +### PR 规模 + +请尽量提交小而易于审查的 PR。一个涉及 5 个文件共 200 行改动的 PR,远比涉及 30 个文件共 2000 行改动的 PR 容易审查。如果你的功能较大,可以考虑将其拆分为一系列逻辑完整的小 PR。 + +--- + +## 分支策略 + +### 长期分支 + +- **`main`** — 活跃开发分支。所有功能 PR 均以 `main` 为目标。该分支受保护:禁止直接推送,合并前必须获得至少一名维护者的批准。 +- **`release/x.y`** — 稳定发布分支,在某个版本准备发布时从 `main` 切出。这些分支的保护级别高于 `main`。 + +### 合并到 `main` 的前提条件 + +PR 必须同时满足以下所有条件,才能被合并: + +1. **CI 全部通过** — 所有 GitHub Actions 工作流(lint、test、build)均为绿色。 +2. **获得审查者批准** — 至少一名维护者已批准该 PR。 +3. **无未解决的审查意见** — 所有审查讨论线程均已关闭。 +4. **PR 模板填写完整** — 包括 AI 披露和测试环境信息。 + +### 谁可以合并 + +只有维护者才能合并 PR。贡献者不能合并自己的 PR,即使拥有写权限也不行。 + +### 合并策略 + +为保持 `main` 历史清晰可读,我们对大多数 PR 使用 **Squash Merge**。每个合并的 PR 变为一个包含 PR 编号的单独 commit,例如: + +``` +feat: Add Ollama provider support (#491) +``` + +如果一个 PR 包含多个独立、结构清晰、能讲述完整故事的 commit,维护者可视情况使用普通 merge。 + +### Release 分支 + +当某个版本准备就绪时,维护者会从 `main` 切出 `release/x.y` 分支。此后: + +- **新功能不会被回溯(backport)。** Release 分支切出后,不再接收任何新功能。 +- **安全修复和关键 Bug 修复会被 cherry-pick 进来。** 若 `main` 上的某个修复属于安全漏洞、数据丢失或崩溃类问题,维护者会将相关 commit cherry-pick 到受影响的 `release/x.y` 分支,并发布补丁版本。 + +如果你认为 `main` 上的某个修复应该被回溯到某个 release 分支,请在 PR 描述中注明,或单独开一个 Issue 说明。最终决定由维护者做出。 + +Release 分支的保护级别高于 `main`,在任何情况下均不允许直接推送。 + +--- + +## 代码审查 + +### 对贡献者的建议 + +- 在合理时间内回复审查意见。如果需要更多时间,请告知。 +- 更新 PR 以响应反馈时,简要说明改动内容(例如:"按建议改用了 `sync.RWMutex`")。 +- 如果你不同意某条反馈,请礼貌地阐述你的理由——审查者也可能有判断失误的时候。 +- 审查开始后请不要 force push——这会让审查者难以追踪变化。请使用额外的 commit,维护者在合并时会进行 squash。 + +### 对审查者的建议 + +审查重点: + +1. **正确性** — 代码是否实现了其声称的功能?是否存在边界情况? +2. **安全性** — 对 AI 生成代码、tool 实现和 channel 处理器尤其需要关注。 +3. **架构** — 实现方式是否与现有设计一致? +4. **简洁性** — 是否有更简单的方案?是否引入了不必要的复杂度? +5. **测试** — 改动是否有测试覆盖?现有测试是否仍然有意义? + +请给出建设性且具体的反馈。"如果两个 goroutine 同时调用这个函数可能会有竞态条件,建议在这里加一个 mutex" 远比 "这里看起来有问题" 更有帮助。 + +### 审查者列表 +提交对应PR后,可以参考下表联系对应的审查人员沟通 + +|Function| Reviewer| +|--- |--- | +|Provider|@yinwm | +|Channel |@yinwm | +|Agent |@lxowalle| +|Tools |@lxowalle| +|SKill || +|MCP || +|Optimization|@lxowalle| +|Security|| +|AI CI |@imguoguo| +|UX || +|Document|| + + + +--- + +## 沟通渠道 + +- **GitHub Issues** — Bug 报告、功能建议、设计讨论。 +- **GitHub Discussions** — 一般性问题、想法交流、社区讨论。 +- **Pull Request 评论** — 与具体代码相关的反馈。 +- **Wechat&Discord** — 当你有至少一个已合并的PR后,我们会邀请你加入开发者交流群 + +有疑问时,请先开 Issue 讨论,再动手写代码。这几乎没有成本,却能避免大量无效投入。 + +--- + +## 关于本项目的 AI 驱动起源 + +PicoClaw 的架构在人工监督下,经由 AI 辅助完成了大量设计和实现工作。如果你发现某处看起来奇怪或过度设计,这可能是该过程留下的痕迹——欢迎提 Issue 讨论。 + +我们相信,负责任地使用 AI 辅助开发能产生优秀的成果。我们同样相信,人类必须对自己提交的内容负责。这两点并不矛盾。 + +感谢你的贡献! diff --git a/Dockerfile b/Dockerfile index 0360cfda6..480244127 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ============================================================ # Stage 1: Build the picoclaw binary # ============================================================ -FROM golang:1.26.0-alpine AS builder +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git make diff --git a/Makefile b/Makefile index a5ad4a02d..29e2fc964 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ GOLANGCI_LINT?=golangci-lint INSTALL_PREFIX?=$(HOME)/.local INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1 +INSTALL_TMP_SUFFIX=.new # Workspace and Skills PICOCLAW_HOME?=$(HOME)/.picoclaw @@ -99,8 +100,10 @@ build-all: generate install: build @echo "Installing $(BINARY_NAME)..." @mkdir -p $(INSTALL_BIN_DIR) - @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME) - @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME) + # Copy binary with temporary suffix to ensure atomic update + @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) + @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) + @mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME) @echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)" @echo "Installation complete!" diff --git a/README.fr.md b/README.fr.md index 7199f7098..d09276c27 100644 --- a/README.fr.md +++ b/README.fr.md @@ -50,7 +50,7 @@ ## 📢 Actualités -2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir ! +2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir ! 2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw. 🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire. @@ -171,6 +171,10 @@ vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc. # 3. Compiler & Démarrer docker compose --profile gateway up -d +> [!TIP] +> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`. + + # 4. Voir les logs docker compose logs -f picoclaw-gateway diff --git a/README.ja.md b/README.ja.md index bb0bdfb28..67eccddc2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -133,6 +133,10 @@ vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キ # 3. ビルドと起動 docker compose --profile gateway up -d +> [!TIP] +> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。 + + # 4. ログ確認 docker compose logs -f picoclaw-gateway @@ -162,7 +166,7 @@ docker compose --profile gateway up -d > [!TIP] > `~/.picoclaw/config.json` に API キーを設定してください。 > API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料) +> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料) **1. 初期化** @@ -193,14 +197,34 @@ picoclaw onboard "token": "YOUR_TELEGRAM_BOT_TOKEN", "allow_from": [] } + }, + "tools": { + "web": { + "search": { + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 } } ``` **3. API キーの取得** -- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) · [Qwen](https://dashscope.console.aliyun.com) -- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト) +- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト) > **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。 @@ -985,7 +1009,7 @@ Discord: https://discord.gg/V4sAZ9XWpN 検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。 Web 検索を有効にするには: -1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料) +1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料) 2. `~/.picoclaw/config.json` に追加: ```json { @@ -1023,5 +1047,6 @@ Web 検索を有効にするには: | **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 | | **Qwen** | 無料枠あり | 通義千問 (Qwen) | | **Brave Search** | 月 2000 クエリ | Web 検索機能 | +| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 | | **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) | | **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) | diff --git a/README.md b/README.md index 218e6d59b..1b79344da 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Twitter

- [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English** +[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English** + --- @@ -42,16 +43,17 @@ > **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** > > * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**. +> > * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)** > * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties. > * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release. > * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state. - ## 📢 News -2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board! -2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. +2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board! + +2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. 🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. 2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go! @@ -100,9 +102,12 @@ ### 📱 Run on old Android Phones + Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start: + 1. **Install Termux** (Available on F-Droid or Google Play). 2. **Execute cmds** + ```bash # Note: Replace v0.1.1 with the latest version from the Releases page wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 @@ -110,6 +115,7 @@ chmod +x picoclaw-linux-arm64 pkg install proot termux-chroot ./picoclaw-linux-arm64 onboard ``` + And then follow the instructions in the "Quick Start" section to complete the configuration! PicoClaw @@ -165,6 +171,10 @@ vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc. # 3. Build & Start docker compose --profile gateway up -d +> [!TIP] +> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. + + # 4. Check logs docker compose logs -f picoclaw-gateway @@ -194,7 +204,7 @@ docker compose --profile gateway up -d > [!TIP] > Set your API key in `~/.picoclaw/config.json`. > Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback. +> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback. **1. Initialize** @@ -234,6 +244,11 @@ picoclaw onboard "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", + "max_results": 5 + }, "duckduckgo": { "enabled": true, "max_results": 5 @@ -248,7 +263,7 @@ picoclaw onboard **3. Get API Keys** * **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) -* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month) +* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month) > **Note**: See `config.example.json` for a complete configuration template. @@ -326,7 +341,6 @@ picoclaw gateway * (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data **3. Get your User ID** - * Discord Settings → Advanced → enable **Developer Mode** * Right-click your avatar → **Copy User ID** @@ -428,7 +442,6 @@ picoclaw gateway ```bash picoclaw gateway ``` -
@@ -527,7 +540,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile * Go to WeCom Admin Console → App Management → Create App * Copy **AgentId** and **Secret** * Go to "My Company" page, copy **CorpID** - **2. Configure receive message** * In App details, click "Receive Message" → "Set API" @@ -673,23 +685,23 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f } ``` -| Option | Default | Description | -|--------|---------|-------------| -| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | -| `restrict_to_workspace` | `true` | Restrict file/command access to workspace | +| Option | Default | Description | +| ----------------------- | ----------------------- | ----------------------------------------- | +| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | +| `restrict_to_workspace` | `true` | Restrict file/command access to workspace | #### Protected Tools When `restrict_to_workspace: true`, the following tools are sandboxed: -| Tool | Function | Restriction | -|------|----------|-------------| -| `read_file` | Read files | Only files within workspace | -| `write_file` | Write files | Only files within workspace | -| `list_dir` | List directories | Only directories within workspace | -| `edit_file` | Edit files | Only files within workspace | -| `append_file` | Append to files | Only files within workspace | -| `exec` | Execute commands | Command paths must be within workspace | +| Tool | Function | Restriction | +| ------------- | ---------------- | -------------------------------------- | +| `read_file` | Read files | Only files within workspace | +| `write_file` | Write files | Only files within workspace | +| `list_dir` | List directories | Only directories within workspace | +| `edit_file` | Edit files | Only files within workspace | +| `append_file` | Append to files | Only files within workspace | +| `exec` | Execute commands | Command paths must be within workspace | #### Additional Exec Protection @@ -742,11 +754,11 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false The `restrict_to_workspace` setting applies consistently across all execution paths: -| Execution Path | Security Boundary | -|----------------|-------------------| -| Main Agent | `restrict_to_workspace` ✅ | +| Execution Path | Security Boundary | +| ---------------- | ---------------------------- | +| Main Agent | `restrict_to_workspace` ✅ | | Subagent / Spawn | Inherits same restriction ✅ | -| Heartbeat tasks | Inherits same restriction ✅ | +| Heartbeat tasks | Inherits same restriction ✅ | All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks. @@ -772,21 +784,23 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a # Periodic Tasks ## Quick Tasks (respond directly) + - Report current time ## Long Tasks (use spawn for async) + - Search the web for AI news and summarize - Check email and report important messages ``` **Key behaviors:** -| Feature | Description | -|---------|-------------| -| **spawn** | Creates async subagent, doesn't block heartbeat | -| **Independent context** | Subagent has its own context, no session history | -| **message tool** | Subagent communicates with user directly via message tool | -| **Non-blocking** | After spawning, heartbeat continues to next task | +| Feature | Description | +| ----------------------- | --------------------------------------------------------- | +| **spawn** | Creates async subagent, doesn't block heartbeat | +| **Independent context** | Subagent has its own context, no session history | +| **message tool** | Subagent communicates with user directly via message tool | +| **Non-blocking** | After spawning, heartbeat continues to next task | #### How Subagent Communication Works @@ -817,10 +831,10 @@ The subagent has access to tools (message, web_search, etc.) and can communicate } ``` -| Option | Default | Description | -|--------|---------|-------------| -| `enabled` | `true` | Enable/disable heartbeat | -| `interval` | `30` | Check interval in minutes (min: 5) | +| Option | Default | Description | +| ---------- | ------- | ---------------------------------- | +| `enabled` | `true` | Enable/disable heartbeat | +| `interval` | `30` | Check interval in minutes (min: 5) | **Environment variables:** @@ -832,17 +846,17 @@ The subagent has access to tools (message, web_search, etc.) and can communicate > [!NOTE] > Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. -| Provider | Purpose | Get API Key | -| -------------------------- | --------------------------------------- | ------------------------------------------------------ | -| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) | -| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | -| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | -| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | -| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | +| Provider | Purpose | Get API Key | +| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- | +| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | +| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | +| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | -| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | +| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | ### Model Configuration (model_list) @@ -857,25 +871,25 @@ This design also enables **multi-agent support** with flexible provider selectio #### 📋 All Supported Vendors -| Vendor | `model` Prefix | Default API Base | Protocol | API Key | -|--------|----------------|------------------|----------|---------| -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | -| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | -| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | +| Vendor | `model` Prefix | Default API Base | Protocol | API Key | +| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | +| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | +| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | +| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | +| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | +| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### Basic Configuration @@ -909,6 +923,7 @@ This design also enables **multi-agent support** with flexible provider selectio #### Vendor-Specific Examples **OpenAI** + ```json { "model_name": "gpt-5.2", @@ -918,6 +933,7 @@ This design also enables **multi-agent support** with flexible provider selectio ``` **智谱 AI (GLM)** + ```json { "model_name": "glm-4.7", @@ -927,6 +943,7 @@ This design also enables **multi-agent support** with flexible provider selectio ``` **DeepSeek** + ```json { "model_name": "deepseek-chat", @@ -936,6 +953,7 @@ This design also enables **multi-agent support** with flexible provider selectio ``` **Anthropic (with API key)** + ```json { "model_name": "claude-sonnet-4.6", @@ -943,9 +961,11 @@ This design also enables **multi-agent support** with flexible provider selectio "api_key": "sk-ant-your-key" } ``` + > Run `picoclaw auth login --provider anthropic` to paste your API token. **Ollama (local)** + ```json { "model_name": "llama3", @@ -954,6 +974,7 @@ This design also enables **multi-agent support** with flexible provider selectio ``` **Custom Proxy/API** + ```json { "model_name": "my-custom-model", @@ -991,6 +1012,7 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical The old `providers` configuration is **deprecated** but still supported for backward compatibility. **Old Config (deprecated):** + ```json { "providers": { @@ -1009,6 +1031,7 @@ The old `providers` configuration is **deprecated** but still supported for back ``` **New Config (recommended):** + ```json { "model_list": [ @@ -1173,13 +1196,13 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically. PRs welcome! The codebase is intentionally small and readable. 🤗 -Roadmap coming soon... +See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md). -Developer group building, Entry Requirement: At least 1 Merged PR. +Developer group building, join after your first merged PR! User Groups: -discord: +discord: PicoClaw diff --git a/README.pt-br.md b/README.pt-br.md index ec8fe8e1c..8d87333bc 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -50,7 +50,7 @@ ## 📢 Novidades -2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo! +2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo! 2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw. @@ -172,6 +172,10 @@ vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. # 3. Build & Iniciar docker compose --profile gateway up -d +> [!TIP] +> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`. + + # 4. Ver logs docker compose logs -f picoclaw-gateway diff --git a/README.vi.md b/README.vi.md index 161842933..1be58d9f6 100644 --- a/README.vi.md +++ b/README.vi.md @@ -50,7 +50,7 @@ ## 📢 Tin tức -2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn! +2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn! 2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw. 🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần. @@ -152,6 +152,10 @@ vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v. # 3. Build & Khởi động docker compose --profile gateway up -d +> [!TIP] +> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`. + + # 4. Xem logs docker compose logs -f picoclaw-gateway diff --git a/README.zh.md b/README.zh.md index ab896b6c0..74760b3b1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,7 +14,8 @@ Twitter

- **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) +**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) + --- @@ -42,15 +43,16 @@ > [!CAUTION] > **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** -> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。 -> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 -> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 -> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中 -> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化. - +> +> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。 +> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 +> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 +> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中 +> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化. ## 📢 新闻 (News) -2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与! + +2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与! 2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。 🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。 @@ -69,12 +71,12 @@ 🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。 -| | OpenClaw | NanoBot | **PicoClaw** | -| --- | --- | --- | --- | -| **语言** | TypeScript | Python | **Go** | -| **RAM** | >1GB | >100MB | **< 10MB** | -| **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | -| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | +| | OpenClaw | NanoBot | **PicoClaw** | +| ------------------------------ | ------------- | ------------------------ | -------------------------------------- | +| **语言** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | +| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | PicoClaw @@ -101,9 +103,12 @@ ### 📱 在手机上轻松运行 + picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南: + 1. 先去应用商店下载安装Termux 2. 打开后执行指令 + ```bash # 注意: 下面的v0.1.1 可以换为你实际看到的最新版本 wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 @@ -111,19 +116,17 @@ chmod +x picoclaw-linux-arm64 pkg install proot termux-chroot ./picoclaw-linux-arm64 onboard ``` -然后跟随下面的“快速开始”章节继续配置picoclaw即可使用! + +然后跟随下面的“快速开始”章节继续配置picoclaw即可使用! PicoClaw - - - ### 🐜 创新的低占用部署 PicoClaw 几乎可以部署在任何 Linux 设备上! -* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。 -* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。 -* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。 +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。 +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。 +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。 [https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4) @@ -170,6 +173,9 @@ vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等 # 3. 构建并启动 docker compose --profile gateway up -d +> [!TIP] +**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。 + # 4. 查看日志 docker compose logs -f picoclaw-gateway @@ -202,7 +208,7 @@ docker compose --profile gateway up -d > [!TIP] > 在 `~/.picoclaw/config.json` 中设置您的 API Key。 > 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询) +> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询) **1. 初始化 (Initialize)** @@ -243,8 +249,9 @@ picoclaw onboard "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 }, - "duckduckgo": { - "enabled": true, + "tavily": { + "enabled": false, + "api_key": "YOUR_TAVILY_API_KEY", "max_results": 5 } }, @@ -253,7 +260,6 @@ picoclaw onboard } } } - ``` > **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。 @@ -261,7 +267,7 @@ picoclaw onboard **3. 获取 API Key** * **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) -* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月) +* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月) > **注意**: 完整的配置模板请参考 `config.example.json`。 @@ -278,260 +284,28 @@ picoclaw agent -m "2+2 等于几?" ## 💬 聊天应用集成 (Chat Apps) -通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。 - -| 渠道 | 设置难度 | -| --- | --- | -| **Telegram** | 简单 (仅需 token) | -| **Discord** | 简单 (bot token + intents) | -| **QQ** | 简单 (AppID + AppSecret) | -| **钉钉 (DingTalk)** | 中等 (应用凭证) | -| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) | - -
-Telegram (推荐) - -**1. 创建机器人** - -* 打开 Telegram,搜索 `@BotFather` -* 发送 `/newbot`,按照提示操作 -* 复制 token - -**2. 配置** - -```json -{ - "channels": { - "telegram": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"] - } - } -} - -``` - -> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。 - -**3. 运行** - -```bash -picoclaw gateway - -``` - -
- -
-Discord - -**1. 创建机器人** - -* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications) -* Create an application → Bot → Add Bot -* 复制 bot token - -**2. 开启 Intents** - -* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT** -* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT** - -**3. 获取您的 User ID** - -* Discord 设置 → Advanced → 开启 **Developer Mode** -* 右键点击您的头像 → **Copy User ID** - -**4. 配置** - -```json -{ - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"], - "mention_only": false - } - } -} - -``` - -**5. 邀请机器人** - -* OAuth2 → URL Generator -* Scopes: `bot` -* Bot Permissions: `Send Messages`, `Read Message History` -* 打开生成的邀请 URL,将机器人添加到您的服务器 - -**6. 运行** - -```bash -picoclaw gateway - -``` - -
- -
-QQ - -**1. 创建机器人** - -* 前往 [QQ 开放平台](https://q.qq.com/#) -* 创建应用 → 获取 **AppID** 和 **AppSecret** - -**2. 配置** - -```json -{ - "channels": { - "qq": { - "enabled": true, - "app_id": "YOUR_APP_ID", - "app_secret": "YOUR_APP_SECRET", - "allow_from": [] - } - } -} - -``` - -> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。 - -**3. 运行** - -```bash -picoclaw gateway - -``` - -
- -
-钉钉 (DingTalk) - -**1. 创建机器人** - -* 前往 [开放平台](https://open.dingtalk.com/) -* 创建内部应用 -* 复制 Client ID 和 Client Secret - -**2. 配置** - -```json -{ - "channels": { - "dingtalk": { - "enabled": true, - "client_id": "YOUR_CLIENT_ID", - "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] - } - } -} - -``` - -> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。 - -**3. 运行** - -```bash -picoclaw gateway - -``` - -
- -
-企业微信 (WeCom) - -PicoClaw 支持两种企业微信集成方式: - -**选项1: 智能机器人 (WeCom Bot)** - 设置更简单,支持群聊 -**选项2: 自建应用 (WeCom App)** - 功能更丰富,支持主动推送消息 - -详见 [企业微信自建应用配置指南](docs/wecom-app-configuration.md)。 - -**快速设置 - 智能机器人:** - -**1. 创建机器人** - -* 前往企业微信管理后台 → 群聊 → 添加群机器人 -* 复制 Webhook URL (格式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`) - -**2. 配置** - -```json -{ - "channels": { - "wecom": { - "enabled": true, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18793, - "webhook_path": "/webhook/wecom", - "allow_from": [] - } - } -} -``` - -**快速设置 - 自建应用:** - -**1. 创建应用** - -* 前往企业微信管理后台 → 应用管理 → 创建应用 -* 复制 **AgentId** 和 **Secret** -* 前往"我的企业"页面,复制 **CorpID** - -**2. 配置接收消息** - -* 在应用详情页,点击"接收消息" → "设置API" -* 设置 URL 为 `http://your-server:18792/webhook/wecom-app` -* 生成 **Token** 和 **EncodingAESKey** - -**3. 配置** - -```json -{ - "channels": { - "wecom_app": { - "enabled": true, - "corp_id": "wwxxxxxxxxxxxxxxxx", - "corp_secret": "YOUR_CORP_SECRET", - "agent_id": 1000002, - "token": "YOUR_TOKEN", - "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18792, - "webhook_path": "/webhook/wecom-app", - "allow_from": [] - } - } -} -``` - -**4. 运行** - -```bash -picoclaw gateway - -``` - -> **注意**: 自建应用需要开放 18792 端口用于接收 Webhook 回调。生产环境建议使用反向代理配置 HTTPS。 - -
+PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 + +### 核心渠道 + +| 渠道 | 设置难度 | 特性说明 | 文档链接 | +| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) | +| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) | +| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) | +| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) | +| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) | +| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) | +| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) | +| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) | +| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | +| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | ## ClawdChat 加入 Agent 社交网络 只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 -**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai) +\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai) ## ⚙️ 配置详解 @@ -567,7 +341,6 @@ PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` - Check my email for important messages - Review my calendar for upcoming events - Check the weather forecast - ``` Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。 @@ -580,22 +353,23 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具 # Periodic Tasks ## Quick Tasks (respond directly) + - Report current time ## Long Tasks (use spawn for async) + - Search the web for AI news and summarize - Check email and report important messages - ``` **关键行为:** -| 特性 | 描述 | -| --- | --- | -| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | -| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | +| 特性 | 描述 | +| ---------------- | ---------------------------------------- | +| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | +| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | | **message tool** | 子 Agent 通过 message 工具直接与用户通信 | -| **非阻塞** | spawn 后,心跳继续处理下一个任务 | +| **非阻塞** | spawn 后,心跳继续处理下一个任务 | #### 子 Agent 通信原理 @@ -625,35 +399,34 @@ Agent 读取 HEARTBEAT.md "interval": 30 } } - ``` -| 选项 | 默认值 | 描述 | -| --- | --- | --- | -| `enabled` | `true` | 启用/禁用心跳 | -| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | +| 选项 | 默认值 | 描述 | +| ---------- | ------ | ---------------------------- | +| `enabled` | `true` | 启用/禁用心跳 | +| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | **环境变量:** -* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 -* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 +- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 +- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 ### 提供商 (Providers) > [!NOTE] > Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。 -| 提供商 | 用途 | 获取 API Key | -| --- | --- | --- | -| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) | -| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | -| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | -| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | -| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | -| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | -| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | -| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | +| 提供商 | 用途 | 获取 API Key | +| -------------------- | ---------------------------- | -------------------------------------------------------------------- | +| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) | +| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | +| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | +| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | +| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | +| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | +| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | ### 模型配置 (model_list) @@ -668,25 +441,25 @@ Agent 读取 HEARTBEAT.md #### 📋 所有支持的厂商 -| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | -|------|-------------|---------------|------|--------------| -| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | -| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | -| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | -| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | -| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | -| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | -| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | -| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | -| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | -| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | -| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | -| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | -| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | -| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | -| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | -| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | +| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | +| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | +| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | +| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | +| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | +| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | +| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | +| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | +| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | +| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | +| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | +| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | +| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | +| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | +| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | +| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | +| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | #### 基础配置示例 @@ -720,6 +493,7 @@ Agent 读取 HEARTBEAT.md #### 各厂商配置示例 **OpenAI** + ```json { "model_name": "gpt-5.2", @@ -729,6 +503,7 @@ Agent 读取 HEARTBEAT.md ``` **智谱 AI (GLM)** + ```json { "model_name": "glm-4.7", @@ -738,6 +513,7 @@ Agent 读取 HEARTBEAT.md ``` **DeepSeek** + ```json { "model_name": "deepseek-chat", @@ -747,6 +523,7 @@ Agent 读取 HEARTBEAT.md ``` **Anthropic (使用 OAuth)** + ```json { "model_name": "claude-sonnet-4.6", @@ -754,9 +531,11 @@ Agent 读取 HEARTBEAT.md "auth_method": "oauth" } ``` + > 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。 **Ollama (本地)** + ```json { "model_name": "llama3", @@ -765,6 +544,7 @@ Agent 读取 HEARTBEAT.md ``` **自定义代理/API** + ```json { "model_name": "my-custom-model", @@ -802,6 +582,7 @@ Agent 读取 HEARTBEAT.md 旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。 **旧配置(已弃用):** + ```json { "providers": { @@ -820,6 +601,7 @@ Agent 读取 HEARTBEAT.md ``` **新配置(推荐):** + ```json { "model_list": [ @@ -844,7 +626,7 @@ Agent 读取 HEARTBEAT.md **1. 获取 API key 和 base URL** -* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) +- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) **2. 配置** @@ -866,7 +648,6 @@ Agent 读取 HEARTBEAT.md } } } - ``` **3. 运行** @@ -946,30 +727,29 @@ picoclaw agent -m "你好" "interval": 30 } } - ```
## CLI 命令行参考 -| 命令 | 描述 | -| --- | --- | -| `picoclaw onboard` | 初始化配置和工作区 | -| `picoclaw agent -m "..."` | 与 Agent 对话 | -| `picoclaw agent` | 交互式聊天模式 | -| `picoclaw gateway` | 启动网关 (Gateway) | -| `picoclaw status` | 显示状态 | -| `picoclaw cron list` | 列出所有定时任务 | -| `picoclaw cron add ...` | 添加定时任务 | +| 命令 | 描述 | +| ------------------------- | ------------------ | +| `picoclaw onboard` | 初始化配置和工作区 | +| `picoclaw agent -m "..."` | 与 Agent 对话 | +| `picoclaw agent` | 交互式聊天模式 | +| `picoclaw gateway` | 启动网关 (Gateway) | +| `picoclaw status` | 显示状态 | +| `picoclaw cron list` | 列出所有定时任务 | +| `picoclaw cron add ...` | 添加定时任务 | ### 定时任务 / 提醒 (Scheduled Tasks) PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: -* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次 -* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发 -* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式 +- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次 +- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发 +- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式 任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。 @@ -983,7 +763,7 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: 用户群组: -Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) +Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) PicoClaw @@ -995,8 +775,9 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) 启用网络搜索: -1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询) +1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费) 2. 添加到 `~/.picoclaw/config.json`: + ```json { "tools": { @@ -1013,11 +794,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) } } } - ``` - - ### 遇到内容过滤错误 (Content Filtering Errors) 某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。 @@ -1035,5 +813,5 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) | **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | | **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 | | **Brave Search** | 2000 次查询/月 | 网络搜索功能 | +| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 | | **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | -| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) | \ No newline at end of file diff --git a/assets/wechat.png b/assets/wechat.png index 8fc41ea7d..a34217c33 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index 6d6ff935f..1c92e0b6c 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -70,6 +70,7 @@ func agentCmd() { } msgBus := bus.NewMessageBus() + defer msgBus.Close() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) // Print agent startup info (only for interactive mode) @@ -148,7 +149,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { reader := bufio.NewReader(os.Stdin) for { - fmt.Print(fmt.Sprintf("%s You: ", logo)) + fmt.Printf("%s You: ", logo) line, err := reader.ReadString('\n') if err != nil { if err == io.EOF { diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go index 5bed7f116..729c56177 100644 --- a/cmd/picoclaw/cmd_auth.go +++ b/cmd/picoclaw/cmd_auth.go @@ -114,7 +114,7 @@ func authLoginOpenAI(useDeviceCode bool) { os.Exit(1) } - if err := auth.SetCredential("openai", cred); err != nil { + if err = auth.SetCredential("openai", cred); err != nil { fmt.Printf("Failed to save credentials: %v\n", err) os.Exit(1) } @@ -188,7 +188,7 @@ func authLoginGoogleAntigravity() { fmt.Printf("Project: %s\n", projectID) } - if err := auth.SetCredential("google-antigravity", cred); err != nil { + if err = auth.SetCredential("google-antigravity", cred); err != nil { fmt.Printf("Failed to save credentials: %v\n", err) os.Exit(1) } @@ -265,7 +265,7 @@ func authLoginPasteToken(provider string) { os.Exit(1) } - if err := auth.SetCredential(provider, cred); err != nil { + if err = auth.SetCredential(provider, cred); err != nil { fmt.Printf("Failed to save credentials: %v\n", err) os.Exit(1) } diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 00ec0f96d..798ad2813 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -6,7 +6,6 @@ package main import ( "context" "fmt" - "net/http" "os" "os/signal" "path/filepath" @@ -15,16 +14,28 @@ import ( "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" + _ "github.com/sipeed/picoclaw/pkg/channels/discord" + _ "github.com/sipeed/picoclaw/pkg/channels/feishu" + _ "github.com/sipeed/picoclaw/pkg/channels/line" + _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" + _ "github.com/sipeed/picoclaw/pkg/channels/onebot" + _ "github.com/sipeed/picoclaw/pkg/channels/pico" + _ "github.com/sipeed/picoclaw/pkg/channels/qq" + _ "github.com/sipeed/picoclaw/pkg/channels/slack" + _ "github.com/sipeed/picoclaw/pkg/channels/telegram" + _ "github.com/sipeed/picoclaw/pkg/channels/wecom" + _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/voice" ) func gatewayCmd() { @@ -98,7 +109,8 @@ func gatewayCmd() { channel, chatID = "cli", "direct" } // Use ProcessHeartbeat - no session history, each heartbeat is independent - response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) + var response string + response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) if err != nil { return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) } @@ -110,41 +122,18 @@ func gatewayCmd() { return tools.SilentResult(response) }) - channelManager, err := channels.NewManager(cfg, msgBus) + // Create media store for file lifecycle management + mediaStore := media.NewFileMediaStore() + + channelManager, err := channels.NewManager(cfg, msgBus, mediaStore) if err != nil { fmt.Printf("Error creating channel manager: %v\n", err) os.Exit(1) } - // Inject channel manager into agent loop for command handling + // Inject channel manager and media store into agent loop agentLoop.SetChannelManager(channelManager) - - var transcriber *voice.GroqTranscriber - if cfg.Providers.Groq.APIKey != "" { - transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) - logger.InfoC("voice", "Groq voice transcription enabled") - } - - if transcriber != nil { - if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { - if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { - tc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Telegram channel") - } - } - if discordChannel, ok := channelManager.GetChannel("discord"); ok { - if dc, ok := discordChannel.(*channels.DiscordChannel); ok { - dc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Discord channel") - } - } - if slackChannel, ok := channelManager.GetChannel("slack"); ok { - if sc, ok := slackChannel.(*channels.SlackChannel); ok { - sc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Slack channel") - } - } - } + agentLoop.SetMediaStore(mediaStore) enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { @@ -181,16 +170,15 @@ func gatewayCmd() { fmt.Println("✓ Device event service started") } + // Setup shared HTTP server with health endpoints and webhook handlers + healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) + channelManager.SetupHTTPServer(addr, healthServer) + if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) } - healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) - go func() { - if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()}) - } - }() fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) go agentLoop.Run(ctx) @@ -201,12 +189,18 @@ func gatewayCmd() { fmt.Println("\nShutting down...") cancel() - healthServer.Stop(context.Background()) + msgBus.Close() + + // Use a fresh context with timeout for graceful shutdown, + // since the original ctx is already cancelled. + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + + channelManager.StopAll(shutdownCtx) deviceService.Stop() heartbeatService.Stop() cronService.Stop() agentLoop.Stop() - channelManager.StopAll(ctx) fmt.Println("✓ Gateway stopped") } diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go index 2dd46756a..0814494b3 100644 --- a/cmd/picoclaw/cmd_skills.go +++ b/cmd/picoclaw/cmd_skills.go @@ -118,7 +118,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { workspace := cfg.WorkspacePath() targetDir := filepath.Join(workspace, "skills", slug) - if _, err := os.Stat(targetDir); err == nil { + if _, err = os.Stat(targetDir); err == nil { fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir) os.Exit(1) } @@ -126,7 +126,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil { + if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil { fmt.Printf("\u2717 Failed to create skills directory: %v\n", err) os.Exit(1) } diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 1e4b393f8..25ad701ca 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -131,7 +131,7 @@ func main() { workspace := cfg.WorkspacePath() installer := skills.NewSkillInstaller(workspace) - // 获取全局配置目录和内置 skills 目录 + // get global config directory and builtin skills directory globalDir := filepath.Dir(getConfigPath()) globalSkillsDir := filepath.Join(globalDir, "skills") builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") diff --git a/config/config.example.json b/config/config.example.json index 2079b365d..d80efec64 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -215,6 +215,10 @@ "volcengine": { "api_key": "", "api_base": "" + }, + "mistral": { + "api_key": "", + "api_base": "https://api.mistral.ai/v1" } }, "tools": { @@ -262,7 +266,7 @@ "monitor_usb": true }, "gateway": { - "host": "0.0.0.0", + "host": "127.0.0.1", "port": 18790 } } diff --git a/docker-compose.yml b/docker-compose.yml index 32e8ee339..c268b01cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,9 @@ services: container_name: picoclaw-agent profiles: - agent + # Uncomment to access host network; leave commented unless needed. + #extra_hosts: + # - "host.docker.internal:host-gateway" volumes: - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace @@ -29,6 +32,9 @@ services: restart: unless-stopped profiles: - gateway + # Uncomment to access host network; leave commented unless needed. + #extra_hosts: + # - "host.docker.internal:host-gateway" volumes: # Configuration file - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md new file mode 100644 index 000000000..1e445d0b0 --- /dev/null +++ b/docs/channels/dingtalk/README.zh.md @@ -0,0 +1,33 @@ +# 钉钉 + +钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。 + +## 配置 + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ------------- | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | 是否启用钉钉频道 | +| client_id | string | 是 | 钉钉应用的 Client ID | +| client_secret | string | 是 | 钉钉应用的 Client Secret | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | + +## 设置流程 + +1. 前往 [钉钉开放平台](https://open.dingtalk.com/) +2. 创建一个企业内部应用 +3. 从应用设置中获取 Client ID 和 Client Secret +4. 配置OAuth和事件订阅(如需要) +5. 将 Client ID 和 Client Secret 填入配置文件中 diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md new file mode 100644 index 000000000..5b597eced --- /dev/null +++ b/docs/channels/discord/README.zh.md @@ -0,0 +1,35 @@ +# Discord + +Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。 + +## 配置 + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allow_from": ["YOUR_USER_ID"], + "mention_only": false + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ------------ | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | 是否启用 Discord 频道 | +| token | string | 是 | Discord 机器人 Token | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| mention_only | bool | 否 | 是否仅响应提及机器人的消息 | + +## 设置流程 + +1. 前往 [Discord 开发者门户](https://discord.com/developers/applications) 创建一个新的应用 +2. 启用 Intents: + - Message Content Intent + - Server Members Intent +3. 获取 Bot Token +4. 将 Bot Token 填入配置文件中 +5. 邀请机器人加入服务器并授予必要权限(例如发送消息、读取消息历史等) diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md new file mode 100644 index 000000000..310827723 --- /dev/null +++ b/docs/channels/feishu/README.zh.md @@ -0,0 +1,37 @@ +# 飞书 + +飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。 + +## 配置 + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ------------------ | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | 是否启用飞书频道 | +| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) | +| app_secret | string | 是 | 飞书应用的 App Secret | +| encrypt_key | string | 否 | 事件回调加密密钥 | +| verification_token | string | 否 | 用于Webhook事件验证的Token | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | + +## 设置流程 + +1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序 +2. 获取 App ID 和 App Secret +3. 配置事件订阅和Webhook URL +4. 设置加密(可选,生产环境建议启用) +5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中 diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md new file mode 100644 index 000000000..fd3aa80da --- /dev/null +++ b/docs/channels/line/README.zh.md @@ -0,0 +1,41 @@ +# Line + +PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。 + +## 配置 + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| -------------------- | ------ | ---- | ------------------------------------------ | +| enabled | bool | 是 | 是否启用 LINE Channel | +| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret | +| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token | +| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) | +| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) | +| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | + +## 设置流程 + +1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel +2. 获取 Channel Secret 和 Channel Access Token +3. 配置Webhook: + - Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网 + - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line` + - 启用 Webhook 并验证 URL +4. 将 Channel Secret 和 Channel Access Token 填入配置文件中 diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md new file mode 100644 index 000000000..8d53d4bef --- /dev/null +++ b/docs/channels/maixcam/README.zh.md @@ -0,0 +1,31 @@ +# MaixCam + +MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。 + +## 配置 + +```json +{ + "channels": { + "maixcam": { + "enabled": true, + "server_address": "0.0.0.0:8899", + "allow_from": [] + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| -------------- | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | 是否启用 MaixCam 频道 | +| server_address | string | 是 | TCP 服务器监听地址和端口 | +| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 | + +## 使用场景 + +MaixCam 通道使 PicoClaw 能够作为边缘设备的 AI 后端运行: + +- **智能监控** :MaixCAM 发送图像帧,PicoClaw 通过视觉模型进行分析 +- **物联网控制** :设备发送传感器数据,PicoClaw 协调响应 +- **离线AI** :在本地网络部署 PicoClaw 实现低延迟推理 diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md new file mode 100644 index 000000000..6195f1c98 --- /dev/null +++ b/docs/channels/onebot/README.zh.md @@ -0,0 +1,31 @@ +# OneBot + +OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。 + +## 配置 + +```json +{ + "channels": { + "onebot": { + "enabled": true, + "ws_url": "ws://localhost:8080", + "access_token": "", + "allow_from": [] + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ------------ | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | 是否启用 OneBot 频道 | +| ws_url | string | 是 | OneBot 服务器的 WebSocket URL | +| access_token | string | 否 | 连接 OneBot 服务器的访问令牌 | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | + +## 设置流程 + +1. 部署一个 OneBot 兼容的实现(例如napcat) +2. 配置 OneBot 实现以启用 WebSocket 服务并设置访问令牌(如果需要) +3. 将 WebSocket URL 和访问令牌填入配置文件中 diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md new file mode 100644 index 000000000..bd774960f --- /dev/null +++ b/docs/channels/qq/README.zh.md @@ -0,0 +1,32 @@ +# QQ + +PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。 + +## 配置 + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------- | ------ | ---- | -------------------------------- | +| enabled | bool | 是 | 是否启用 QQ Channel | +| app_id | string | 是 | QQ 机器人应用的 App ID | +| app_secret | string | 是 | QQ 机器人应用的 App Secret | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | + +## 设置流程 + +1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人 +2. 通过仪表盘获取 App ID 和 App Secret +3. 开启机器人沙箱模式, 将用户和群添加到沙箱中 +4. 将 App ID 和 App Secret 填入配置文件中 diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md new file mode 100644 index 000000000..58ebcb566 --- /dev/null +++ b/docs/channels/slack/README.zh.md @@ -0,0 +1,33 @@ +# Slack + +Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。 + +## 配置 + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "xoxb-...", + "app_token": "xapp-...", + "allow_from": [] + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------- | ------ | ---- | -------------------------------------------------------- | +| enabled | bool | 是 | 是否启用 Slack 频道 | +| bot_token | string | 是 | Slack 机器人的 Bot User OAuth Token (以 xoxb- 开头) | +| app_token | string | 是 | Slack 应用的 Socket Mode App Level Token (以 xapp- 开头) | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | + +## 设置流程 + +1. 前往 [Slack API](https://api.slack.com/) 创建一个新的 Slack 应用 +2. 启用 Socket Mode 并获取 App Level Token +3. 添加 Bot Token Scopes(例如`chat:write`、`im:history`等) +4. 安装应用到工作区并获取 Bot User OAuth Token +5. 将 Bot Token 和 App Token 填入配置文件中 diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md new file mode 100644 index 000000000..d453c68fa --- /dev/null +++ b/docs/channels/telegram/README.zh.md @@ -0,0 +1,33 @@ +# Telegram + +Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。 + +## 配置 + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "allow_from": ["123456789"], + "proxy": "" + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------- | ------ | ---- | --------------------------------------------------------- | +| enabled | bool | 是 | 是否启用 Telegram 频道 | +| token | string | 是 | Telegram 机器人 API Token | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) | + +## 设置流程 + +1. 在 Telegram 中搜索 `@BotFather` +2. 发送 `/newbot` 命令并按照提示创建新机器人 +3. 获取 HTTP API Token +4. 将 Token 填入配置文件中 +5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID) diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md new file mode 100644 index 000000000..1e6a0e2b3 --- /dev/null +++ b/docs/channels/wecom/wecom_app/README.zh.md @@ -0,0 +1,47 @@ +# 企业微信自建应用 + +企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。 + +## 配置 + +```json +{ + "channels": { + "wecom_app": { + "enabled": true, + "corp_id": "wwxxxxxxxxxxxxxxxx", + "corp_secret": "YOUR_CORP_SECRET", + "agent_id": 1000002, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18792, + "webhook_path": "/webhook/wecom-app", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------------- | ------ | ---- | ---------------------------------------- | +| corp_id | string | 是 | 企业 ID | +| corp_secret | string | 是 | 应用程序密钥 | +| agent_id | int | 是 | 应用程序代理 ID | +| token | string | 是 | 回调验证令牌 | +| encoding_aes_key | string | 是 | 43 字符 AES 密钥 | +| webhook_host | string | 否 | HTTP 服务器绑定地址 | +| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) | +| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) | +| allow_from | array | 否 | 用户 ID 白名单 | +| reply_timeout | int | 否 | 回复超时时间(秒) | + +## 设置流程 + +1. 登录 [企业微信管理后台](https://work.weixin.qq.com/) +2. 进入“应用管理” -> “创建应用” +3. 获取企业 ID (CorpID) 和应用 Secret +4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey +5. 设置回调 URL 为 `http://:/webhook/wecom-app` +6. 将 CorpID, Secret, AgentID 等信息填入配置文件 diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md new file mode 100644 index 000000000..c4bb1c87e --- /dev/null +++ b/docs/channels/wecom/wecom_bot/README.zh.md @@ -0,0 +1,41 @@ +# 企业微信机器人 + +企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。 + +## 配置 + +```json +{ + "channels": { + "wecom": { + "enabled": true, + "token": "YOUR_TOKEN", + "encoding_aes_key": "YOUR_ENCODING_AES_KEY", + "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", + "webhook_host": "0.0.0.0", + "webhook_port": 18793, + "webhook_path": "/webhook/wecom", + "allow_from": [], + "reply_timeout": 5 + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------------- | ------ | ---- | -------------------------------------------- | +| token | string | 是 | 签名验证代币 | +| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 | +| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL | +| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) | +| webhook_port | int | 否 | HTTP 服务器端口(默认:18793) | +| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) | +| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) | +| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) | + +## 设置流程 + +1. 在企业微信群中添加机器人 +2. 获取 Webhook URL +3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey +4. 将相关信息填入配置文件 diff --git a/docs/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md deleted file mode 100644 index 95de768c6..000000000 --- a/docs/picoclaw_community_roadmap_260216.md +++ /dev/null @@ -1,112 +0,0 @@ -## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal - -**Hello, PicoClaw Community!** - -First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs! - -PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum. - -This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**. - -### 🎁 Community Perks - -To show our appreciation, developers who officially join our community operations will receive: - -* **Exclusive AI Hardware:** Our upcoming, unreleased AI device. -* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers). - -### 🎥 Calling All Content Creators! - -Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**. - -* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**. -* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM. -We will be rewarding high-quality content creators with the same perks as our community developers! - ---- - -## 🛠️ Urgent Volunteer Roles - -We are looking for experts in the following areas: - -1. **Issue/PR Reviewers** -* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging. -* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles. - - -2. **Resource Optimization Experts** -* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean. -* **Focus:** Analyzing resource growth between releases and trimming redundancy. -* **Priority:** **RAM usage optimization** > Binary size reduction. - - -3. **Security Audit & Bug Fixes** -* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management. -* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes. - - -4. **Documentation & DX (Developer Experience)** -* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow. -* **Focus:** Creating clear, user-friendly documentation for both setup and development. - - -5. **AI-Powered CI/CD Optimization** -* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it. -* **Focus:** Automating builds with AI and exploring AI-driven issue resolution. - -**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role. -Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors! - ---- - -## 📍 The Roadmap - -Interested in a specific feature? You can "claim" these tasks and start building: - -### -* **Provider:** - * **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days) - * You can still submit code; Daming will merge it into the new implementation. -* **Channels:** - * Support for OneBot, additional platforms - * attachments (images, audio, video, files). -* **Skills:** - * Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms. -* **Operations:** * MCP Support. - * Android operations (e.g., botdrop). - * Browser automation via CDP or ActionBook. - - -* **Multi-Agent Ecosystem:** - * **Basic Model-Agent** - * **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens). - * **Swarm Mode.** - * **AIEOS Integration.** - - -* **Branding:** - * **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch! - - -We have officially created these tasks as GitHub Issues, all marked with the roadmap tag. -This list will be updated continuously as we progress. -If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue! - ---- - -## 🤝 How to Join - -**Everything is open to your creativity!** If you have a wild idea, just PR it. - -1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw. -2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject: -> `[Apply Join PicoClaw Dev Group] + Your GitHub Account` -> Include the role you're interested in and any evidence of your development experience. - - - -### Looking Ahead - -Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential. - -**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎 diff --git a/go.mod b/go.mod index 1fb3e44ba..56878797e 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/smartystreets/assertions v1.2.0 // indirect github.com/smartystreets/goconvey v1.7.2 // indirect + golang.org/x/time v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 299948ba8..1e3d7fc71 100644 --- a/go.sum +++ b/go.sum @@ -262,6 +262,8 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190308142131-b40df0fb21c3/go.mod h1:25r3+/G6/xytQM8iWZKq3Hn0kr0rgFKPUNVEL/dr3z4= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= diff --git a/pkg/agent/context.go b/pkg/agent/context.go index e989ffaaf..a9db5afdd 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -80,7 +80,7 @@ Your workspace is at: %s 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. -3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`, +3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`, now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b36f4a0c4..088e8c4d2 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "strings" "sync" "sync/atomic" @@ -21,6 +22,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/skills" @@ -38,6 +40,7 @@ type AgentLoop struct { summarizing sync.Map fallback *providers.FallbackChain channelManager *channels.Manager + mediaStore media.MediaStore } // processOptions configures how a message is processed @@ -97,6 +100,10 @@ func registerSharedTools( BraveAPIKey: cfg.Tools.Web.Brave.APIKey, BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, @@ -114,12 +121,13 @@ func registerSharedTools( // Message tool messageTool := tools.NewMessageTool() messageTool.SetSendCallback(func(channel, chatID, content string) error { - msgBus.PublishOutbound(bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: content, }) - return nil }) agent.Tools.Register(messageTool) @@ -163,33 +171,49 @@ func (al *AgentLoop) Run(ctx context.Context) error { continue } - response, err := al.processMessage(ctx, msg) - if err != nil { - response = fmt.Sprintf("Error processing message: %v", err) - } + // Process message + func() { + // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. + // Currently disabled because files are deleted before the LLM can access their content. + // defer func() { + // if al.mediaStore != nil && msg.MediaScope != "" { + // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { + // logger.WarnCF("agent", "Failed to release media", map[string]any{ + // "scope": msg.MediaScope, + // "error": releaseErr.Error(), + // }) + // } + // } + // }() - if response != "" { - // Check if the message tool already sent a response during this round. - // If so, skip publishing to avoid duplicate messages to the user. - // Use default agent's tools to check (message tool is shared). - alreadySent := false - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent != nil { - if tool, ok := defaultAgent.Tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySent = mt.HasSentInRound() + response, err := al.processMessage(ctx, msg) + if err != nil { + response = fmt.Sprintf("Error processing message: %v", err) + } + + if response != "" { + // Check if the message tool already sent a response during this round. + // If so, skip publishing to avoid duplicate messages to the user. + // Use default agent's tools to check (message tool is shared). + alreadySent := false + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent != nil { + if tool, ok := defaultAgent.Tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok { + alreadySent = mt.HasSentInRound() + } } } - } - if !alreadySent { - al.bus.PublishOutbound(bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: response, - }) + if !alreadySent { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: response, + }) + } } - } + }() } } @@ -212,6 +236,41 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { al.channelManager = cm } +// SetMediaStore injects a MediaStore for media lifecycle management. +func (al *AgentLoop) SetMediaStore(s media.MediaStore) { + al.mediaStore = s +} + +// inferMediaType determines the media type ("image", "audio", "video", "file") +// from a filename and MIME content type. +func inferMediaType(filename, contentType string) string { + ct := strings.ToLower(contentType) + fn := strings.ToLower(filename) + + if strings.HasPrefix(ct, "image/") { + return "image" + } + if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { + return "audio" + } + if strings.HasPrefix(ct, "video/") { + return "video" + } + + // Fallback: infer from extension + ext := filepath.Ext(fn) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + } + + return "file" +} + // RecordLastChannel records the last active channel for this workspace. // This uses the atomic state save mechanism to prevent data loss on crash. func (al *AgentLoop) RecordLastChannel(channel string) error { @@ -446,7 +505,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 8. Optional: send response via bus if opts.SendResponse { - al.bus.PublishOutbound(bus.OutboundMessage{ + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: finalContent, @@ -561,7 +620,7 @@ func (al *AgentLoop) runLLMIteration( }) if retry == 0 && !constants.IsInternalChannel(opts.Channel) { - al.bus.PublishOutbound(bus.OutboundMessage{ + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: "Context window exceeded. Compressing history and retrying...", @@ -690,7 +749,7 @@ func (al *AgentLoop) runLLMIteration( // Send ForUser content to user immediately if not Silent if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { - al.bus.PublishOutbound(bus.OutboundMessage{ + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: toolResult.ForUser, @@ -702,6 +761,28 @@ func (al *AgentLoop) runLLMIteration( }) } + // If tool returned media refs, publish them as outbound media + if len(toolResult.Media) > 0 && opts.SendResponse { + parts := make([]bus.MediaPart, 0, len(toolResult.Media)) + for _, ref := range toolResult.Media { + part := bus.MediaPart{Ref: ref} + // Populate metadata from MediaStore when available + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Parts: parts, + }) + } + // Determine content for LLM based on tool result contentForLLM := toolResult.ForLLM if contentForLLM == "" && toolResult.Err != nil { @@ -755,7 +836,9 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c go func() { defer al.summarizing.Delete(summarizeKey) if !constants.IsInternalChannel(channel) { - al.bus.PublishOutbound(bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: "Memory threshold reached. Optimizing conversation history...", @@ -1118,21 +1201,20 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) return "", false } -// extractPeer extracts the routing peer from inbound message metadata. +// extractPeer extracts the routing peer from the inbound message's structured Peer field. func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { - peerKind := msg.Metadata["peer_kind"] - if peerKind == "" { + if msg.Peer.Kind == "" { return nil } - peerID := msg.Metadata["peer_id"] + peerID := msg.Peer.ID if peerID == "" { - if peerKind == "direct" { + if msg.Peer.Kind == "direct" { peerID = msg.SenderID } else { peerID = msg.ChatID } } - return &routing.RoutePeer{Kind: peerKind, ID: peerID} + return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID} } // extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 58c0a25d5..d2b6838c5 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -2,81 +2,145 @@ package bus import ( "context" - "sync" + "errors" + "sync/atomic" + + "github.com/sipeed/picoclaw/pkg/logger" ) +// ErrBusClosed is returned when publishing to a closed MessageBus. +var ErrBusClosed = errors.New("message bus closed") + type MessageBus struct { - inbound chan InboundMessage - outbound chan OutboundMessage - handlers map[string]MessageHandler - closed bool - mu sync.RWMutex + inbound chan InboundMessage + outbound chan OutboundMessage + outboundMedia chan OutboundMediaMessage + done chan struct{} + closed atomic.Bool } func NewMessageBus() *MessageBus { return &MessageBus{ - inbound: make(chan InboundMessage, 100), - outbound: make(chan OutboundMessage, 100), - handlers: make(map[string]MessageHandler), + inbound: make(chan InboundMessage, 100), + outbound: make(chan OutboundMessage, 100), + outboundMedia: make(chan OutboundMediaMessage, 100), + done: make(chan struct{}), } } -func (mb *MessageBus) PublishInbound(msg InboundMessage) { - mb.mu.RLock() - defer mb.mu.RUnlock() - if mb.closed { - return +func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { + if mb.closed.Load() { + return ErrBusClosed + } + select { + case mb.inbound <- msg: + return nil + case <-mb.done: + return ErrBusClosed + case <-ctx.Done(): + return ctx.Err() } - mb.inbound <- msg } func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) { select { - case msg := <-mb.inbound: - return msg, true + case msg, ok := <-mb.inbound: + return msg, ok + case <-mb.done: + return InboundMessage{}, false case <-ctx.Done(): return InboundMessage{}, false } } -func (mb *MessageBus) PublishOutbound(msg OutboundMessage) { - mb.mu.RLock() - defer mb.mu.RUnlock() - if mb.closed { - return +func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { + if mb.closed.Load() { + return ErrBusClosed + } + select { + case mb.outbound <- msg: + return nil + case <-mb.done: + return ErrBusClosed + case <-ctx.Done(): + return ctx.Err() } - mb.outbound <- msg } func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) { select { - case msg := <-mb.outbound: - return msg, true + case msg, ok := <-mb.outbound: + return msg, ok + case <-mb.done: + return OutboundMessage{}, false case <-ctx.Done(): return OutboundMessage{}, false } } -func (mb *MessageBus) RegisterHandler(channel string, handler MessageHandler) { - mb.mu.Lock() - defer mb.mu.Unlock() - mb.handlers[channel] = handler +func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { + if mb.closed.Load() { + return ErrBusClosed + } + select { + case mb.outboundMedia <- msg: + return nil + case <-mb.done: + return ErrBusClosed + case <-ctx.Done(): + return ctx.Err() + } } -func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) { - mb.mu.RLock() - defer mb.mu.RUnlock() - handler, ok := mb.handlers[channel] - return handler, ok +func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) { + select { + case msg, ok := <-mb.outboundMedia: + return msg, ok + case <-mb.done: + return OutboundMediaMessage{}, false + case <-ctx.Done(): + return OutboundMediaMessage{}, false + } } func (mb *MessageBus) Close() { - mb.mu.Lock() - defer mb.mu.Unlock() - if mb.closed { - return + if mb.closed.CompareAndSwap(false, true) { + close(mb.done) + + // Drain buffered channels so messages aren't silently lost. + // Channels are NOT closed to avoid send-on-closed panics from concurrent publishers. + drained := 0 + for { + select { + case <-mb.inbound: + drained++ + default: + goto doneInbound + } + } + doneInbound: + for { + select { + case <-mb.outbound: + drained++ + default: + goto doneOutbound + } + } + doneOutbound: + for { + select { + case <-mb.outboundMedia: + drained++ + default: + goto doneMedia + } + } + doneMedia: + if drained > 0 { + logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{ + "count": drained, + }) + } } - mb.closed = true - close(mb.inbound) - close(mb.outbound) } diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go new file mode 100644 index 000000000..47826824e --- /dev/null +++ b/pkg/bus/bus_test.go @@ -0,0 +1,229 @@ +package bus + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestPublishConsume(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctx := context.Background() + + msg := InboundMessage{ + Channel: "test", + SenderID: "user1", + ChatID: "chat1", + Content: "hello", + } + + if err := mb.PublishInbound(ctx, msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + + got, ok := mb.ConsumeInbound(ctx) + if !ok { + t.Fatal("ConsumeInbound returned ok=false") + } + if got.Content != "hello" { + t.Fatalf("expected content 'hello', got %q", got.Content) + } + if got.Channel != "test" { + t.Fatalf("expected channel 'test', got %q", got.Channel) + } +} + +func TestPublishOutboundSubscribe(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctx := context.Background() + + msg := OutboundMessage{ + Channel: "telegram", + ChatID: "123", + Content: "world", + } + + if err := mb.PublishOutbound(ctx, msg); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + + got, ok := mb.SubscribeOutbound(ctx) + if !ok { + t.Fatal("SubscribeOutbound returned ok=false") + } + if got.Content != "world" { + t.Fatalf("expected content 'world', got %q", got.Content) + } +} + +func TestPublishInbound_ContextCancel(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + // Fill the buffer + ctx := context.Background() + for i := 0; i < 100; i++ { + if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { + t.Fatalf("fill failed at %d: %v", i, err) + } + } + + // Now buffer is full; publish with a cancelled context + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() + + err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"}) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } + if err != context.Canceled { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestPublishInbound_BusClosed(t *testing.T) { + mb := NewMessageBus() + mb.Close() + + err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) + if err != ErrBusClosed { + t.Fatalf("expected ErrBusClosed, got %v", err) + } +} + +func TestPublishOutbound_BusClosed(t *testing.T) { + mb := NewMessageBus() + mb.Close() + + err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"}) + if err != ErrBusClosed { + t.Fatalf("expected ErrBusClosed, got %v", err) + } +} + +func TestConsumeInbound_ContextCancel(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, ok := mb.ConsumeInbound(ctx) + if ok { + t.Fatal("expected ok=false when context is cancelled") + } +} + +func TestConsumeInbound_BusClosed(t *testing.T) { + mb := NewMessageBus() + mb.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, ok := mb.ConsumeInbound(ctx) + if ok { + t.Fatal("expected ok=false when bus is closed") + } +} + +func TestSubscribeOutbound_BusClosed(t *testing.T) { + mb := NewMessageBus() + mb.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, ok := mb.SubscribeOutbound(ctx) + if ok { + t.Fatal("expected ok=false when bus is closed") + } +} + +func TestConcurrentPublishClose(t *testing.T) { + mb := NewMessageBus() + ctx := context.Background() + + const numGoroutines = 100 + var wg sync.WaitGroup + wg.Add(numGoroutines + 1) + + // Spawn many goroutines trying to publish + for i := 0; i < numGoroutines; i++ { + go func() { + defer wg.Done() + // Use a short timeout context so we don't block forever after close + publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + // Errors are expected; we just must not panic or deadlock + _ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"}) + }() + } + + // Close from another goroutine + go func() { + defer wg.Done() + time.Sleep(5 * time.Millisecond) + mb.Close() + }() + + // Must complete without deadlock + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // success + case <-time.After(5 * time.Second): + t.Fatal("test timed out - possible deadlock") + } +} + +func TestPublishInbound_FullBuffer(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctx := context.Background() + + // Fill the buffer + for i := 0; i < 100; i++ { + if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { + t.Fatalf("fill failed at %d: %v", i, err) + } + } + + // Buffer is full; publish with short timeout + timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"}) + if err == nil { + t.Fatal("expected error when buffer is full and context times out") + } + if err != context.DeadlineExceeded { + t.Fatalf("expected context.DeadlineExceeded, got %v", err) + } +} + +func TestCloseIdempotent(t *testing.T) { + mb := NewMessageBus() + + // Multiple Close calls must not panic + mb.Close() + mb.Close() + mb.Close() + + // After close, publish should return ErrBusClosed + err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) + if err != ErrBusClosed { + t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err) + } +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 44f9181a5..7ad8f0417 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -1,11 +1,30 @@ 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", ... + PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456" + CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format + Username string `json:"username,omitempty"` // username (e.g. @alice) + DisplayName string `json:"display_name,omitempty"` // display name +} + 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"` } @@ -16,4 +35,18 @@ type OutboundMessage struct { Content string `json:"content"` } -type MessageHandler func(InboundMessage) error +// MediaPart describes a single media attachment to send. +type MediaPart struct { + Type string `json:"type"` // "image" | "audio" | "video" | "file" + Ref string `json:"ref"` // media store ref, e.g. "media://abc123" + Caption string `json:"caption,omitempty"` // optional caption text + Filename string `json:"filename,omitempty"` // original filename hint + ContentType string `json:"content_type,omitempty"` // MIME type hint +} + +// 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"` +} diff --git a/pkg/channels/base.go b/pkg/channels/base.go index cd6419ebb..418933af7 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -3,8 +3,15 @@ package channels import ( "context" "strings" + "sync/atomic" + + "github.com/google/uuid" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" ) type Channel interface { @@ -14,24 +21,106 @@ type Channel interface { Send(ctx context.Context, msg bus.OutboundMessage) error IsRunning() bool IsAllowed(senderID string) bool + IsAllowedSender(sender bus.SenderInfo) bool +} + +// BaseChannelOption is a functional option for configuring a BaseChannel. +type BaseChannelOption func(*BaseChannel) + +// WithMaxMessageLength sets the maximum message length (in runes) for a channel. +// Messages exceeding this limit will be automatically split by the Manager. +// A value of 0 means no limit. +func WithMaxMessageLength(n int) BaseChannelOption { + return func(c *BaseChannel) { c.maxMessageLength = n } +} + +// WithGroupTrigger sets the group trigger configuration for a channel. +func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption { + return func(c *BaseChannel) { c.groupTrigger = gt } +} + +// MessageLengthProvider is an opt-in interface that channels implement +// to advertise their maximum message length. The Manager uses this via +// type assertion to decide whether to split outbound messages. +type MessageLengthProvider interface { + MaxMessageLength() int } type BaseChannel struct { - config any - bus *bus.MessageBus - running bool - name string - allowList []string + config any + bus *bus.MessageBus + running atomic.Bool + name string + allowList []string + maxMessageLength int + groupTrigger config.GroupTriggerConfig + mediaStore media.MediaStore + placeholderRecorder PlaceholderRecorder } -func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel { - return &BaseChannel{ +func NewBaseChannel( + name string, + config any, + bus *bus.MessageBus, + allowList []string, + opts ...BaseChannelOption, +) *BaseChannel { + bc := &BaseChannel{ config: config, bus: bus, name: name, allowList: allowList, - running: false, } + for _, opt := range opts { + opt(bc) + } + return bc +} + +// MaxMessageLength returns the maximum message length (in runes) for this channel. +// A value of 0 means no limit. +func (c *BaseChannel) MaxMessageLength() int { + return c.maxMessageLength +} + +// ShouldRespondInGroup determines whether the bot should respond in a group chat. +// Each channel is responsible for: +// 1. Detecting isMentioned (platform-specific) +// 2. Stripping bot mention from content (platform-specific) +// 3. Calling this method to get the group response decision +// +// Logic: +// - If isMentioned → always respond +// - If mention_only configured and not mentioned → ignore +// - If prefixes configured → respond if content starts with any prefix (strip it) +// - If prefixes configured but no match and not mentioned → ignore +// - Otherwise (no group_trigger configured) → respond to all (permissive default) +func (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) { + gt := c.groupTrigger + + // Mentioned → always respond + if isMentioned { + return true, strings.TrimSpace(content) + } + + // mention_only → require mention + if gt.MentionOnly { + return false, content + } + + // Prefix matching + if len(gt.Prefixes) > 0 { + for _, prefix := range gt.Prefixes { + if prefix != "" && strings.HasPrefix(content, prefix) { + return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) + } + } + // Prefixes configured but none matched and not mentioned → ignore + return false, content + } + + // No group_trigger configured → permissive (respond to all) + return true, strings.TrimSpace(content) } func (c *BaseChannel) Name() string { @@ -39,7 +128,7 @@ func (c *BaseChannel) Name() string { } func (c *BaseChannel) IsRunning() bool { - return c.running + return c.running.Load() } func (c *BaseChannel) IsAllowed(senderID string) bool { @@ -81,23 +170,101 @@ func (c *BaseChannel) IsAllowed(senderID string) bool { return false } -func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []string, metadata map[string]string) { - if !c.IsAllowed(senderID) { - return +// IsAllowedSender checks whether a structured SenderInfo is permitted by the allow-list. +// It delegates to identity.MatchAllowed for each entry, providing unified matching +// across all legacy formats and the new canonical "platform:id" format. +func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool { + if len(c.allowList) == 0 { + return true } + for _, allowed := range c.allowList { + if identity.MatchAllowed(sender, allowed) { + return true + } + } + + return false +} + +func (c *BaseChannel) HandleMessage( + ctx context.Context, + peer bus.Peer, + messageID, senderID, chatID, content string, + media []string, + metadata map[string]string, + senderOpts ...bus.SenderInfo, +) { + // Use SenderInfo-based allow check when available, else fall back to string + var sender bus.SenderInfo + if len(senderOpts) > 0 { + sender = senderOpts[0] + } + if sender.CanonicalID != "" || sender.PlatformID != "" { + if !c.IsAllowedSender(sender) { + return + } + } else { + if !c.IsAllowed(senderID) { + return + } + } + + // Set SenderID to canonical if available, otherwise keep the raw senderID + resolvedSenderID := senderID + if sender.CanonicalID != "" { + resolvedSenderID = sender.CanonicalID + } + + scope := BuildMediaScope(c.name, chatID, messageID) + msg := bus.InboundMessage{ - Channel: c.name, - SenderID: senderID, - ChatID: chatID, - Content: content, - Media: media, - Metadata: metadata, + Channel: c.name, + SenderID: resolvedSenderID, + Sender: sender, + ChatID: chatID, + Content: content, + Media: media, + Peer: peer, + MessageID: messageID, + MediaScope: scope, + Metadata: metadata, } - c.bus.PublishInbound(msg) + 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, + "error": err.Error(), + }) + } } -func (c *BaseChannel) setRunning(running bool) { - c.running = running +func (c *BaseChannel) SetRunning(running bool) { + c.running.Store(running) +} + +// SetMediaStore injects a MediaStore into the channel. +func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s } + +// GetMediaStore returns the injected MediaStore (may be nil). +func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore } + +// SetPlaceholderRecorder injects a PlaceholderRecorder into the channel. +func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) { + c.placeholderRecorder = r +} + +// GetPlaceholderRecorder returns the injected PlaceholderRecorder (may be nil). +func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder { + return c.placeholderRecorder +} + +// BuildMediaScope constructs a scope key for media lifecycle tracking. +func BuildMediaScope(channel, chatID, messageID string) string { + id := messageID + if id == "" { + id = uuid.New().String() + } + return channel + ":" + chatID + ":" + id } diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go index 78c6d1d66..6132b8bf9 100644 --- a/pkg/channels/base_test.go +++ b/pkg/channels/base_test.go @@ -1,6 +1,11 @@ package channels -import "testing" +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) func TestBaseChannelIsAllowed(t *testing.T) { tests := []struct { @@ -50,3 +55,211 @@ func TestBaseChannelIsAllowed(t *testing.T) { }) } } + +func TestShouldRespondInGroup(t *testing.T) { + tests := []struct { + name string + gt config.GroupTriggerConfig + isMentioned bool + content string + wantRespond bool + wantContent string + }{ + { + name: "no config - permissive default", + gt: config.GroupTriggerConfig{}, + isMentioned: false, + content: "hello world", + wantRespond: true, + wantContent: "hello world", + }, + { + name: "no config - mentioned", + gt: config.GroupTriggerConfig{}, + isMentioned: true, + content: "hello world", + wantRespond: true, + wantContent: "hello world", + }, + { + name: "mention_only - not mentioned", + gt: config.GroupTriggerConfig{MentionOnly: true}, + isMentioned: false, + content: "hello world", + wantRespond: false, + wantContent: "hello world", + }, + { + name: "mention_only - mentioned", + gt: config.GroupTriggerConfig{MentionOnly: true}, + isMentioned: true, + content: "hello world", + wantRespond: true, + wantContent: "hello world", + }, + { + name: "prefix match", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}}, + isMentioned: false, + content: "/ask hello", + wantRespond: true, + wantContent: "hello", + }, + { + name: "prefix no match - not mentioned", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}}, + isMentioned: false, + content: "hello world", + wantRespond: false, + wantContent: "hello world", + }, + { + name: "prefix no match - but mentioned", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}}, + isMentioned: true, + content: "hello world", + wantRespond: true, + wantContent: "hello world", + }, + { + name: "multiple prefixes - second matches", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask", "/bot"}}, + isMentioned: false, + content: "/bot help me", + wantRespond: true, + wantContent: "help me", + }, + { + name: "mention_only with prefixes - mentioned overrides", + gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}}, + isMentioned: true, + content: "hello", + wantRespond: true, + wantContent: "hello", + }, + { + name: "mention_only with prefixes - not mentioned, no prefix", + gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}}, + isMentioned: false, + content: "hello", + wantRespond: false, + wantContent: "hello", + }, + { + name: "empty prefix in list is skipped", + gt: config.GroupTriggerConfig{Prefixes: []string{"", "/ask"}}, + isMentioned: false, + content: "/ask test", + wantRespond: true, + wantContent: "test", + }, + { + name: "prefix strips leading whitespace after prefix", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask "}}, + isMentioned: false, + content: "/ask hello", + wantRespond: true, + wantContent: "hello", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := NewBaseChannel("test", nil, nil, nil, WithGroupTrigger(tt.gt)) + gotRespond, gotContent := ch.ShouldRespondInGroup(tt.isMentioned, tt.content) + if gotRespond != tt.wantRespond { + t.Errorf("ShouldRespondInGroup() respond = %v, want %v", gotRespond, tt.wantRespond) + } + if gotContent != tt.wantContent { + t.Errorf("ShouldRespondInGroup() content = %q, want %q", gotContent, tt.wantContent) + } + }) + } +} + +func TestIsAllowedSender(t *testing.T) { + tests := []struct { + name string + allowList []string + sender bus.SenderInfo + want bool + }{ + { + name: "empty allowlist allows all", + allowList: nil, + sender: bus.SenderInfo{PlatformID: "anyone"}, + want: true, + }, + { + name: "numeric ID matches PlatformID", + allowList: []string{"123456"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + }, + want: true, + }, + { + name: "canonical format matches", + allowList: []string{"telegram:123456"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + }, + want: true, + }, + { + name: "canonical format wrong platform", + allowList: []string{"discord:123456"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + }, + want: false, + }, + { + name: "@username matches", + allowList: []string{"@alice"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + Username: "alice", + }, + want: true, + }, + { + name: "compound id|username matches by ID", + allowList: []string{"123456|alice"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + Username: "alice", + }, + want: true, + }, + { + name: "non matching sender denied", + allowList: []string{"654321"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := NewBaseChannel("test", nil, nil, tt.allowList) + if got := ch.IsAllowedSender(tt.sender); got != tt.want { + t.Fatalf("IsAllowedSender(%+v) = %v, want %v", tt.sender, got, tt.want) + } + }) + } +} diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go similarity index 83% rename from pkg/channels/dingtalk.go rename to pkg/channels/dingtalk/dingtalk.go index 662fba3b7..7a3aaca78 100644 --- a/pkg/channels/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -1,7 +1,7 @@ // PicoClaw - Ultra-lightweight personal AI agent // DingTalk channel implementation using Stream Mode -package channels +package dingtalk import ( "context" @@ -12,7 +12,9 @@ import ( "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -20,7 +22,7 @@ import ( // DingTalkChannel implements the Channel interface for DingTalk (钉钉) // It uses WebSocket for receiving messages via stream mode and API for sending type DingTalkChannel struct { - *BaseChannel + *channels.BaseChannel config config.DingTalkConfig clientID string clientSecret string @@ -37,7 +39,10 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } - base := NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(20000), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &DingTalkChannel{ BaseChannel: base, @@ -70,7 +75,7 @@ func (c *DingTalkChannel) Start(ctx context.Context) error { return fmt.Errorf("failed to start stream client: %w", err) } - c.setRunning(true) + c.SetRunning(true) logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)") return nil } @@ -87,7 +92,7 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error { c.streamClient.Close() } - c.setRunning(false) + c.SetRunning(false) logger.InfoC("dingtalk", "DingTalk channel stopped") return nil } @@ -95,7 +100,7 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error { // Send sends a message to DingTalk via the chatbot reply API func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("dingtalk channel not running") + return channels.ErrNotRunning } // Get session webhook from storage @@ -159,12 +164,17 @@ func (c *DingTalkChannel) onChatBotMessageReceived( "session_webhook": data.SessionWebhook, } + var peer bus.Peer if data.ConversationType == "1" { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} } else { - metadata["peer_kind"] = "group" - metadata["peer_id"] = data.ConversationId + peer = bus.Peer{Kind: "group", ID: data.ConversationId} + // In group chats, apply unified group trigger filtering + respond, cleaned := c.ShouldRespondInGroup(false, content) + if !respond { + return nil, nil + } + content = cleaned } logger.DebugCF("dingtalk", "Received message", map[string]any{ @@ -173,8 +183,20 @@ func (c *DingTalkChannel) onChatBotMessageReceived( "preview": utils.Truncate(content, 50), }) + // Build sender info + sender := bus.SenderInfo{ + Platform: "dingtalk", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("dingtalk", senderID), + DisplayName: senderNick, + } + + if !c.IsAllowedSender(sender) { + return nil, nil + } + // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender) // Return nil to indicate we've handled the message asynchronously // The response will be sent through the message bus @@ -197,7 +219,7 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c contentBytes, ) if err != nil { - return fmt.Errorf("failed to send reply: %w", err) + return fmt.Errorf("dingtalk send: %w", channels.ErrTemporary) } return nil diff --git a/pkg/channels/dingtalk/init.go b/pkg/channels/dingtalk/init.go new file mode 100644 index 000000000..5f49bce8c --- /dev/null +++ b/pkg/channels/dingtalk/init.go @@ -0,0 +1,13 @@ +package dingtalk + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewDingTalkChannel(cfg.Channels.DingTalk, b) + }) +} diff --git a/pkg/channels/discord.go b/pkg/channels/discord/discord.go similarity index 55% rename from pkg/channels/discord.go rename to pkg/channels/discord/discord.go index 20f3b267c..dc49e7413 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord/discord.go @@ -1,4 +1,4 @@ -package channels +package discord import ( "context" @@ -11,26 +11,27 @@ import ( "github.com/bwmarrin/discordgo" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) const ( - transcriptionTimeout = 30 * time.Second - sendTimeout = 10 * time.Second + sendTimeout = 10 * time.Second ) type DiscordChannel struct { - *BaseChannel - session *discordgo.Session - config config.DiscordConfig - transcriber *voice.GroqTranscriber - ctx context.Context - typingMu sync.Mutex - typingStop map[string]chan struct{} // chatID → stop signal - botUserID string // stored for mention checking + *channels.BaseChannel + session *discordgo.Session + config config.DiscordConfig + ctx context.Context + cancel context.CancelFunc + typingMu sync.Mutex + typingStop map[string]chan struct{} // chatID → stop signal + botUserID string // stored for mention checking } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { @@ -39,33 +40,24 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC return nil, fmt.Errorf("failed to create discord session: %w", err) } - base := NewBaseChannel("discord", cfg, bus, cfg.AllowFrom) + base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, + channels.WithMaxMessageLength(2000), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &DiscordChannel{ BaseChannel: base, session: session, config: cfg, - transcriber: nil, ctx: context.Background(), typingStop: make(map[string]chan struct{}), }, nil } -func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - -func (c *DiscordChannel) getContext() context.Context { - if c.ctx == nil { - return context.Background() - } - return c.ctx -} - func (c *DiscordChannel) Start(ctx context.Context) error { logger.InfoC("discord", "Starting Discord bot") - c.ctx = ctx + c.ctx, c.cancel = context.WithCancel(ctx) // Get bot user ID before opening session to avoid race condition botUser, err := c.session.User("@me") @@ -80,7 +72,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error { return fmt.Errorf("failed to open discord session: %w", err) } - c.setRunning(true) + c.SetRunning(true) logger.InfoCF("discord", "Discord bot connected", map[string]any{ "username": botUser.Username, @@ -92,7 +84,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error { func (c *DiscordChannel) Stop(ctx context.Context) error { logger.InfoC("discord", "Stopping Discord bot") - c.setRunning(false) + c.SetRunning(false) // Stop all typing goroutines before closing session c.typingMu.Lock() @@ -102,6 +94,11 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { } c.typingMu.Unlock() + // Cancel our context so typing goroutines using c.ctx.Done() exit + if c.cancel != nil { + c.cancel() + } + if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) } @@ -110,10 +107,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { } func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - c.stopTyping(msg.ChatID) - if !c.IsRunning() { - return fmt.Errorf("discord bot not running") + return channels.ErrNotRunning } channelID := msg.ChatID @@ -121,20 +116,112 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return fmt.Errorf("channel ID is empty") } - runes := []rune(msg.Content) - if len(runes) == 0 { + if len([]rune(msg.Content)) == 0 { return nil } - chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars + return c.sendChunk(ctx, channelID, msg.Content) +} - for _, chunk := range chunks { - if err := c.sendChunk(ctx, channelID, chunk); err != nil { - return err +// SendMedia implements the channels.MediaSender interface. +func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + channelID := msg.ChatID + if channelID == "" { + return fmt.Errorf("channel ID is empty") + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + // Collect all files into a single ChannelMessageSendComplex call + files := make([]*discordgo.File, 0, len(msg.Parts)) + var caption string + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("discord", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + file, err := os.Open(localPath) + if err != nil { + logger.ErrorCF("discord", "Failed to open media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + // Note: discordgo reads from the Reader and we can't close it before send + + filename := part.Filename + if filename == "" { + filename = "file" + } + + files = append(files, &discordgo.File{ + Name: filename, + ContentType: part.ContentType, + Reader: file, + }) + + if part.Caption != "" && caption == "" { + caption = part.Caption } } - return nil + if len(files) == 0 { + return nil + } + + sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Content: caption, + Files: files, + }) + done <- err + }() + + select { + case err := <-done: + // Close all file readers + for _, f := range files { + if closer, ok := f.Reader.(*os.File); ok { + closer.Close() + } + } + if err != nil { + return fmt.Errorf("discord send media: %w", channels.ErrTemporary) + } + return nil + case <-sendCtx.Done(): + // Close all file readers + for _, f := range files { + if closer, ok := f.Reader.(*os.File); ok { + closer.Close() + } + } + return sendCtx.Err() + } +} + +// 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) + return err } func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { @@ -151,11 +238,11 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin select { case err := <-done: if err != nil { - return fmt.Errorf("failed to send discord message: %w", err) + return fmt.Errorf("discord send: %w", channels.ErrTemporary) } return nil case <-sendCtx.Done(): - return fmt.Errorf("send message timeout: %w", sendCtx.Err()) + return sendCtx.Err() } } @@ -176,17 +263,32 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } - // Check allowlist first to avoid downloading attachments and transcribing for rejected users - if !c.IsAllowed(m.Author.ID) { + // Check allowlist first to avoid downloading attachments for rejected users + sender := bus.SenderInfo{ + Platform: "discord", + PlatformID: m.Author.ID, + CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID), + Username: m.Author.Username, + } + // Build display name + displayName := m.Author.Username + if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { + displayName += "#" + m.Author.Discriminator + } + sender.DisplayName = displayName + + if !c.IsAllowedSender(sender) { logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ "user_id": m.Author.ID, }) return } - // If configured to only respond to mentions, check if bot is mentioned - // Skip this check for DMs (GuildID is empty) - DMs should always be responded to - if c.config.MentionOnly && m.GuildID != "" { + content := m.Content + + // In guild (group) channels, apply unified group trigger filtering + // DMs (GuildID is empty) always get a response + if m.GuildID != "" { isMentioned := false for _, mention := range m.Mentions { if mention.ID == c.botUserID { @@ -194,36 +296,39 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag break } } - if !isMentioned { - logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{ + content = c.stripBotMention(content) + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) + if !respond { + logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{ "user_id": m.Author.ID, }) return } + content = cleaned + } else { + // DMs: just strip bot mention without filtering + content = c.stripBotMention(content) } senderID := m.Author.ID - senderName := m.Author.Username - if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { - senderName += "#" + m.Author.Discriminator - } - content := m.Content - content = c.stripBotMention(content) mediaPaths := make([]string, 0, len(m.Attachments)) - localFiles := make([]string, 0, len(m.Attachments)) - // Ensure temp files are cleaned up when function returns - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) + scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID) + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "discord", + }, scope) + if err == nil { + return ref } } - }() + return localPath // fallback + } for _, attachment := range m.Attachments { isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType) @@ -231,30 +336,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if isAudio { localPath := c.downloadAttachment(attachment.URL, attachment.Filename) if localPath != "" { - localFiles = append(localFiles, localPath) - - transcribedText := "" - if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout) - result, err := c.transcriber.Transcribe(ctx, localPath) - cancel() // Release context resources immediately to avoid leaks in for loop - - if err != nil { - logger.ErrorCF("discord", "Voice transcription failed", map[string]any{ - "error": err.Error(), - }) - transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename) - } else { - transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text) - logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{ - "text": result.Text, - }) - } - } else { - transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename) - } - - content = appendContent(content, transcribedText) + mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename)) + content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename)) } else { logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{ "url": attachment.URL, @@ -279,9 +362,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag // Start typing after all early returns — guaranteed to have a matching Send() c.startTyping(m.ChannelID) + // Register typing stop with Manager for outbound orchestration + if rec := c.GetPlaceholderRecorder(); rec != nil { + rec.RecordTypingStop("discord", m.ChannelID, func() { c.stopTyping(m.ChannelID) }) + } logger.DebugCF("discord", "Received message", map[string]any{ - "sender_name": senderName, + "sender_name": sender.DisplayName, "sender_id": senderID, "preview": utils.Truncate(content, 50), }) @@ -293,19 +380,18 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag peerID = senderID } + peer := bus.Peer{Kind: peerKind, ID: peerID} + metadata := map[string]string{ - "message_id": m.ID, "user_id": senderID, "username": m.Author.Username, - "display_name": senderName, + "display_name": sender.DisplayName, "guild_id": m.GuildID, "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), - "peer_kind": peerKind, - "peer_id": peerID, } - c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata) + c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender) } // startTyping starts a continuous typing indicator loop for the given chatID. diff --git a/pkg/channels/discord/init.go b/pkg/channels/discord/init.go new file mode 100644 index 000000000..15a539804 --- /dev/null +++ b/pkg/channels/discord/init.go @@ -0,0 +1,13 @@ +package discord + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewDiscordChannel(cfg.Channels.Discord, b) + }) +} diff --git a/pkg/channels/email.go b/pkg/channels/email.go index f305b6920..6ba0a9ac9 100644 --- a/pkg/channels/email.go +++ b/pkg/channels/email.go @@ -90,7 +90,7 @@ func (c *EmailChannel) Start(ctx context.Context) error { return fmt.Errorf("failed to connect to IMAP server: %w", err) } - c.setRunning(true) + c.SetRunning(true) logger.InfoC("email", "Email channel started") c.loopWg.Add(1) @@ -119,7 +119,7 @@ func (c *EmailChannel) Stop(ctx context.Context) error { c.loopWg.Wait() // wait for checkLoop goroutine to exit - c.setRunning(false) + c.SetRunning(false) logger.InfoC("email", "Email channel stopped") return nil } diff --git a/pkg/channels/errors.go b/pkg/channels/errors.go new file mode 100644 index 000000000..09ee88b3f --- /dev/null +++ b/pkg/channels/errors.go @@ -0,0 +1,21 @@ +package channels + +import "errors" + +var ( + // ErrNotRunning indicates the channel is not running. + // Manager will not retry. + ErrNotRunning = errors.New("channel not running") + + // ErrRateLimit indicates the platform returned a rate-limit response (e.g. HTTP 429). + // Manager will wait a fixed delay and retry. + ErrRateLimit = errors.New("rate limited") + + // ErrTemporary indicates a transient failure (e.g. network timeout, 5xx). + // Manager will use exponential backoff and retry. + ErrTemporary = errors.New("temporary failure") + + // ErrSendFailed indicates a permanent failure (e.g. invalid chat ID, 4xx non-429). + // Manager will not retry. + ErrSendFailed = errors.New("send failed") +) diff --git a/pkg/channels/errors_test.go b/pkg/channels/errors_test.go new file mode 100644 index 000000000..e5592345a --- /dev/null +++ b/pkg/channels/errors_test.go @@ -0,0 +1,56 @@ +package channels + +import ( + "errors" + "fmt" + "testing" +) + +func TestErrorsIs(t *testing.T) { + wrapped := fmt.Errorf("telegram API: %w", ErrRateLimit) + if !errors.Is(wrapped, ErrRateLimit) { + t.Error("wrapped ErrRateLimit should match") + } + if errors.Is(wrapped, ErrTemporary) { + t.Error("wrapped ErrRateLimit should not match ErrTemporary") + } +} + +func TestErrorsIsAllTypes(t *testing.T) { + sentinels := []error{ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed} + + for _, sentinel := range sentinels { + wrapped := fmt.Errorf("context: %w", sentinel) + if !errors.Is(wrapped, sentinel) { + t.Errorf("wrapped %v should match itself", sentinel) + } + + // Verify it doesn't match other sentinel errors + for _, other := range sentinels { + if other == sentinel { + continue + } + if errors.Is(wrapped, other) { + t.Errorf("wrapped %v should not match %v", sentinel, other) + } + } + } +} + +func TestErrorMessages(t *testing.T) { + tests := []struct { + err error + want string + }{ + {ErrNotRunning, "channel not running"}, + {ErrRateLimit, "rate limited"}, + {ErrTemporary, "temporary failure"}, + {ErrSendFailed, "send failed"}, + } + + for _, tt := range tests { + if got := tt.err.Error(); got != tt.want { + t.Errorf("error message = %q, want %q", got, tt.want) + } + } +} diff --git a/pkg/channels/errutil.go b/pkg/channels/errutil.go new file mode 100644 index 000000000..319e3c980 --- /dev/null +++ b/pkg/channels/errutil.go @@ -0,0 +1,30 @@ +package channels + +import ( + "fmt" + "net/http" +) + +// ClassifySendError wraps a raw error with the appropriate sentinel based on +// an HTTP status code. Channels that perform HTTP API calls should use this +// in their Send path. +func ClassifySendError(statusCode int, rawErr error) error { + switch { + case statusCode == http.StatusTooManyRequests: + return fmt.Errorf("%w: %v", ErrRateLimit, rawErr) + case statusCode >= 500: + return fmt.Errorf("%w: %v", ErrTemporary, rawErr) + case statusCode >= 400: + return fmt.Errorf("%w: %v", ErrSendFailed, rawErr) + default: + return rawErr + } +} + +// ClassifyNetError wraps a network/timeout error as ErrTemporary. +func ClassifyNetError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("%w: %v", ErrTemporary, err) +} diff --git a/pkg/channels/errutil_test.go b/pkg/channels/errutil_test.go new file mode 100644 index 000000000..e3d35f65b --- /dev/null +++ b/pkg/channels/errutil_test.go @@ -0,0 +1,97 @@ +package channels + +import ( + "errors" + "fmt" + "testing" +) + +func TestClassifySendError(t *testing.T) { + raw := fmt.Errorf("some API error") + + tests := []struct { + name string + statusCode int + wantIs error + wantNil bool + }{ + {"429 -> ErrRateLimit", 429, ErrRateLimit, false}, + {"500 -> ErrTemporary", 500, ErrTemporary, false}, + {"502 -> ErrTemporary", 502, ErrTemporary, false}, + {"503 -> ErrTemporary", 503, ErrTemporary, false}, + {"400 -> ErrSendFailed", 400, ErrSendFailed, false}, + {"403 -> ErrSendFailed", 403, ErrSendFailed, false}, + {"404 -> ErrSendFailed", 404, ErrSendFailed, false}, + {"200 -> raw error", 200, nil, false}, + {"201 -> raw error", 201, nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ClassifySendError(tt.statusCode, raw) + if err == nil { + t.Fatal("expected non-nil error") + } + if tt.wantIs != nil { + if !errors.Is(err, tt.wantIs) { + t.Errorf("errors.Is(err, %v) = false, want true; err = %v", tt.wantIs, err) + } + } else { + // Should return the raw error unchanged + if err != raw { + t.Errorf("expected raw error to be returned unchanged for status %d, got %v", tt.statusCode, err) + } + } + }) + } +} + +func TestClassifySendErrorNoFalsePositive(t *testing.T) { + raw := fmt.Errorf("some error") + + // 429 should NOT match ErrTemporary or ErrSendFailed + err := ClassifySendError(429, raw) + if errors.Is(err, ErrTemporary) { + t.Error("429 should not match ErrTemporary") + } + if errors.Is(err, ErrSendFailed) { + t.Error("429 should not match ErrSendFailed") + } + + // 500 should NOT match ErrRateLimit or ErrSendFailed + err = ClassifySendError(500, raw) + if errors.Is(err, ErrRateLimit) { + t.Error("500 should not match ErrRateLimit") + } + if errors.Is(err, ErrSendFailed) { + t.Error("500 should not match ErrSendFailed") + } + + // 400 should NOT match ErrRateLimit or ErrTemporary + err = ClassifySendError(400, raw) + if errors.Is(err, ErrRateLimit) { + t.Error("400 should not match ErrRateLimit") + } + if errors.Is(err, ErrTemporary) { + t.Error("400 should not match ErrTemporary") + } +} + +func TestClassifyNetError(t *testing.T) { + t.Run("nil error returns nil", func(t *testing.T) { + if err := ClassifyNetError(nil); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + + t.Run("non-nil error wraps as ErrTemporary", func(t *testing.T) { + raw := fmt.Errorf("connection refused") + err := ClassifyNetError(raw) + if err == nil { + t.Fatal("expected non-nil error") + } + if !errors.Is(err, ErrTemporary) { + t.Errorf("errors.Is(err, ErrTemporary) = false, want true; err = %v", err) + } + }) +} diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go new file mode 100644 index 000000000..e8a057741 --- /dev/null +++ b/pkg/channels/feishu/common.go @@ -0,0 +1,9 @@ +package feishu + +// stringValue safely dereferences a *string pointer. +func stringValue(v *string) string { + if v == nil { + return "" + } + return *v +} diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu/feishu_32.go similarity index 93% rename from pkg/channels/feishu_32.go rename to pkg/channels/feishu/feishu_32.go index 5109b8195..d0ec758c6 100644 --- a/pkg/channels/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -1,18 +1,19 @@ //go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 -package channels +package feishu import ( "context" "errors" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) // FeishuChannel is a stub implementation for 32-bit architectures type FeishuChannel struct { - *BaseChannel + *channels.BaseChannel } // NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu/feishu_64.go similarity index 79% rename from pkg/channels/feishu_64.go rename to pkg/channels/feishu/feishu_64.go index 42e74980f..62bf69486 100644 --- a/pkg/channels/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -1,6 +1,6 @@ //go:build amd64 || arm64 || riscv64 || mips64 || ppc64 -package channels +package feishu import ( "context" @@ -15,13 +15,15 @@ import ( larkws "github.com/larksuite/oapi-sdk-go/v3/ws" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) type FeishuChannel struct { - *BaseChannel + *channels.BaseChannel config config.FeishuConfig client *lark.Client wsClient *larkws.Client @@ -31,7 +33,9 @@ type FeishuChannel struct { } func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { - base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom) + base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom, + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &FeishuChannel{ BaseChannel: base, @@ -60,7 +64,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error { wsClient := c.wsClient c.mu.Unlock() - c.setRunning(true) + c.SetRunning(true) logger.InfoC("feishu", "Feishu channel started (websocket mode)") go func() { @@ -83,14 +87,14 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { c.wsClient = nil c.mu.Unlock() - c.setRunning(false) + c.SetRunning(false) logger.InfoC("feishu", "Feishu channel stopped") return nil } func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("feishu channel not running") + return channels.ErrNotRunning } if msg.ChatID == "" { @@ -114,11 +118,11 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("failed to send feishu message: %w", err) + return fmt.Errorf("feishu send: %w", channels.ErrTemporary) } if !resp.Success() { - return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg) + return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu message sent", map[string]any{ @@ -128,7 +132,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } -func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error { +func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error { if event == nil || event.Event == nil || event.Event.Message == nil { return nil } @@ -152,8 +156,9 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 } metadata := map[string]string{} - if messageID := stringValue(message.MessageId); messageID != "" { - metadata["message_id"] = messageID + messageID := "" + if mid := stringValue(message.MessageId); mid != "" { + messageID = mid } if messageType := stringValue(message.MessageType); messageType != "" { metadata["message_type"] = messageType @@ -166,12 +171,17 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 } chatType := stringValue(message.ChatType) + var peer bus.Peer if chatType == "p2p" { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} } else { - metadata["peer_kind"] = "group" - metadata["peer_id"] = chatID + peer = bus.Peer{Kind: "group", ID: chatID} + // In group chats, apply unified group trigger filtering + respond, cleaned := c.ShouldRespondInGroup(false, content) + if !respond { + return nil + } + content = cleaned } logger.InfoCF("feishu", "Feishu message received", map[string]any{ @@ -180,7 +190,17 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 "preview": utils.Truncate(content, 80), }) - c.HandleMessage(senderID, chatID, content, nil, metadata) + senderInfo := bus.SenderInfo{ + Platform: "feishu", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("feishu", senderID), + } + + if !c.IsAllowedSender(senderInfo) { + return nil + } + + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo) return nil } @@ -218,10 +238,3 @@ func extractFeishuMessageContent(message *larkim.EventMessage) string { return *message.Content } - -func stringValue(v *string) string { - if v == nil { - return "" - } - return *v -} diff --git a/pkg/channels/feishu/init.go b/pkg/channels/feishu/init.go new file mode 100644 index 000000000..7e5a62dae --- /dev/null +++ b/pkg/channels/feishu/init.go @@ -0,0 +1,13 @@ +package feishu + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewFeishuChannel(cfg.Channels.Feishu, b) + }) +} diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go new file mode 100644 index 000000000..32bfe95f8 --- /dev/null +++ b/pkg/channels/interfaces.go @@ -0,0 +1,24 @@ +package channels + +import "context" + +// TypingCapable — channels that can show a typing/thinking indicator. +// StartTyping begins the indicator and returns a stop function. +// The stop function MUST be idempotent and safe to call multiple times. +type TypingCapable interface { + StartTyping(ctx context.Context, chatID string) (stop func(), err error) +} + +// MessageEditor — channels that can edit an existing message. +// messageID is always string; channels convert platform-specific types internally. +type MessageEditor interface { + EditMessage(ctx context.Context, chatID string, messageID string, content string) error +} + +// PlaceholderRecorder is injected into channels by Manager. +// Channels call these methods on inbound to register typing/placeholder state. +// Manager uses the registered state on outbound to stop typing and edit placeholders. +type PlaceholderRecorder interface { + RecordPlaceholder(channel, chatID, placeholderID string) + RecordTypingStop(channel, chatID string, stop func()) +} diff --git a/pkg/channels/line/init.go b/pkg/channels/line/init.go new file mode 100644 index 000000000..9265575cc --- /dev/null +++ b/pkg/channels/line/init.go @@ -0,0 +1,13 @@ +package line + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewLINEChannel(cfg.Channels.LINE, b) + }) +} diff --git a/pkg/channels/line.go b/pkg/channels/line/line.go similarity index 81% rename from pkg/channels/line.go rename to pkg/channels/line/line.go index 44134996f..28d5ad8f7 100644 --- a/pkg/channels/line.go +++ b/pkg/channels/line/line.go @@ -1,4 +1,4 @@ -package channels +package line import ( "bytes" @@ -10,14 +10,16 @@ import ( "fmt" "io" "net/http" - "os" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -41,9 +43,8 @@ type replyTokenEntry struct { // using the LINE Messaging API with HTTP webhook for receiving messages // and REST API for sending messages. type LINEChannel struct { - *BaseChannel + *channels.BaseChannel config config.LINEConfig - httpServer *http.Server botUserID string // Bot's user ID botBasicID string // Bot's basic ID (e.g. @216ru...) botDisplayName string // Bot's display name for text-based mention detection @@ -59,7 +60,10 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } - base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(5000), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &LINEChannel{ BaseChannel: base, @@ -67,7 +71,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha }, nil } -// Start launches the HTTP webhook server. +// Start initializes the LINE channel. func (c *LINEChannel) Start(ctx context.Context) error { logger.InfoC("line", "Starting LINE channel (Webhook Mode)") @@ -86,32 +90,7 @@ func (c *LINEChannel) Start(ctx context.Context) error { }) } - mux := http.NewServeMux() - path := c.config.WebhookPath - if path == "" { - path = "/webhook/line" - } - mux.HandleFunc(path, c.webhookHandler) - - addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) - c.httpServer = &http.Server{ - Addr: addr, - Handler: mux, - } - - go func() { - logger.InfoCF("line", "LINE webhook server listening", map[string]any{ - "addr": addr, - "path": path, - }) - if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("line", "Webhook server error", map[string]any{ - "error": err.Error(), - }) - } - }() - - c.setRunning(true) + c.SetRunning(true) logger.InfoC("line", "LINE channel started (Webhook Mode)") return nil } @@ -150,7 +129,7 @@ func (c *LINEChannel) fetchBotInfo() error { return nil } -// Stop gracefully shuts down the HTTP server. +// Stop gracefully stops the LINE channel. func (c *LINEChannel) Stop(ctx context.Context) error { logger.InfoC("line", "Stopping LINE channel") @@ -158,21 +137,24 @@ func (c *LINEChannel) Stop(ctx context.Context) error { c.cancel() } - if c.httpServer != nil { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - if err := c.httpServer.Shutdown(shutdownCtx); err != nil { - logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{ - "error": err.Error(), - }) - } - } - - c.setRunning(false) + c.SetRunning(false) logger.InfoC("line", "LINE channel stopped") return nil } +// WebhookPath returns the path for registering on the shared HTTP server. +func (c *LINEChannel) WebhookPath() string { + if c.config.WebhookPath != "" { + return c.config.WebhookPath + } + return "/webhook/line" +} + +// ServeHTTP implements http.Handler for the shared HTTP server. +func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.webhookHandler(w, r) +} + // webhookHandler handles incoming LINE webhook requests. func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -284,14 +266,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } - // In group chats, only respond when the bot is mentioned - if isGroup && !c.isBotMentioned(msg) { - logger.DebugCF("line", "Ignoring group message without mention", map[string]any{ - "chat_id": chatID, - }) - return - } - // Store reply token for later use if event.ReplyToken != "" { c.replyTokens.Store(chatID, replyTokenEntry{ @@ -307,18 +281,22 @@ func (c *LINEChannel) processEvent(event lineEvent) { var content string var mediaPaths []string - localFiles := []string{} - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) + scope := channels.BuildMediaScope("line", chatID, msg.ID) + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "line", + }, scope) + if err == nil { + return ref } } - }() + return localPath // fallback + } switch msg.Type { case "text": @@ -330,22 +308,19 @@ func (c *LINEChannel) processEvent(event lineEvent) { case "image": localPath := c.downloadContent(msg.ID, "image.jpg") if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg")) content = "[image]" } case "audio": localPath := c.downloadContent(msg.ID, "audio.m4a") if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a")) content = "[audio]" } case "video": localPath := c.downloadContent(msg.ID, "video.mp4") if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4")) content = "[video]" } case "file": @@ -360,18 +335,29 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } + // In group chats, apply unified group trigger filtering + if isGroup { + isMentioned := c.isBotMentioned(msg) + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) + if !respond { + logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{ + "chat_id": chatID, + }) + return + } + content = cleaned + } + metadata := map[string]string{ "platform": "line", "source_type": event.Source.Type, - "message_id": msg.ID, } + var peer bus.Peer if isGroup { - metadata["peer_kind"] = "group" - metadata["peer_id"] = chatID + peer = bus.Peer{Kind: "group", ID: chatID} } else { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} } logger.DebugCF("line", "Received message", map[string]any{ @@ -385,7 +371,17 @@ func (c *LINEChannel) processEvent(event lineEvent) { // Show typing/loading indicator (requires user ID, not group ID) c.sendLoading(senderID) - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + sender := bus.SenderInfo{ + Platform: "line", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("line", senderID), + } + + if !c.IsAllowedSender(sender) { + return + } + + c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) } // isBotMentioned checks if the bot is mentioned in the message. @@ -491,7 +487,7 @@ func (c *LINEChannel) resolveChatID(source lineSource) string { // using a cached reply token, then falls back to the Push API. func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("line channel not running") + return channels.ErrNotRunning } // Load and consume quote token for this chat @@ -519,6 +515,36 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) } +// SendMedia implements the channels.MediaSender interface. +// LINE requires media to be accessible via public URL; since we only have local files, +// we fall back to sending a text message with the filename/caption. +// For full support, an external file hosting service would be needed. +func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + // LINE Messaging API requires publicly accessible URLs for media messages. + // Since we only have local file paths, send caption text as fallback. + for _, part := range msg.Parts { + caption := part.Caption + if caption == "" { + caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) + } + + if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil { + return err + } + } + + return nil +} + // buildTextMessage creates a text message object, optionally with quoteToken. func buildTextMessage(content, quoteToken string) map[string]string { msg := map[string]string{ @@ -582,13 +608,13 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("API request failed: %w", err) + return channels.ClassifyNetError(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody)) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody))) } return nil diff --git a/pkg/channels/maixcam/init.go b/pkg/channels/maixcam/init.go new file mode 100644 index 000000000..5a269b22b --- /dev/null +++ b/pkg/channels/maixcam/init.go @@ -0,0 +1,13 @@ +package maixcam + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewMaixCamChannel(cfg.Channels.MaixCam, b) + }) +} diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam/maixcam.go similarity index 79% rename from pkg/channels/maixcam.go rename to pkg/channels/maixcam/maixcam.go index 34ce62b20..142a4b7e7 100644 --- a/pkg/channels/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -1,4 +1,4 @@ -package channels +package maixcam import ( "context" @@ -6,16 +6,21 @@ import ( "fmt" "net" "sync" + "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) type MaixCamChannel struct { - *BaseChannel + *channels.BaseChannel config config.MaixCamConfig listener net.Listener + ctx context.Context + cancel context.CancelFunc clients map[net.Conn]bool clientsMux sync.RWMutex } @@ -28,7 +33,7 @@ type MaixCamMessage struct { } func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { - base := NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom) + base := channels.NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom) return &MaixCamChannel{ BaseChannel: base, @@ -40,37 +45,40 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC func (c *MaixCamChannel) Start(ctx context.Context) error { logger.InfoC("maixcam", "Starting MaixCam channel server") + c.ctx, c.cancel = context.WithCancel(ctx) + addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port) listener, err := net.Listen("tcp", addr) if err != nil { + c.cancel() return fmt.Errorf("failed to listen on %s: %w", addr, err) } c.listener = listener - c.setRunning(true) + c.SetRunning(true) logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{ "host": c.config.Host, "port": c.config.Port, }) - go c.acceptConnections(ctx) + go c.acceptConnections() return nil } -func (c *MaixCamChannel) acceptConnections(ctx context.Context) { +func (c *MaixCamChannel) acceptConnections() { logger.DebugC("maixcam", "Starting connection acceptor") for { select { - case <-ctx.Done(): + case <-c.ctx.Done(): logger.InfoC("maixcam", "Stopping connection acceptor") return default: conn, err := c.listener.Accept() if err != nil { - if c.running { + if c.IsRunning() { logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{ "error": err.Error(), }) @@ -86,12 +94,12 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) { c.clients[conn] = true c.clientsMux.Unlock() - go c.handleConnection(conn, ctx) + go c.handleConnection(conn) } } } -func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { +func (c *MaixCamChannel) handleConnection(conn net.Conn) { logger.DebugC("maixcam", "Handling MaixCam connection") defer func() { @@ -106,7 +114,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { for { select { - case <-ctx.Done(): + case <-c.ctx.Done(): return default: var msg MaixCamMessage @@ -170,11 +178,29 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { "y": fmt.Sprintf("%.0f", y), "w": fmt.Sprintf("%.0f", w), "h": fmt.Sprintf("%.0f", h), - "peer_kind": "channel", - "peer_id": "default", } - c.HandleMessage(senderID, chatID, content, []string{}, metadata) + sender := bus.SenderInfo{ + Platform: "maixcam", + PlatformID: "maixcam", + CanonicalID: identity.BuildCanonicalID("maixcam", "maixcam"), + } + + if !c.IsAllowedSender(sender) { + return + } + + c.HandleMessage( + c.ctx, + bus.Peer{Kind: "channel", ID: "default"}, + "", + senderID, + chatID, + content, + []string{}, + metadata, + sender, + ) } func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { @@ -185,7 +211,12 @@ func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { func (c *MaixCamChannel) Stop(ctx context.Context) error { logger.InfoC("maixcam", "Stopping MaixCam channel") - c.setRunning(false) + c.SetRunning(false) + + // Cancel context first to signal goroutines to exit + if c.cancel != nil { + c.cancel() + } if c.listener != nil { c.listener.Close() @@ -205,7 +236,14 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error { func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("maixcam channel not running") + return channels.ErrNotRunning + } + + // Check ctx before entering write path + select { + case <-ctx.Done(): + return ctx.Err() + default: } c.clientsMux.RLock() @@ -230,13 +268,15 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro var sendErr error for conn := range c.clients { + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if _, err := conn.Write(data); err != nil { logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{ "client": conn.RemoteAddr().String(), "error": err.Error(), }) - sendErr = err + sendErr = fmt.Errorf("maixcam send: %w", channels.ErrTemporary) } + _ = conn.SetWriteDeadline(time.Time{}) } return sendErr diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 6be337dec..67277bb87 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -8,32 +8,115 @@ package channels import ( "context" + "errors" "fmt" + "math" + "net/http" "sync" + "time" + + "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" ) +const ( + defaultChannelQueueSize = 100 + defaultRateLimit = 10 // default 10 msg/s + maxRetries = 3 + rateLimitDelay = 1 * time.Second + baseBackoff = 500 * time.Millisecond + maxBackoff = 8 * time.Second +) + +// channelRateConfig maps channel name to per-second rate limit. +var channelRateConfig = map[string]float64{ + "telegram": 20, + "discord": 1, + "slack": 1, + "line": 10, +} + +type channelWorker struct { + ch Channel + queue chan bus.OutboundMessage + mediaQueue chan bus.OutboundMediaMessage + done chan struct{} + mediaDone chan struct{} + limiter *rate.Limiter +} + type Manager struct { channels map[string]Channel + workers map[string]*channelWorker bus *bus.MessageBus config *config.Config + mediaStore media.MediaStore dispatchTask *asyncTask + mux *http.ServeMux + httpServer *http.Server mu sync.RWMutex + placeholders sync.Map // "channel:chatID" → placeholderID (string) + typingStops sync.Map // "channel:chatID" → func() } type asyncTask struct { cancel context.CancelFunc } -func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error) { +// RecordPlaceholder registers a placeholder message for later editing. +// Implements PlaceholderRecorder. +func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { + key := channel + ":" + chatID + m.placeholders.Store(key, placeholderID) +} + +// RecordTypingStop registers a typing stop function for later invocation. +// Implements PlaceholderRecorder. +func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { + key := channel + ":" + chatID + m.typingStops.Store(key, stop) +} + +// preSend handles typing stop and placeholder editing before sending a message. +// Returns true if the message was edited into a placeholder (skip Send). +func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { + key := name + ":" + msg.ChatID + + // 1. Stop typing + if v, loaded := m.typingStops.LoadAndDelete(key); loaded { + if stop, ok := v.(func()); ok { + stop() // idempotent, safe + } + } + + // 2. Try editing placeholder + if v, loaded := m.placeholders.LoadAndDelete(key); loaded { + if placeholderID, ok := v.(string); ok && placeholderID != "" { + if editor, ok := ch.(MessageEditor); ok { + if err := editor.EditMessage(ctx, msg.ChatID, placeholderID, msg.Content); err == nil { + return true // edited successfully, skip Send + } + // edit failed → fall through to normal Send + } + } + } + + return false +} + +func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { m := &Manager{ - channels: make(map[string]Channel), - bus: messageBus, - config: cfg, + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + bus: messageBus, + config: cfg, + mediaStore: store, } if err := m.initChannels(); err != nil { @@ -43,137 +126,84 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error return m, 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) + if !ok { + logger.WarnCF("channels", "Factory not registered", map[string]any{ + "channel": displayName, + }) + return + } + logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{ + "channel": displayName, + }) + ch, err := f(m.config, m.bus) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{ + "channel": displayName, + "error": err.Error(), + }) + } else { + // Inject MediaStore if channel supports it + if m.mediaStore != nil { + if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok { + setter.SetMediaStore(m.mediaStore) + } + } + // Inject PlaceholderRecorder if channel supports it + if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok { + setter.SetPlaceholderRecorder(m) + } + m.channels[name] = ch + m.workers[name] = newChannelWorker(name, ch) + logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ + "channel": displayName, + }) + } +} + func (m *Manager) initChannels() error { logger.InfoC("channels", "Initializing channel manager") if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" { - logger.DebugC("channels", "Attempting to initialize Telegram channel") - telegram, err := NewTelegramChannel(m.config, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["telegram"] = telegram - logger.InfoC("channels", "Telegram channel enabled successfully") - } + m.initChannel("telegram", "Telegram") } if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" { - logger.DebugC("channels", "Attempting to initialize WhatsApp channel") - whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["whatsapp"] = whatsapp - logger.InfoC("channels", "WhatsApp channel enabled successfully") - } + m.initChannel("whatsapp", "WhatsApp") } if m.config.Channels.Feishu.Enabled { - logger.DebugC("channels", "Attempting to initialize Feishu channel") - feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["feishu"] = feishu - logger.InfoC("channels", "Feishu channel enabled successfully") - } + m.initChannel("feishu", "Feishu") } if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" { - logger.DebugC("channels", "Attempting to initialize Discord channel") - discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["discord"] = discord - logger.InfoC("channels", "Discord channel enabled successfully") - } + m.initChannel("discord", "Discord") } if m.config.Channels.MaixCam.Enabled { - logger.DebugC("channels", "Attempting to initialize MaixCam channel") - maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["maixcam"] = maixcam - logger.InfoC("channels", "MaixCam channel enabled successfully") - } + m.initChannel("maixcam", "MaixCam") } if m.config.Channels.QQ.Enabled { - logger.DebugC("channels", "Attempting to initialize QQ channel") - qq, err := NewQQChannel(m.config.Channels.QQ, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["qq"] = qq - logger.InfoC("channels", "QQ channel enabled successfully") - } + m.initChannel("qq", "QQ") } if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" { - logger.DebugC("channels", "Attempting to initialize DingTalk channel") - dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["dingtalk"] = dingtalk - logger.InfoC("channels", "DingTalk channel enabled successfully") - } + m.initChannel("dingtalk", "DingTalk") } if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" { - logger.DebugC("channels", "Attempting to initialize Slack channel") - slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["slack"] = slackCh - logger.InfoC("channels", "Slack channel enabled successfully") - } + m.initChannel("slack", "Slack") } if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" { - logger.DebugC("channels", "Attempting to initialize LINE channel") - line, err := NewLINEChannel(m.config.Channels.LINE, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["line"] = line - logger.InfoC("channels", "LINE channel enabled successfully") - } + m.initChannel("line", "LINE") } if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" { - logger.DebugC("channels", "Attempting to initialize OneBot channel") - onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["onebot"] = onebot - logger.InfoC("channels", "OneBot channel enabled successfully") - } + m.initChannel("onebot", "OneBot") } if m.config.Channels.Email.Enabled { logger.DebugC("channels", "Attempting to initialize Email channel") @@ -189,29 +219,15 @@ func (m *Manager) initChannels() error { } if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" { - logger.DebugC("channels", "Attempting to initialize WeCom channel") - wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["wecom"] = wecom - logger.InfoC("channels", "WeCom channel enabled successfully") - } + m.initChannel("wecom", "WeCom") } if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" { - logger.DebugC("channels", "Attempting to initialize WeCom App channel") - wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["wecom_app"] = wecomApp - logger.InfoC("channels", "WeCom App channel enabled successfully") - } + m.initChannel("wecom_app", "WeCom App") + } + + if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" { + m.initChannel("pico", "Pico") } logger.InfoCF("channels", "Channel initialization completed", map[string]any{ @@ -221,6 +237,43 @@ func (m *Manager) initChannels() error { return nil } +// SetupHTTPServer creates a shared HTTP server with the given listen address. +// 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.mux = http.NewServeMux() + + // Register health endpoints + if healthServer != nil { + healthServer.RegisterOnMux(m.mux) + } + + // Discover and register webhook handlers and health checkers + for name, ch := range m.channels { + if wh, ok := ch.(WebhookHandler); ok { + m.mux.Handle(wh.WebhookPath(), wh) + logger.InfoCF("channels", "Webhook handler registered", map[string]any{ + "channel": name, + "path": wh.WebhookPath(), + }) + } + if hc, ok := ch.(HealthChecker); ok { + m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler) + logger.InfoCF("channels", "Health endpoint registered", map[string]any{ + "channel": name, + "path": hc.HealthPath(), + }) + } + } + + m.httpServer = &http.Server{ + Addr: addr, + Handler: m.mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } +} + func (m *Manager) StartAll(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() @@ -235,8 +288,6 @@ func (m *Manager) StartAll(ctx context.Context) error { dispatchCtx, cancel := context.WithCancel(ctx) m.dispatchTask = &asyncTask{cancel: cancel} - go m.dispatchOutbound(dispatchCtx) - for name, channel := range m.channels { logger.InfoCF("channels", "Starting channel", map[string]any{ "channel": name, @@ -249,6 +300,30 @@ func (m *Manager) StartAll(ctx context.Context) error { } } + // Start per-channel workers + for name, w := range m.workers { + go m.runWorker(dispatchCtx, name, w) + go m.runMediaWorker(dispatchCtx, name, w) + } + + // Start the dispatcher that reads from the bus and routes to workers + go m.dispatchOutbound(dispatchCtx) + go m.dispatchOutboundMedia(dispatchCtx) + + // 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.ErrorCF("channels", "Shared HTTP server error", map[string]any{ + "error": err.Error(), + }) + } + }() + } + logger.InfoC("channels", "All channels started") return nil } @@ -259,11 +334,40 @@ func (m *Manager) StopAll(ctx context.Context) error { logger.InfoC("channels", "Stopping all channels") + // Shutdown shared HTTP server first + if m.httpServer != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := m.httpServer.Shutdown(shutdownCtx); err != nil { + logger.ErrorCF("channels", "Shared HTTP server shutdown error", map[string]any{ + "error": err.Error(), + }) + } + m.httpServer = nil + } + + // Cancel dispatcher if m.dispatchTask != nil { m.dispatchTask.cancel() m.dispatchTask = nil } + // Close all worker queues and wait for them to drain + for _, w := range m.workers { + close(w.queue) + } + for _, w := range m.workers { + <-w.done + } + // Close all media worker queues and wait for them to drain + for _, w := range m.workers { + close(w.mediaQueue) + } + for _, w := range m.workers { + <-w.mediaDone + } + + // Stop all channels for name, channel := range m.channels { logger.InfoCF("channels", "Stopping channel", map[string]any{ "channel": name, @@ -280,6 +384,117 @@ func (m *Manager) StopAll(ctx context.Context) error { return nil } +// newChannelWorker creates a channelWorker with a rate limiter configured +// for the given channel name. +func newChannelWorker(name string, ch Channel) *channelWorker { + rateVal := float64(defaultRateLimit) + if r, ok := channelRateConfig[name]; ok { + rateVal = r + } + burst := int(math.Max(1, math.Ceil(rateVal/2))) + + return &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, defaultChannelQueueSize), + mediaQueue: make(chan bus.OutboundMediaMessage, defaultChannelQueueSize), + done: make(chan struct{}), + mediaDone: make(chan struct{}), + limiter: rate.NewLimiter(rate.Limit(rateVal), burst), + } +} + +// runWorker processes outbound messages for a single channel, splitting +// messages that exceed the channel's maximum message length. +func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) { + defer close(w.done) + for { + select { + case msg, ok := <-w.queue: + if !ok { + return + } + maxLen := 0 + if mlp, ok := w.ch.(MessageLengthProvider); ok { + maxLen = mlp.MaxMessageLength() + } + if maxLen > 0 && len([]rune(msg.Content)) > maxLen { + chunks := SplitMessage(msg.Content, maxLen) + for _, chunk := range chunks { + chunkMsg := msg + chunkMsg.Content = chunk + m.sendWithRetry(ctx, name, w, chunkMsg) + } + } else { + m.sendWithRetry(ctx, name, w, msg) + } + case <-ctx.Done(): + return + } + } +} + +// sendWithRetry sends a message through the channel with rate limiting and +// retry logic. It classifies errors to determine the retry strategy: +// - ErrNotRunning / ErrSendFailed: permanent, no retry +// - ErrRateLimit: fixed delay retry +// - ErrTemporary / unknown: exponential backoff retry +func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) { + // Rate limit: wait for token + if err := w.limiter.Wait(ctx); err != nil { + // ctx cancelled, shutting down + return + } + + // Pre-send: stop typing and try to edit placeholder + if m.preSend(ctx, name, msg, w.ch) { + return // placeholder was edited successfully, skip Send + } + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + lastErr = w.ch.Send(ctx, msg) + if lastErr == nil { + return + } + + // Permanent failures — don't retry + if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) { + break + } + + // Last attempt exhausted — don't sleep + if attempt == maxRetries { + break + } + + // Rate limit error — fixed delay + if errors.Is(lastErr, ErrRateLimit) { + select { + case <-time.After(rateLimitDelay): + continue + case <-ctx.Done(): + return + } + } + + // ErrTemporary or unknown error — exponential backoff + backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + } + + // All retries exhausted or permanent failure + logger.ErrorCF("channels", "Send failed", map[string]any{ + "channel": name, + "chat_id": msg.ChatID, + "error": lastErr.Error(), + "retries": maxRetries, + }) +} + func (m *Manager) dispatchOutbound(ctx context.Context) { logger.InfoC("channels", "Outbound dispatcher started") @@ -300,7 +515,8 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { } m.mu.RLock() - channel, exists := m.channels[msg.Channel] + _, exists := m.channels[msg.Channel] + w, wExists := m.workers[msg.Channel] m.mu.RUnlock() if !exists { @@ -310,16 +526,136 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { continue } - if err := channel.Send(ctx, msg); err != nil { - logger.ErrorCF("channels", "Error sending message to channel", map[string]any{ - "channel": msg.Channel, - "error": err.Error(), - }) + if wExists { + select { + case w.queue <- msg: + case <-ctx.Done(): + return + } } } } } +func (m *Manager) dispatchOutboundMedia(ctx context.Context) { + logger.InfoC("channels", "Outbound media dispatcher started") + + for { + select { + case <-ctx.Done(): + logger.InfoC("channels", "Outbound media dispatcher stopped") + return + default: + msg, ok := m.bus.SubscribeOutboundMedia(ctx) + if !ok { + continue + } + + // Silently skip internal channels + if constants.IsInternalChannel(msg.Channel) { + continue + } + + m.mu.RLock() + _, exists := m.channels[msg.Channel] + w, wExists := m.workers[msg.Channel] + m.mu.RUnlock() + + if !exists { + logger.WarnCF("channels", "Unknown channel for outbound media message", map[string]any{ + "channel": msg.Channel, + }) + continue + } + + if wExists { + select { + case w.mediaQueue <- msg: + case <-ctx.Done(): + return + } + } + } + } +} + +// runMediaWorker processes outbound media messages for a single channel. +func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWorker) { + defer close(w.mediaDone) + for { + select { + case msg, ok := <-w.mediaQueue: + if !ok { + return + } + m.sendMediaWithRetry(ctx, name, w, msg) + case <-ctx.Done(): + return + } + } +} + +// sendMediaWithRetry sends a media message through the channel with rate limiting and +// retry logic. If the channel does not implement MediaSender, it silently skips. +func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage) { + ms, ok := w.ch.(MediaSender) + if !ok { + logger.DebugCF("channels", "Channel does not support MediaSender, skipping media", map[string]any{ + "channel": name, + }) + return + } + + // Rate limit: wait for token + if err := w.limiter.Wait(ctx); err != nil { + return + } + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + lastErr = ms.SendMedia(ctx, msg) + if lastErr == nil { + return + } + + // Permanent failures — don't retry + if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) { + break + } + + // Last attempt exhausted — don't sleep + if attempt == maxRetries { + break + } + + // Rate limit error — fixed delay + if errors.Is(lastErr, ErrRateLimit) { + select { + case <-time.After(rateLimitDelay): + continue + case <-ctx.Done(): + return + } + } + + // ErrTemporary or unknown error — exponential backoff + backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + } + + // All retries exhausted or permanent failure + logger.ErrorCF("channels", "SendMedia failed", map[string]any{ + "channel": name, + "chat_id": msg.ChatID, + "error": lastErr.Error(), + "retries": maxRetries, + }) +} + func (m *Manager) GetChannel(name string) (Channel, bool) { m.mu.RLock() defer m.mu.RUnlock() @@ -356,17 +692,26 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { m.mu.Lock() defer m.mu.Unlock() m.channels[name] = channel + m.workers[name] = newChannelWorker(name, channel) } func (m *Manager) UnregisterChannel(name string) { m.mu.Lock() defer m.mu.Unlock() + if w, ok := m.workers[name]; ok { + close(w.queue) + <-w.done + close(w.mediaQueue) + <-w.mediaDone + } + delete(m.workers, name) delete(m.channels, name) } func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { m.mu.RLock() - channel, exists := m.channels[channelName] + _, exists := m.channels[channelName] + w, wExists := m.workers[channelName] m.mu.RUnlock() if !exists { @@ -379,5 +724,16 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten Content: content, } + if wExists { + select { + case w.queue <- msg: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + + // Fallback: direct send (should not happen) + channel, _ := m.channels[channelName] return channel.Send(ctx, msg) } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go new file mode 100644 index 000000000..0573c0a8e --- /dev/null +++ b/pkg/channels/manager_test.go @@ -0,0 +1,634 @@ +package channels + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/time/rate" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +// mockChannel is a test double that delegates Send to a configurable function. +type mockChannel struct { + BaseChannel + sendFn func(ctx context.Context, msg bus.OutboundMessage) error +} + +func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + return m.sendFn(ctx, msg) +} + +func (m *mockChannel) Start(ctx context.Context) error { return nil } +func (m *mockChannel) Stop(ctx context.Context) error { return nil } + +// newTestManager creates a minimal Manager suitable for unit tests. +func newTestManager() *Manager { + return &Manager{ + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + } +} + +func TestSendWithRetry_Success(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 1 { + t.Fatalf("expected 1 Send call, got %d", callCount) + } +} + +func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + if callCount <= 2 { + return fmt.Errorf("network error: %w", ErrTemporary) + } + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 3 { + t.Fatalf("expected 3 Send calls (2 failures + 1 success), got %d", callCount) + } +} + +func TestSendWithRetry_PermanentFailure(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return fmt.Errorf("bad chat ID: %w", ErrSendFailed) + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 1 { + t.Fatalf("expected 1 Send call (no retry for permanent failure), got %d", callCount) + } +} + +func TestSendWithRetry_NotRunning(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return ErrNotRunning + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 1 { + t.Fatalf("expected 1 Send call (no retry for ErrNotRunning), got %d", callCount) + } +} + +func TestSendWithRetry_RateLimitRetry(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + if callCount == 1 { + return fmt.Errorf("429: %w", ErrRateLimit) + } + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + start := time.Now() + m.sendWithRetry(ctx, "test", w, msg) + elapsed := time.Since(start) + + if callCount != 2 { + t.Fatalf("expected 2 Send calls (1 rate limit + 1 success), got %d", callCount) + } + // Should have waited at least rateLimitDelay (1s) but allow some slack + if elapsed < 900*time.Millisecond { + t.Fatalf("expected at least ~1s delay for rate limit retry, got %v", elapsed) + } +} + +func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return fmt.Errorf("timeout: %w", ErrTemporary) + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + expected := maxRetries + 1 // initial attempt + maxRetries retries + if callCount != expected { + t.Fatalf("expected %d Send calls, got %d", expected, callCount) + } +} + +func TestSendWithRetry_UnknownError(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + if callCount == 1 { + return errors.New("random unexpected error") + } + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 2 { + t.Fatalf("expected 2 Send calls (unknown error treated as temporary), got %d", callCount) + } +} + +func TestSendWithRetry_ContextCancelled(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return fmt.Errorf("timeout: %w", ErrTemporary) + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + // Cancel context after first Send attempt returns + ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + cancel() + return fmt.Errorf("timeout: %w", ErrTemporary) + } + + m.sendWithRetry(ctx, "test", w, msg) + + // Should have called Send once, then noticed ctx cancelled during backoff + if callCount != 1 { + t.Fatalf("expected 1 Send call before context cancellation, got %d", callCount) + } +} + +func TestWorkerRateLimiter(t *testing.T) { + m := newTestManager() + + var mu sync.Mutex + var sendTimes []time.Time + + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + mu.Lock() + sendTimes = append(sendTimes, time.Now()) + mu.Unlock() + return nil + }, + } + + // Create a worker with a low rate: 2 msg/s, burst 1 + w := &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, 10), + done: make(chan struct{}), + limiter: rate.NewLimiter(2, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go m.runWorker(ctx, "test", w) + + // Enqueue 4 messages + for i := 0; i < 4; i++ { + w.queue <- 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) + time.Sleep(3 * time.Second) + + mu.Lock() + times := make([]time.Time, len(sendTimes)) + copy(times, sendTimes) + mu.Unlock() + + if len(times) != 4 { + t.Fatalf("expected 4 sends, got %d", len(times)) + } + + // Verify rate limiting: total duration should be at least 1s + // (first message immediate, then ~500ms between each subsequent one at 2/s) + totalDuration := times[len(times)-1].Sub(times[0]) + if totalDuration < 1*time.Second { + t.Fatalf("expected total duration >= 1s for 4 msgs at 2/s rate, got %v", totalDuration) + } +} + +func TestNewChannelWorker_DefaultRate(t *testing.T) { + ch := &mockChannel{} + w := newChannelWorker("unknown_channel", ch) + + if w.limiter == nil { + t.Fatal("expected limiter to be non-nil") + } + if w.limiter.Limit() != rate.Limit(defaultRateLimit) { + t.Fatalf("expected rate limit %v, got %v", rate.Limit(defaultRateLimit), w.limiter.Limit()) + } +} + +func TestNewChannelWorker_ConfiguredRate(t *testing.T) { + ch := &mockChannel{} + + for name, expectedRate := range channelRateConfig { + w := newChannelWorker(name, ch) + if w.limiter.Limit() != rate.Limit(expectedRate) { + t.Fatalf("channel %s: expected rate %v, got %v", name, expectedRate, w.limiter.Limit()) + } + } +} + +func TestRunWorker_MessageSplitting(t *testing.T) { + m := newTestManager() + + var mu sync.Mutex + var received []string + + ch := &mockChannelWithLength{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + mu.Lock() + received = append(received, msg.Content) + mu.Unlock() + return nil + }, + }, + maxLen: 5, + } + + w := &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, 10), + done: make(chan struct{}), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go m.runWorker(ctx, "test", w) + + // Send a message that should be split + w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"} + + time.Sleep(100 * time.Millisecond) + + mu.Lock() + count := len(received) + mu.Unlock() + + if count < 2 { + t.Fatalf("expected message to be split into at least 2 chunks, got %d", count) + } +} + +// mockChannelWithLength implements MessageLengthProvider. +type mockChannelWithLength struct { + mockChannel + maxLen int +} + +func (m *mockChannelWithLength) MaxMessageLength() int { + return m.maxLen +} + +func TestSendWithRetry_ExponentialBackoff(t *testing.T) { + m := newTestManager() + + var callTimes []time.Time + var callCount atomic.Int32 + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callTimes = append(callTimes, time.Now()) + callCount.Add(1) + return fmt.Errorf("timeout: %w", ErrTemporary) + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + start := time.Now() + m.sendWithRetry(ctx, "test", w, msg) + totalElapsed := time.Since(start) + + // With maxRetries=3: attempts at 0, ~500ms, ~1.5s, ~3.5s + // Total backoff: 500ms + 1s + 2s = 3.5s + // Allow some margin + if totalElapsed < 3*time.Second { + t.Fatalf("expected total elapsed >= 3s for exponential backoff, got %v", totalElapsed) + } + + if int(callCount.Load()) != maxRetries+1 { + t.Fatalf("expected %d calls, got %d", maxRetries+1, callCount.Load()) + } +} + +// --- Phase 10: preSend orchestration tests --- + +// mockMessageEditor is a channel that supports MessageEditor. +type mockMessageEditor struct { + mockChannel + editFn func(ctx context.Context, chatID, messageID, content string) error +} + +func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error { + return m.editFn(ctx, chatID, messageID, content) +} + +func TestPreSend_PlaceholderEditSuccess(t *testing.T) { + m := newTestManager() + var sendCalled bool + var editCalled bool + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + sendCalled = true + return nil + }, + }, + editFn: func(_ context.Context, chatID, messageID, content string) error { + editCalled = true + if chatID != "123" { + t.Fatalf("expected chatID 123, got %s", chatID) + } + if messageID != "456" { + t.Fatalf("expected messageID 456, got %s", messageID) + } + if content != "hello" { + t.Fatalf("expected content 'hello', got %s", content) + } + return nil + }, + } + + // Register placeholder + m.RecordPlaceholder("test", "123", "456") + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if !edited { + t.Fatal("expected preSend to return true (placeholder edited)") + } + if !editCalled { + t.Fatal("expected EditMessage to be called") + } + if sendCalled { + t.Fatal("expected Send to NOT be called when placeholder edited") + } +} + +func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { + m := newTestManager() + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + return fmt.Errorf("edit failed") + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if edited { + t.Fatal("expected preSend to return false when edit fails") + } +} + +func TestPreSend_TypingStopCalled(t *testing.T) { + m := newTestManager() + var stopCalled bool + + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + } + + m.RecordTypingStop("test", "123", func() { + stopCalled = true + }) + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + m.preSend(context.Background(), "test", msg, ch) + + if !stopCalled { + t.Fatal("expected typing stop func to be called") + } +} + +func TestPreSend_NoRegisteredState(t *testing.T) { + m := newTestManager() + + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + } + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if edited { + t.Fatal("expected preSend to return false with no registered state") + } +} + +func TestPreSend_TypingAndPlaceholder(t *testing.T) { + m := newTestManager() + var stopCalled bool + var editCalled bool + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + editCalled = true + return nil + }, + } + + m.RecordTypingStop("test", "123", func() { + stopCalled = true + }) + m.RecordPlaceholder("test", "123", "456") + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if !stopCalled { + t.Fatal("expected typing stop to be called") + } + if !editCalled { + t.Fatal("expected EditMessage to be called") + } + if !edited { + t.Fatal("expected preSend to return true") + } +} + +func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) { + m := newTestManager() + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + chatID := fmt.Sprintf("chat_%d", i%10) + m.RecordPlaceholder("test", chatID, fmt.Sprintf("msg_%d", i)) + }(i) + } + wg.Wait() +} + +func TestRecordTypingStop_ConcurrentSafe(t *testing.T) { + m := newTestManager() + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + chatID := fmt.Sprintf("chat_%d", i%10) + m.RecordTypingStop("test", chatID, func() {}) + }(i) + } + wg.Wait() +} + +func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) { + m := newTestManager() + var sendCalled bool + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + sendCalled = true + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + return nil // edit succeeds + }, + } + + m.RecordPlaceholder("test", "123", "456") + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + m.sendWithRetry(context.Background(), "test", w, msg) + + if sendCalled { + t.Fatal("expected Send to NOT be called when placeholder was edited") + } +} diff --git a/pkg/channels/media.go b/pkg/channels/media.go new file mode 100644 index 000000000..c645a6180 --- /dev/null +++ b/pkg/channels/media.go @@ -0,0 +1,15 @@ +package channels + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +// MediaSender is an optional interface for channels that can send +// media attachments (images, files, audio, video). +// Manager discovers channels implementing this interface via type +// assertion and routes OutboundMediaMessage to them. +type MediaSender interface { + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error +} diff --git a/pkg/channels/onebot/init.go b/pkg/channels/onebot/init.go new file mode 100644 index 000000000..84c06dfd6 --- /dev/null +++ b/pkg/channels/onebot/init.go @@ -0,0 +1,13 @@ +package onebot + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("onebot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewOneBotChannel(cfg.Channels.OneBot, b) + }) +} diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot/onebot.go similarity index 78% rename from pkg/channels/onebot.go rename to pkg/channels/onebot/onebot.go index cee8ad9d3..feb198d7d 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -1,10 +1,9 @@ -package channels +package onebot import ( "context" "encoding/json" "fmt" - "os" "strconv" "strings" "sync" @@ -14,14 +13,16 @@ import ( "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) type OneBotChannel struct { - *BaseChannel + *channels.BaseChannel config config.OneBotConfig conn *websocket.Conn ctx context.Context @@ -35,7 +36,6 @@ type OneBotChannel struct { selfID int64 pending map[string]chan json.RawMessage pendingMu sync.Mutex - transcriber *voice.GroqTranscriber lastMessageID sync.Map pendingEmojiMsg sync.Map } @@ -98,7 +98,9 @@ type oneBotMessageSegment struct { } func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { - base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom, + channels.WithGroupTrigger(cfg.GroupTrigger), + ) const dedupSize = 1024 return &OneBotChannel{ @@ -111,10 +113,6 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One }, nil } -func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) { go func() { _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{ @@ -159,7 +157,7 @@ func (c *OneBotChannel) Start(ctx context.Context) error { } } - c.setRunning(true) + c.SetRunning(true) logger.InfoC("onebot", "OneBot channel started successfully") return nil @@ -297,7 +295,9 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D } c.writeMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) err = conn.WriteMessage(websocket.TextMessage, data) + _ = conn.SetWriteDeadline(time.Time{}) c.writeMu.Unlock() if err != nil { @@ -306,6 +306,9 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D select { case resp := <-ch: + if resp == nil { + return nil, fmt.Errorf("API request %s: channel stopped", action) + } return resp, nil case <-time.After(timeout): return nil, fmt.Errorf("API request %s timed out after %v", action, timeout) @@ -346,7 +349,7 @@ func (c *OneBotChannel) reconnectLoop() { func (c *OneBotChannel) Stop(ctx context.Context) error { logger.InfoC("onebot", "Stopping OneBot channel") - c.setRunning(false) + c.SetRunning(false) if c.cancel != nil { c.cancel() @@ -354,7 +357,10 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { c.pendingMu.Lock() for echo, ch := range c.pending { - close(ch) + select { + case ch <- nil: // non-blocking wake for blocked sendAPIRequest goroutines + default: + } delete(c.pending, echo) } c.pendingMu.Unlock() @@ -371,7 +377,14 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("OneBot channel not running") + return channels.ErrNotRunning + } + + // Check ctx before entering write path + select { + case <-ctx.Done(): + return ctx.Err() + default: } c.mu.Lock() @@ -401,20 +414,127 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error } c.writeMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) err = conn.WriteMessage(websocket.TextMessage, data) + _ = conn.SetWriteDeadline(time.Time{}) c.writeMu.Unlock() if err != nil { logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) - return err + return fmt.Errorf("onebot send: %w", channels.ErrTemporary) } - if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok { - if mid, ok := msgID.(string); ok && mid != "" { - c.setMsgEmojiLike(mid, 289, false) + return nil +} + +// SendMedia implements the channels.MediaSender interface. +func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + return fmt.Errorf("OneBot WebSocket not connected") + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + // Build media segments + var segments []oneBotMessageSegment + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("onebot", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue } + + segType := "image" + switch part.Type { + case "image": + segType = "image" + case "video": + segType = "video" + case "audio": + segType = "record" + default: + segType = "file" + } + + segments = append(segments, oneBotMessageSegment{ + Type: segType, + Data: map[string]any{"file": "file://" + localPath}, + }) + + if part.Caption != "" { + segments = append(segments, oneBotMessageSegment{ + Type: "text", + Data: map[string]any{"text": part.Caption}, + }) + } + } + + if len(segments) == 0 { + return nil + } + + chatID := msg.ChatID + var action, idKey string + var rawID string + if rest, ok := strings.CutPrefix(chatID, "group:"); ok { + action, idKey, rawID = "send_group_msg", "group_id", rest + } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok { + action, idKey, rawID = "send_private_msg", "user_id", rest + } else { + action, idKey, rawID = "send_private_msg", "user_id", chatID + } + + id, err := strconv.ParseInt(rawID, 10, 64) + if err != nil { + return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) + } + + echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) + + req := oneBotAPIRequest{ + Action: action, + Params: map[string]any{idKey: id, "message": segments}, + Echo: echo, + } + + data, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal OneBot request: %w", err) + } + + c.writeMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + err = conn.WriteMessage(websocket.TextMessage, data) + _ = conn.SetWriteDeadline(time.Time{}) + c.writeMu.Unlock() + + if err != nil { + logger.ErrorCF("onebot", "Failed to send media message", map[string]any{ + "error": err.Error(), + }) + return fmt.Errorf("onebot send media: %w", channels.ErrTemporary) } return nil @@ -571,11 +691,15 @@ type parseMessageResult struct { Text string IsBotMentioned bool Media []string - LocalFiles []string ReplyTo string } -func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult { +func (c *OneBotChannel) parseMessageSegments( + raw json.RawMessage, + selfID int64, + store media.MediaStore, + scope string, +) parseMessageResult { if len(raw) == 0 { return parseMessageResult{} } @@ -602,10 +726,23 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) var textParts []string mentioned := false selfIDStr := strconv.FormatInt(selfID, 10) - var media []string - var localFiles []string + var mediaRefs []string var replyTo string + // Helper to register a local file with the media store + storeFile := func(localPath, filename string) string { + if store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "onebot", + }, scope) + if err == nil { + return ref + } + } + return localPath // fallback + } + for _, seg := range segments { segType, _ := seg["type"].(string) data, _ := seg["data"].(map[string]any) @@ -641,8 +778,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) LoggerPrefix: "onebot", }) if localPath != "" { - media = append(media, localPath) - localFiles = append(localFiles, localPath) + mediaRefs = append(mediaRefs, storeFile(localPath, filename)) textParts = append(textParts, fmt.Sprintf("[%s]", segType)) } } @@ -656,24 +792,8 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) LoggerPrefix: "onebot", }) if localPath != "" { - localFiles = append(localFiles, localPath) - if c.transcriber != nil && c.transcriber.IsAvailable() { - tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second) - result, err := c.transcriber.Transcribe(tctx, localPath) - tcancel() - if err != nil { - logger.WarnCF("onebot", "Voice transcription failed", map[string]any{ - "error": err.Error(), - }) - textParts = append(textParts, "[voice (transcription failed)]") - media = append(media, localPath) - } else { - textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text)) - } - } else { - textParts = append(textParts, "[voice]") - media = append(media, localPath) - } + textParts = append(textParts, "[voice]") + mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr")) } } } @@ -702,8 +822,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) return parseMessageResult{ Text: strings.TrimSpace(strings.Join(textParts, "")), IsBotMentioned: mentioned, - Media: media, - LocalFiles: localFiles, + Media: mediaRefs, ReplyTo: replyTo, } } @@ -712,7 +831,13 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { switch raw.PostType { case "message": if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 { - if !c.IsAllowed(strconv.FormatInt(userID, 10)) { + // Build minimal sender for allowlist check + sender := bus.SenderInfo{ + Platform: "onebot", + PlatformID: strconv.FormatInt(userID, 10), + CanonicalID: identity.BuildCanonicalID("onebot", strconv.FormatInt(userID, 10)), + } + if !c.IsAllowedSender(sender) { logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{ "user_id": userID, }) @@ -795,7 +920,17 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { selfID = atomic.LoadInt64(&c.selfID) } - parsed := c.parseMessageSegments(raw.Message, selfID) + // Compute scope for media store before parsing (parsing may download files) + var chatIDForScope string + switch raw.MessageType { + case "group": + chatIDForScope = "group:" + strconv.FormatInt(groupID, 10) + default: + chatIDForScope = "private:" + strconv.FormatInt(userID, 10) + } + scope := channels.BuildMediaScope("onebot", chatIDForScope, messageID) + + parsed := c.parseMessageSegments(raw.Message, selfID, c.GetMediaStore(), scope) isBotMentioned := parsed.IsBotMentioned content := raw.RawMessage @@ -824,20 +959,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { } } - // Clean up temp files when done - if len(parsed.LocalFiles) > 0 { - defer func() { - for _, f := range parsed.LocalFiles { - if err := os.Remove(f); err != nil { - logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{ - "path": f, - "error": err.Error(), - }) - } - } - }() - } - if c.isDuplicate(messageID) { logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{ "message_id": messageID, @@ -855,9 +976,9 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { senderID := strconv.FormatInt(userID, 10) var chatID string - metadata := map[string]string{ - "message_id": messageID, - } + var peer bus.Peer + + metadata := map[string]string{} if parsed.ReplyTo != "" { metadata["reply_to_message_id"] = parsed.ReplyTo @@ -866,14 +987,12 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { switch raw.MessageType { case "private": chatID = "private:" + senderID - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} case "group": groupIDStr := strconv.FormatInt(groupID, 10) chatID = "group:" + groupIDStr - metadata["peer_kind"] = "group" - metadata["peer_id"] = groupIDStr + peer = bus.Peer{Kind: "group", ID: groupIDStr} metadata["group_id"] = groupIDStr senderUserID, _ := parseJSONInt64(sender.UserID) @@ -887,8 +1006,8 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { metadata["sender_name"] = sender.Nickname } - triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned) - if !triggered { + respond, strippedContent := c.ShouldRespondInGroup(isBotMentioned, content) + if !respond { logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{ "sender": senderID, "group": groupIDStr, @@ -926,9 +1045,30 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { if raw.MessageType == "group" && messageID != "" && messageID != "0" { c.setMsgEmojiLike(messageID, 289, true) c.pendingEmojiMsg.Store(chatID, messageID) + // Register emoji stop with Manager for outbound orchestration + if rec := c.GetPlaceholderRecorder(); rec != nil { + capturedMsgID := messageID + rec.RecordTypingStop("onebot", chatID, func() { + c.setMsgEmojiLike(capturedMsgID, 289, false) + }) + } } - c.HandleMessage(senderID, chatID, content, parsed.Media, metadata) + senderInfo := bus.SenderInfo{ + Platform: "onebot", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("onebot", senderID), + DisplayName: sender.Nickname, + } + + if !c.IsAllowedSender(senderInfo) { + logger.DebugCF("onebot", "Message rejected by allowlist (senderInfo)", map[string]any{ + "sender": senderID, + }) + return + } + + c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo) } func (c *OneBotChannel) isDuplicate(messageID string) bool { @@ -960,23 +1100,3 @@ func truncate(s string, n int) string { } return string(runes[:n]) + "..." } - -func (c *OneBotChannel) checkGroupTrigger( - content string, - isBotMentioned bool, -) (triggered bool, strippedContent string) { - if isBotMentioned { - return true, strings.TrimSpace(content) - } - - for _, prefix := range c.config.GroupTriggerPrefix { - if prefix == "" { - continue - } - if strings.HasPrefix(content, prefix) { - return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) - } - } - - return false, content -} diff --git a/pkg/channels/pico/init.go b/pkg/channels/pico/init.go new file mode 100644 index 000000000..96d764418 --- /dev/null +++ b/pkg/channels/pico/init.go @@ -0,0 +1,13 @@ +package pico + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewPicoChannel(cfg.Channels.Pico, b) + }) +} diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go new file mode 100644 index 000000000..c646a3b0b --- /dev/null +++ b/pkg/channels/pico/pico.go @@ -0,0 +1,444 @@ +package pico + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// picoConn represents a single WebSocket connection. +type picoConn struct { + id string + conn *websocket.Conn + sessionID string + writeMu sync.Mutex + closed atomic.Bool +} + +// writeJSON sends a JSON message to the connection with write locking. +func (pc *picoConn) writeJSON(v any) error { + if pc.closed.Load() { + return fmt.Errorf("connection closed") + } + pc.writeMu.Lock() + defer pc.writeMu.Unlock() + return pc.conn.WriteJSON(v) +} + +// close closes the connection. +func (pc *picoConn) close() { + if pc.closed.CompareAndSwap(false, true) { + pc.conn.Close() + } +} + +// PicoChannel implements the native Pico Protocol WebSocket channel. +// It serves as the reference implementation for all optional capability interfaces. +type PicoChannel struct { + *channels.BaseChannel + config config.PicoConfig + upgrader websocket.Upgrader + connections sync.Map // connID → *picoConn + connCount atomic.Int32 + ctx context.Context + cancel context.CancelFunc +} + +// NewPicoChannel creates a new Pico Protocol channel. +func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) { + if cfg.Token == "" { + return nil, fmt.Errorf("pico token is required") + } + + base := channels.NewBaseChannel("pico", cfg, messageBus, cfg.AllowFrom) + + allowOrigins := cfg.AllowOrigins + checkOrigin := func(r *http.Request) bool { + if len(allowOrigins) == 0 { + return true // allow all if not configured + } + origin := r.Header.Get("Origin") + for _, allowed := range allowOrigins { + if allowed == "*" || allowed == origin { + return true + } + } + return false + } + + return &PicoChannel{ + BaseChannel: base, + config: cfg, + upgrader: websocket.Upgrader{ + CheckOrigin: checkOrigin, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + }, + }, nil +} + +// Start implements Channel. +func (c *PicoChannel) Start(ctx context.Context) error { + logger.InfoC("pico", "Starting Pico Protocol channel") + c.ctx, c.cancel = context.WithCancel(ctx) + c.SetRunning(true) + logger.InfoC("pico", "Pico Protocol channel started") + return nil +} + +// Stop implements Channel. +func (c *PicoChannel) Stop(ctx context.Context) error { + logger.InfoC("pico", "Stopping Pico Protocol channel") + c.SetRunning(false) + + // Close all connections + c.connections.Range(func(key, value any) bool { + if pc, ok := value.(*picoConn); ok { + pc.close() + } + c.connections.Delete(key) + return true + }) + + if c.cancel != nil { + c.cancel() + } + + logger.InfoC("pico", "Pico Protocol channel stopped") + return nil +} + +// WebhookPath implements channels.WebhookHandler. +func (c *PicoChannel) WebhookPath() string { return "/pico/" } + +// ServeHTTP implements http.Handler for the shared HTTP server. +func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/pico") + + switch { + case path == "/ws" || path == "/ws/": + c.handleWebSocket(w, r) + default: + http.NotFound(w, r) + } +} + +// Send implements Channel — sends a message to the appropriate WebSocket connection. +func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + outMsg := newMessage(TypeMessageCreate, map[string]any{ + "content": msg.Content, + }) + + return c.broadcastToSession(msg.ChatID, outMsg) +} + +// EditMessage implements channels.MessageEditor. +func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { + outMsg := newMessage(TypeMessageUpdate, map[string]any{ + "message_id": messageID, + "content": content, + }) + return c.broadcastToSession(chatID, outMsg) +} + +// StartTyping implements channels.TypingCapable. +func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + startMsg := newMessage(TypeTypingStart, nil) + if err := c.broadcastToSession(chatID, startMsg); err != nil { + return func() {}, err + } + return func() { + stopMsg := newMessage(TypeTypingStop, nil) + c.broadcastToSession(chatID, stopMsg) + }, nil +} + +// broadcastToSession sends a message to all connections with a matching session. +func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { + // chatID format: "pico:" + sessionID := strings.TrimPrefix(chatID, "pico:") + msg.SessionID = sessionID + + var sent bool + c.connections.Range(func(key, value any) bool { + pc, ok := value.(*picoConn) + if !ok { + return true + } + if pc.sessionID == sessionID { + if err := pc.writeJSON(msg); err != nil { + logger.DebugCF("pico", "Write to connection failed", map[string]any{ + "conn_id": pc.id, + "error": err.Error(), + }) + } else { + sent = true + } + } + return true + }) + + if !sent { + return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed) + } + return nil +} + +// handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle. +func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { + if !c.IsRunning() { + http.Error(w, "channel not running", http.StatusServiceUnavailable) + return + } + + // Authenticate + if !c.authenticate(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Check connection limit + maxConns := c.config.MaxConnections + if maxConns <= 0 { + maxConns = 100 + } + if int(c.connCount.Load()) >= maxConns { + http.Error(w, "too many connections", http.StatusServiceUnavailable) + return + } + + conn, err := c.upgrader.Upgrade(w, r, nil) + if err != nil { + logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{ + "error": err.Error(), + }) + return + } + + // Determine session ID from query param or generate one + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + sessionID = uuid.New().String() + } + + pc := &picoConn{ + id: uuid.New().String(), + conn: conn, + sessionID: sessionID, + } + + c.connections.Store(pc.id, pc) + c.connCount.Add(1) + + logger.InfoCF("pico", "WebSocket client connected", map[string]any{ + "conn_id": pc.id, + "session_id": sessionID, + }) + + go c.readLoop(pc) +} + +// authenticate checks the Bearer token from the Authorization header. +// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled. +func (c *PicoChannel) authenticate(r *http.Request) bool { + token := c.config.Token + if token == "" { + return false + } + + // Check Authorization header + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + if strings.TrimPrefix(auth, "Bearer ") == token { + return true + } + } + + // Check query parameter only when explicitly allowed + if c.config.AllowTokenQuery { + if r.URL.Query().Get("token") == token { + return true + } + } + + return false +} + +// readLoop reads messages from a WebSocket connection. +func (c *PicoChannel) readLoop(pc *picoConn) { + defer func() { + pc.close() + c.connections.Delete(pc.id) + c.connCount.Add(-1) + logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{ + "conn_id": pc.id, + "session_id": pc.sessionID, + }) + }() + + readTimeout := time.Duration(c.config.ReadTimeout) * time.Second + if readTimeout <= 0 { + readTimeout = 60 * time.Second + } + + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + pc.conn.SetPongHandler(func(appData string) error { + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + return nil + }) + + // Start ping ticker + pingInterval := time.Duration(c.config.PingInterval) * time.Second + if pingInterval <= 0 { + pingInterval = 30 * time.Second + } + go c.pingLoop(pc, pingInterval) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + _, rawMsg, err := pc.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + logger.DebugCF("pico", "WebSocket read error", map[string]any{ + "conn_id": pc.id, + "error": err.Error(), + }) + } + return + } + + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + + var msg PicoMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { + errMsg := newError("invalid_message", "failed to parse message") + pc.writeJSON(errMsg) + continue + } + + c.handleMessage(pc, msg) + } +} + +// pingLoop sends periodic ping frames to keep the connection alive. +func (c *PicoChannel) pingLoop(pc *picoConn, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + if pc.closed.Load() { + return + } + pc.writeMu.Lock() + err := pc.conn.WriteMessage(websocket.PingMessage, nil) + pc.writeMu.Unlock() + if err != nil { + return + } + } + } +} + +// handleMessage processes an inbound Pico Protocol message. +func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) { + switch msg.Type { + case TypePing: + pong := newMessage(TypePong, nil) + pong.ID = msg.ID + pc.writeJSON(pong) + + case TypeMessageSend: + c.handleMessageSend(pc, msg) + + default: + errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type)) + pc.writeJSON(errMsg) + } +} + +// handleMessageSend processes an inbound message.send from a client. +func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { + content, _ := msg.Payload["content"].(string) + if strings.TrimSpace(content) == "" { + errMsg := newError("empty_content", "message content is empty") + pc.writeJSON(errMsg) + return + } + + sessionID := msg.SessionID + if sessionID == "" { + sessionID = pc.sessionID + } + + chatID := "pico:" + sessionID + senderID := "pico-user" + + peer := bus.Peer{Kind: "direct", ID: "pico:" + sessionID} + + metadata := map[string]string{ + "platform": "pico", + "session_id": sessionID, + "conn_id": pc.id, + } + + logger.DebugCF("pico", "Received message", map[string]any{ + "session_id": sessionID, + "preview": truncate(content, 50), + }) + + // Register typing with Manager + if rec := c.GetPlaceholderRecorder(); rec != nil { + stop, err := c.StartTyping(c.ctx, chatID) + if err == nil { + rec.RecordTypingStop("pico", chatID, stop) + } + } + + sender := bus.SenderInfo{ + Platform: "pico", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("pico", senderID), + } + + if !c.IsAllowedSender(sender) { + return + } + + c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender) +} + +// truncate truncates a string to maxLen runes. +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen]) + "..." +} diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go new file mode 100644 index 000000000..0a630e193 --- /dev/null +++ b/pkg/channels/pico/protocol.go @@ -0,0 +1,46 @@ +package pico + +import "time" + +// Protocol message types. +const ( + // TypeMessageSend is sent from client to server. + TypeMessageSend = "message.send" + TypeMediaSend = "media.send" + TypePing = "ping" + + // TypeMessageCreate is sent from server to client. + TypeMessageCreate = "message.create" + TypeMessageUpdate = "message.update" + TypeMediaCreate = "media.create" + TypeTypingStart = "typing.start" + TypeTypingStop = "typing.stop" + TypeError = "error" + TypePong = "pong" +) + +// PicoMessage is the wire format for all Pico Protocol messages. +type PicoMessage struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +// newMessage creates a PicoMessage with the given type and payload. +func newMessage(msgType string, payload map[string]any) PicoMessage { + return PicoMessage{ + Type: msgType, + Timestamp: time.Now().UnixMilli(), + Payload: payload, + } +} + +// newError creates an error PicoMessage. +func newError(code, message string) PicoMessage { + return newMessage(TypeError, map[string]any{ + "code": code, + "message": message, + }) +} diff --git a/pkg/channels/qq/init.go b/pkg/channels/qq/init.go new file mode 100644 index 000000000..15b955089 --- /dev/null +++ b/pkg/channels/qq/init.go @@ -0,0 +1,13 @@ +package qq + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("qq", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewQQChannel(cfg.Channels.QQ, b) + }) +} diff --git a/pkg/channels/qq.go b/pkg/channels/qq/qq.go similarity index 70% rename from pkg/channels/qq.go rename to pkg/channels/qq/qq.go index e66cac533..1e2cc2354 100644 --- a/pkg/channels/qq.go +++ b/pkg/channels/qq/qq.go @@ -1,4 +1,4 @@ -package channels +package qq import ( "context" @@ -14,12 +14,14 @@ import ( "golang.org/x/oauth2" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) type QQChannel struct { - *BaseChannel + *channels.BaseChannel config config.QQConfig api openapi.OpenAPI tokenSource oauth2.TokenSource @@ -31,7 +33,9 @@ type QQChannel struct { } func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { - base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &QQChannel{ BaseChannel: base, @@ -47,31 +51,31 @@ func (c *QQChannel) Start(ctx context.Context) error { logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") - // 创建 token source + // create token source credentials := &token.QQBotCredentials{ AppID: c.config.AppID, AppSecret: c.config.AppSecret, } c.tokenSource = token.NewQQBotTokenSource(credentials) - // 创建子 context + // create child context c.ctx, c.cancel = context.WithCancel(ctx) - // 启动自动刷新 token 协程 + // start auto-refresh token goroutine if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil { return fmt.Errorf("failed to start token refresh: %w", err) } - // 初始化 OpenAPI 客户端 + // initialize OpenAPI client c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) - // 注册事件处理器 + // register event handlers intent := event.RegisterHandlers( c.handleC2CMessage(), c.handleGroupATMessage(), ) - // 获取 WebSocket 接入点 + // get WebSocket endpoint wsInfo, err := c.api.WS(c.ctx, nil, "") if err != nil { return fmt.Errorf("failed to get websocket info: %w", err) @@ -81,20 +85,20 @@ func (c *QQChannel) Start(ctx context.Context) error { "shards": wsInfo.Shards, }) - // 创建并保存 sessionManager + // create and save sessionManager c.sessionManager = botgo.NewSessionManager() - // 在 goroutine 中启动 WebSocket 连接,避免阻塞 + // start WebSocket connection in goroutine to avoid blocking go func() { if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil { logger.ErrorCF("qq", "WebSocket session error", map[string]any{ "error": err.Error(), }) - c.setRunning(false) + c.SetRunning(false) } }() - c.setRunning(true) + c.SetRunning(true) logger.InfoC("qq", "QQ bot started successfully") return nil @@ -102,7 +106,7 @@ func (c *QQChannel) Start(ctx context.Context) error { func (c *QQChannel) Stop(ctx context.Context) error { logger.InfoC("qq", "Stopping QQ bot") - c.setRunning(false) + c.SetRunning(false) if c.cancel != nil { c.cancel() @@ -113,35 +117,35 @@ func (c *QQChannel) Stop(ctx context.Context) error { func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("QQ bot not running") + return channels.ErrNotRunning } - // 构造消息 + // construct message msgToCreate := &dto.MessageToCreate{ Content: msg.Content, } - // C2C 消息发送 + // send C2C message _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) if err != nil { logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ "error": err.Error(), }) - return err + return fmt.Errorf("qq send: %w", channels.ErrTemporary) } return nil } -// handleC2CMessage 处理 QQ 私聊消息 +// handleC2CMessage handles QQ private messages func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { - // 去重检查 + // deduplication check if c.isDuplicate(data.ID) { return nil } - // 提取用户信息 + // extract user info var senderID string if data.Author != nil && data.Author.ID != "" { senderID = data.Author.ID @@ -150,7 +154,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return nil } - // 提取消息内容 + // extract message content content := data.Content if content == "" { logger.DebugC("qq", "Received empty message, ignoring") @@ -163,27 +167,42 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { }) // 转发到消息总线 - metadata := map[string]string{ - "message_id": data.ID, - "peer_kind": "direct", - "peer_id": senderID, + metadata := map[string]string{} + + sender := bus.SenderInfo{ + Platform: "qq", + PlatformID: data.Author.ID, + CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), } - c.HandleMessage(senderID, senderID, content, []string{}, metadata) + if !c.IsAllowedSender(sender) { + return nil + } + + c.HandleMessage(c.ctx, + bus.Peer{Kind: "direct", ID: senderID}, + data.ID, + senderID, + senderID, + content, + []string{}, + metadata, + sender, + ) return nil } } -// handleGroupATMessage 处理群@消息 +// handleGroupATMessage handles QQ group @ messages func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { - // 去重检查 + // deduplication check if c.isDuplicate(data.ID) { return nil } - // 提取用户信息 + // extract user info var senderID string if data.Author != nil && data.Author.ID != "" { senderID = data.Author.ID @@ -192,13 +211,20 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return nil } - // 提取消息内容(去掉 @ 机器人部分) + // extract message content (remove @ bot part) content := data.Content if content == "" { logger.DebugC("qq", "Received empty group message, ignoring") return nil } + // GroupAT event means bot is always mentioned; apply group trigger filtering + respond, cleaned := c.ShouldRespondInGroup(true, content) + if !respond { + return nil + } + content = cleaned + logger.InfoCF("qq", "Received group AT message", map[string]any{ "sender": senderID, "group": data.GroupID, @@ -207,13 +233,29 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { // 转发到消息总线(使用 GroupID 作为 ChatID) metadata := map[string]string{ - "message_id": data.ID, - "group_id": data.GroupID, - "peer_kind": "group", - "peer_id": data.GroupID, + "group_id": data.GroupID, } - c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata) + sender := bus.SenderInfo{ + Platform: "qq", + PlatformID: data.Author.ID, + CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), + } + + if !c.IsAllowedSender(sender) { + return nil + } + + c.HandleMessage(c.ctx, + bus.Peer{Kind: "group", ID: data.GroupID}, + data.ID, + senderID, + data.GroupID, + content, + []string{}, + metadata, + sender, + ) return nil } diff --git a/pkg/channels/registry.go b/pkg/channels/registry.go new file mode 100644 index 000000000..36a05bf3e --- /dev/null +++ b/pkg/channels/registry.go @@ -0,0 +1,32 @@ +package channels + +import ( + "sync" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// ChannelFactory is a constructor function that creates a Channel from config and message bus. +// Each channel subpackage registers one or more factories via init(). +type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) + +var ( + factoriesMu sync.RWMutex + factories = map[string]ChannelFactory{} +) + +// RegisterFactory registers a named channel factory. Called from subpackage init() functions. +func RegisterFactory(name string, f ChannelFactory) { + factoriesMu.Lock() + defer factoriesMu.Unlock() + factories[name] = f +} + +// getFactory looks up a channel factory by name. +func getFactory(name string) (ChannelFactory, bool) { + factoriesMu.RLock() + defer factoriesMu.RUnlock() + f, ok := factories[name] + return f, ok +} diff --git a/pkg/channels/slack/init.go b/pkg/channels/slack/init.go new file mode 100644 index 000000000..c131bb291 --- /dev/null +++ b/pkg/channels/slack/init.go @@ -0,0 +1,13 @@ +package slack + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("slack", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewSlackChannel(cfg.Channels.Slack, b) + }) +} diff --git a/pkg/channels/slack.go b/pkg/channels/slack/slack.go similarity index 65% rename from pkg/channels/slack.go rename to pkg/channels/slack/slack.go index f7359cd6d..7128980e4 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack/slack.go @@ -1,32 +1,31 @@ -package channels +package slack import ( "context" "fmt" - "os" "strings" "sync" - "time" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) type SlackChannel struct { - *BaseChannel + *channels.BaseChannel config config.SlackConfig api *slack.Client socketClient *socketmode.Client botUserID string teamID string - transcriber *voice.GroqTranscriber ctx context.Context cancel context.CancelFunc pendingAcks sync.Map @@ -49,7 +48,10 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack socketClient := socketmode.New(api) - base := NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(40000), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &SlackChannel{ BaseChannel: base, @@ -59,10 +61,6 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack }, nil } -func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *SlackChannel) Start(ctx context.Context) error { logger.InfoC("slack", "Starting Slack channel (Socket Mode)") @@ -92,7 +90,7 @@ func (c *SlackChannel) Start(ctx context.Context) error { } }() - c.setRunning(true) + c.SetRunning(true) logger.InfoC("slack", "Slack channel started (Socket Mode)") return nil } @@ -104,14 +102,14 @@ func (c *SlackChannel) Stop(ctx context.Context) error { c.cancel() } - c.setRunning(false) + c.SetRunning(false) logger.InfoC("slack", "Slack channel stopped") return nil } func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("slack channel not running") + return channels.ErrNotRunning } channelID, threadTS := parseSlackChatID(msg.ChatID) @@ -129,7 +127,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) if err != nil { - return fmt.Errorf("failed to send slack message: %w", err) + return fmt.Errorf("slack send: %w", channels.ErrTemporary) } if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { @@ -148,6 +146,60 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } +// SendMedia implements the channels.MediaSender interface. +func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + channelID, _ := parseSlackChatID(msg.ChatID) + if channelID == "" { + return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("slack", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + filename := part.Filename + if filename == "" { + filename = "file" + } + + title := part.Caption + if title == "" { + title = filename + } + + _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ + Channel: channelID, + File: localPath, + Filename: filename, + Title: title, + }) + if err != nil { + logger.ErrorCF("slack", "Failed to upload media", map[string]any{ + "filename": filename, + "error": err.Error(), + }) + return fmt.Errorf("slack send media: %w", channels.ErrTemporary) + } + } + + return nil +} + func (c *SlackChannel) eventLoop() { for { select { @@ -200,8 +252,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { return } - // 检查白名单,避免为被拒绝的用户下载附件 - if !c.IsAllowed(ev.User) { + // check allowlist to avoid downloading attachments for rejected users + sender := bus.SenderInfo{ + Platform: "slack", + PlatformID: ev.User, + CanonicalID: identity.BuildCanonicalID("slack", ev.User), + } + if !c.IsAllowedSender(sender) { logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{ "user_id": ev.User, }) @@ -223,6 +280,18 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { Timestamp: messageTS, }) + // Register typing stop (remove "eyes" reaction) with Manager + if rec := c.GetPlaceholderRecorder(); rec != nil { + capturedChannelID := channelID + capturedMessageTS := messageTS + rec.RecordTypingStop("slack", chatID, func() { + c.api.RemoveReaction("eyes", slack.ItemRef{ + Channel: capturedChannelID, + Timestamp: capturedMessageTS, + }) + }) + } + c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, @@ -231,20 +300,32 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { content := ev.Text content = c.stripBotMention(content) - var mediaPaths []string - localFiles := []string{} // 跟踪需要清理的本地文件 + // In non-DM channels, apply group trigger filtering + if !strings.HasPrefix(channelID, "D") { + respond, cleaned := c.ShouldRespondInGroup(false, content) + if !respond { + return + } + content = cleaned + } - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) + var mediaPaths []string + + scope := channels.BuildMediaScope("slack", chatID, messageTS) + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "slack", + }, scope) + if err == nil { + return ref } } - }() + return localPath // fallback + } if ev.Message != nil && len(ev.Message.Files) > 0 { for _, file := range ev.Message.Files { @@ -252,23 +333,8 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { if localPath == "" { continue } - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) - - if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) - defer cancel() - result, err := c.transcriber.Transcribe(ctx, localPath) - - if err != nil { - logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()}) - content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name) - } else { - content += fmt.Sprintf("\n[voice transcription: %s]", result.Text) - } - } else { - content += fmt.Sprintf("\n[file: %s]", file.Name) - } + mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name)) + content += fmt.Sprintf("\n[file: %s]", file.Name) } } @@ -283,13 +349,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { peerID = senderID } + peer := bus.Peer{Kind: peerKind, ID: peerID} + metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, "thread_ts": threadTS, "platform": "slack", - "peer_kind": peerKind, - "peer_id": peerID, "team_id": c.teamID, } @@ -300,7 +366,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { "has_thread": threadTS != "", }) - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender) } func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { @@ -308,7 +374,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { return } - if !c.IsAllowed(ev.User) { + if !c.IsAllowedSender(bus.SenderInfo{ + Platform: "slack", + PlatformID: ev.User, + CanonicalID: identity.BuildCanonicalID("slack", ev.User), + }) { logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{ "user_id": ev.User, }) @@ -316,6 +386,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { } senderID := ev.User + mentionSender := bus.SenderInfo{ + Platform: "slack", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("slack", senderID), + } channelID := ev.Channel threadTS := ev.ThreadTimeStamp messageTS := ev.TimeStamp @@ -332,6 +407,18 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { Timestamp: messageTS, }) + // Register typing stop (remove "eyes" reaction) with Manager + if rec := c.GetPlaceholderRecorder(); rec != nil { + capturedChannelID := channelID + capturedMessageTS := messageTS + rec.RecordTypingStop("slack", chatID, func() { + c.api.RemoveReaction("eyes", slack.ItemRef{ + Channel: capturedChannelID, + Timestamp: capturedMessageTS, + }) + }) + } + c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, @@ -350,18 +437,18 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { mentionPeerID = senderID } + mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID} + metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, "thread_ts": threadTS, "platform": "slack", "is_mention": "true", - "peer_kind": mentionPeerKind, - "peer_id": mentionPeerID, "team_id": c.teamID, } - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender) } func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { @@ -374,7 +461,12 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { c.socketClient.Ack(*event.Request) } - if !c.IsAllowed(cmd.UserID) { + cmdSender := bus.SenderInfo{ + Platform: "slack", + PlatformID: cmd.UserID, + CanonicalID: identity.BuildCanonicalID("slack", cmd.UserID), + } + if !c.IsAllowedSender(cmdSender) { logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{ "user_id": cmd.UserID, }) @@ -395,8 +487,6 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "platform": "slack", "is_command": "true", "trigger_id": cmd.TriggerID, - "peer_kind": "channel", - "peer_id": channelID, "team_id": c.teamID, } @@ -406,7 +496,17 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "text": utils.Truncate(content, 50), }) - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage( + c.ctx, + bus.Peer{Kind: "channel", ID: channelID}, + "", + senderID, + chatID, + content, + nil, + metadata, + cmdSender, + ) } func (c *SlackChannel) downloadSlackFile(file slack.File) string { diff --git a/pkg/channels/slack_test.go b/pkg/channels/slack/slack_test.go similarity index 99% rename from pkg/channels/slack_test.go rename to pkg/channels/slack/slack_test.go index 3707c2703..30e0d2d73 100644 --- a/pkg/channels/slack_test.go +++ b/pkg/channels/slack/slack_test.go @@ -1,4 +1,4 @@ -package channels +package slack import ( "testing" diff --git a/pkg/channels/split.go b/pkg/channels/split.go new file mode 100644 index 000000000..27d76df1b --- /dev/null +++ b/pkg/channels/split.go @@ -0,0 +1,209 @@ +package channels + +import ( + "strings" +) + +// SplitMessage splits long messages into chunks, preserving code block integrity. +// The maxLen parameter is measured in runes (Unicode characters), not bytes. +// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks, +// but may extend to maxLen when needed. +// Call SplitMessage with the full text content and the maximum allowed length of a single message; +// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. +func SplitMessage(content string, maxLen int) []string { + if maxLen <= 0 { + if content == "" { + return nil + } + return []string{content} + } + + runes := []rune(content) + var messages []string + + // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible + codeBlockBuffer := maxLen / 10 + if codeBlockBuffer < 50 { + codeBlockBuffer = 50 + } + if codeBlockBuffer > maxLen/2 { + codeBlockBuffer = maxLen / 2 + } + + for len(runes) > 0 { + if len(runes) <= maxLen { + messages = append(messages, string(runes)) + break + } + + // Effective split point: maxLen minus buffer, to leave room for code blocks + effectiveLimit := maxLen - codeBlockBuffer + if effectiveLimit < maxLen/2 { + effectiveLimit = maxLen / 2 + } + + // Find natural split point within the effective limit + msgEnd := findLastNewlineRunes(runes[:effectiveLimit], 200) + if msgEnd <= 0 { + msgEnd = findLastSpaceRunes(runes[:effectiveLimit], 100) + } + if msgEnd <= 0 { + msgEnd = effectiveLimit + } + + // Check if this would end with an incomplete code block + candidate := runes[:msgEnd] + unclosedIdx := findLastUnclosedCodeBlockRunes(candidate) + + if unclosedIdx >= 0 { + // Message would end with incomplete code block + // Try to extend up to maxLen to include the closing ``` + if len(runes) > msgEnd { + closingIdx := findNextClosingCodeBlockRunes(runes, msgEnd) + if closingIdx > 0 && closingIdx <= maxLen { + // Extend to include the closing ``` + msgEnd = closingIdx + } else { + // Code block is too long to fit in one chunk or missing closing fence. + // Try to split inside by injecting closing and reopening fences. + fenceRunes := runes[unclosedIdx:] + headerEnd := findNewlineInRunes(fenceRunes) + var header string + if headerEnd == -1 { + header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3])) + } else { + header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+headerEnd])) + } + headerEndIdx := unclosedIdx + len([]rune(header)) + if headerEnd != -1 { + headerEndIdx = unclosedIdx + headerEnd + } + + // If we have a reasonable amount of content after the header, split inside + if msgEnd > headerEndIdx+20 { + // Find a better split point closer to maxLen + innerLimit := maxLen - 5 // Leave room for "\n```" + betterEnd := findLastNewlineRunes(runes[:innerLimit], 200) + if betterEnd > headerEndIdx { + msgEnd = betterEnd + } else { + msgEnd = innerLimit + } + chunk := strings.TrimRight(string(runes[:msgEnd]), " \t\n\r") + "\n```" + messages = append(messages, chunk) + remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:])) + runes = []rune(remaining) + continue + } + + // Otherwise, try to split before the code block starts + newEnd := findLastNewlineRunes(runes[:unclosedIdx], 200) + if newEnd <= 0 { + newEnd = findLastSpaceRunes(runes[:unclosedIdx], 100) + } + if newEnd > 0 { + msgEnd = newEnd + } else { + // If we can't split before, we MUST split inside (last resort) + if unclosedIdx > 20 { + msgEnd = unclosedIdx + } else { + msgEnd = maxLen - 5 + chunk := strings.TrimRight(string(runes[:msgEnd]), " \t\n\r") + "\n```" + messages = append(messages, chunk) + remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:])) + runes = []rune(remaining) + continue + } + } + } + } + } + + if msgEnd <= 0 { + msgEnd = effectiveLimit + } + + messages = append(messages, string(runes[:msgEnd])) + remaining := strings.TrimSpace(string(runes[msgEnd:])) + runes = []rune(remaining) + } + + return messages +} + +// findLastUnclosedCodeBlockRunes finds the last opening ``` that doesn't have a closing ``` +// Returns the rune position of the opening ``` or -1 if all code blocks are complete +func findLastUnclosedCodeBlockRunes(runes []rune) int { + inCodeBlock := false + lastOpenIdx := -1 + + for i := 0; i < len(runes); i++ { + if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { + // Toggle code block state on each fence + if !inCodeBlock { + // Entering a code block: record this opening fence + lastOpenIdx = i + } + inCodeBlock = !inCodeBlock + i += 2 + } + } + + if inCodeBlock { + return lastOpenIdx + } + return -1 +} + +// findNextClosingCodeBlockRunes finds the next closing ``` starting from a rune position +// Returns the rune position after the closing ``` or -1 if not found +func findNextClosingCodeBlockRunes(runes []rune, startIdx int) int { + for i := startIdx; i < len(runes); i++ { + if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { + return i + 3 + } + } + return -1 +} + +// findNewlineInRunes finds the first newline character in a rune slice. +// Returns the rune index of the newline or -1 if not found. +func findNewlineInRunes(runes []rune) int { + for i, r := range runes { + if r == '\n' { + return i + } + } + return -1 +} + +// findLastNewlineRunes finds the last newline character within the last N runes +// Returns the rune position of the newline or -1 if not found +func findLastNewlineRunes(runes []rune, searchWindow int) int { + searchStart := len(runes) - searchWindow + if searchStart < 0 { + searchStart = 0 + } + for i := len(runes) - 1; i >= searchStart; i-- { + if runes[i] == '\n' { + return i + } + } + return -1 +} + +// findLastSpaceRunes finds the last space character within the last N runes +// Returns the rune position of the space or -1 if not found +func findLastSpaceRunes(runes []rune, searchWindow int) int { + searchStart := len(runes) - searchWindow + if searchStart < 0 { + searchStart = 0 + } + for i := len(runes) - 1; i >= searchStart; i-- { + if runes[i] == ' ' || runes[i] == '\t' { + return i + } + } + return -1 +} diff --git a/pkg/utils/message_test.go b/pkg/channels/split_test.go similarity index 67% rename from pkg/utils/message_test.go rename to pkg/channels/split_test.go index 338509437..d6356bdb9 100644 --- a/pkg/utils/message_test.go +++ b/pkg/channels/split_test.go @@ -1,4 +1,4 @@ -package utils +package channels import ( "strings" @@ -34,11 +34,15 @@ func TestSplitMessage(t *testing.T) { maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { - if len(chunks[0]) > 2000 { - t.Errorf("Chunk 0 too large: %d", len(chunks[0])) + if len([]rune(chunks[0])) > 2000 { + t.Errorf("Chunk 0 too large: %d runes", len([]rune(chunks[0]))) } - if len(chunks[0])+len(chunks[1]) != len(longText) { - t.Errorf("Total length mismatch. Got %d, want %d", len(chunks[0])+len(chunks[1]), len(longText)) + if len([]rune(chunks[0]))+len([]rune(chunks[1])) != len([]rune(longText)) { + t.Errorf( + "Total rune length mismatch. Got %d, want %d", + len([]rune(chunks[0]))+len([]rune(chunks[1])), + len([]rune(longText)), + ) } }, }, @@ -53,11 +57,11 @@ func TestSplitMessage(t *testing.T) { maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { - if len(chunks[0]) != 1750 { - t.Errorf("Expected chunk 0 to be 1750 length (split at newline), got %d", len(chunks[0])) + if len([]rune(chunks[0])) != 1750 { + t.Errorf("Expected chunk 0 to be 1750 runes (split at newline), got %d", len([]rune(chunks[0]))) } if chunks[1] != strings.Repeat("b", 300) { - t.Errorf("Chunk 1 content mismatch. Len: %d", len(chunks[1])) + t.Errorf("Chunk 1 content mismatch. Len: %d", len([]rune(chunks[1]))) } }, }, @@ -78,17 +82,39 @@ func TestSplitMessage(t *testing.T) { }, }, { - name: "Preserve Unicode characters", - content: strings.Repeat("\u4e16", 1000), // 3000 bytes + name: "Preserve Unicode characters (rune-aware)", + content: strings.Repeat("\u4e16", 2500), // 2500 runes, 7500 bytes maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { - // Just verify we didn't panic and got valid strings. - // Go strings are UTF-8, if we split mid-rune it would be bad, - // but standard slicing might do that. - // Let's assume standard behavior is acceptable or check if it produces invalid rune? - if !strings.Contains(chunks[0], "\u4e16") { - t.Error("Chunk should contain unicode characters") + // Verify chunks contain valid unicode and don't split mid-rune + for i, chunk := range chunks { + runeCount := len([]rune(chunk)) + if runeCount > 2000 { + t.Errorf("Chunk %d has %d runes, exceeds maxLen 2000", i, runeCount) + } + if !strings.Contains(chunk, "\u4e16") { + t.Errorf("Chunk %d should contain unicode characters", i) + } + } + // Verify total rune count is preserved + totalRunes := 0 + for _, chunk := range chunks { + totalRunes += len([]rune(chunk)) + } + if totalRunes != 2500 { + t.Errorf("Total rune count mismatch. Got %d, want 2500", totalRunes) + } + }, + }, + { + name: "Zero maxLen returns single chunk", + content: "Hello world", + maxLen: 0, + expectChunks: 1, + checkContent: func(t *testing.T, chunks []string) { + if chunks[0] != "Hello world" { + t.Errorf("Expected original content, got %q", chunks[0]) } }, }, @@ -145,7 +171,7 @@ func TestSplitMessage_CodeBlockIntegrity(t *testing.T) { } // First chunk should contain meaningful content - if len(chunks[0]) > 40 { - t.Errorf("First chunk exceeded maxLen: length %d", len(chunks[0])) + if len([]rune(chunks[0])) > 40 { + t.Errorf("First chunk exceeded maxLen: length %d runes", len([]rune(chunks[0]))) } } diff --git a/pkg/channels/telegram/init.go b/pkg/channels/telegram/init.go new file mode 100644 index 000000000..ac87bb805 --- /dev/null +++ b/pkg/channels/telegram/init.go @@ -0,0 +1,13 @@ +package telegram + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewTelegramChannel(cfg, b) + }) +} diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram/telegram.go similarity index 56% rename from pkg/channels/telegram.go rename to pkg/channels/telegram/telegram.go index 2a971e147..005b311a2 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -1,4 +1,4 @@ -package channels +package telegram import ( "context" @@ -7,8 +7,8 @@ import ( "net/url" "os" "regexp" + "strconv" "strings" - "sync" "time" "github.com/mymmrac/telego" @@ -17,31 +17,23 @@ import ( tu "github.com/mymmrac/telego/telegoutil" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) type TelegramChannel struct { - *BaseChannel - bot *telego.Bot - commands TelegramCommander - config *config.Config - chatIDs map[string]int64 - transcriber *voice.GroqTranscriber - placeholders sync.Map // chatID -> messageID - stopThinking sync.Map // chatID -> thinkingCancel -} - -type thinkingCancel struct { - fn context.CancelFunc -} - -func (c *thinkingCancel) Cancel() { - if c != nil && c.fn != nil { - c.fn() - } + *channels.BaseChannel + bot *telego.Bot + bh *telegohandler.BotHandler + commands TelegramCommander + config *config.Config + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc } func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { @@ -72,38 +64,43 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann return nil, fmt.Errorf("failed to create telegram bot: %w", err) } - base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom) + base := channels.NewBaseChannel( + "telegram", + telegramCfg, + bus, + telegramCfg.AllowFrom, + channels.WithMaxMessageLength(4096), + channels.WithGroupTrigger(telegramCfg.GroupTrigger), + ) return &TelegramChannel{ - BaseChannel: base, - commands: NewTelegramCommands(bot, cfg), - bot: bot, - config: cfg, - chatIDs: make(map[string]int64), - transcriber: nil, - placeholders: sync.Map{}, - stopThinking: sync.Map{}, + BaseChannel: base, + commands: NewTelegramCommands(bot, cfg), + bot: bot, + config: cfg, + chatIDs: make(map[string]int64), }, nil } -func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") - updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{ + c.ctx, c.cancel = context.WithCancel(ctx) + + updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{ Timeout: 30, }) if err != nil { + c.cancel() return fmt.Errorf("failed to start long polling: %w", err) } bh, err := telegohandler.NewBotHandler(c.bot, updates) if err != nil { + c.cancel() return fmt.Errorf("failed to create bot handler: %w", err) } + c.bh = bh bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { c.commands.Help(ctx, message) @@ -125,59 +122,46 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) - c.setRunning(true) + c.SetRunning(true) logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ "username": c.bot.Username(), }) go bh.Start() - go func() { - <-ctx.Done() - bh.Stop() - }() - return nil } func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") - c.setRunning(false) + c.SetRunning(false) + + // Stop the bot handler + if c.bh != nil { + c.bh.Stop() + } + + // Cancel our context (stops long polling) + if c.cancel != nil { + c.cancel() + } + return nil } func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("telegram bot not running") + return channels.ErrNotRunning } chatID, err := parseChatID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat ID: %w", err) - } - - // Stop thinking animation - if stop, ok := c.stopThinking.Load(msg.ChatID); ok { - if cf, ok := stop.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - c.stopThinking.Delete(msg.ChatID) + return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } htmlContent := markdownToTelegramHTML(msg.Content) - // Try to edit placeholder - if pID, ok := c.placeholders.Load(msg.ChatID); ok { - c.placeholders.Delete(msg.ChatID) - editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) - editMsg.ParseMode = telego.ModeHTML - - if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { - return nil - } - // Fallback to new message if edit fails - } - + // Typing/placeholder handled by Manager.preSend — just send the message tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML @@ -186,9 +170,112 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err "error": err.Error(), }) tgMsg.ParseMode = "" - _, err = c.bot.SendMessage(ctx, tgMsg) + if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { + return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + } + } + + return nil +} + +// EditMessage implements channels.MessageEditor. +func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { + cid, err := parseChatID(chatID) + if err != nil { return err } + mid, err := strconv.Atoi(messageID) + if err != nil { + return err + } + htmlContent := markdownToTelegramHTML(content) + editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent) + editMsg.ParseMode = telego.ModeHTML + _, err = c.bot.EditMessageText(ctx, editMsg) + return err +} + +// SendMedia implements the channels.MediaSender interface. +func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + chatID, err := parseChatID(msg.ChatID) + if err != nil { + return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + file, err := os.Open(localPath) + if err != nil { + logger.ErrorCF("telegram", "Failed to open media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + + filename := part.Filename + if filename == "" { + filename = "file" + } + + switch part.Type { + case "image": + params := &telego.SendPhotoParams{ + ChatID: tu.ID(chatID), + Photo: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendPhoto(ctx, params) + case "audio": + params := &telego.SendAudioParams{ + ChatID: tu.ID(chatID), + Audio: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendAudio(ctx, params) + case "video": + params := &telego.SendVideoParams{ + ChatID: tu.ID(chatID), + Video: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendVideo(ctx, params) + default: // "file" or unknown types + params := &telego.SendDocumentParams{ + ChatID: tu.ID(chatID), + Document: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendDocument(ctx, params) + } + + file.Close() + + if err != nil { + logger.ErrorCF("telegram", "Failed to send media", map[string]any{ + "type": part.Type, + "error": err.Error(), + }) + return fmt.Errorf("telegram send media: %w", channels.ErrTemporary) + } + } return nil } @@ -203,37 +290,46 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes return fmt.Errorf("message sender (user) is nil") } - senderID := fmt.Sprintf("%d", user.ID) - if user.Username != "" { - senderID = fmt.Sprintf("%d|%s", user.ID, user.Username) + platformID := fmt.Sprintf("%d", user.ID) + sender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: platformID, + CanonicalID: identity.BuildCanonicalID("telegram", platformID), + Username: user.Username, + DisplayName: user.FirstName, } - // 检查白名单,避免为被拒绝的用户下载附件 - if !c.IsAllowed(senderID) { + // check allowlist to avoid downloading attachments for rejected users + if !c.IsAllowedSender(sender) { logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{ - "user_id": senderID, + "user_id": platformID, }) return nil } chatID := message.Chat.ID - c.chatIDs[senderID] = chatID + c.chatIDs[platformID] = chatID content := "" mediaPaths := []string{} - localFiles := []string{} // 跟踪需要清理的本地文件 - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) + chatIDStr := fmt.Sprintf("%d", chatID) + messageIDStr := fmt.Sprintf("%d", message.MessageID) + scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr) + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "telegram", + }, scope) + if err == nil { + return ref } } - }() + return localPath // fallback: use raw path + } if message.Text != "" { content += message.Text @@ -250,8 +346,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes photo := message.Photo[len(message.Photo)-1] photoPath := c.downloadPhoto(ctx, photo.FileID) if photoPath != "" { - localFiles = append(localFiles, photoPath) - mediaPaths = append(mediaPaths, photoPath) + mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg")) if content != "" { content += "\n" } @@ -262,43 +357,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Voice != nil { voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") if voicePath != "" { - localFiles = append(localFiles, voicePath) - mediaPaths = append(mediaPaths, voicePath) - - transcribedText := "" - if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - result, err := c.transcriber.Transcribe(ctx, voicePath) - if err != nil { - logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{ - "error": err.Error(), - "path": voicePath, - }) - transcribedText = "[voice (transcription failed)]" - } else { - transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) - logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{ - "text": result.Text, - }) - } - } else { - transcribedText = "[voice]" - } + mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg")) if content != "" { content += "\n" } - content += transcribedText + content += "[voice]" } } if message.Audio != nil { audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") if audioPath != "" { - localFiles = append(localFiles, audioPath) - mediaPaths = append(mediaPaths, audioPath) + mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3")) if content != "" { content += "\n" } @@ -309,8 +380,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Document != nil { docPath := c.downloadFile(ctx, message.Document.FileID, "") if docPath != "" { - localFiles = append(localFiles, docPath) - mediaPaths = append(mediaPaths, docPath) + mediaPaths = append(mediaPaths, storeMedia(docPath, "document")) if content != "" { content += "\n" } @@ -322,8 +392,21 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = "[empty message]" } + // In group chats, apply unified group trigger filtering + if message.Chat.Type != "private" { + isMentioned := c.isBotMentioned(message) + if isMentioned { + content = c.stripBotMention(content) + } + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) + if !respond { + return nil + } + content = cleaned + } + logger.DebugCF("telegram", "Received message", map[string]any{ - "sender_id": senderID, + "sender_id": sender.CanonicalID, "chat_id": fmt.Sprintf("%d", chatID), "preview": utils.Truncate(content, 50), }) @@ -336,22 +419,21 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes }) } - // Stop any previous thinking animation - chatIDStr := fmt.Sprintf("%d", chatID) - if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { - if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - } - - // Create cancel function for thinking state + // Create cancel function for thinking state and register with Manager _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) - c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) + if rec := c.GetPlaceholderRecorder(); rec != nil { + rec.RecordTypingStop("telegram", chatIDStr, thinkCancel) + } else { + // No recorder — cancel immediately to avoid context leak + thinkCancel() + } pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) if err == nil { pID := pMsg.MessageID - c.placeholders.Store(chatIDStr, pID) + if rec := c.GetPlaceholderRecorder(); rec != nil { + rec.RecordPlaceholder("telegram", chatIDStr, fmt.Sprintf("%d", pID)) + } } peerKind := "direct" @@ -361,17 +443,26 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes peerID = fmt.Sprintf("%d", chatID) } + peer := bus.Peer{Kind: peerKind, ID: peerID} + messageID := fmt.Sprintf("%d", message.MessageID) + metadata := map[string]string{ - "message_id": fmt.Sprintf("%d", message.MessageID), "user_id": fmt.Sprintf("%d", user.ID), "username": user.Username, "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), - "peer_kind": peerKind, - "peer_id": peerID, } - c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) + c.HandleMessage(c.ctx, + peer, + messageID, + platformID, + fmt.Sprintf("%d", chatID), + content, + mediaPaths, + metadata, + sender, + ) return nil } @@ -527,3 +618,52 @@ func escapeHTML(text string) string { text = strings.ReplaceAll(text, ">", ">") return text } + +// isBotMentioned checks if the bot is mentioned in the message via entities. +func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool { + botUsername := c.bot.Username() + if botUsername == "" { + return false + } + + entities := message.Entities + if entities == nil { + entities = message.CaptionEntities + } + + for _, entity := range entities { + if entity.Type == "mention" { + // Extract the mention text from the message + text := message.Text + if text == "" { + text = message.Caption + } + runes := []rune(text) + end := entity.Offset + entity.Length + if end <= len(runes) { + mention := string(runes[entity.Offset:end]) + if strings.EqualFold(mention, "@"+botUsername) { + return true + } + } + } + if entity.Type == "text_mention" && entity.User != nil { + if entity.User.Username == botUsername { + return true + } + } + } + return false +} + +// stripBotMention removes the @bot mention from the content. +func (c *TelegramChannel) stripBotMention(content string) string { + botUsername := c.bot.Username() + if botUsername == "" { + return content + } + // Case-insensitive replacement + re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername)) + content = re.ReplaceAllString(content, "") + return strings.TrimSpace(content) +} diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go similarity index 99% rename from pkg/channels/telegram_commands.go rename to pkg/channels/telegram/telegram_commands.go index a084b641b..f17912260 100644 --- a/pkg/channels/telegram_commands.go +++ b/pkg/channels/telegram/telegram_commands.go @@ -1,4 +1,4 @@ -package channels +package telegram import ( "context" diff --git a/pkg/channels/webhook.go b/pkg/channels/webhook.go new file mode 100644 index 000000000..3cf27baf6 --- /dev/null +++ b/pkg/channels/webhook.go @@ -0,0 +1,20 @@ +package channels + +import "net/http" + +// WebhookHandler is an optional interface for channels that receive messages +// via HTTP webhooks. Manager discovers channels implementing this interface +// and registers them on the shared HTTP server. +type WebhookHandler interface { + // WebhookPath returns the path to mount this handler on the shared server. + // Examples: "/webhook/line", "/webhook/wecom" + WebhookPath() string + http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request) +} + +// HealthChecker is an optional interface for channels that expose +// a health check endpoint on the shared HTTP server. +type HealthChecker interface { + HealthPath() string + HealthHandler(w http.ResponseWriter, r *http.Request) +} diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom/app.go similarity index 68% rename from pkg/channels/wecom_app.go rename to pkg/channels/wecom/app.go index 878504106..f1e764864 100644 --- a/pkg/channels/wecom_app.go +++ b/pkg/channels/wecom/app.go @@ -1,8 +1,4 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// WeCom App (企业微信自建应用) channel implementation -// Supports receiving messages via webhook callback and sending messages proactively - -package channels +package wecom import ( "bytes" @@ -11,14 +7,19 @@ import ( "encoding/xml" "fmt" "io" + "mime/multipart" "net/http" "net/url" + "os" + "path/filepath" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -29,9 +30,8 @@ const ( // WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用) type WeComAppChannel struct { - *BaseChannel + *channels.BaseChannel config config.WeComAppConfig - server *http.Server accessToken string tokenExpiry time.Time tokenMu sync.RWMutex @@ -123,7 +123,10 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) ( return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required") } - base := NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(2048), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &WeComAppChannel{ BaseChannel: base, @@ -137,7 +140,7 @@ func (c *WeComAppChannel) Name() string { return "wecom_app" } -// Start initializes the WeCom App channel with HTTP webhook server +// Start initializes the WeCom App channel func (c *WeComAppChannel) Start(ctx context.Context) error { logger.InfoC("wecom_app", "Starting WeCom App channel...") @@ -153,37 +156,8 @@ func (c *WeComAppChannel) Start(ctx context.Context) error { // Start token refresh goroutine go c.tokenRefreshLoop() - // Setup HTTP server for webhook - mux := http.NewServeMux() - webhookPath := c.config.WebhookPath - if webhookPath == "" { - webhookPath = "/webhook/wecom-app" - } - mux.HandleFunc(webhookPath, c.handleWebhook) - - // Health check endpoint - mux.HandleFunc("/health/wecom-app", c.handleHealth) - - addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) - c.server = &http.Server{ - Addr: addr, - Handler: mux, - } - - c.setRunning(true) - logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{ - "address": addr, - "path": webhookPath, - }) - - // Start server in goroutine - go func() { - if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{ - "error": err.Error(), - }) - } - }() + c.SetRunning(true) + logger.InfoC("wecom_app", "WeCom App channel started") return nil } @@ -196,13 +170,7 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error { c.cancel() } - if c.server != nil { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - c.server.Shutdown(shutdownCtx) - } - - c.setRunning(false) + c.SetRunning(false) logger.InfoC("wecom_app", "WeCom App channel stopped") return nil } @@ -210,7 +178,7 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error { // Send sends a message to WeCom user proactively using access token func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("wecom_app channel not running") + return channels.ErrNotRunning } accessToken := c.getAccessToken() @@ -226,6 +194,220 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) } +// SendMedia implements the channels.MediaSender interface. +func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + accessToken := c.getAccessToken() + if accessToken == "" { + return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary) + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + // Map part type to WeCom media type + mediaType := "file" + switch part.Type { + case "image": + mediaType = "image" + case "audio": + mediaType = "voice" + case "video": + mediaType = "video" + default: + mediaType = "file" + } + + // Upload media to get media_id + mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{ + "type": mediaType, + "error": err.Error(), + }) + // Fallback: send caption as text + if part.Caption != "" { + _ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption) + } + continue + } + + // Send media message using the media_id + if mediaType == "image" { + err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID) + } else { + // For non-image types, send as text fallback with caption + caption := part.Caption + if caption == "" { + caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) + } + err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption) + } + + if err != nil { + return err + } + } + + return nil +} + +// uploadMedia uploads a local file to WeCom temporary media storage. +func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) { + apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s", + wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType)) + + file, err := os.Open(localPath) + if err != nil { + return "", fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + filename := filepath.Base(localPath) + formFile, err := writer.CreateFormFile("media", filename) + if err != nil { + return "", fmt.Errorf("failed to create form file: %w", err) + } + + if _, err = io.Copy(formFile, file); err != nil { + return "", fmt.Errorf("failed to copy file content: %w", err) + } + writer.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", channels.ClassifyNetError(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody))) + } + + var result struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + MediaID string `json:"media_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to parse upload response: %w", err) + } + + if result.ErrCode != 0 { + return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode) + } + + return result.MediaID, nil +} + +// sendImageMessage sends an image message using a media_id. +func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error { + apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) + + msg := WeComImageMessage{ + ToUser: userID, + MsgType: "image", + AgentID: c.config.AgentID, + } + msg.Image.MediaID = mediaID + + jsonData, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + timeout := c.config.ReplyTimeout + if timeout <= 0 { + timeout = 5 + } + + reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + resp, err := client.Do(req) + if err != nil { + return channels.ClassifyNetError(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody))) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var sendResp WeComSendMessageResponse + if err := json.Unmarshal(respBody, &sendResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if sendResp.ErrCode != 0 { + return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) + } + + return nil +} + +// WebhookPath returns the path for registering on the shared HTTP server. +func (c *WeComAppChannel) WebhookPath() string { + if c.config.WebhookPath != "" { + return c.config.WebhookPath + } + return "/webhook/wecom-app" +} + +// ServeHTTP implements http.Handler for the shared HTTP server. +func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.handleWebhook(w, r) +} + +// HealthPath returns the health check endpoint path. +func (c *WeComAppChannel) HealthPath() string { + return "/health/wecom-app" +} + +// HealthHandler handles health check requests. +func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { + c.handleHealth(w, r) +} + // handleWebhook handles incoming webhook requests from WeCom func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -279,7 +461,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons } // Verify signature - if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{ "token": c.config.Token, "msg_signature": msgSignature, @@ -298,7 +480,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons "encoding_aes_key": c.config.EncodingAESKey, "corp_id": c.config.CorpID, }) - decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) + decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) if err != nil { logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), @@ -348,7 +530,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp AgentID string `xml:"AgentID"` } - if err := xml.Unmarshal(body, &encryptedMsg); err != nil { + if err = xml.Unmarshal(body, &encryptedMsg); err != nil { logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{ "error": err.Error(), }) @@ -357,7 +539,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp } // Verify signature - if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { logger.WarnC("wecom_app", "Message signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return @@ -365,7 +547,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp // Decrypt message with CorpID verification // For WeCom App (自建应用), receiveid should be corp_id - decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) + decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) if err != nil { logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{ "error": err.Error(), @@ -428,6 +610,9 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag // Build metadata // WeCom App only supports direct messages (private chat) + peer := bus.Peer{Kind: "direct", ID: senderID} + messageID := fmt.Sprintf("%d", msg.MsgId) + metadata := map[string]string{ "msg_type": msg.MsgType, "msg_id": fmt.Sprintf("%d", msg.MsgId), @@ -435,8 +620,6 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag "platform": "wecom_app", "media_id": msg.MediaId, "create_time": fmt.Sprintf("%d", msg.CreateTime), - "peer_kind": "direct", - "peer_id": senderID, } content := msg.Content @@ -447,8 +630,15 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag "preview": utils.Truncate(content, 50), }) + // Build sender info + appSender := bus.SenderInfo{ + Platform: "wecom", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("wecom", senderID), + } + // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender) } // tokenRefreshLoop periodically refreshes the access token @@ -550,10 +740,15 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user client := &http.Client{Timeout: time.Duration(timeout) * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to send message: %w", err) + return channels.ClassifyNetError(err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body))) + } + body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) @@ -605,10 +800,15 @@ func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, client := &http.Client{Timeout: time.Duration(timeout) * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to send message: %w", err) + return channels.ClassifyNetError(err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body))) + } + body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom/app_test.go similarity index 96% rename from pkg/channels/wecom_app_test.go rename to pkg/channels/wecom/app_test.go index 6778520f3..5420949de 100644 --- a/pkg/channels/wecom_app_test.go +++ b/pkg/channels/wecom/app_test.go @@ -1,7 +1,4 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// WeCom App (企业微信自建应用) channel tests - -package channels +package wecom import ( "bytes" @@ -197,7 +194,7 @@ func TestWeComAppVerifySignature(t *testing.T) { msgEncrypt := "test_message" expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt) - if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { + if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { t.Error("valid signature should pass verification") } }) @@ -207,7 +204,7 @@ func TestWeComAppVerifySignature(t *testing.T) { nonce := "test_nonce" msgEncrypt := "test_message" - if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { + if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { t.Error("invalid signature should fail verification") } }) @@ -221,7 +218,7 @@ func TestWeComAppVerifySignature(t *testing.T) { } chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) - if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { + if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { t.Error("empty token should skip verification and return true") } }) @@ -243,7 +240,7 @@ func TestWeComAppDecryptMessage(t *testing.T) { plainText := "hello world" encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) - result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey) + result, err := decryptMessage(encoded, ch.config.EncodingAESKey) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -268,7 +265,7 @@ func TestWeComAppDecryptMessage(t *testing.T) { t.Fatalf("failed to encrypt test message: %v", err) } - result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey) + result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -286,7 +283,7 @@ func TestWeComAppDecryptMessage(t *testing.T) { } ch, _ := NewWeComAppChannel(cfg, msgBus) - _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) + _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) if err == nil { t.Error("expected error for invalid base64, got nil") } @@ -301,7 +298,7 @@ func TestWeComAppDecryptMessage(t *testing.T) { } ch, _ := NewWeComAppChannel(cfg, msgBus) - _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) + _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) if err == nil { t.Error("expected error for invalid AES key, got nil") } @@ -319,7 +316,7 @@ func TestWeComAppDecryptMessage(t *testing.T) { // Encrypt a very short message that results in ciphertext less than block size shortData := make([]byte, 8) - _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey) + _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey) if err == nil { t.Error("expected error for short ciphertext, got nil") } @@ -361,7 +358,7 @@ func TestWeComAppPKCS7Unpad(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := pkcs7UnpadWeCom(tt.input) + result, err := pkcs7Unpad(tt.input) if tt.expected == nil { // This case should return an error if err == nil { @@ -863,6 +860,15 @@ func TestWeComAppMessageStructures(t *testing.T) { if msg.Image.MediaID != "media_123456" { t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456") } + if msg.ToUser != "user123" { + t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123") + } + if msg.MsgType != "image" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "image") + } + if msg.AgentID != 1000002 { + t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) + } }) t.Run("WeComAccessTokenResponse structure", func(t *testing.T) { diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom/bot.go similarity index 66% rename from pkg/channels/wecom.go rename to pkg/channels/wecom/bot.go index 07bd8488c..460997dab 100644 --- a/pkg/channels/wecom.go +++ b/pkg/channels/wecom/bot.go @@ -1,29 +1,21 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// WeCom Bot (企业微信智能机器人) channel implementation -// Uses webhook callback mode for receiving messages and webhook API for sending replies - -package channels +package wecom import ( "bytes" "context" - "crypto/aes" - "crypto/cipher" - "crypto/sha1" - "encoding/base64" - "encoding/binary" "encoding/json" "encoding/xml" "fmt" "io" "net/http" - "sort" "strings" "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -31,9 +23,8 @@ import ( // WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人) // Uses webhook callback mode - simpler than WeCom App but only supports passive replies type WeComBotChannel struct { - *BaseChannel + *channels.BaseChannel config config.WeComConfig - server *http.Server ctx context.Context cancel context.CancelFunc processedMsgs map[string]bool // Message deduplication: msg_id -> processed @@ -96,7 +87,10 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We return nil, fmt.Errorf("wecom token and webhook_url are required") } - base := NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(2048), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &WeComBotChannel{ BaseChannel: base, @@ -110,43 +104,14 @@ func (c *WeComBotChannel) Name() string { return "wecom" } -// Start initializes the WeCom Bot channel with HTTP webhook server +// Start initializes the WeCom Bot channel func (c *WeComBotChannel) Start(ctx context.Context) error { logger.InfoC("wecom", "Starting WeCom Bot channel...") c.ctx, c.cancel = context.WithCancel(ctx) - // Setup HTTP server for webhook - mux := http.NewServeMux() - webhookPath := c.config.WebhookPath - if webhookPath == "" { - webhookPath = "/webhook/wecom" - } - mux.HandleFunc(webhookPath, c.handleWebhook) - - // Health check endpoint - mux.HandleFunc("/health/wecom", c.handleHealth) - - addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) - c.server = &http.Server{ - Addr: addr, - Handler: mux, - } - - c.setRunning(true) - logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{ - "address": addr, - "path": webhookPath, - }) - - // Start server in goroutine - go func() { - if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom", "HTTP server error", map[string]any{ - "error": err.Error(), - }) - } - }() + c.SetRunning(true) + logger.InfoC("wecom", "WeCom Bot channel started") return nil } @@ -159,13 +124,7 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error { c.cancel() } - if c.server != nil { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - c.server.Shutdown(shutdownCtx) - } - - c.setRunning(false) + c.SetRunning(false) logger.InfoC("wecom", "WeCom Bot channel stopped") return nil } @@ -175,7 +134,7 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error { // For delayed responses, we use the webhook URL func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("wecom channel not running") + return channels.ErrNotRunning } logger.DebugCF("wecom", "Sending message via webhook", map[string]any{ @@ -186,6 +145,29 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return c.sendWebhookReply(ctx, msg.ChatID, msg.Content) } +// WebhookPath returns the path for registering on the shared HTTP server. +func (c *WeComBotChannel) WebhookPath() string { + if c.config.WebhookPath != "" { + return c.config.WebhookPath + } + return "/webhook/wecom" +} + +// ServeHTTP implements http.Handler for the shared HTTP server. +func (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.handleWebhook(w, r) +} + +// HealthPath returns the health check endpoint path. +func (c *WeComBotChannel) HealthPath() string { + return "/health/wecom" +} + +// HealthHandler handles health check requests. +func (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { + c.handleHealth(w, r) +} + // handleWebhook handles incoming webhook requests from WeCom func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -219,7 +201,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons } // Verify signature - if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { logger.WarnC("wecom", "Signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return @@ -228,7 +210,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons // Decrypt echostr // For AIBOT (智能机器人), receiveid should be empty string "" // Reference: https://developer.work.weixin.qq.com/document/path/101033 - decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") + decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") if err != nil { logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), @@ -272,7 +254,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp AgentID string `xml:"AgentID"` } - if err := xml.Unmarshal(body, &encryptedMsg); err != nil { + if err = xml.Unmarshal(body, &encryptedMsg); err != nil { logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{ "error": err.Error(), }) @@ -281,7 +263,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp } // Verify signature - if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { logger.WarnC("wecom", "Message signature verification failed") http.Error(w, "Invalid signature", http.StatusForbidden) return @@ -290,7 +272,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp // Decrypt message // For AIBOT (智能机器人), receiveid should be empty string "" // Reference: https://developer.work.weixin.qq.com/document/path/101033 - decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") + decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") if err != nil { logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{ "error": err.Error(), @@ -387,12 +369,21 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag } // Build metadata + peer := bus.Peer{Kind: peerKind, ID: peerID} + + // In group chats, apply unified group trigger filtering + if isGroupChat { + respond, cleaned := c.ShouldRespondInGroup(false, content) + if !respond { + return + } + content = cleaned + } + metadata := map[string]string{ "msg_type": msg.MsgType, "msg_id": msg.MsgID, "platform": "wecom", - "peer_kind": peerKind, - "peer_id": peerID, "response_url": msg.ResponseURL, } if isGroupChat { @@ -408,8 +399,19 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag "preview": utils.Truncate(content, 50), }) + // Build sender info + sender := bus.SenderInfo{ + Platform: "wecom", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("wecom", senderID), + } + + if !c.IsAllowedSender(sender) { + return + } + // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender) } // sendWebhookReply sends a reply using the webhook URL @@ -442,10 +444,15 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content client := &http.Client{Timeout: time.Duration(timeout) * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to send webhook reply: %w", err) + return channels.ClassifyNetError(err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body))) + } + body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) @@ -477,129 +484,3 @@ func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) } - -// WeCom common utilities for both WeCom Bot and WeCom App -// The following functions were moved from wecom_common.go - -// WeComVerifySignature verifies the message signature for WeCom -// This is a common function used by both WeCom Bot and WeCom App -func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { - if token == "" { - return true // Skip verification if token is not set - } - - // Sort parameters - params := []string{token, timestamp, nonce, msgEncrypt} - sort.Strings(params) - - // Concatenate - str := strings.Join(params, "") - - // SHA1 hash - hash := sha1.Sum([]byte(str)) - expectedSignature := fmt.Sprintf("%x", hash) - - return expectedSignature == msgSignature -} - -// WeComDecryptMessage decrypts the encrypted message using AES -// This is a common function used by both WeCom Bot and WeCom App -// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id -func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) { - return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "") -} - -// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid -// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification. -func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) { - if encodingAESKey == "" { - // No encryption, return as is (base64 decode) - decoded, err := base64.StdEncoding.DecodeString(encryptedMsg) - if err != nil { - return "", err - } - return string(decoded), nil - } - - // Decode AES key (base64) - aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") - if err != nil { - return "", fmt.Errorf("failed to decode AES key: %w", err) - } - - // Decode encrypted message - cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg) - if err != nil { - return "", fmt.Errorf("failed to decode message: %w", err) - } - - // AES decrypt - block, err := aes.NewCipher(aesKey) - if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) - } - - if len(cipherText) < aes.BlockSize { - return "", fmt.Errorf("ciphertext too short") - } - - // IV is the first 16 bytes of AESKey - iv := aesKey[:aes.BlockSize] - mode := cipher.NewCBCDecrypter(block, iv) - plainText := make([]byte, len(cipherText)) - mode.CryptBlocks(plainText, cipherText) - - // Remove PKCS7 padding - plainText, err = pkcs7UnpadWeCom(plainText) - if err != nil { - return "", fmt.Errorf("failed to unpad: %w", err) - } - - // Parse message structure - // Format: random(16) + msg_len(4) + msg + receiveid - if len(plainText) < 20 { - return "", fmt.Errorf("decrypted message too short") - } - - msgLen := binary.BigEndian.Uint32(plainText[16:20]) - if int(msgLen) > len(plainText)-20 { - return "", fmt.Errorf("invalid message length") - } - - msg := plainText[20 : 20+msgLen] - - // Verify receiveid if provided - if receiveid != "" && len(plainText) > 20+int(msgLen) { - actualReceiveID := string(plainText[20+msgLen:]) - if actualReceiveID != receiveid { - return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID) - } - } - - return string(msg), nil -} - -// pkcs7UnpadWeCom removes PKCS7 padding with validation -// WeCom uses block size of 32 (not standard AES block size of 16) -const wecomBlockSize = 32 - -func pkcs7UnpadWeCom(data []byte) ([]byte, error) { - if len(data) == 0 { - return data, nil - } - padding := int(data[len(data)-1]) - // WeCom uses 32-byte block size for PKCS7 padding - if padding == 0 || padding > wecomBlockSize { - return nil, fmt.Errorf("invalid padding size: %d", padding) - } - if padding > len(data) { - return nil, fmt.Errorf("padding size larger than data") - } - // Verify all padding bytes - for i := 0; i < padding; i++ { - if data[len(data)-1-i] != byte(padding) { - return nil, fmt.Errorf("invalid padding byte at position %d", i) - } - } - return data[:len(data)-padding], nil -} diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom/bot_test.go similarity index 95% rename from pkg/channels/wecom_test.go rename to pkg/channels/wecom/bot_test.go index 53cde2693..328b145c2 100644 --- a/pkg/channels/wecom_test.go +++ b/pkg/channels/wecom/bot_test.go @@ -1,7 +1,4 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// WeCom Bot (企业微信智能机器人) channel tests - -package channels +package wecom import ( "bytes" @@ -177,7 +174,7 @@ func TestWeComBotVerifySignature(t *testing.T) { msgEncrypt := "test_message" expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt) - if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { + if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { t.Error("valid signature should pass verification") } }) @@ -187,7 +184,7 @@ func TestWeComBotVerifySignature(t *testing.T) { nonce := "test_nonce" msgEncrypt := "test_message" - if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { + if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { t.Error("invalid signature should fail verification") } }) @@ -198,13 +195,11 @@ func TestWeComBotVerifySignature(t *testing.T) { Token: "", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } - base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom) chEmpty := &WeComBotChannel{ - BaseChannel: base, - config: cfgEmpty, + config: cfgEmpty, } - if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { + if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { t.Error("empty token should skip verification and return true") } }) @@ -225,7 +220,7 @@ func TestWeComBotDecryptMessage(t *testing.T) { plainText := "hello world" encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) - result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey) + result, err := decryptMessage(encoded, ch.config.EncodingAESKey) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -249,7 +244,7 @@ func TestWeComBotDecryptMessage(t *testing.T) { t.Fatalf("failed to encrypt test message: %v", err) } - result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey) + result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -266,7 +261,7 @@ func TestWeComBotDecryptMessage(t *testing.T) { } ch, _ := NewWeComBotChannel(cfg, msgBus) - _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) + _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) if err == nil { t.Error("expected error for invalid base64, got nil") } @@ -280,7 +275,7 @@ func TestWeComBotDecryptMessage(t *testing.T) { } ch, _ := NewWeComBotChannel(cfg, msgBus) - _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) + _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) if err == nil { t.Error("expected error for invalid AES key, got nil") } @@ -322,20 +317,20 @@ func TestWeComBotPKCS7Unpad(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := pkcs7UnpadWeCom(tt.input) + result, err := pkcs7Unpad(tt.input) if tt.expected == nil { // This case should return an error if err == nil { - t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result) + t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result) } return } if err != nil { - t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err) + t.Errorf("pkcs7Unpad() unexpected error: %v", err) return } if !bytes.Equal(result, tt.expected) { - t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected) + t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected) } }) } diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go new file mode 100644 index 000000000..3c1629577 --- /dev/null +++ b/pkg/channels/wecom/common.go @@ -0,0 +1,134 @@ +package wecom + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "fmt" + "sort" + "strings" +) + +// blockSize is the PKCS7 block size used by WeCom (32) +const blockSize = 32 + +// verifySignature verifies the message signature for WeCom +// This is a common function used by both WeCom Bot and WeCom App +func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { + if token == "" { + return true // Skip verification if token is not set + } + + // Sort parameters + params := []string{token, timestamp, nonce, msgEncrypt} + sort.Strings(params) + + // Concatenate + str := strings.Join(params, "") + + // SHA1 hash + hash := sha1.Sum([]byte(str)) + expectedSignature := fmt.Sprintf("%x", hash) + + return expectedSignature == msgSignature +} + +// decryptMessage decrypts the encrypted message using AES +// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id +func decryptMessage(encryptedMsg, encodingAESKey string) (string, error) { + return decryptMessageWithVerify(encryptedMsg, encodingAESKey, "") +} + +// decryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid +// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification. +func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) { + if encodingAESKey == "" { + // No encryption, return as is (base64 decode) + decoded, err := base64.StdEncoding.DecodeString(encryptedMsg) + if err != nil { + return "", err + } + return string(decoded), nil + } + + // Decode AES key (base64) + aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + if err != nil { + return "", fmt.Errorf("failed to decode AES key: %w", err) + } + + // Decode encrypted message + cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg) + if err != nil { + return "", fmt.Errorf("failed to decode message: %w", err) + } + + // AES decrypt + block, err := aes.NewCipher(aesKey) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + if len(cipherText) < aes.BlockSize { + return "", fmt.Errorf("ciphertext too short") + } + + // IV is the first 16 bytes of AESKey + iv := aesKey[:aes.BlockSize] + mode := cipher.NewCBCDecrypter(block, iv) + plainText := make([]byte, len(cipherText)) + mode.CryptBlocks(plainText, cipherText) + + // Remove PKCS7 padding + plainText, err = pkcs7Unpad(plainText) + if err != nil { + return "", fmt.Errorf("failed to unpad: %w", err) + } + + // Parse message structure + // Format: random(16) + msg_len(4) + msg + receiveid + if len(plainText) < 20 { + return "", fmt.Errorf("decrypted message too short") + } + + msgLen := binary.BigEndian.Uint32(plainText[16:20]) + if int(msgLen) > len(plainText)-20 { + return "", fmt.Errorf("invalid message length") + } + + msg := plainText[20 : 20+msgLen] + + // Verify receiveid if provided + if receiveid != "" && len(plainText) > 20+int(msgLen) { + actualReceiveID := string(plainText[20+msgLen:]) + if actualReceiveID != receiveid { + return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID) + } + } + + return string(msg), nil +} + +// pkcs7Unpad removes PKCS7 padding with validation +func pkcs7Unpad(data []byte) ([]byte, error) { + if len(data) == 0 { + return data, nil + } + padding := int(data[len(data)-1]) + // WeCom uses 32-byte block size for PKCS7 padding + if padding == 0 || padding > blockSize { + return nil, fmt.Errorf("invalid padding size: %d", padding) + } + if padding > len(data) { + return nil, fmt.Errorf("padding size larger than data") + } + // Verify all padding bytes + for i := 0; i < padding; i++ { + if data[len(data)-1-i] != byte(padding) { + return nil, fmt.Errorf("invalid padding byte at position %d", i) + } + } + return data[:len(data)-padding], nil +} diff --git a/pkg/channels/wecom/init.go b/pkg/channels/wecom/init.go new file mode 100644 index 000000000..3ef1ecdf3 --- /dev/null +++ b/pkg/channels/wecom/init.go @@ -0,0 +1,16 @@ +package wecom + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewWeComBotChannel(cfg.Channels.WeCom, b) + }) + channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewWeComAppChannel(cfg.Channels.WeComApp, b) + }) +} diff --git a/pkg/channels/whatsapp/init.go b/pkg/channels/whatsapp/init.go new file mode 100644 index 000000000..d9c2669c3 --- /dev/null +++ b/pkg/channels/whatsapp/init.go @@ -0,0 +1,13 @@ +package whatsapp + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("whatsapp", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewWhatsAppChannel(cfg.Channels.WhatsApp, b) + }) +} diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go similarity index 55% rename from pkg/channels/whatsapp.go rename to pkg/channels/whatsapp/whatsapp.go index 958d850bb..106114090 100644 --- a/pkg/channels/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -1,31 +1,35 @@ -package channels +package whatsapp import ( "context" "encoding/json" "fmt" - "log" "sync" "time" "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) type WhatsAppChannel struct { - *BaseChannel + *channels.BaseChannel conn *websocket.Conn config config.WhatsAppConfig url string + ctx context.Context + cancel context.CancelFunc mu sync.Mutex connected bool } func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { - base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) + base := channels.NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) return &WhatsAppChannel{ BaseChannel: base, @@ -36,13 +40,18 @@ func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsA } func (c *WhatsAppChannel) Start(ctx context.Context) error { - log.Printf("Starting WhatsApp channel connecting to %s...", c.url) + logger.InfoCF("whatsapp", "Starting WhatsApp channel", map[string]any{ + "bridge_url": c.url, + }) + + c.ctx, c.cancel = context.WithCancel(ctx) dialer := websocket.DefaultDialer dialer.HandshakeTimeout = 10 * time.Second conn, _, err := dialer.Dial(c.url, nil) if err != nil { + c.cancel() return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err) } @@ -51,39 +60,57 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error { c.connected = true c.mu.Unlock() - c.setRunning(true) - log.Println("WhatsApp channel connected") + c.SetRunning(true) + logger.InfoC("whatsapp", "WhatsApp channel connected") - go c.listen(ctx) + go c.listen() return nil } func (c *WhatsAppChannel) Stop(ctx context.Context) error { - log.Println("Stopping WhatsApp channel...") + logger.InfoC("whatsapp", "Stopping WhatsApp channel...") + + // Cancel context first to signal listen goroutine to exit + if c.cancel != nil { + c.cancel() + } c.mu.Lock() defer c.mu.Unlock() if c.conn != nil { if err := c.conn.Close(); err != nil { - log.Printf("Error closing WhatsApp connection: %v", err) + logger.ErrorCF("whatsapp", "Error closing WhatsApp connection", map[string]any{ + "error": err.Error(), + }) } c.conn = nil } c.connected = false - c.setRunning(false) + c.SetRunning(false) return nil } func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + // Check ctx before acquiring lock + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + c.mu.Lock() defer c.mu.Unlock() if c.conn == nil { - return fmt.Errorf("whatsapp connection not established") + return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } payload := map[string]any{ @@ -97,17 +124,20 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("failed to marshal message: %w", err) } + _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { - return fmt.Errorf("failed to send message: %w", err) + _ = c.conn.SetWriteDeadline(time.Time{}) + return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } + _ = c.conn.SetWriteDeadline(time.Time{}) return nil } -func (c *WhatsAppChannel) listen(ctx context.Context) { +func (c *WhatsAppChannel) listen() { for { select { - case <-ctx.Done(): + case <-c.ctx.Done(): return default: c.mu.Lock() @@ -121,14 +151,18 @@ func (c *WhatsAppChannel) listen(ctx context.Context) { _, message, err := conn.ReadMessage() if err != nil { - log.Printf("WhatsApp read error: %v", err) + logger.ErrorCF("whatsapp", "WhatsApp read error", map[string]any{ + "error": err.Error(), + }) time.Sleep(2 * time.Second) continue } var msg map[string]any if err := json.Unmarshal(message, &msg); err != nil { - log.Printf("Failed to unmarshal WhatsApp message: %v", err) + logger.ErrorCF("whatsapp", "Failed to unmarshal WhatsApp message", map[string]any{ + "error": err.Error(), + }) continue } @@ -171,22 +205,38 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { } metadata := make(map[string]string) - if messageID, ok := msg["id"].(string); ok { - metadata["message_id"] = messageID + var messageID string + if mid, ok := msg["id"].(string); ok { + messageID = mid } if userName, ok := msg["from_name"].(string); ok { metadata["user_name"] = userName } + var peer bus.Peer if chatID == senderID { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} } else { - metadata["peer_kind"] = "group" - metadata["peer_id"] = chatID + peer = bus.Peer{Kind: "group", ID: chatID} } - log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50)) + logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{ + "sender": senderID, + "preview": utils.Truncate(content, 50), + }) - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + sender := bus.SenderInfo{ + Platform: "whatsapp", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("whatsapp", senderID), + } + if display, ok := metadata["user_name"]; ok { + sender.DisplayName = display + } + + if !c.IsAllowedSender(sender) { + return + } + + c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } diff --git a/pkg/config/config.go b/pkg/config/config.go index b9f4db65a..6132efcd4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -194,6 +194,24 @@ type ChannelsConfig struct { WeCom WeComConfig `json:"wecom"` WeComApp WeComAppConfig `json:"wecom_app"` Email EmailConfig `json:"email"` + Pico PicoConfig `json:"pico"` +} + +// GroupTriggerConfig controls when the bot responds in group chats. +type GroupTriggerConfig struct { + MentionOnly bool `json:"mention_only,omitempty"` + Prefixes []string `json:"prefixes,omitempty"` +} + +// TypingConfig controls typing indicator behavior (Phase 10). +type TypingConfig struct { + Enabled bool `json:"enabled,omitempty"` +} + +// PlaceholderConfig controls placeholder message behavior (Phase 10). +type PlaceholderConfig struct { + Enabled bool `json:"enabled,omitempty"` + Text string `json:"text,omitempty"` } type WhatsAppConfig struct { @@ -203,26 +221,33 @@ type WhatsAppConfig struct { } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` - VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` - MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type MaixCamConfig struct { @@ -233,34 +258,42 @@ type MaixCamConfig struct { } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` - ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type LINEConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` - ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` - ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` + ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type EmailConfig struct { @@ -290,38 +323,55 @@ type EmailConfig struct { } type OneBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` - WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` - ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` - GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` + WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type WeComConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` - WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` + WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } type WeComAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` - CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` - CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` - AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` + CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` + CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` + AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` +} + +type PicoConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` + AllowTokenQuery bool `json:"allow_token_query,omitempty"` + AllowOrigins []string `json:"allow_origins,omitempty"` + PingInterval int `json:"ping_interval,omitempty"` + ReadTimeout int `json:"read_timeout,omitempty"` + WriteTimeout int `json:"write_timeout,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` } type HeartbeatConfig struct { @@ -352,6 +402,7 @@ type ProvidersConfig struct { GitHubCopilot ProviderConfig `json:"github_copilot"` Antigravity ProviderConfig `json:"antigravity"` Qwen ProviderConfig `json:"qwen"` + Mistral ProviderConfig `json:"mistral"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) @@ -373,7 +424,8 @@ func (p ProvidersConfig) IsEmpty() bool { p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" && p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && - p.Qwen.APIKey == "" && p.Qwen.APIBase == "" + p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && + p.Mistral.APIKey == "" && p.Mistral.APIBase == "" } // MarshalJSON implements custom JSON marshaling for ProvidersConfig @@ -446,6 +498,13 @@ type BraveConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } +type TavilyConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` +} + type DuckDuckGoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` @@ -459,6 +518,7 @@ type PerplexityConfig struct { type WebToolsConfig struct { Brave BraveConfig `json:"brave"` + Tavily TavilyConfig `json:"tavily"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` Perplexity PerplexityConfig `json:"perplexity"` } @@ -525,6 +585,9 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + // Migrate legacy channel config fields to new unified structures + cfg.migrateChannelConfigs() + // Auto-migrate: if only legacy providers config exists, convert to model_list if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() { cfg.ModelList = ConvertProvidersToModelList(cfg) @@ -538,6 +601,18 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +func (c *Config) migrateChannelConfigs() { + // Discord: mention_only -> group_trigger.mention_only + if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { + c.Channels.Discord.GroupTrigger.MentionOnly = true + } + + // OneBot: group_trigger_prefix -> group_trigger.prefixes + if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { + c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix + } +} + func SaveConfig(path string, cfg *Config) error { data, err := json.MarshalIndent(cfg, "", " ") if err != nil { @@ -664,7 +739,8 @@ func (c *Config) HasProvidersConfig() bool { v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" || v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" || v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" || - v.Qwen.APIKey != "" || v.Qwen.APIBase != "" + v.Qwen.APIKey != "" || v.Qwen.APIBase != "" || + v.Mistral.APIKey != "" || v.Mistral.APIBase != "" } // ValidateModelList validates all ModelConfig entries in the model_list. diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 0898217d6..f88c0269c 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -246,7 +246,7 @@ func TestDefaultConfig_Temperature(t *testing.T) { func TestDefaultConfig_Gateway(t *testing.T) { cfg := DefaultConfig() - if cfg.Gateway.Host != "0.0.0.0" { + if cfg.Gateway.Host != "127.0.0.1" { t.Error("Gateway host should have default value") } if cfg.Gateway.Port == 0 { @@ -343,7 +343,7 @@ func TestConfig_Complete(t *testing.T) { if cfg.Agents.Defaults.MaxToolIterations == 0 { t.Error("MaxToolIterations should not be zero") } - if cfg.Gateway.Host != "0.0.0.0" { + if cfg.Gateway.Host != "127.0.0.1" { t.Error("Gateway host should have default value") } if cfg.Gateway.Port == 0 { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 7654326e7..604b53e24 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -33,6 +33,11 @@ func DefaultConfig() *Config { Enabled: false, Token: "", AllowFrom: FlexibleStringSlice{}, + Typing: TypingConfig{Enabled: true}, + Placeholder: PlaceholderConfig{ + Enabled: true, + Text: "Thinking... 💭", + }, }, Feishu: FeishuConfig{ Enabled: false, @@ -80,6 +85,7 @@ func DefaultConfig() *Config { WebhookPort: 18791, WebhookPath: "/webhook/line", AllowFrom: FlexibleStringSlice{}, + GroupTrigger: GroupTriggerConfig{MentionOnly: true}, }, OneBot: OneBotConfig{ Enabled: false, @@ -113,6 +119,15 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, }, + Pico: PicoConfig{ + Enabled: false, + Token: "", + PingInterval: 30, + ReadTimeout: 60, + WriteTimeout: 10, + MaxConnections: 100, + AllowFrom: FlexibleStringSlice{}, + }, }, Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{WebSearch: true}, @@ -255,6 +270,14 @@ func DefaultConfig() *Config { APIKey: "ollama", }, + // Mistral AI - https://console.mistral.ai/api-keys + { + ModelName: "mistral-small", + Model: "mistral/mistral-small-latest", + APIBase: "https://api.mistral.ai/v1", + APIKey: "", + }, + // VLLM (local) - http://localhost:8000 { ModelName: "local-model", @@ -264,7 +287,7 @@ func DefaultConfig() *Config { }, }, Gateway: GatewayConfig{ - Host: "0.0.0.0", + Host: "127.0.0.1", Port: 18790, }, Tools: ToolsConfig{ diff --git a/pkg/config/migration.go b/pkg/config/migration.go index 689e2312f..30eaa7474 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -324,6 +324,22 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { }, true }, }, + { + providerNames: []string{"mistral"}, + protocol: "mistral", + buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" { + return ModelConfig{}, false + } + return ModelConfig{ + ModelName: "mistral", + Model: "mistral/mistral-small-latest", + APIKey: p.Mistral.APIKey, + APIBase: p.Mistral.APIBase, + Proxy: p.Mistral.Proxy, + }, true + }, + }, } // Process each provider migration diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index 1e8139e68..42165cb71 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -131,14 +131,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) { GitHubCopilot: ProviderConfig{ConnectMode: "grpc"}, Antigravity: ProviderConfig{AuthMethod: "oauth"}, Qwen: ProviderConfig{APIKey: "key17"}, + Mistral: ProviderConfig{APIKey: "key18"}, }, } result := ConvertProvidersToModelList(cfg) - // All 17 providers should be converted - if len(result) != 17 { - t.Errorf("len(result) = %d, want 17", len(result)) + // All 18 providers should be converted + if len(result) != 18 { + t.Errorf("len(result) = %d, want 18", len(result)) } } diff --git a/pkg/devices/service.go b/pkg/devices/service.go index 1541d3c57..1bafe6085 100644 --- a/pkg/devices/service.go +++ b/pkg/devices/service.go @@ -4,6 +4,7 @@ import ( "context" "strings" "sync" + "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/constants" @@ -127,7 +128,9 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) { } msg := ev.FormatMessage() - msgBus.PublishOutbound(bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: platform, ChatID: userID, Content: msg, diff --git a/pkg/health/server.go b/pkg/health/server.go index 77b36034d..de1ff60fe 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -156,6 +156,13 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { }) } +// RegisterOnMux registers /health and /ready handlers onto the given mux. +// This allows the health endpoints to be served by a shared HTTP server. +func (s *Server) RegisterOnMux(mux *http.ServeMux) { + mux.HandleFunc("/health", s.healthHandler) + mux.HandleFunc("/ready", s.readyHandler) +} + func statusString(ok bool) string { if ok { return "ok" diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index 75d6248b9..475f10509 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -7,6 +7,7 @@ package heartbeat import ( + "context" "fmt" "os" "path/filepath" @@ -307,7 +308,9 @@ func (hs *HeartbeatService) sendResponse(response string) { return } - msgBus.PublishOutbound(bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: platform, ChatID: userID, Content: response, diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go new file mode 100644 index 000000000..6bc09c210 --- /dev/null +++ b/pkg/identity/identity.go @@ -0,0 +1,107 @@ +// Package identity provides unified user identity utilities for PicoClaw. +// It introduces a canonical "platform:id" format and matching logic +// that is backward-compatible with all legacy allow-list formats. +package identity + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +// BuildCanonicalID constructs a canonical "platform:id" identifier. +// Both platform and platformID are lowercased and trimmed. +func BuildCanonicalID(platform, platformID string) string { + p := strings.ToLower(strings.TrimSpace(platform)) + id := strings.TrimSpace(platformID) + if p == "" || id == "" { + return "" + } + return p + ":" + id +} + +// ParseCanonicalID splits a canonical ID ("platform:id") into its parts. +// Returns ok=false if the input does not contain a colon separator. +func ParseCanonicalID(canonical string) (platform, id string, ok bool) { + canonical = strings.TrimSpace(canonical) + idx := strings.Index(canonical, ":") + if idx <= 0 || idx == len(canonical)-1 { + return "", "", false + } + return canonical[:idx], canonical[idx+1:], true +} + +// MatchAllowed checks whether the given sender matches a single allow-list entry. +// It is backward-compatible with all legacy formats: +// +// - "123456" → matches sender.PlatformID +// - "@alice" → matches sender.Username +// - "123456|alice" → matches PlatformID or Username +// - "telegram:123456" → exact match on sender.CanonicalID +func MatchAllowed(sender bus.SenderInfo, allowed string) bool { + allowed = strings.TrimSpace(allowed) + if allowed == "" { + return false + } + + // Try canonical match first: "platform:id" format + if platform, id, ok := ParseCanonicalID(allowed); ok { + // Only treat as canonical if the platform portion looks like a known platform name + // (not a pure-numeric string, which could be a compound ID) + if !isNumeric(platform) { + candidate := BuildCanonicalID(platform, id) + if candidate != "" && sender.CanonicalID != "" { + return strings.EqualFold(sender.CanonicalID, candidate) + } + // If sender has no canonical ID, try matching platform + platformID + return strings.EqualFold(platform, sender.Platform) && + sender.PlatformID == id + } + } + + // Strip leading "@" for username matching + trimmed := strings.TrimPrefix(allowed, "@") + + // Split compound "id|username" format + allowedID := trimmed + allowedUser := "" + if idx := strings.Index(trimmed, "|"); idx > 0 { + allowedID = trimmed[:idx] + allowedUser = trimmed[idx+1:] + } + + // Match against PlatformID + if sender.PlatformID != "" && sender.PlatformID == allowedID { + return true + } + + // Match against Username + if sender.Username != "" { + if sender.Username == trimmed || sender.Username == allowedUser { + return true + } + } + + // Match compound sender format against allowed parts + if allowedUser != "" && sender.PlatformID != "" && sender.PlatformID == allowedID { + return true + } + if allowedUser != "" && sender.Username != "" && sender.Username == allowedUser { + return true + } + + return false +} + +// isNumeric returns true if s consists entirely of digits. +func isNumeric(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go new file mode 100644 index 000000000..3d24bd794 --- /dev/null +++ b/pkg/identity/identity_test.go @@ -0,0 +1,229 @@ +package identity + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +func TestBuildCanonicalID(t *testing.T) { + tests := []struct { + platform string + platformID string + want string + }{ + {"telegram", "123456", "telegram:123456"}, + {"Discord", "98765432", "discord:98765432"}, + {"SLACK", "U123ABC", "slack:U123ABC"}, + {"", "123", ""}, + {"telegram", "", ""}, + {" telegram ", " 123 ", "telegram:123"}, + } + + for _, tt := range tests { + got := BuildCanonicalID(tt.platform, tt.platformID) + if got != tt.want { + t.Errorf("BuildCanonicalID(%q, %q) = %q, want %q", + tt.platform, tt.platformID, got, tt.want) + } + } +} + +func TestParseCanonicalID(t *testing.T) { + tests := []struct { + input string + wantPlatform string + wantID string + wantOk bool + }{ + {"telegram:123456", "telegram", "123456", true}, + {"discord:98765432", "discord", "98765432", true}, + {"slack:U123ABC", "slack", "U123ABC", true}, + {"nocolon", "", "", false}, + {"", "", "", false}, + {":missing", "", "", false}, + {"missing:", "", "", false}, + } + + for _, tt := range tests { + platform, id, ok := ParseCanonicalID(tt.input) + if ok != tt.wantOk || platform != tt.wantPlatform || id != tt.wantID { + t.Errorf("ParseCanonicalID(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.input, platform, id, ok, + tt.wantPlatform, tt.wantID, tt.wantOk) + } + } +} + +func TestMatchAllowed(t *testing.T) { + telegramSender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + Username: "alice", + DisplayName: "Alice Smith", + } + + discordSender := bus.SenderInfo{ + Platform: "discord", + PlatformID: "98765432", + CanonicalID: "discord:98765432", + Username: "bob", + DisplayName: "bob#1234", + } + + noCanonicalSender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: "999", + Username: "carol", + } + + tests := []struct { + name string + sender bus.SenderInfo + allowed string + want bool + }{ + // Pure numeric ID matching + { + name: "numeric ID matches PlatformID", + sender: telegramSender, + allowed: "123456", + want: true, + }, + { + name: "numeric ID does not match", + sender: telegramSender, + allowed: "654321", + want: false, + }, + // Username matching + { + name: "@username matches Username", + sender: telegramSender, + allowed: "@alice", + want: true, + }, + { + name: "@username does not match", + sender: telegramSender, + allowed: "@bob", + want: false, + }, + // Compound format "id|username" + { + name: "compound matches by ID", + sender: telegramSender, + allowed: "123456|alice", + want: true, + }, + { + name: "compound matches by username", + sender: telegramSender, + allowed: "999|alice", + want: true, + }, + { + name: "compound does not match", + sender: telegramSender, + allowed: "654321|bob", + want: false, + }, + // Canonical format "platform:id" + { + name: "canonical matches exactly", + sender: telegramSender, + allowed: "telegram:123456", + want: true, + }, + { + name: "canonical case-insensitive platform", + sender: telegramSender, + allowed: "Telegram:123456", + want: true, + }, + { + name: "canonical wrong platform", + sender: telegramSender, + allowed: "discord:123456", + want: false, + }, + { + name: "canonical wrong ID", + sender: telegramSender, + allowed: "telegram:654321", + want: false, + }, + // Cross-platform canonical + { + name: "discord canonical match", + sender: discordSender, + allowed: "discord:98765432", + want: true, + }, + { + name: "telegram canonical does not match discord sender", + sender: discordSender, + allowed: "telegram:98765432", + want: false, + }, + // Sender without canonical ID + { + name: "canonical match falls back to platform+platformID", + sender: noCanonicalSender, + allowed: "telegram:999", + want: true, + }, + { + name: "platform mismatch on fallback", + sender: noCanonicalSender, + allowed: "discord:999", + want: false, + }, + // Empty allowed string + { + name: "empty allowed never matches", + sender: telegramSender, + allowed: "", + want: false, + }, + // Whitespace handling + { + name: "trimmed allowed matches", + sender: telegramSender, + allowed: " 123456 ", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchAllowed(tt.sender, tt.allowed) + if got != tt.want { + t.Errorf("MatchAllowed(%+v, %q) = %v, want %v", + tt.sender, tt.allowed, got, tt.want) + } + }) + } +} + +func TestIsNumeric(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"123456", true}, + {"0", true}, + {"", false}, + {"abc", false}, + {"12a34", false}, + {"telegram", false}, + } + + for _, tt := range tests { + got := isNumeric(tt.input) + if got != tt.want { + t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 54de66bf9..c14fbd464 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -119,13 +119,15 @@ func logMessage(level LogLevel, component string, message string, fields map[str if logger.file != nil { jsonData, err := json.Marshal(entry) if err == nil { - logger.file.WriteString(string(jsonData) + "\n") + logger.file.Write(append(jsonData, '\n')) } } var fieldStr string if len(fields) > 0 { fieldStr = " " + formatFields(fields) + } else { + fieldStr = "" } logLine := fmt.Sprintf("[%s] [%s]%s %s%s", diff --git a/pkg/media/store.go b/pkg/media/store.go new file mode 100644 index 000000000..2df4420e9 --- /dev/null +++ b/pkg/media/store.go @@ -0,0 +1,123 @@ +package media + +import ( + "fmt" + "os" + "sync" + + "github.com/google/uuid" +) + +// MediaMeta holds metadata about a stored media file. +type MediaMeta struct { + Filename string + ContentType string + Source string // "telegram", "discord", "tool:image-gen", etc. +} + +// MediaStore manages the lifecycle of media files associated with processing scopes. +type MediaStore interface { + // Store registers an existing local file under the given scope. + // Returns a ref identifier (e.g. "media://"). + // Store does not move or copy the file; it only records the mapping. + Store(localPath string, meta MediaMeta, scope string) (ref string, err error) + + // Resolve returns the local file path for a given ref. + Resolve(ref string) (localPath string, err error) + + // ResolveWithMeta returns the local file path and metadata for a given ref. + ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error) + + // ReleaseAll deletes all files registered under the given scope + // and removes the mapping entries. File-not-exist errors are ignored. + ReleaseAll(scope string) error +} + +// mediaEntry holds the path and metadata for a stored media file. +type mediaEntry struct { + path string + meta MediaMeta +} + +// FileMediaStore is a pure in-memory implementation of MediaStore. +// Files are expected to already exist on disk (e.g. in /tmp/picoclaw_media/). +type FileMediaStore struct { + mu sync.RWMutex + refs map[string]mediaEntry + scopeToRefs map[string]map[string]struct{} +} + +// NewFileMediaStore creates a new FileMediaStore. +func NewFileMediaStore() *FileMediaStore { + return &FileMediaStore{ + refs: make(map[string]mediaEntry), + scopeToRefs: make(map[string]map[string]struct{}), + } +} + +// Store registers a local file under the given scope. The file must exist. +func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (string, error) { + if _, err := os.Stat(localPath); err != nil { + return "", fmt.Errorf("media store: %s: %w", localPath, err) + } + + ref := "media://" + uuid.New().String() + + s.mu.Lock() + defer s.mu.Unlock() + + s.refs[ref] = mediaEntry{path: localPath, meta: meta} + if s.scopeToRefs[scope] == nil { + s.scopeToRefs[scope] = make(map[string]struct{}) + } + s.scopeToRefs[scope][ref] = struct{}{} + + return ref, nil +} + +// Resolve returns the local path for the given ref. +func (s *FileMediaStore) Resolve(ref string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.refs[ref] + if !ok { + return "", fmt.Errorf("media store: unknown ref: %s", ref) + } + return entry.path, nil +} + +// ResolveWithMeta returns the local path and metadata for the given ref. +func (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.refs[ref] + if !ok { + return "", MediaMeta{}, fmt.Errorf("media store: unknown ref: %s", ref) + } + return entry.path, entry.meta, nil +} + +// ReleaseAll removes all files under the given scope and cleans up mappings. +func (s *FileMediaStore) ReleaseAll(scope string) error { + s.mu.Lock() + defer s.mu.Unlock() + + refs, ok := s.scopeToRefs[scope] + if !ok { + return nil + } + + for ref := range refs { + if entry, exists := s.refs[ref]; exists { + if err := os.Remove(entry.path); err != nil && !os.IsNotExist(err) { + // Log but continue — best effort cleanup + } + delete(s.refs, ref) + } + } + + delete(s.scopeToRefs, scope) + return nil +} diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go new file mode 100644 index 000000000..95bd1eb7a --- /dev/null +++ b/pkg/media/store_test.go @@ -0,0 +1,223 @@ +package media + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func createTempFile(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("test content"), 0o644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + return path +} + +func TestStoreAndResolve(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + path := createTempFile(t, dir, "photo.jpg") + + ref, err := store.Store(path, MediaMeta{Filename: "photo.jpg", Source: "telegram"}, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + if !strings.HasPrefix(ref, "media://") { + t.Errorf("ref should start with media://, got %q", ref) + } + + resolved, err := store.Resolve(ref) + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if resolved != path { + t.Errorf("Resolve returned %q, want %q", resolved, path) + } +} + +func TestReleaseAll(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + paths := make([]string, 3) + refs := make([]string, 3) + for i := 0; i < 3; i++ { + paths[i] = createTempFile(t, dir, strings.Repeat("a", i+1)+".jpg") + var err error + refs[i], err = store.Store(paths[i], MediaMeta{Source: "test"}, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + } + + if err := store.ReleaseAll("scope1"); err != nil { + t.Fatalf("ReleaseAll failed: %v", err) + } + + // Files should be deleted + for _, p := range paths { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("file %q should have been deleted", p) + } + } + + // Refs should be unresolvable + for _, ref := range refs { + if _, err := store.Resolve(ref); err == nil { + t.Errorf("Resolve(%q) should fail after ReleaseAll", ref) + } + } +} + +func TestMultiScopeIsolation(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + pathA := createTempFile(t, dir, "fileA.jpg") + pathB := createTempFile(t, dir, "fileB.jpg") + + refA, _ := store.Store(pathA, MediaMeta{Source: "test"}, "scopeA") + refB, _ := store.Store(pathB, MediaMeta{Source: "test"}, "scopeB") + + // Release only scopeA + if err := store.ReleaseAll("scopeA"); err != nil { + t.Fatalf("ReleaseAll(scopeA) failed: %v", err) + } + + // scopeA file should be gone + if _, err := os.Stat(pathA); !os.IsNotExist(err) { + t.Error("file A should have been deleted") + } + if _, err := store.Resolve(refA); err == nil { + t.Error("refA should be unresolvable after release") + } + + // scopeB file should still exist + if _, err := os.Stat(pathB); err != nil { + t.Error("file B should still exist") + } + resolved, err := store.Resolve(refB) + if err != nil { + t.Fatalf("refB should still resolve: %v", err) + } + if resolved != pathB { + t.Errorf("resolved %q, want %q", resolved, pathB) + } +} + +func TestReleaseAllIdempotent(t *testing.T) { + store := NewFileMediaStore() + + // ReleaseAll on non-existent scope should not error + if err := store.ReleaseAll("nonexistent"); err != nil { + t.Fatalf("ReleaseAll on empty scope should not error: %v", err) + } + + // Create and release, then release again + dir := t.TempDir() + path := createTempFile(t, dir, "file.jpg") + _, _ = store.Store(path, MediaMeta{Source: "test"}, "scope1") + + if err := store.ReleaseAll("scope1"); err != nil { + t.Fatalf("first ReleaseAll failed: %v", err) + } + if err := store.ReleaseAll("scope1"); err != nil { + t.Fatalf("second ReleaseAll should not error: %v", err) + } +} + +func TestStoreNonexistentFile(t *testing.T) { + store := NewFileMediaStore() + + _, err := store.Store("/nonexistent/path/file.jpg", MediaMeta{Source: "test"}, "scope1") + if err == nil { + t.Error("Store should fail for nonexistent file") + } + // Error message should include the underlying os error, not just "file does not exist" + if !strings.Contains(err.Error(), "no such file or directory") { + t.Errorf("Error should contain OS error detail, got: %v", err) + } +} + +func TestResolveWithMeta(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + path := createTempFile(t, dir, "image.png") + meta := MediaMeta{ + Filename: "image.png", + ContentType: "image/png", + Source: "telegram", + } + + ref, err := store.Store(path, meta, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + resolvedPath, resolvedMeta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("ResolveWithMeta failed: %v", err) + } + if resolvedPath != path { + t.Errorf("ResolveWithMeta path = %q, want %q", resolvedPath, path) + } + if resolvedMeta.Filename != meta.Filename { + t.Errorf("ResolveWithMeta Filename = %q, want %q", resolvedMeta.Filename, meta.Filename) + } + if resolvedMeta.ContentType != meta.ContentType { + t.Errorf("ResolveWithMeta ContentType = %q, want %q", resolvedMeta.ContentType, meta.ContentType) + } + if resolvedMeta.Source != meta.Source { + t.Errorf("ResolveWithMeta Source = %q, want %q", resolvedMeta.Source, meta.Source) + } + + // Unknown ref should fail + _, _, err = store.ResolveWithMeta("media://nonexistent") + if err == nil { + t.Error("ResolveWithMeta should fail for unknown ref") + } +} + +func TestConcurrentSafety(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + const goroutines = 20 + const filesPerGoroutine = 5 + + var wg sync.WaitGroup + wg.Add(goroutines) + + for g := 0; g < goroutines; g++ { + go func(gIdx int) { + defer wg.Done() + scope := strings.Repeat("s", gIdx+1) + + for i := 0; i < filesPerGoroutine; i++ { + path := createTempFile(t, dir, strings.Repeat("f", gIdx*filesPerGoroutine+i+1)+".tmp") + ref, err := store.Store(path, MediaMeta{Source: "test"}, scope) + if err != nil { + t.Errorf("Store failed: %v", err) + return + } + + if _, err := store.Resolve(ref); err != nil { + t.Errorf("Resolve failed: %v", err) + } + } + + if err := store.ReleaseAll(scope); err != nil { + t.Errorf("ReleaseAll failed: %v", err) + } + }(g) + } + + wg.Wait() +} diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 0e09308e1..0515d21c8 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -22,6 +22,7 @@ var supportedProviders = map[string]bool{ "qwen": true, "deepseek": true, "github_copilot": true, + "mistral": true, } var supportedChannels = map[string]bool{ diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go index ab2635890..cfa82b7d7 100644 --- a/pkg/migrate/migrate.go +++ b/pkg/migrate/migrate.go @@ -67,7 +67,7 @@ func Run(opts Options) (*Result, error) { return nil, err } - if _, err := os.Stat(openclawHome); os.IsNotExist(err) { + if _, err = os.Stat(openclawHome); os.IsNotExist(err) { return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome) } diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index f86486761..66ba66646 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -58,10 +58,10 @@ func TestConvertKeysToSnake(t *testing.T) { t.Fatal("expected map[string]interface{}") } - if _, ok := m["api_key"]; !ok { + if _, ok = m["api_key"]; !ok { t.Error("expected key 'api_key' after conversion") } - if _, ok := m["api_base"]; !ok { + if _, ok = m["api_base"]; !ok { t.Error("expected key 'api_base' after conversion") } @@ -69,10 +69,10 @@ func TestConvertKeysToSnake(t *testing.T) { if !ok { t.Fatal("expected nested map") } - if _, ok := nested["max_tokens"]; !ok { + if _, ok = nested["max_tokens"]; !ok { t.Error("expected key 'max_tokens' in nested map") } - if _, ok := nested["allow_from"]; !ok { + if _, ok = nested["allow_from"]; !ok { t.Error("expected key 'allow_from' in nested map") } @@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) { if err != nil { t.Fatal(err) } - if err := os.WriteFile(configPath, data, 0o644); err != nil { + if err = os.WriteFile(configPath, data, 0o644); err != nil { t.Fatal(err) } diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go index 46ba24b12..40f3ee2a1 100644 --- a/pkg/providers/codex_cli_credentials.go +++ b/pkg/providers/codex_cli_credentials.go @@ -31,7 +31,7 @@ func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Ti } var auth CodexCliAuth - if err := json.Unmarshal(data, &auth); err != nil { + if err = json.Unmarshal(data, &auth); err != nil { return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err) } diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index b6f1b5e21..cda4753ea 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -172,6 +172,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { sel.model = "deepseek-chat" } } + case "mistral": + if cfg.Providers.Mistral.APIKey != "" { + sel.apiKey = cfg.Providers.Mistral.APIKey + sel.apiBase = cfg.Providers.Mistral.APIBase + sel.proxy = cfg.Providers.Mistral.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.mistral.ai/v1" + } + } case "github_copilot", "copilot": sel.providerType = providerTypeGitHubCopilot if cfg.Providers.GitHubCopilot.APIBase != "" { @@ -275,6 +284,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if sel.apiBase == "" { sel.apiBase = "http://localhost:11434/v1" } + case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "": + sel.apiKey = cfg.Providers.Mistral.APIKey + sel.apiBase = cfg.Providers.Mistral.APIBase + sel.proxy = cfg.Providers.Mistral.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.mistral.ai/v1" + } case cfg.Providers.VLLM.APIBase != "": sel.apiKey = cfg.Providers.VLLM.APIKey sel.apiBase = cfg.Providers.VLLM.APIBase diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 74fe8a36c..7d5566eef 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "volcengine", "vllm", "qwen": + "volcengine", "vllm", "qwen", "mistral": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -186,6 +186,8 @@ func getDefaultAPIBase(protocol string) string { return "https://dashscope.aliyuncs.com/compatible-mode/v1" case "vllm": return "http://localhost:8000/v1" + case "mistral": + return "https://api.mistral.ai/v1" default: return "" } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index b8528953a..236a048c4 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -240,7 +240,7 @@ func normalizeModel(model, apiBase string) string { prefix := strings.ToLower(model[:idx]) switch prefix { - case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu": + case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral": return model[idx+1:] default: return model diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 3a089ca47..5e1c6d397 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -4,8 +4,8 @@ type ToolCall struct { ID string `json:"id"` Type string `json:"type,omitempty"` Function *FunctionCall `json:"function,omitempty"` - Name string `json:"name,omitempty"` - Arguments map[string]any `json:"arguments,omitempty"` + Name string `json:"-"` + Arguments map[string]any `json:"-"` ThoughtSignature string `json:"-"` // Internal use only ExtraContent *ExtraContent `json:"extra_content,omitempty"` } diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go index e12f0d1d8..eab592bec 100644 --- a/pkg/routing/session_key.go +++ b/pkg/routing/session_key.go @@ -163,6 +163,15 @@ func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID stri scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID)) candidates[scopedCandidate] = true } + + // If peerID is already in canonical "platform:id" format, also add the + // bare ID part as a candidate for backward compatibility with identity_links + // that use raw IDs (e.g. "123" instead of "telegram:123"). + if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 { + bareID := rawCandidate[idx+1:] + candidates[bareID] = true + } + if len(candidates) == 0 { return "" } diff --git a/pkg/routing/session_key_test.go b/pkg/routing/session_key_test.go index 81e4ce018..ad7a1ca02 100644 --- a/pkg/routing/session_key_test.go +++ b/pkg/routing/session_key_test.go @@ -115,6 +115,51 @@ func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) { } } +func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) { + // When peerID is already in canonical "platform:id" format, + // it should match identity_links that use the bare ID. + links := map[string][]string{ + "john": {"123"}, + } + got := resolveLinkedPeerID(links, "telegram", "telegram:123") + if got != "john" { + t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john") + } +} + +func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) { + // When identity_links contain canonical IDs and peerID is canonical too + links := map[string][]string{ + "john": {"telegram:123", "discord:456"}, + } + got := resolveLinkedPeerID(links, "telegram", "telegram:123") + if got != "john" { + t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john") + } +} + +func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) { + // When peerID is bare "123" and links have "telegram:123", + // the scoped candidate "telegram:123" should match. + links := map[string][]string{ + "john": {"telegram:123"}, + } + got := resolveLinkedPeerID(links, "telegram", "123") + if got != "john" { + t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john") + } +} + +func TestResolveLinkedPeerID_NoMatch(t *testing.T) { + links := map[string][]string{ + "john": {"telegram:123"}, + } + got := resolveLinkedPeerID(links, "discord", "999") + if got != "" { + t.Errorf("resolveLinkedPeerID no match = %q, want empty", got) + } +} + func TestParseAgentSessionKey_Valid(t *testing.T) { parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123") if parsed == nil { diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index eb0d5f322..5749d8983 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -55,9 +55,9 @@ func (info SkillInfo) validate() error { type SkillsLoader struct { workspace string - workspaceSkills string // workspace skills (项目级别) - globalSkills string // 全局 skills (~/.picoclaw/skills) - builtinSkills string // 内置 skills + workspaceSkills string // workspace skills (project-level) + globalSkills string // global skills (~/.picoclaw/skills) + builtinSkills string // builtin skills } func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader { @@ -71,118 +71,56 @@ func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string func (sl *SkillsLoader) ListSkills() []SkillInfo { skills := make([]SkillInfo, 0) + seen := make(map[string]bool) - if sl.workspaceSkills != "" { - if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil { - for _, dir := range dirs { - if dir.IsDir() { - skillFile := filepath.Join(sl.workspaceSkills, dir.Name(), "SKILL.md") - if _, err := os.Stat(skillFile); err == nil { - info := SkillInfo{ - Name: dir.Name(), - Path: skillFile, - Source: "workspace", - } - metadata := sl.getSkillMetadata(skillFile) - if metadata != nil { - info.Description = metadata.Description - info.Name = metadata.Name - } - if err := info.validate(); err != nil { - slog.Warn("invalid skill from workspace", "name", info.Name, "error", err) - continue - } - skills = append(skills, info) - } - } + addSkills := func(dir, source string) { + if dir == "" { + return + } + dirs, err := os.ReadDir(dir) + if err != nil { + return + } + for _, d := range dirs { + if !d.IsDir() { + continue } + skillFile := filepath.Join(dir, d.Name(), "SKILL.md") + if _, err := os.Stat(skillFile); err != nil { + continue + } + info := SkillInfo{ + Name: d.Name(), + Path: skillFile, + Source: source, + } + metadata := sl.getSkillMetadata(skillFile) + if metadata != nil { + info.Description = metadata.Description + info.Name = metadata.Name + } + if err := info.validate(); err != nil { + slog.Warn("invalid skill from "+source, "name", info.Name, "error", err) + continue + } + if seen[info.Name] { + continue + } + seen[info.Name] = true + skills = append(skills, info) } } - // 全局 skills (~/.picoclaw/skills) - 被 workspace skills 覆盖 - if sl.globalSkills != "" { - if dirs, err := os.ReadDir(sl.globalSkills); err == nil { - for _, dir := range dirs { - if dir.IsDir() { - skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md") - if _, err := os.Stat(skillFile); err == nil { - // 检查是否已被 workspace skills 覆盖 - exists := false - for _, s := range skills { - if s.Name == dir.Name() && s.Source == "workspace" { - exists = true - break - } - } - if exists { - continue - } - - info := SkillInfo{ - Name: dir.Name(), - Path: skillFile, - Source: "global", - } - metadata := sl.getSkillMetadata(skillFile) - if metadata != nil { - info.Description = metadata.Description - info.Name = metadata.Name - } - if err := info.validate(); err != nil { - slog.Warn("invalid skill from global", "name", info.Name, "error", err) - continue - } - skills = append(skills, info) - } - } - } - } - } - - if sl.builtinSkills != "" { - if dirs, err := os.ReadDir(sl.builtinSkills); err == nil { - for _, dir := range dirs { - if dir.IsDir() { - skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md") - if _, err := os.Stat(skillFile); err == nil { - // 检查是否已被 workspace 或 global skills 覆盖 - exists := false - for _, s := range skills { - if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") { - exists = true - break - } - } - if exists { - continue - } - - info := SkillInfo{ - Name: dir.Name(), - Path: skillFile, - Source: "builtin", - } - metadata := sl.getSkillMetadata(skillFile) - if metadata != nil { - info.Description = metadata.Description - info.Name = metadata.Name - } - if err := info.validate(); err != nil { - slog.Warn("invalid skill from builtin", "name", info.Name, "error", err) - continue - } - skills = append(skills, info) - } - } - } - } - } + // Priority: workspace > global > builtin + addSkills(sl.workspaceSkills, "workspace") + addSkills(sl.globalSkills, "global") + addSkills(sl.builtinSkills, "builtin") return skills } func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { - // 1. 优先从 workspace skills 加载(项目级别) + // 1. load from workspace skills first (project-level) if sl.workspaceSkills != "" { skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { @@ -190,7 +128,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { } } - // 2. 其次从全局 skills 加载 (~/.picoclaw/skills) + // 2. then load from global skills (~/.picoclaw/skills) if sl.globalSkills != "" { skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { @@ -198,7 +136,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { } } - // 3. 最后从内置 skills 加载 + // 3. finally load from builtin skills if sl.builtinSkills != "" { skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go index aca901d33..9428bea62 100644 --- a/pkg/skills/loader_test.go +++ b/pkg/skills/loader_test.go @@ -1,9 +1,12 @@ package skills import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSkillsInfoValidate(t *testing.T) { @@ -135,6 +138,134 @@ func TestExtractFrontmatter(t *testing.T) { } } +// createSkillDir creates a skill directory with a SKILL.md file containing the given frontmatter. +func createSkillDir(t *testing.T, base, dirName, name, description string) { + t.Helper() + dir := filepath.Join(base, dirName) + require.NoError(t, os.MkdirAll(dir, 0o755)) + content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + name + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644)) +} + +func TestListSkillsWorkspaceOverridesGlobal(t *testing.T) { + tmp := t.TempDir() + ws := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + + createSkillDir(t, filepath.Join(ws, "skills"), "my-skill", "my-skill", "workspace version") + createSkillDir(t, global, "my-skill", "my-skill", "global version") + + sl := NewSkillsLoader(ws, global, "") + skills := sl.ListSkills() + + assert.Len(t, skills, 1) + assert.Equal(t, "workspace", skills[0].Source) + assert.Equal(t, "workspace version", skills[0].Description) +} + +func TestListSkillsGlobalOverridesBuiltin(t *testing.T) { + tmp := t.TempDir() + ws := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + builtin := filepath.Join(tmp, "builtin") + + createSkillDir(t, global, "my-skill", "my-skill", "global version") + createSkillDir(t, builtin, "my-skill", "my-skill", "builtin version") + + sl := NewSkillsLoader(ws, global, builtin) + skills := sl.ListSkills() + + assert.Len(t, skills, 1) + assert.Equal(t, "global", skills[0].Source) + assert.Equal(t, "global version", skills[0].Description) +} + +func TestListSkillsMetadataNameDedup(t *testing.T) { + tmp := t.TempDir() + ws := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + + // Different directory names but same metadata name + createSkillDir(t, filepath.Join(ws, "skills"), "dir-a", "shared-name", "workspace version") + createSkillDir(t, global, "dir-b", "shared-name", "global version") + + sl := NewSkillsLoader(ws, global, "") + skills := sl.ListSkills() + + assert.Len(t, skills, 1) + assert.Equal(t, "shared-name", skills[0].Name) + assert.Equal(t, "workspace", skills[0].Source) +} + +func TestListSkillsMultipleDistinctSkills(t *testing.T) { + tmp := t.TempDir() + ws := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + builtin := filepath.Join(tmp, "builtin") + + createSkillDir(t, filepath.Join(ws, "skills"), "skill-a", "skill-a", "desc a") + createSkillDir(t, global, "skill-b", "skill-b", "desc b") + createSkillDir(t, builtin, "skill-c", "skill-c", "desc c") + + sl := NewSkillsLoader(ws, global, builtin) + skills := sl.ListSkills() + + assert.Len(t, skills, 3) + names := map[string]string{} + for _, s := range skills { + names[s.Name] = s.Source + } + assert.Equal(t, "workspace", names["skill-a"]) + assert.Equal(t, "global", names["skill-b"]) + assert.Equal(t, "builtin", names["skill-c"]) +} + +func TestListSkillsInvalidSkillSkipped(t *testing.T) { + tmp := t.TempDir() + ws := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + + // Invalid name (underscore) + createSkillDir(t, filepath.Join(ws, "skills"), "bad_skill", "bad_skill", "desc") + // Valid skill + createSkillDir(t, global, "good-skill", "good-skill", "desc") + + sl := NewSkillsLoader(ws, global, "") + skills := sl.ListSkills() + + assert.Len(t, skills, 1) + assert.Equal(t, "good-skill", skills[0].Name) +} + +func TestListSkillsEmptyAndNonexistentDirs(t *testing.T) { + tmp := t.TempDir() + ws := filepath.Join(tmp, "workspace") + emptyDir := filepath.Join(tmp, "empty") + require.NoError(t, os.MkdirAll(emptyDir, 0o755)) + + sl := NewSkillsLoader(ws, emptyDir, filepath.Join(tmp, "nonexistent")) + skills := sl.ListSkills() + + assert.Empty(t, skills) +} + +func TestListSkillsDirWithoutSkillMD(t *testing.T) { + tmp := t.TempDir() + ws := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + + // Directory exists but has no SKILL.md + require.NoError(t, os.MkdirAll(filepath.Join(global, "no-skillmd"), 0o755)) + // Valid skill alongside + createSkillDir(t, global, "real-skill", "real-skill", "desc") + + sl := NewSkillsLoader(ws, global, "") + skills := sl.ListSkills() + + assert.Len(t, skills, 1) + assert.Equal(t, "real-skill", skills[0].Name) +} + func TestStripFrontmatter(t *testing.T) { sl := &SkillsLoader{} diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 562fffc84..52f914622 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -294,7 +294,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM) } - t.msgBus.PublishOutbound(bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: output, @@ -304,7 +306,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // If deliver=true, send message directly without agent processing if job.Payload.Deliver { - t.msgBus.PublishOutbound(bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: job.Payload.Message, diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index 39d2642d4..d3ab267bf 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -2,24 +2,27 @@ package tools import ( "context" + "errors" "fmt" - "os" + "io/fs" "strings" ) // EditFileTool edits a file by replacing old_text with new_text. // The old_text must exist exactly in the file. type EditFileTool struct { - allowedDir string - restrict bool + fs fileSystem } // NewEditFileTool creates a new EditFileTool with optional directory restriction. -func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool { - return &EditFileTool{ - allowedDir: allowedDir, - restrict: restrict, +func NewEditFileTool(workspace string, restrict bool) *EditFileTool { + var fs fileSystem + if restrict { + fs = &sandboxFs{workspace: workspace} + } else { + fs = &hostFs{} } + return &EditFileTool{fs: fs} } func (t *EditFileTool) Name() string { @@ -67,49 +70,24 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("new_text is required") } - resolvedPath, err := validatePath(path, t.allowedDir, t.restrict) - if err != nil { + if err := editFile(t.fs, path, oldText, newText); err != nil { return ErrorResult(err.Error()) } - - if _, err := os.Stat(resolvedPath); os.IsNotExist(err) { - return ErrorResult(fmt.Sprintf("file not found: %s", path)) - } - - content, err := os.ReadFile(resolvedPath) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) - } - - contentStr := string(content) - - if !strings.Contains(contentStr, oldText) { - return ErrorResult("old_text not found in file. Make sure it matches exactly") - } - - count := strings.Count(contentStr, oldText) - if count > 1 { - return ErrorResult( - fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count), - ) - } - - newContent := strings.Replace(contentStr, oldText, newText, 1) - - if err := os.WriteFile(resolvedPath, []byte(newContent), 0o644); err != nil { - return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) - } - return SilentResult(fmt.Sprintf("File edited: %s", path)) } type AppendFileTool struct { - workspace string - restrict bool + fs fileSystem } func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool { - return &AppendFileTool{workspace: workspace, restrict: restrict} + var fs fileSystem + if restrict { + fs = &sandboxFs{workspace: workspace} + } else { + fs = &hostFs{} + } + return &AppendFileTool{fs: fs} } func (t *AppendFileTool) Name() string { @@ -148,20 +126,52 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool return ErrorResult("content is required") } - resolvedPath, err := validatePath(path, t.workspace, t.restrict) - if err != nil { + if err := appendFile(t.fs, path, content); err != nil { return ErrorResult(err.Error()) } - - f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to open file: %v", err)) - } - defer f.Close() - - if _, err := f.WriteString(content); err != nil { - return ErrorResult(fmt.Sprintf("failed to append to file: %v", err)) - } - return SilentResult(fmt.Sprintf("Appended to %s", path)) } + +// editFile reads the file via sysFs, performs the replacement, and writes back. +// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes. +func editFile(sysFs fileSystem, path, oldText, newText string) error { + content, err := sysFs.ReadFile(path) + if err != nil { + return err + } + + newContent, err := replaceEditContent(content, oldText, newText) + if err != nil { + return err + } + + return sysFs.WriteFile(path, newContent) +} + +// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back. +func appendFile(sysFs fileSystem, path, appendContent string) error { + content, err := sysFs.ReadFile(path) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + + newContent := append(content, []byte(appendContent)...) + return sysFs.WriteFile(path, newContent) +} + +// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText. +func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) { + contentStr := string(content) + + if !strings.Contains(contentStr, oldText) { + return nil, fmt.Errorf("old_text not found in file. Make sure it matches exactly") + } + + count := strings.Count(contentStr, oldText) + if count > 1 { + return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) + } + + newContent := strings.Replace(contentStr, oldText, newText, 1) + return []byte(newContent), nil +} diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go index 6780dd9f6..83a7e778c 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/edit_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/assert" ) // TestEditTool_EditFile_Success verifies successful file editing @@ -151,14 +153,18 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) { result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { - t.Errorf("Expected error when path is outside allowed directory") - } + assert.True(t, result.IsError, "Expected error when path is outside allowed directory") // Should mention outside allowed directory - if !strings.Contains(result.ForLLM, "outside") && !strings.Contains(result.ForUser, "outside") { - t.Errorf("Expected 'outside allowed' message, got ForLLM: %s", result.ForLLM) - } + // Note: ErrorResult only sets ForLLM by default, so ForUser might be empty. + // We check ForLLM as it's the primary error channel. + assert.True( + t, + strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") || + strings.Contains(result.ForLLM, "escapes"), + "Expected 'outside allowed' or 'access denied' message, got ForLLM: %s", + result.ForLLM, + ) } // TestEditTool_EditFile_MissingPath verifies error handling for missing path @@ -287,3 +293,145 @@ func TestEditTool_AppendFile_MissingContent(t *testing.T) { t.Errorf("Expected error when content is missing") } } + +// TestReplaceEditContent verifies the helper function replaceEditContent +func TestReplaceEditContent(t *testing.T) { + tests := []struct { + name string + content []byte + oldText string + newText string + expected []byte + expectError bool + }{ + { + name: "successful replacement", + content: []byte("hello world"), + oldText: "world", + newText: "universe", + expected: []byte("hello universe"), + expectError: false, + }, + { + name: "old text not found", + content: []byte("hello world"), + oldText: "golang", + newText: "rust", + expected: nil, + expectError: true, + }, + { + name: "multiple matches found", + content: []byte("test text test"), + oldText: "test", + newText: "done", + expected: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := replaceEditContent(tt.content, tt.oldText, tt.newText) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode +// can append to a file that does not yet exist — it should silently create the file. +// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW. +func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) { + workspace := t.TempDir() + tool := NewAppendFileTool(workspace, true) + ctx := context.Background() + + args := map[string]any{ + "path": "brand_new_file.txt", + "content": "first content", + } + + result := tool.Execute(ctx, args) + assert.False( + t, + result.IsError, + "Expected success when appending to non-existent file in restricted mode, got: %s", + result.ForLLM, + ) + + // Verify the file was created with correct content + data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt")) + assert.NoError(t, err) + assert.Equal(t, "first content", string(data)) +} + +// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode +// correctly appends to an existing file within the sandbox. +func TestAppendFileTool_Restricted_Success(t *testing.T) { + workspace := t.TempDir() + testFile := "existing.txt" + err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644) + assert.NoError(t, err) + + tool := NewAppendFileTool(workspace, true) + ctx := context.Background() + args := map[string]any{ + "path": testFile, + "content": " appended", + } + + result := tool.Execute(ctx, args) + assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) + assert.True(t, result.Silent) + + data, err := os.ReadFile(filepath.Join(workspace, testFile)) + assert.NoError(t, err) + assert.Equal(t, "initial appended", string(data)) +} + +// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode +// correctly edits a file using the single-open editFileInRoot path. +func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) { + workspace := t.TempDir() + testFile := "edit_target.txt" + err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644) + assert.NoError(t, err) + + tool := NewEditFileTool(workspace, true) + ctx := context.Background() + args := map[string]any{ + "path": testFile, + "old_text": "World", + "new_text": "Go", + } + + result := tool.Execute(ctx, args) + assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) + assert.True(t, result.Silent) + + data, err := os.ReadFile(filepath.Join(workspace, testFile)) + assert.NoError(t, err) + assert.Equal(t, "Hello Go", string(data)) +} + +// TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper +// error message when the target file does not exist. +func TestEditFileTool_Restricted_FileNotFound(t *testing.T) { + workspace := t.TempDir() + tool := NewEditFileTool(workspace, true) + ctx := context.Background() + args := map[string]any{ + "path": "no_such_file.txt", + "old_text": "old", + "new_text": "new", + } + + result := tool.Execute(ctx, args) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "not found") +} diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index dd996bc0d..37db8b4ae 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -3,15 +3,17 @@ package tools import ( "context" "fmt" + "io/fs" "os" "path/filepath" "strings" + "time" ) // validatePath ensures the given path is within the workspace if restrict is true. func validatePath(path, workspace string, restrict bool) (string, error) { if workspace == "" { - return path, nil + return path, fmt.Errorf("workspace is not defined") } absWorkspace, err := filepath.Abs(workspace) @@ -34,17 +36,19 @@ func validatePath(path, workspace string, restrict bool) (string, error) { return "", fmt.Errorf("access denied: path is outside the workspace") } + var resolved string workspaceReal := absWorkspace - if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil { + if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { workspaceReal = resolved } - if resolved, err := filepath.EvalSymlinks(absPath); err == nil { + if resolved, err = filepath.EvalSymlinks(absPath); err == nil { if !isWithinWorkspace(resolved, workspaceReal) { return "", fmt.Errorf("access denied: symlink resolves outside workspace") } } else if os.IsNotExist(err) { - if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil { + var parentResolved string + if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { if !isWithinWorkspace(parentResolved, workspaceReal) { return "", fmt.Errorf("access denied: symlink resolves outside workspace") } @@ -74,16 +78,21 @@ func resolveExistingAncestor(path string) (string, error) { func isWithinWorkspace(candidate, workspace string) bool { rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) - return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) + return err == nil && filepath.IsLocal(rel) } type ReadFileTool struct { - workspace string - restrict bool + fs fileSystem } func NewReadFileTool(workspace string, restrict bool) *ReadFileTool { - return &ReadFileTool{workspace: workspace, restrict: restrict} + var fs fileSystem + if restrict { + fs = &sandboxFs{workspace: workspace} + } else { + fs = &hostFs{} + } + return &ReadFileTool{fs: fs} } func (t *ReadFileTool) Name() string { @@ -113,26 +122,25 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("path is required") } - resolvedPath, err := validatePath(path, t.workspace, t.restrict) + content, err := t.fs.ReadFile(path) if err != nil { return ErrorResult(err.Error()) } - - content, err := os.ReadFile(resolvedPath) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to read file: %v", err)) - } - return NewToolResult(string(content)) } type WriteFileTool struct { - workspace string - restrict bool + fs fileSystem } func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool { - return &WriteFileTool{workspace: workspace, restrict: restrict} + var fs fileSystem + if restrict { + fs = &sandboxFs{workspace: workspace} + } else { + fs = &hostFs{} + } + return &WriteFileTool{fs: fs} } func (t *WriteFileTool) Name() string { @@ -171,30 +179,25 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR return ErrorResult("content is required") } - resolvedPath, err := validatePath(path, t.workspace, t.restrict) - if err != nil { + if err := t.fs.WriteFile(path, []byte(content)); err != nil { return ErrorResult(err.Error()) } - dir := filepath.Dir(resolvedPath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return ErrorResult(fmt.Sprintf("failed to create directory: %v", err)) - } - - if err := os.WriteFile(resolvedPath, []byte(content), 0o644); err != nil { - return ErrorResult(fmt.Sprintf("failed to write file: %v", err)) - } - return SilentResult(fmt.Sprintf("File written: %s", path)) } type ListDirTool struct { - workspace string - restrict bool + fs fileSystem } func NewListDirTool(workspace string, restrict bool) *ListDirTool { - return &ListDirTool{workspace: workspace, restrict: restrict} + var fs fileSystem + if restrict { + fs = &sandboxFs{workspace: workspace} + } else { + fs = &hostFs{} + } + return &ListDirTool{fs: fs} } func (t *ListDirTool) Name() string { @@ -224,24 +227,179 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes path = "." } - resolvedPath, err := validatePath(path, t.workspace, t.restrict) - if err != nil { - return ErrorResult(err.Error()) - } - - entries, err := os.ReadDir(resolvedPath) + entries, err := t.fs.ReadDir(path) if err != nil { return ErrorResult(fmt.Sprintf("failed to read directory: %v", err)) } + return formatDirEntries(entries) +} - result := "" +func formatDirEntries(entries []os.DirEntry) *ToolResult { + var result strings.Builder for _, entry := range entries { if entry.IsDir() { - result += "DIR: " + entry.Name() + "\n" + result.WriteString("DIR: " + entry.Name() + "\n") } else { - result += "FILE: " + entry.Name() + "\n" + result.WriteString("FILE: " + entry.Name() + "\n") + } + } + return NewToolResult(result.String()) +} + +// fileSystem abstracts reading, writing, and listing files, allowing both +// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. +type fileSystem interface { + ReadFile(path string) ([]byte, error) + WriteFile(path string, data []byte) error + ReadDir(path string) ([]os.DirEntry, error) +} + +// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. +type hostFs struct{} + +func (h *hostFs) ReadFile(path string) ([]byte, error) { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("failed to read file: file not found: %w", err) + } + if os.IsPermission(err) { + return nil, fmt.Errorf("failed to read file: access denied: %w", err) + } + return nil, fmt.Errorf("failed to read file: %w", err) + } + return content, nil +} + +func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { + return os.ReadDir(path) +} + +func (h *hostFs) WriteFile(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create parent directories: %w", err) + } + + // We use a "write-then-rename" pattern here to ensure an atomic write. + // This prevents the target file from being left in a truncated or partial state + // if the operation is interrupted, as the rename operation is atomic on Linux. + tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano()) + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file + return fmt.Errorf("failed to write temp file: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to replace original file: %w", err) + } + return nil +} + +// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. +type sandboxFs struct { + workspace string +} + +func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error { + if r.workspace == "" { + return fmt.Errorf("workspace is not defined") + } + + root, err := os.OpenRoot(r.workspace) + if err != nil { + return fmt.Errorf("failed to open workspace: %w", err) + } + defer root.Close() + + relPath, err := getSafeRelPath(r.workspace, path) + if err != nil { + return err + } + + return fn(root, relPath) +} + +func (r *sandboxFs) ReadFile(path string) ([]byte, error) { + var content []byte + err := r.execute(path, func(root *os.Root, relPath string) error { + fileContent, err := root.ReadFile(relPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("failed to read file: file not found: %w", err) + } + // os.Root returns "escapes from parent" for paths outside the root + if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || + strings.Contains(err.Error(), "permission denied") { + return fmt.Errorf("failed to read file: access denied: %w", err) + } + return fmt.Errorf("failed to read file: %w", err) + } + content = fileContent + return nil + }) + return content, err +} + +func (r *sandboxFs) WriteFile(path string, data []byte) error { + return r.execute(path, func(root *os.Root, relPath string) error { + dir := filepath.Dir(relPath) + if dir != "." && dir != "/" { + if err := root.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create parent directories: %w", err) + } + } + + // We use a "write-then-rename" pattern here to ensure an atomic write. + // This prevents the target file from being left in a truncated or partial state + // if the operation is interrupted, as the rename operation is atomic on Linux. + tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano()) + + if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil { + root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file + return fmt.Errorf("failed to write to temp file: %w", err) + } + + if err := root.Rename(tmpRelPath, relPath); err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to rename temp file over target: %w", err) + } + return nil + }) +} + +func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { + var entries []os.DirEntry + err := r.execute(path, func(root *os.Root, relPath string) error { + dirEntries, err := fs.ReadDir(root.FS(), relPath) + if err != nil { + return err + } + entries = dirEntries + return nil + }) + return entries, err +} + +// Helper to get a safe relative path for os.Root usage +func getSafeRelPath(workspace, path string) (string, error) { + if workspace == "" { + return "", fmt.Errorf("workspace is not defined") + } + + rel := filepath.Clean(path) + if filepath.IsAbs(rel) { + var err error + rel, err = filepath.Rel(workspace, rel) + if err != nil { + return "", fmt.Errorf("failed to calculate relative path: %w", err) } } - return NewToolResult(result) + if !filepath.IsLocal(rel) { + return "", fmt.Errorf("path escapes workspace: %s", path) + } + + return rel, nil } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 5daa3dcea..6f896e22d 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -2,10 +2,13 @@ package tools import ( "context" + "io" "os" "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/assert" ) // TestFilesystemTool_ReadFile_Success verifies successful file reading @@ -14,7 +17,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("test content"), 0o644) - tool := &ReadFileTool{} + tool := NewReadFileTool("", false) ctx := context.Background() args := map[string]any{ "path": testFile, @@ -41,7 +44,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { // TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { - tool := &ReadFileTool{} + tool := NewReadFileTool("", false) ctx := context.Background() args := map[string]any{ "path": "/nonexistent_file_12345.txt", @@ -84,7 +87,7 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "newfile.txt") - tool := &WriteFileTool{} + tool := NewWriteFileTool("", false) ctx := context.Background() args := map[string]any{ "path": testFile, @@ -123,7 +126,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "subdir", "newfile.txt") - tool := &WriteFileTool{} + tool := NewWriteFileTool("", false) ctx := context.Background() args := map[string]any{ "path": testFile, @@ -149,7 +152,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { // TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { - tool := &WriteFileTool{} + tool := NewWriteFileTool("", false) ctx := context.Background() args := map[string]any{ "content": "test", @@ -165,7 +168,7 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { // TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { - tool := &WriteFileTool{} + tool := NewWriteFileTool("", false) ctx := context.Background() args := map[string]any{ "path": "/tmp/test.txt", @@ -192,7 +195,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644) os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755) - tool := &ListDirTool{} + tool := NewListDirTool("", false) ctx := context.Background() args := map[string]any{ "path": tmpDir, @@ -216,7 +219,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { // TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory func TestFilesystemTool_ListDir_NotFound(t *testing.T) { - tool := &ListDirTool{} + tool := NewListDirTool("", false) ctx := context.Background() args := map[string]any{ "path": "/nonexistent_directory_12345", @@ -237,7 +240,7 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { // TestFilesystemTool_ListDir_DefaultPath verifies default to current directory func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { - tool := &ListDirTool{} + tool := NewListDirTool("", false) ctx := context.Background() args := map[string]any{} @@ -275,7 +278,211 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { if !result.IsError { t.Fatalf("expected symlink escape to be blocked") } - if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") { + // os.Root might return different errors depending on platform/implementation + // but it definitely should error. + // Our wrapper returns "access denied or file not found" + if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && + !strings.Contains(result.ForLLM, "no such file") { t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) } } + +func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { + tool := NewReadFileTool("", true) // restrict=true but workspace="" + + // Try to read a sensitive file (simulated by a temp file outside workspace) + tmpDir := t.TempDir() + secretFile := filepath.Join(tmpDir, "shadow") + os.WriteFile(secretFile, []byte("secret data"), 0o600) + + result := tool.Execute(context.Background(), map[string]any{ + "path": secretFile, + }) + + // We EXPECT IsError=true (access blocked due to empty workspace) + assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM) + + // Verify it failed for the right reason + assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") +} + +// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: +// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path. +func TestRootMkdirAll(t *testing.T) { + workspace := t.TempDir() + root, err := os.OpenRoot(workspace) + if err != nil { + t.Fatalf("failed to open root: %v", err) + } + defer root.Close() + + // Case 1: Single directory + err = root.MkdirAll("dir1", 0o755) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(workspace, "dir1")) + assert.NoError(t, err) + + // Case 2: Deeply nested directory + err = root.MkdirAll("a/b/c/d", 0o755) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(workspace, "a/b/c/d")) + assert.NoError(t, err) + + // Case 3: Already exists — must be idempotent + err = root.MkdirAll("a/b/c/d", 0o755) + assert.NoError(t, err) + + // Case 4: A regular file blocks directory creation — must error + err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644) + assert.NoError(t, err) + err = root.MkdirAll("file_exists", 0o755) + assert.Error(t, err, "expected error when a file exists at the directory path") +} + +func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) { + workspace := t.TempDir() + tool := NewWriteFileTool(workspace, true) + ctx := context.Background() + + testFile := "deep/nested/path/to/file.txt" + content := "deep content" + args := map[string]any{ + "path": testFile, + "content": content, + } + + result := tool.Execute(ctx, args) + assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) + + // Verify file content + actualPath := filepath.Join(workspace, testFile) + data, err := os.ReadFile(actualPath) + assert.NoError(t, err) + assert.Equal(t, content, string(data)) +} + +// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors. +func TestHostRW_Read_PermissionDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("skipping permission test: running as root") + } + tmpDir := t.TempDir() + protected := filepath.Join(tmpDir, "protected.txt") + err := os.WriteFile(protected, []byte("secret"), 0o000) + assert.NoError(t, err) + defer os.Chmod(protected, 0o644) // ensure cleanup + + _, err = (&hostFs{}).ReadFile(protected) + assert.Error(t, err) + assert.Contains(t, err.Error(), "access denied") +} + +// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path. +func TestHostRW_Read_Directory(t *testing.T) { + tmpDir := t.TempDir() + + _, err := (&hostFs{}).ReadFile(tmpDir) + assert.Error(t, err, "expected error when reading a directory as a file") +} + +// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory. +func TestRootRW_Read_Directory(t *testing.T) { + workspace := t.TempDir() + root, err := os.OpenRoot(workspace) + assert.NoError(t, err) + defer root.Close() + + // Create a subdirectory + err = root.Mkdir("subdir", 0o755) + assert.NoError(t, err) + + _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir") + assert.Error(t, err, "expected error when reading a directory as a file") +} + +// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically. +func TestHostRW_Write_ParentDirMissing(t *testing.T) { + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "a", "b", "c", "file.txt") + + err := (&hostFs{}).WriteFile(target, []byte("hello")) + assert.NoError(t, err) + + data, err := os.ReadFile(target) + assert.NoError(t, err) + assert.Equal(t, "hello", string(data)) +} + +// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates +// nested parent directories automatically within the sandbox. +func TestRootRW_Write_ParentDirMissing(t *testing.T) { + workspace := t.TempDir() + + relPath := "x/y/z/file.txt" + err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested")) + assert.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(workspace, relPath)) + assert.NoError(t, err) + assert.Equal(t, "nested", string(data)) +} + +// TestHostRW_Write verifies the hostRW.Write helper function +func TestHostRW_Write(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "atomic_test.txt") + testData := []byte("atomic test content") + + err := (&hostFs{}).WriteFile(testFile, testData) + assert.NoError(t, err) + + content, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, testData, content) + + // Verify it overwrites correctly + newData := []byte("new atomic content") + err = (&hostFs{}).WriteFile(testFile, newData) + assert.NoError(t, err) + + content, err = os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, newData, content) +} + +// TestRootRW_Write verifies the rootRW.Write helper function +func TestRootRW_Write(t *testing.T) { + tmpDir := t.TempDir() + + relPath := "atomic_root_test.txt" + testData := []byte("atomic root test content") + + erw := &sandboxFs{workspace: tmpDir} + err := erw.WriteFile(relPath, testData) + assert.NoError(t, err) + + root, err := os.OpenRoot(tmpDir) + assert.NoError(t, err) + defer root.Close() + + f, err := root.Open(relPath) + assert.NoError(t, err) + defer f.Close() + + content, err := io.ReadAll(f) + assert.NoError(t, err) + assert.Equal(t, testData, content) + + // Verify it overwrites correctly + newData := []byte("new root atomic content") + err = erw.WriteFile(relPath, newData) + assert.NoError(t, err) + + f2, err := root.Open(relPath) + assert.NoError(t, err) + defer f2.Close() + + content, err = io.ReadAll(f2) + assert.NoError(t, err) + assert.Equal(t, newData, content) +} diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go index 2a0626340..4eaaf8f09 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/i2c_linux.go @@ -182,7 +182,7 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult { if reg < 0 || reg > 255 { return ErrorResult("register must be between 0x00 and 0xFF") } - _, err := syscall.Write(fd, []byte{byte(reg)}) + _, err = syscall.Write(fd, []byte{byte(reg)}) if err != nil { return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err)) } diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go new file mode 100644 index 000000000..8ae13b20c --- /dev/null +++ b/pkg/tools/registry_test.go @@ -0,0 +1,350 @@ +package tools + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// --- mock types --- + +type mockRegistryTool struct { + name string + desc string + params map[string]any + result *ToolResult +} + +func (m *mockRegistryTool) Name() string { return m.name } +func (m *mockRegistryTool) Description() string { return m.desc } +func (m *mockRegistryTool) Parameters() map[string]any { return m.params } +func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult { + return m.result +} + +type mockCtxTool struct { + mockRegistryTool + channel string + chatID string +} + +func (m *mockCtxTool) SetContext(channel, chatID string) { + m.channel = channel + m.chatID = chatID +} + +type mockAsyncRegistryTool struct { + mockRegistryTool + cb AsyncCallback +} + +func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) { + m.cb = cb +} + +// --- helpers --- + +func newMockTool(name, desc string) *mockRegistryTool { + return &mockRegistryTool{ + name: name, + desc: desc, + params: map[string]any{"type": "object"}, + result: SilentResult("ok"), + } +} + +// --- tests --- + +func TestNewToolRegistry(t *testing.T) { + r := NewToolRegistry() + if r.Count() != 0 { + t.Errorf("expected empty registry, got count %d", r.Count()) + } + if len(r.List()) != 0 { + t.Errorf("expected empty list, got %v", r.List()) + } +} + +func TestToolRegistry_RegisterAndGet(t *testing.T) { + r := NewToolRegistry() + tool := newMockTool("echo", "echoes input") + r.Register(tool) + + got, ok := r.Get("echo") + if !ok { + t.Fatal("expected to find registered tool") + } + if got.Name() != "echo" { + t.Errorf("expected name 'echo', got %q", got.Name()) + } +} + +func TestToolRegistry_Get_NotFound(t *testing.T) { + r := NewToolRegistry() + _, ok := r.Get("nonexistent") + if ok { + t.Error("expected ok=false for unregistered tool") + } +} + +func TestToolRegistry_RegisterOverwrite(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("dup", "first")) + r.Register(newMockTool("dup", "second")) + + if r.Count() != 1 { + t.Errorf("expected count 1 after overwrite, got %d", r.Count()) + } + tool, _ := r.Get("dup") + if tool.Description() != "second" { + t.Errorf("expected overwritten description 'second', got %q", tool.Description()) + } +} + +func TestToolRegistry_Execute_Success(t *testing.T) { + r := NewToolRegistry() + r.Register(&mockRegistryTool{ + name: "greet", + desc: "says hello", + params: map[string]any{}, + result: SilentResult("hello"), + }) + + result := r.Execute(context.Background(), "greet", nil) + if result.IsError { + t.Errorf("expected success, got error: %s", result.ForLLM) + } + if result.ForLLM != "hello" { + t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM) + } +} + +func TestToolRegistry_Execute_NotFound(t *testing.T) { + r := NewToolRegistry() + result := r.Execute(context.Background(), "missing", nil) + if !result.IsError { + t.Error("expected error for missing tool") + } + if !strings.Contains(result.ForLLM, "not found") { + t.Errorf("expected 'not found' in error, got %q", result.ForLLM) + } + if result.Err == nil { + t.Error("expected Err to be set via WithError") + } +} + +func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) { + r := NewToolRegistry() + ct := &mockCtxTool{ + mockRegistryTool: *newMockTool("ctx_tool", "needs context"), + } + r.Register(ct) + + r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil) + + if ct.channel != "telegram" { + t.Errorf("expected channel 'telegram', got %q", ct.channel) + } + if ct.chatID != "chat-42" { + t.Errorf("expected chatID 'chat-42', got %q", ct.chatID) + } +} + +func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) { + r := NewToolRegistry() + ct := &mockCtxTool{ + mockRegistryTool: *newMockTool("ctx_tool", "needs context"), + } + r.Register(ct) + + r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil) + + if ct.channel != "" || ct.chatID != "" { + t.Error("SetContext should not be called with empty channel/chatID") + } +} + +func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) { + r := NewToolRegistry() + at := &mockAsyncRegistryTool{ + mockRegistryTool: *newMockTool("async_tool", "async work"), + } + at.result = AsyncResult("started") + r.Register(at) + + called := false + cb := func(_ context.Context, _ *ToolResult) { called = true } + + result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb) + if at.cb == nil { + t.Error("expected SetCallback to have been called") + } + if !result.Async { + t.Error("expected async result") + } + + at.cb(context.Background(), SilentResult("done")) + if !called { + t.Error("expected callback to be invoked") + } +} + +func TestToolRegistry_GetDefinitions(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("alpha", "tool A")) + + defs := r.GetDefinitions() + if len(defs) != 1 { + t.Fatalf("expected 1 definition, got %d", len(defs)) + } + if defs[0]["type"] != "function" { + t.Errorf("expected type 'function', got %v", defs[0]["type"]) + } + fn, ok := defs[0]["function"].(map[string]any) + if !ok { + t.Fatal("expected 'function' key to be a map") + } + if fn["name"] != "alpha" { + t.Errorf("expected name 'alpha', got %v", fn["name"]) + } + if fn["description"] != "tool A" { + t.Errorf("expected description 'tool A', got %v", fn["description"]) + } +} + +func TestToolRegistry_ToProviderDefs(t *testing.T) { + r := NewToolRegistry() + params := map[string]any{"type": "object", "properties": map[string]any{}} + r.Register(&mockRegistryTool{ + name: "beta", + desc: "tool B", + params: params, + result: SilentResult("ok"), + }) + + defs := r.ToProviderDefs() + if len(defs) != 1 { + t.Fatalf("expected 1 provider def, got %d", len(defs)) + } + + want := providers.ToolDefinition{ + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: "beta", + Description: "tool B", + Parameters: params, + }, + } + got := defs[0] + if got.Type != want.Type { + t.Errorf("Type: want %q, got %q", want.Type, got.Type) + } + if got.Function.Name != want.Function.Name { + t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name) + } + if got.Function.Description != want.Function.Description { + t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description) + } +} + +func TestToolRegistry_List(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("x", "")) + r.Register(newMockTool("y", "")) + + names := r.List() + if len(names) != 2 { + t.Fatalf("expected 2 names, got %d", len(names)) + } + + nameSet := map[string]bool{} + for _, n := range names { + nameSet[n] = true + } + if !nameSet["x"] || !nameSet["y"] { + t.Errorf("expected names {x, y}, got %v", names) + } +} + +func TestToolRegistry_Count(t *testing.T) { + r := NewToolRegistry() + if r.Count() != 0 { + t.Errorf("expected 0, got %d", r.Count()) + } + + r.Register(newMockTool("a", "")) + r.Register(newMockTool("b", "")) + if r.Count() != 2 { + t.Errorf("expected 2, got %d", r.Count()) + } + + r.Register(newMockTool("a", "replaced")) + if r.Count() != 2 { + t.Errorf("expected 2 after overwrite, got %d", r.Count()) + } +} + +func TestToolRegistry_GetSummaries(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("read_file", "Reads a file")) + + summaries := r.GetSummaries() + if len(summaries) != 1 { + t.Fatalf("expected 1 summary, got %d", len(summaries)) + } + if !strings.Contains(summaries[0], "`read_file`") { + t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0]) + } + if !strings.Contains(summaries[0], "Reads a file") { + t.Errorf("expected description in summary, got %q", summaries[0]) + } +} + +func TestToolToSchema(t *testing.T) { + tool := newMockTool("demo", "demo tool") + schema := ToolToSchema(tool) + + if schema["type"] != "function" { + t.Errorf("expected type 'function', got %v", schema["type"]) + } + fn, ok := schema["function"].(map[string]any) + if !ok { + t.Fatal("expected 'function' to be a map") + } + if fn["name"] != "demo" { + t.Errorf("expected name 'demo', got %v", fn["name"]) + } + if fn["description"] != "demo tool" { + t.Errorf("expected description 'demo tool', got %v", fn["description"]) + } + if fn["parameters"] == nil { + t.Error("expected parameters to be set") + } +} + +func TestToolRegistry_ConcurrentAccess(t *testing.T) { + r := NewToolRegistry() + var wg sync.WaitGroup + + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + name := string(rune('A' + n%26)) + r.Register(newMockTool(name, "concurrent")) + r.Get(name) + r.Count() + r.List() + r.GetDefinitions() + }(i) + } + + wg.Wait() + + if r.Count() == 0 { + t.Error("expected tools to be registered after concurrent access") + } +} diff --git a/pkg/tools/result.go b/pkg/tools/result.go index b13055b1c..cab833284 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/result.go @@ -30,6 +30,10 @@ type ToolResult struct { // Err is the underlying error (not JSON serialized). // Used for internal error handling and logging. Err error `json:"-"` + + // Media contains media store refs produced by this tool. + // When non-empty, the agent will publish these as OutboundMediaMessage. + Media []string `json:"media,omitempty"` } // NewToolResult creates a basic ToolResult with content for the LLM. @@ -120,6 +124,19 @@ func UserResult(content string) *ToolResult { } } +// MediaResult creates a ToolResult with media refs for the user. +// The agent will publish these refs as OutboundMediaMessage. +// +// Example: +// +// result := MediaResult("Image generated successfully", []string{"media://abc123"}) +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return &ToolResult{ + ForLLM: forLLM, + Media: mediaRefs, + } +} + // MarshalJSON implements custom JSON serialization. // The Err field is excluded from JSON output via the json:"-" tag. func (tr *ToolResult) MarshalJSON() ([]byte, error) { diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index d2adb6468..6883172cd 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -81,6 +81,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf execConfig := config.Tools.Exec enableDenyPatterns = execConfig.EnableDenyPatterns if enableDenyPatterns { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) if len(execConfig.CustomDenyPatterns) > 0 { fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) for _, pattern := range execConfig.CustomDenyPatterns { @@ -91,8 +92,6 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf } denyPatterns = append(denyPatterns, re) } - } else { - denyPatterns = append(denyPatterns, defaultDenyPatterns...) } } else { // If deny patterns are disabled, we won't add any patterns, allowing all commands. @@ -144,7 +143,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult cwd := t.workingDir if wd, ok := args["working_dir"].(string); ok && wd != "" { - cwd = wd + if t.restrictToWorkspace && t.workingDir != "" { + resolvedWD, err := validatePath(wd, t.workingDir, true) + if err != nil { + return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") + } + cwd = resolvedWD + } else { + cwd = wd + } } if cwd == "" { diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index f85b5a008..6d35815e8 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -186,6 +186,66 @@ func TestShellTool_OutputTruncation(t *testing.T) { } } +// TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly +func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + outsideDir := filepath.Join(root, "outside") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatalf("failed to create workspace: %v", err) + } + if err := os.MkdirAll(outsideDir, 0o755); err != nil { + t.Fatalf("failed to create outside dir: %v", err) + } + + tool := NewExecTool(workspace, true) + result := tool.Execute(context.Background(), map[string]any{ + "command": "pwd", + "working_dir": outsideDir, + }) + + if !result.IsError { + t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "blocked") { + t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) + } +} + +// TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace +// pointing outside cannot be used as working_dir to escape the sandbox. +func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + secretDir := filepath.Join(root, "secret") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatalf("failed to create workspace: %v", err) + } + if err := os.MkdirAll(secretDir, 0o755); err != nil { + t.Fatalf("failed to create secret dir: %v", err) + } + os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0o644) + + // symlink lives inside the workspace but resolves to secretDir outside it + link := filepath.Join(workspace, "escape") + if err := os.Symlink(secretDir, link); err != nil { + t.Skipf("symlinks not supported in this environment: %v", err) + } + + tool := NewExecTool(workspace, true) + result := tool.Execute(context.Background(), map[string]any{ + "command": "cat secret.txt", + "working_dir": link, + }) + + if !result.IsError { + t.Fatalf("expected symlink working_dir escape to be blocked, got output: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "blocked") { + t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) + } +} + // TestShellTool_RestrictToWorkspace verifies workspace restriction func TestShellTool_RestrictToWorkspace(t *testing.T) { tmpDir := t.TempDir() diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 91ebff636..fee53fc28 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -218,7 +218,9 @@ After completing the task, provide a clear summary of what was done.` // Send announce message back to main agent if sm.bus != nil { announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result) - sm.bus.PublishInbound(bus.InboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + sm.bus.PublishInbound(pubCtx, bus.InboundMessage{ Channel: "system", SenderID: fmt.Sprintf("subagent:%s", task.ID), // Format: "original_channel:original_chat_id" for routing back diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 301e00daf..452e95e0f 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -1,6 +1,7 @@ package tools import ( + "bytes" "context" "encoding/json" "fmt" @@ -84,6 +85,88 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in return strings.Join(lines, "\n"), nil } +type TavilySearchProvider struct { + apiKey string + baseURL string +} + +func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := p.baseURL + if searchURL == "" { + searchURL = "https://api.tavily.com/search" + } + + payload := map[string]any{ + "api_key": p.apiKey, + "query": query, + "search_depth": "advanced", + "include_answer": false, + "include_images": false, + "include_raw_content": false, + "max_results": count, + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body)) + } + + var searchResp struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + } `json:"results"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.Results + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query)) + for i, item := range results { + if i >= count { + break + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL)) + if item.Content != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Content)) + } + } + + return strings.Join(lines, "\n"), nil +} + type DuckDuckGoSearchProvider struct{} func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { @@ -256,6 +339,10 @@ type WebSearchToolOptions struct { BraveAPIKey string BraveMaxResults int BraveEnabled bool + TavilyAPIKey string + TavilyBaseURL string + TavilyMaxResults int + TavilyEnabled bool DuckDuckGoMaxResults int DuckDuckGoEnabled bool PerplexityAPIKey string @@ -267,7 +354,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { var provider SearchProvider maxResults := 5 - // Priority: Perplexity > Brave > DuckDuckGo + // Priority: Perplexity > Brave > Tavily > DuckDuckGo if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey} if opts.PerplexityMaxResults > 0 { @@ -278,6 +365,14 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { if opts.BraveMaxResults > 0 { maxResults = opts.BraveMaxResults } + } else if opts.TavilyEnabled && opts.TavilyAPIKey != "" { + provider = &TavilySearchProvider{ + apiKey: opts.TavilyAPIKey, + baseURL: opts.TavilyBaseURL, + } + if opts.TavilyMaxResults > 0 { + maxResults = opts.TavilyMaxResults + } } else if opts.DuckDuckGoEnabled { provider = &DuckDuckGoSearchProvider{} if opts.DuckDuckGoMaxResults > 0 { diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index d999d8958..75e0d8d16 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -333,3 +333,75 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) { t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM) } } + +// TestWebTool_TavilySearch_Success verifies successful Tavily search +func TestWebTool_TavilySearch_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + + // Verify payload + var payload map[string]any + json.NewDecoder(r.Body).Decode(&payload) + if payload["api_key"] != "test-key" { + t.Errorf("Expected api_key test-key, got %v", payload["api_key"]) + } + if payload["query"] != "test query" { + t.Errorf("Expected query 'test query', got %v", payload["query"]) + } + + // Return mock response + response := map[string]any{ + "results": []map[string]any{ + { + "title": "Test Result 1", + "url": "https://example.com/1", + "content": "Content for result 1", + }, + { + "title": "Test Result 2", + "url": "https://example.com/2", + "content": "Content for result 2", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + tool := NewWebSearchTool(WebSearchToolOptions{ + TavilyEnabled: true, + TavilyAPIKey: "test-key", + TavilyBaseURL: server.URL, + TavilyMaxResults: 5, + }) + + ctx := context.Background() + args := map[string]any{ + "query": "test query", + } + + result := tool.Execute(ctx, args) + + // Success should not be an error + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + + // ForUser should contain result titles and URLs + if !strings.Contains(result.ForUser, "Test Result 1") || + !strings.Contains(result.ForUser, "https://example.com/1") { + t.Errorf("Expected results in output, got: %s", result.ForUser) + } + + // Should mention via Tavily + if !strings.Contains(result.ForUser, "via Tavily") { + t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser) + } +} diff --git a/pkg/utils/message.go b/pkg/utils/message.go deleted file mode 100644 index 1d05950d9..000000000 --- a/pkg/utils/message.go +++ /dev/null @@ -1,179 +0,0 @@ -package utils - -import ( - "strings" -) - -// SplitMessage splits long messages into chunks, preserving code block integrity. -// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks, -// but may extend to maxLen when needed. -// Call SplitMessage with the full text content and the maximum allowed length of a single message; -// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. -func SplitMessage(content string, maxLen int) []string { - var messages []string - - // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible - codeBlockBuffer := maxLen / 10 - if codeBlockBuffer < 50 { - codeBlockBuffer = 50 - } - if codeBlockBuffer > maxLen/2 { - codeBlockBuffer = maxLen / 2 - } - - for len(content) > 0 { - if len(content) <= maxLen { - messages = append(messages, content) - break - } - - // Effective split point: maxLen minus buffer, to leave room for code blocks - effectiveLimit := maxLen - codeBlockBuffer - if effectiveLimit < maxLen/2 { - effectiveLimit = maxLen / 2 - } - - // Find natural split point within the effective limit - msgEnd := findLastNewline(content[:effectiveLimit], 200) - if msgEnd <= 0 { - msgEnd = findLastSpace(content[:effectiveLimit], 100) - } - if msgEnd <= 0 { - msgEnd = effectiveLimit - } - - // Check if this would end with an incomplete code block - candidate := content[:msgEnd] - unclosedIdx := findLastUnclosedCodeBlock(candidate) - - if unclosedIdx >= 0 { - // Message would end with incomplete code block - // Try to extend up to maxLen to include the closing ``` - if len(content) > msgEnd { - closingIdx := findNextClosingCodeBlock(content, msgEnd) - if closingIdx > 0 && closingIdx <= maxLen { - // Extend to include the closing ``` - msgEnd = closingIdx - } else { - // Code block is too long to fit in one chunk or missing closing fence. - // Try to split inside by injecting closing and reopening fences. - headerEnd := strings.Index(content[unclosedIdx:], "\n") - if headerEnd == -1 { - headerEnd = unclosedIdx + 3 - } else { - headerEnd += unclosedIdx - } - header := strings.TrimSpace(content[unclosedIdx:headerEnd]) - - // If we have a reasonable amount of content after the header, split inside - if msgEnd > headerEnd+20 { - // Find a better split point closer to maxLen - innerLimit := maxLen - 5 // Leave room for "\n```" - betterEnd := findLastNewline(content[:innerLimit], 200) - if betterEnd > headerEnd { - msgEnd = betterEnd - } else { - msgEnd = innerLimit - } - messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```") - content = strings.TrimSpace(header + "\n" + content[msgEnd:]) - continue - } - - // Otherwise, try to split before the code block starts - newEnd := findLastNewline(content[:unclosedIdx], 200) - if newEnd <= 0 { - newEnd = findLastSpace(content[:unclosedIdx], 100) - } - if newEnd > 0 { - msgEnd = newEnd - } else { - // If we can't split before, we MUST split inside (last resort) - if unclosedIdx > 20 { - msgEnd = unclosedIdx - } else { - msgEnd = maxLen - 5 - messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```") - content = strings.TrimSpace(header + "\n" + content[msgEnd:]) - continue - } - } - } - } - } - - if msgEnd <= 0 { - msgEnd = effectiveLimit - } - - messages = append(messages, content[:msgEnd]) - content = strings.TrimSpace(content[msgEnd:]) - } - - return messages -} - -// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` -// Returns the position of the opening ``` or -1 if all code blocks are complete -func findLastUnclosedCodeBlock(text string) int { - inCodeBlock := false - lastOpenIdx := -1 - - for i := 0; i < len(text); i++ { - if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { - // Toggle code block state on each fence - if !inCodeBlock { - // Entering a code block: record this opening fence - lastOpenIdx = i - } - inCodeBlock = !inCodeBlock - i += 2 - } - } - - if inCodeBlock { - return lastOpenIdx - } - return -1 -} - -// findNextClosingCodeBlock finds the next closing ``` starting from a position -// Returns the position after the closing ``` or -1 if not found -func findNextClosingCodeBlock(text string, startIdx int) int { - for i := startIdx; i < len(text); i++ { - if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { - return i + 3 - } - } - return -1 -} - -// findLastNewline finds the last newline character within the last N characters -// Returns the position of the newline or -1 if not found -func findLastNewline(s string, searchWindow int) int { - searchStart := len(s) - searchWindow - if searchStart < 0 { - searchStart = 0 - } - for i := len(s) - 1; i >= searchStart; i-- { - if s[i] == '\n' { - return i - } - } - return -1 -} - -// findLastSpace finds the last space character within the last N characters -// Returns the position of the space or -1 if not found -func findLastSpace(s string, searchWindow int) int { - searchStart := len(s) - searchWindow - if searchStart < 0 { - searchStart = 0 - } - for i := len(s) - 1; i >= searchStart; i-- { - if s[i] == ' ' || s[i] == '\t' { - return i - } - } - return -1 -} diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 7a6aa37cc..62d9beee0 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -4,6 +4,9 @@ package utils // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. func Truncate(s string, maxLen int) string { + if maxLen <= 0 { + return "" + } runes := []rune(s) if len(runes) <= maxLen { return s diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go new file mode 100644 index 000000000..a44ead228 --- /dev/null +++ b/pkg/utils/string_test.go @@ -0,0 +1,106 @@ +package utils + +import "testing" + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + want string + }{ + { + name: "short string unchanged", + input: "hi", + maxLen: 10, + want: "hi", + }, + { + name: "exact length unchanged", + input: "hello", + maxLen: 5, + want: "hello", + }, + { + name: "long string truncated with ellipsis", + input: "hello world", + maxLen: 8, + want: "hello...", + }, + { + name: "maxLen equals 4 leaves 1 char plus ellipsis", + input: "abcdef", + maxLen: 4, + want: "a...", + }, + { + name: "maxLen 3 returns first 3 chars without ellipsis", + input: "abcdef", + maxLen: 3, + want: "abc", + }, + { + name: "maxLen 2 returns first 2 chars", + input: "abcdef", + maxLen: 2, + want: "ab", + }, + { + name: "maxLen 1 returns first char", + input: "abcdef", + maxLen: 1, + want: "a", + }, + { + name: "maxLen 0 returns empty", + input: "hello", + maxLen: 0, + want: "", + }, + { + name: "negative maxLen returns empty", + input: "hello", + maxLen: -1, + want: "", + }, + { + name: "empty string unchanged", + input: "", + maxLen: 5, + want: "", + }, + { + name: "empty string with zero maxLen", + input: "", + maxLen: 0, + want: "", + }, + { + name: "unicode truncated correctly", + input: "\U0001f600\U0001f601\U0001f602\U0001f603\U0001f604", + maxLen: 4, + want: "\U0001f600...", + }, + { + name: "unicode short enough", + input: "\u00e9\u00e8", + maxLen: 5, + want: "\u00e9\u00e8", + }, + { + name: "mixed ascii and unicode", + input: "Go\U0001f680\U0001f525\U0001f4a5\U0001f30d", + maxLen: 5, + want: "Go...", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Truncate(tt.input, tt.maxLen) + if got != tt.want { + t.Errorf("Truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + }) + } +} diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index ad8767d40..f973e77fe 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -79,17 +79,17 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied}) - if err := writer.WriteField("model", "whisper-large-v3"); err != nil { + if err = writer.WriteField("model", "whisper-large-v3"); err != nil { logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write model field: %w", err) } - if err := writer.WriteField("response_format", "json"); err != nil { + if err = writer.WriteField("response_format", "json"); err != nil { logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err}) return nil, fmt.Errorf("failed to write response_format field: %w", err) } - if err := writer.Close(); err != nil { + if err = writer.Close(); err != nil { logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) return nil, fmt.Errorf("failed to close multipart writer: %w", err) } diff --git a/v0.0.1 b/v0.0.1 new file mode 100644 index 000000000..e69de29bb diff --git a/v0.1.1 b/v0.1.1 new file mode 100644 index 000000000..e69de29bb