Merge branch 'main' into fix/discord-messaging
This commit is contained in:
commit
a97c1ad832
11 changed files with 886 additions and 55 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -37,3 +37,7 @@ coverage.html
|
||||||
ralph/
|
ralph/
|
||||||
.ralph/
|
.ralph/
|
||||||
tasks/
|
tasks/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ RUN make build
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 2: Minimal runtime image
|
# Stage 2: Minimal runtime image
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM alpine:3.21
|
FROM alpine:3.23
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata curl
|
||||||
|
|
||||||
# Copy binary
|
# Copy binary
|
||||||
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
|
||||||
|
|
|
||||||
70
README.md
70
README.md
|
|
@ -17,14 +17,12 @@
|
||||||
[中文](README.zh.md) | [日本語](README.ja.md) | **English**
|
[中文](README.zh.md) | [日本語](README.ja.md) | **English**
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [nanobot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
|
🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [nanobot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
|
||||||
|
|
||||||
⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
|
⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
|
||||||
|
|
||||||
|
|
||||||
<table align="center">
|
<table align="center">
|
||||||
<tr align="center">
|
<tr align="center">
|
||||||
<td align="center" valign="top">
|
<td align="center" valign="top">
|
||||||
|
|
@ -40,7 +38,6 @@
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||||
>
|
>
|
||||||
|
|
@ -50,10 +47,10 @@
|
||||||
>
|
>
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
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-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
|
||||||
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
|
🚀 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!
|
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
@ -225,8 +222,8 @@ picoclaw onboard
|
||||||
|
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
|
|
@ -257,9 +254,9 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
||||||
|
|
||||||
**1. Create a bot**
|
**1. Create a bot**
|
||||||
|
|
||||||
- Open Telegram, search `@BotFather`
|
* Open Telegram, search `@BotFather`
|
||||||
- Send `/newbot`, follow prompts
|
* Send `/newbot`, follow prompts
|
||||||
- Copy the token
|
* Copy the token
|
||||||
|
|
||||||
**2. Configure**
|
**2. Configure**
|
||||||
|
|
||||||
|
|
@ -290,19 +287,19 @@ picoclaw gateway
|
||||||
|
|
||||||
**1. Create a bot**
|
**1. Create a bot**
|
||||||
|
|
||||||
- Go to <https://discord.com/developers/applications>
|
* Go to <https://discord.com/developers/applications>
|
||||||
- Create an application → Bot → Add Bot
|
* Create an application → Bot → Add Bot
|
||||||
- Copy the bot token
|
* Copy the bot token
|
||||||
|
|
||||||
**2. Enable intents**
|
**2. Enable intents**
|
||||||
|
|
||||||
- In the Bot settings, enable **MESSAGE CONTENT INTENT**
|
* In the Bot settings, enable **MESSAGE CONTENT INTENT**
|
||||||
- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||||
|
|
||||||
**3. Get your User ID**
|
**3. Get your User ID**
|
||||||
|
|
||||||
- Discord Settings → Advanced → enable **Developer Mode**
|
* Discord Settings → Advanced → enable **Developer Mode**
|
||||||
- Right-click your avatar → **Copy User ID**
|
* Right-click your avatar → **Copy User ID**
|
||||||
|
|
||||||
**4. Configure**
|
**4. Configure**
|
||||||
|
|
||||||
|
|
@ -320,10 +317,10 @@ picoclaw gateway
|
||||||
|
|
||||||
**5. Invite the bot**
|
**5. Invite the bot**
|
||||||
|
|
||||||
- OAuth2 → URL Generator
|
* OAuth2 → URL Generator
|
||||||
- Scopes: `bot`
|
* Scopes: `bot`
|
||||||
- Bot Permissions: `Send Messages`, `Read Message History`
|
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||||
- Open the generated invite URL and add the bot to your server
|
* Open the generated invite URL and add the bot to your server
|
||||||
|
|
||||||
**6. Run**
|
**6. Run**
|
||||||
|
|
||||||
|
|
@ -371,9 +368,9 @@ picoclaw gateway
|
||||||
|
|
||||||
**1. Create a bot**
|
**1. Create a bot**
|
||||||
|
|
||||||
- Go to [Open Platform](https://open.dingtalk.com/)
|
* Go to [Open Platform](https://open.dingtalk.com/)
|
||||||
- Create an internal app
|
* Create an internal app
|
||||||
- Copy Client ID and Client Secret
|
* Copy Client ID and Client Secret
|
||||||
|
|
||||||
**2. Configure**
|
**2. Configure**
|
||||||
|
|
||||||
|
|
@ -518,12 +515,12 @@ When `restrict_to_workspace: true`, the following tools are sandboxed:
|
||||||
|
|
||||||
Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands:
|
Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands:
|
||||||
|
|
||||||
- `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion
|
* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion
|
||||||
- `format`, `mkfs`, `diskpart` — Disk formatting
|
* `format`, `mkfs`, `diskpart` — Disk formatting
|
||||||
- `dd if=` — Disk imaging
|
* `dd if=` — Disk imaging
|
||||||
- Writing to `/dev/sd[a-z]` — Direct disk writes
|
* Writing to `/dev/sd[a-z]` — Direct disk writes
|
||||||
- `shutdown`, `reboot`, `poweroff` — System shutdown
|
* `shutdown`, `reboot`, `poweroff` — System shutdown
|
||||||
- Fork bomb `:(){ :|:& };:`
|
* Fork bomb `:(){ :|:& };:`
|
||||||
|
|
||||||
#### Error Examples
|
#### Error Examples
|
||||||
|
|
||||||
|
|
@ -542,6 +539,7 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|
||||||
If you need the agent to access paths outside the workspace:
|
If you need the agent to access paths outside the workspace:
|
||||||
|
|
||||||
**Method 1: Config file**
|
**Method 1: Config file**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
|
|
@ -553,6 +551,7 @@ If you need the agent to access paths outside the workspace:
|
||||||
```
|
```
|
||||||
|
|
||||||
**Method 2: Environment variable**
|
**Method 2: Environment variable**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||||
```
|
```
|
||||||
|
|
@ -644,8 +643,9 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||||
|
|
||||||
**Environment variables:**
|
**Environment variables:**
|
||||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
|
|
||||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
|
* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
|
||||||
|
* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
|
||||||
|
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
|
|
@ -667,7 +667,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
||||||
|
|
||||||
**1. Get API key and base URL**
|
**1. Get API key and base URL**
|
||||||
|
|
||||||
- Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||||
|
|
||||||
**2. Configure**
|
**2. Configure**
|
||||||
|
|
||||||
|
|
@ -784,9 +784,9 @@ picoclaw agent -m "Hello"
|
||||||
|
|
||||||
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
|
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
|
||||||
|
|
||||||
- **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
|
* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
|
||||||
- **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
|
* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
|
||||||
- **Cron expressions**: "Remind me at 9am daily" → uses cron expression
|
* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
|
||||||
|
|
||||||
Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,14 @@
|
||||||
"webhook_port": 18791,
|
"webhook_port": 18791,
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"onebot": {
|
||||||
|
"enabled": false,
|
||||||
|
"ws_url": "ws://127.0.0.1:3001",
|
||||||
|
"access_token": "",
|
||||||
|
"reconnect_interval": 5,
|
||||||
|
"group_trigger_prefix": [],
|
||||||
|
"allow_from": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
7
go.mod
7
go.mod
|
|
@ -19,13 +19,17 @@ require (
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/go-resty/resty/v2 v2.17.2 // indirect
|
github.com/github/copilot-sdk/go v0.1.23
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/grbit/go-json v0.11.0 // indirect
|
github.com/grbit/go-json v0.11.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
|
|
@ -43,4 +47,5 @@ require (
|
||||||
golang.org/x/net v0.50.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -32,6 +32,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
|
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||||
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
||||||
|
|
@ -56,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,19 @@ func (m *Manager) initChannels() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize OneBot channel")
|
||||||
|
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["onebot"] = onebot
|
||||||
|
logger.InfoC("channels", "OneBot channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
686
pkg/channels/onebot.go
Normal file
686
pkg/channels/onebot.go
Normal file
|
|
@ -0,0 +1,686 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OneBotChannel struct {
|
||||||
|
*BaseChannel
|
||||||
|
config config.OneBotConfig
|
||||||
|
conn *websocket.Conn
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
dedup map[string]struct{}
|
||||||
|
dedupRing []string
|
||||||
|
dedupIdx int
|
||||||
|
mu sync.Mutex
|
||||||
|
writeMu sync.Mutex
|
||||||
|
echoCounter int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type oneBotRawEvent struct {
|
||||||
|
PostType string `json:"post_type"`
|
||||||
|
MessageType string `json:"message_type"`
|
||||||
|
SubType string `json:"sub_type"`
|
||||||
|
MessageID json.RawMessage `json:"message_id"`
|
||||||
|
UserID json.RawMessage `json:"user_id"`
|
||||||
|
GroupID json.RawMessage `json:"group_id"`
|
||||||
|
RawMessage string `json:"raw_message"`
|
||||||
|
Message json.RawMessage `json:"message"`
|
||||||
|
Sender json.RawMessage `json:"sender"`
|
||||||
|
SelfID json.RawMessage `json:"self_id"`
|
||||||
|
Time json.RawMessage `json:"time"`
|
||||||
|
MetaEventType string `json:"meta_event_type"`
|
||||||
|
Echo string `json:"echo"`
|
||||||
|
RetCode json.RawMessage `json:"retcode"`
|
||||||
|
Status BotStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BotStatus struct {
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Good bool `json:"good"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type oneBotSender struct {
|
||||||
|
UserID json.RawMessage `json:"user_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Card string `json:"card"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type oneBotEvent struct {
|
||||||
|
PostType string
|
||||||
|
MessageType string
|
||||||
|
SubType string
|
||||||
|
MessageID string
|
||||||
|
UserID int64
|
||||||
|
GroupID int64
|
||||||
|
Content string
|
||||||
|
RawContent string
|
||||||
|
IsBotMentioned bool
|
||||||
|
Sender oneBotSender
|
||||||
|
SelfID int64
|
||||||
|
Time int64
|
||||||
|
MetaEventType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type oneBotAPIRequest struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Params interface{} `json:"params"`
|
||||||
|
Echo string `json:"echo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type oneBotSendPrivateMsgParams struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type oneBotSendGroupMsgParams struct {
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
||||||
|
base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
const dedupSize = 1024
|
||||||
|
return &OneBotChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
dedup: make(map[string]struct{}, dedupSize),
|
||||||
|
dedupRing: make([]string, dedupSize),
|
||||||
|
dedupIdx: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) Start(ctx context.Context) error {
|
||||||
|
if c.config.WSUrl == "" {
|
||||||
|
return fmt.Errorf("OneBot ws_url not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{
|
||||||
|
"ws_url": c.config.WSUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
if err := c.connect(); err != nil {
|
||||||
|
logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
go c.listen()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.ReconnectInterval > 0 {
|
||||||
|
go c.reconnectLoop()
|
||||||
|
} else {
|
||||||
|
// If reconnect is disabled but initial connection failed, we cannot recover
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("failed to connect to OneBot and reconnect is disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(true)
|
||||||
|
logger.InfoC("onebot", "OneBot channel started successfully")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) connect() error {
|
||||||
|
dialer := websocket.DefaultDialer
|
||||||
|
dialer.HandshakeTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
header := make(map[string][]string)
|
||||||
|
if c.config.AccessToken != "" {
|
||||||
|
header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, _, err := dialer.Dial(c.config.WSUrl, header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.conn = conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
logger.InfoC("onebot", "WebSocket connected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) reconnectLoop() {
|
||||||
|
interval := time.Duration(c.config.ReconnectInterval) * time.Second
|
||||||
|
if interval < 5*time.Second {
|
||||||
|
interval = 5 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(interval):
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
logger.InfoC("onebot", "Attempting to reconnect...")
|
||||||
|
if err := c.connect(); err != nil {
|
||||||
|
logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
go c.listen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("onebot", "Stopping OneBot channel")
|
||||||
|
c.setRunning(false)
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return fmt.Errorf("OneBot channel not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("OneBot WebSocket not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
action, params, err := c.buildSendRequest(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeMu.Lock()
|
||||||
|
c.echoCounter++
|
||||||
|
echo := fmt.Sprintf("send_%d", c.echoCounter)
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
|
||||||
|
req := oneBotAPIRequest{
|
||||||
|
Action: action,
|
||||||
|
Params: params,
|
||||||
|
Echo: echo,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal OneBot request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.writeMu.Lock()
|
||||||
|
err = conn.WriteMessage(websocket.TextMessage, data)
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
|
||||||
|
chatID := msg.ChatID
|
||||||
|
|
||||||
|
if len(chatID) > 6 && chatID[:6] == "group:" {
|
||||||
|
groupID, err := strconv.ParseInt(chatID[6:], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID)
|
||||||
|
}
|
||||||
|
return "send_group_msg", oneBotSendGroupMsgParams{
|
||||||
|
GroupID: groupID,
|
||||||
|
Message: msg.Content,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chatID) > 8 && chatID[:8] == "private:" {
|
||||||
|
userID, err := strconv.ParseInt(chatID[8:], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID)
|
||||||
|
}
|
||||||
|
return "send_private_msg", oneBotSendPrivateMsgParams{
|
||||||
|
UserID: userID,
|
||||||
|
Message: msg.Content,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.ParseInt(chatID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "send_private_msg", oneBotSendPrivateMsgParams{
|
||||||
|
UserID: userID,
|
||||||
|
Message: msg.Content,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) listen() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if conn == nil {
|
||||||
|
logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, message, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{
|
||||||
|
"length": len(message),
|
||||||
|
"payload": string(message),
|
||||||
|
})
|
||||||
|
|
||||||
|
var raw oneBotRawEvent
|
||||||
|
if err := json.Unmarshal(message, &raw); err != nil {
|
||||||
|
logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"payload": string(message),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw.Echo != "" || raw.Status.Online || raw.Status.Good {
|
||||||
|
logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{
|
||||||
|
"echo": raw.Echo,
|
||||||
|
"status": raw.Status,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{
|
||||||
|
"post_type": raw.PostType,
|
||||||
|
"message_type": raw.MessageType,
|
||||||
|
"sub_type": raw.SubType,
|
||||||
|
"meta_event_type": raw.MetaEventType,
|
||||||
|
})
|
||||||
|
|
||||||
|
c.handleRawEvent(&raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJSONInt64(raw json.RawMessage) (int64, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int64
|
||||||
|
if err := json.Unmarshal(raw, &n); err == nil {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err == nil {
|
||||||
|
return strconv.ParseInt(s, 10, 64)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("cannot parse as int64: %s", string(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJSONString(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
type parseMessageResult struct {
|
||||||
|
Text string
|
||||||
|
IsBotMentioned bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return parseMessageResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err == nil {
|
||||||
|
mentioned := false
|
||||||
|
if selfID > 0 {
|
||||||
|
cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
|
||||||
|
if strings.Contains(s, cqAt) {
|
||||||
|
mentioned = true
|
||||||
|
s = strings.ReplaceAll(s, cqAt, "")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &segments); err == nil {
|
||||||
|
var text string
|
||||||
|
mentioned := false
|
||||||
|
selfIDStr := strconv.FormatInt(selfID, 10)
|
||||||
|
for _, seg := range segments {
|
||||||
|
segType, _ := seg["type"].(string)
|
||||||
|
data, _ := seg["data"].(map[string]interface{})
|
||||||
|
switch segType {
|
||||||
|
case "text":
|
||||||
|
if data != nil {
|
||||||
|
if t, ok := data["text"].(string); ok {
|
||||||
|
text += t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "at":
|
||||||
|
if data != nil && selfID > 0 {
|
||||||
|
qqVal := fmt.Sprintf("%v", data["qq"])
|
||||||
|
if qqVal == selfIDStr || qqVal == "all" {
|
||||||
|
mentioned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned}
|
||||||
|
}
|
||||||
|
return parseMessageResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
|
||||||
|
switch raw.PostType {
|
||||||
|
case "message":
|
||||||
|
evt, err := c.normalizeMessageEvent(raw)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.handleMessage(evt)
|
||||||
|
case "meta_event":
|
||||||
|
c.handleMetaEvent(raw)
|
||||||
|
case "notice":
|
||||||
|
logger.DebugCF("onebot", "Notice event received", map[string]interface{}{
|
||||||
|
"sub_type": raw.SubType,
|
||||||
|
})
|
||||||
|
case "request":
|
||||||
|
logger.DebugCF("onebot", "Request event received", map[string]interface{}{
|
||||||
|
"sub_type": raw.SubType,
|
||||||
|
})
|
||||||
|
case "":
|
||||||
|
logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
|
||||||
|
"echo": raw.Echo,
|
||||||
|
"status": raw.Status,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
|
||||||
|
"post_type": raw.PostType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) {
|
||||||
|
userID, err := parseJSONInt64(raw.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID))
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, _ := parseJSONInt64(raw.GroupID)
|
||||||
|
selfID, _ := parseJSONInt64(raw.SelfID)
|
||||||
|
ts, _ := parseJSONInt64(raw.Time)
|
||||||
|
messageID := parseJSONString(raw.MessageID)
|
||||||
|
|
||||||
|
parsed := parseMessageContentEx(raw.Message, selfID)
|
||||||
|
isBotMentioned := parsed.IsBotMentioned
|
||||||
|
|
||||||
|
content := raw.RawMessage
|
||||||
|
if content == "" {
|
||||||
|
content = parsed.Text
|
||||||
|
} else if selfID > 0 {
|
||||||
|
cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
|
||||||
|
if strings.Contains(content, cqAt) {
|
||||||
|
isBotMentioned = true
|
||||||
|
content = strings.ReplaceAll(content, cqAt, "")
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sender oneBotSender
|
||||||
|
if len(raw.Sender) > 0 {
|
||||||
|
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
|
||||||
|
logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"sender": string(raw.Sender),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{
|
||||||
|
"message_type": raw.MessageType,
|
||||||
|
"user_id": userID,
|
||||||
|
"group_id": groupID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"content_len": len(content),
|
||||||
|
"nickname": sender.Nickname,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &oneBotEvent{
|
||||||
|
PostType: raw.PostType,
|
||||||
|
MessageType: raw.MessageType,
|
||||||
|
SubType: raw.SubType,
|
||||||
|
MessageID: messageID,
|
||||||
|
UserID: userID,
|
||||||
|
GroupID: groupID,
|
||||||
|
Content: content,
|
||||||
|
RawContent: raw.RawMessage,
|
||||||
|
IsBotMentioned: isBotMentioned,
|
||||||
|
Sender: sender,
|
||||||
|
SelfID: selfID,
|
||||||
|
Time: ts,
|
||||||
|
MetaEventType: raw.MetaEventType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
|
||||||
|
switch raw.MetaEventType {
|
||||||
|
case "lifecycle":
|
||||||
|
logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{
|
||||||
|
"sub_type": raw.SubType,
|
||||||
|
})
|
||||||
|
case "heartbeat":
|
||||||
|
logger.DebugC("onebot", "Heartbeat received")
|
||||||
|
default:
|
||||||
|
logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{
|
||||||
|
"meta_event_type": raw.MetaEventType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
|
||||||
|
if c.isDuplicate(evt.MessageID) {
|
||||||
|
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
|
||||||
|
"message_id": evt.MessageID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content := evt.Content
|
||||||
|
if content == "" {
|
||||||
|
logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
|
||||||
|
"message_id": evt.MessageID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := strconv.FormatInt(evt.UserID, 10)
|
||||||
|
var chatID string
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"message_id": evt.MessageID,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch evt.MessageType {
|
||||||
|
case "private":
|
||||||
|
chatID = "private:" + senderID
|
||||||
|
logger.InfoCF("onebot", "Received private message", map[string]interface{}{
|
||||||
|
"sender": senderID,
|
||||||
|
"message_id": evt.MessageID,
|
||||||
|
"length": len(content),
|
||||||
|
"content": truncate(content, 100),
|
||||||
|
})
|
||||||
|
|
||||||
|
case "group":
|
||||||
|
groupIDStr := strconv.FormatInt(evt.GroupID, 10)
|
||||||
|
chatID = "group:" + groupIDStr
|
||||||
|
metadata["group_id"] = groupIDStr
|
||||||
|
|
||||||
|
senderUserID, _ := parseJSONInt64(evt.Sender.UserID)
|
||||||
|
if senderUserID > 0 {
|
||||||
|
metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if evt.Sender.Card != "" {
|
||||||
|
metadata["sender_name"] = evt.Sender.Card
|
||||||
|
} else if evt.Sender.Nickname != "" {
|
||||||
|
metadata["sender_name"] = evt.Sender.Nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned)
|
||||||
|
if !triggered {
|
||||||
|
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
|
||||||
|
"sender": senderID,
|
||||||
|
"group": groupIDStr,
|
||||||
|
"is_mentioned": evt.IsBotMentioned,
|
||||||
|
"content": truncate(content, 100),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = strippedContent
|
||||||
|
|
||||||
|
logger.InfoCF("onebot", "Received group message", map[string]interface{}{
|
||||||
|
"sender": senderID,
|
||||||
|
"group": groupIDStr,
|
||||||
|
"message_id": evt.MessageID,
|
||||||
|
"is_mentioned": evt.IsBotMentioned,
|
||||||
|
"length": len(content),
|
||||||
|
"content": truncate(content, 100),
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
|
||||||
|
"type": evt.MessageType,
|
||||||
|
"message_id": evt.MessageID,
|
||||||
|
"user_id": evt.UserID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if evt.Sender.Nickname != "" {
|
||||||
|
metadata["nickname"] = evt.Sender.Nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"content": truncate(content, 100),
|
||||||
|
})
|
||||||
|
|
||||||
|
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) isDuplicate(messageID string) bool {
|
||||||
|
if messageID == "" || messageID == "0" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := c.dedup[messageID]; exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if old := c.dedupRing[c.dedupIdx]; old != "" {
|
||||||
|
delete(c.dedup, old)
|
||||||
|
}
|
||||||
|
c.dedupRing[c.dedupIdx] = messageID
|
||||||
|
c.dedup[messageID] = struct{}{}
|
||||||
|
c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:n]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) {
|
||||||
|
if isBotMentioned {
|
||||||
|
return true, strings.TrimSpace(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range c.config.GroupTriggerPrefix {
|
||||||
|
if prefix == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(content, prefix) {
|
||||||
|
return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, content
|
||||||
|
}
|
||||||
|
|
@ -78,6 +78,7 @@ type ChannelsConfig struct {
|
||||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
|
|
@ -146,6 +147,15 @@ type LINEConfig struct {
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OneBotConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
||||||
|
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
||||||
|
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||||
|
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||||
|
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
@ -157,24 +167,26 @@ type DevicesConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProvidersConfig struct {
|
type ProvidersConfig struct {
|
||||||
Anthropic ProviderConfig `json:"anthropic"`
|
Anthropic ProviderConfig `json:"anthropic"`
|
||||||
OpenAI ProviderConfig `json:"openai"`
|
OpenAI ProviderConfig `json:"openai"`
|
||||||
OpenRouter ProviderConfig `json:"openrouter"`
|
OpenRouter ProviderConfig `json:"openrouter"`
|
||||||
Groq ProviderConfig `json:"groq"`
|
Groq ProviderConfig `json:"groq"`
|
||||||
Zhipu ProviderConfig `json:"zhipu"`
|
Zhipu ProviderConfig `json:"zhipu"`
|
||||||
VLLM ProviderConfig `json:"vllm"`
|
VLLM ProviderConfig `json:"vllm"`
|
||||||
Gemini ProviderConfig `json:"gemini"`
|
Gemini ProviderConfig `json:"gemini"`
|
||||||
Nvidia ProviderConfig `json:"nvidia"`
|
Nvidia ProviderConfig `json:"nvidia"`
|
||||||
Moonshot ProviderConfig `json:"moonshot"`
|
Moonshot ProviderConfig `json:"moonshot"`
|
||||||
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
|
||||||
DeepSeek ProviderConfig `json:"deepseek"`
|
DeepSeek ProviderConfig `json:"deepseek"`
|
||||||
|
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderConfig struct {
|
type ProviderConfig struct {
|
||||||
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
|
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
|
||||||
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
||||||
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
||||||
|
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GatewayConfig struct {
|
type GatewayConfig struct {
|
||||||
|
|
@ -272,6 +284,14 @@ func DefaultConfig() *Config {
|
||||||
WebhookPath: "/webhook/line",
|
WebhookPath: "/webhook/line",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
OneBot: OneBotConfig{
|
||||||
|
Enabled: false,
|
||||||
|
WSUrl: "ws://127.0.0.1:3001",
|
||||||
|
AccessToken: "",
|
||||||
|
ReconnectInterval: 5,
|
||||||
|
GroupTriggerPrefix: []string{},
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
Anthropic: ProviderConfig{},
|
Anthropic: ProviderConfig{},
|
||||||
|
|
|
||||||
82
pkg/providers/github_copilot_provider.go
Normal file
82
pkg/providers/github_copilot_provider.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
json "encoding/json"
|
||||||
|
|
||||||
|
copilot "github.com/github/copilot-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GitHubCopilotProvider struct {
|
||||||
|
uri string
|
||||||
|
connectMode string // `stdio` or `grpc``
|
||||||
|
|
||||||
|
session *copilot.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
|
||||||
|
|
||||||
|
var session *copilot.Session
|
||||||
|
if connectMode == "" {
|
||||||
|
connectMode = "grpc"
|
||||||
|
}
|
||||||
|
switch connectMode {
|
||||||
|
|
||||||
|
case "stdio":
|
||||||
|
//todo
|
||||||
|
case "grpc":
|
||||||
|
client := copilot.NewClient(&copilot.ClientOptions{
|
||||||
|
CLIUrl: uri,
|
||||||
|
})
|
||||||
|
if err := client.Start(context.Background()); err != nil {
|
||||||
|
return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
|
||||||
|
}
|
||||||
|
defer client.Stop()
|
||||||
|
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
|
||||||
|
Model: model,
|
||||||
|
Hooks: &copilot.SessionHooks{},
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GitHubCopilotProvider{
|
||||||
|
uri: uri,
|
||||||
|
connectMode: connectMode,
|
||||||
|
session: session,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat sends a chat request to GitHub Copilot
|
||||||
|
func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||||
|
type tempMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
out := make([]tempMessage, 0, len(messages))
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
out = append(out, tempMessage{
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: msg.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fullcontent, _ := json.Marshal(out)
|
||||||
|
|
||||||
|
content, _ := p.session.Send(ctx, copilot.MessageOptions{
|
||||||
|
Prompt: string(fullcontent),
|
||||||
|
})
|
||||||
|
|
||||||
|
return &LLMResponse{
|
||||||
|
FinishReason: "stop",
|
||||||
|
Content: content,
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GitHubCopilotProvider) GetDefaultModel() string {
|
||||||
|
|
||||||
|
return "gpt-4.1"
|
||||||
|
}
|
||||||
|
|
@ -314,7 +314,16 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||||
model = "deepseek-chat"
|
model = "deepseek-chat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "github_copilot", "copilot":
|
||||||
|
if cfg.Providers.GitHubCopilot.APIBase != "" {
|
||||||
|
apiBase = cfg.Providers.GitHubCopilot.APIBase
|
||||||
|
} else {
|
||||||
|
apiBase = "localhost:4321"
|
||||||
|
}
|
||||||
|
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: detect provider from model name
|
// Fallback: detect provider from model name
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue