Merge branch 'main' into architecture-32-bit
This commit is contained in:
@@ -9,6 +9,8 @@
|
|||||||
# ── Chat Channel ──────────────────────────
|
# ── Chat Channel ──────────────────────────
|
||||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||||
# DISCORD_BOT_TOKEN=xxx
|
# DISCORD_BOT_TOKEN=xxx
|
||||||
|
# LINE_CHANNEL_SECRET=xxx
|
||||||
|
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
||||||
|
|
||||||
# ── Web Search (optional) ────────────────
|
# ── Web Search (optional) ────────────────
|
||||||
# BRAVE_SEARCH_API_KEY=BSA...
|
# BRAVE_SEARCH_API_KEY=BSA...
|
||||||
|
|||||||
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@@ -16,5 +16,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: fmt
|
||||||
|
run: |
|
||||||
|
make fmt
|
||||||
|
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make build-all
|
run: make build-all
|
||||||
|
|||||||
53
README.ja.md
53
README.ja.md
@@ -223,7 +223,7 @@ picoclaw agent -m "What is 2+2?"
|
|||||||
|
|
||||||
## 💬 チャットアプリ
|
## 💬 チャットアプリ
|
||||||
|
|
||||||
Telegram、Discord、QQ、DingTalk で PicoClaw と会話できます
|
Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
||||||
|
|
||||||
| チャネル | セットアップ |
|
| チャネル | セットアップ |
|
||||||
|---------|------------|
|
|---------|------------|
|
||||||
@@ -231,6 +231,7 @@ Telegram、Discord、QQ、DingTalk で PicoClaw と会話できます
|
|||||||
| **Discord** | 簡単(Bot トークン + Intents) |
|
| **Discord** | 簡単(Bot トークン + Intents) |
|
||||||
| **QQ** | 簡単(AppID + AppSecret) |
|
| **QQ** | 簡単(AppID + AppSecret) |
|
||||||
| **DingTalk** | 普通(アプリ認証情報) |
|
| **DingTalk** | 普通(アプリ認証情報) |
|
||||||
|
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b>(推奨)</summary>
|
<summary><b>Telegram</b>(推奨)</summary>
|
||||||
@@ -376,6 +377,56 @@ picoclaw gateway
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>LINE</b></summary>
|
||||||
|
|
||||||
|
**1. LINE 公式アカウントを作成**
|
||||||
|
|
||||||
|
- [LINE Developers Console](https://developers.line.biz/) にアクセス
|
||||||
|
- プロバイダーを作成 → Messaging API チャネルを作成
|
||||||
|
- **チャネルシークレット** と **チャネルアクセストークン** をコピー
|
||||||
|
|
||||||
|
**2. 設定**
|
||||||
|
|
||||||
|
```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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Webhook URL を設定**
|
||||||
|
|
||||||
|
LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ngrok の例
|
||||||
|
ngrok http 18791
|
||||||
|
```
|
||||||
|
|
||||||
|
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
||||||
|
|
||||||
|
**4. 起動**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
||||||
|
|
||||||
|
> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## ⚙️ 設定
|
## ⚙️ 設定
|
||||||
|
|
||||||
設定ファイル: `~/.picoclaw/config.json`
|
設定ファイル: `~/.picoclaw/config.json`
|
||||||
|
|||||||
65
README.md
65
README.md
@@ -242,14 +242,15 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your picoclaw through Telegram, Discord, or DingTalk
|
Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
| ------------ | -------------------------- |
|
| ------------ | ---------------------------------- |
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
| **QQ** | Easy (AppID + AppSecret) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
|
| **LINE** | Medium (credentials + webhook URL) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
@@ -399,6 +400,56 @@ picoclaw gateway
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>LINE</b></summary>
|
||||||
|
|
||||||
|
**1. Create a LINE Official Account**
|
||||||
|
|
||||||
|
- Go to [LINE Developers Console](https://developers.line.biz/)
|
||||||
|
- Create a provider → Create a Messaging API channel
|
||||||
|
- Copy **Channel Secret** and **Channel Access Token**
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Set up Webhook URL**
|
||||||
|
|
||||||
|
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example with ngrok
|
||||||
|
ngrok http 18791
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
|
||||||
|
|
||||||
|
**4. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
|
||||||
|
|
||||||
|
> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
||||||
|
|
||||||
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
|
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 145 KiB |
@@ -51,6 +51,15 @@
|
|||||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||||
"app_token": "xapp-YOUR-APP-TOKEN",
|
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"enabled": false,
|
||||||
|
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||||
|
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18791,
|
||||||
|
"webhook_path": "/webhook/line",
|
||||||
|
"allow_from": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|||||||
@@ -50,4 +50,3 @@ func TestBaseChannelIsAllowed(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
598
pkg/channels/line.go
Normal file
598
pkg/channels/line.go
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lineAPIBase = "https://api.line.me/v2/bot"
|
||||||
|
lineDataAPIBase = "https://api-data.line.me/v2/bot"
|
||||||
|
lineReplyEndpoint = lineAPIBase + "/message/reply"
|
||||||
|
linePushEndpoint = lineAPIBase + "/message/push"
|
||||||
|
lineContentEndpoint = lineDataAPIBase + "/message/%s/content"
|
||||||
|
lineBotInfoEndpoint = lineAPIBase + "/info"
|
||||||
|
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
||||||
|
lineReplyTokenMaxAge = 25 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type replyTokenEntry struct {
|
||||||
|
token string
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LINEChannel implements the Channel interface for LINE Official Account
|
||||||
|
// using the LINE Messaging API with HTTP webhook for receiving messages
|
||||||
|
// and REST API for sending messages.
|
||||||
|
type LINEChannel struct {
|
||||||
|
*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
|
||||||
|
replyTokens sync.Map // chatID -> replyTokenEntry
|
||||||
|
quoteTokens sync.Map // chatID -> quoteToken (string)
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLINEChannel creates a new LINE channel instance.
|
||||||
|
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
||||||
|
if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
return &LINEChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches the HTTP webhook server.
|
||||||
|
func (c *LINEChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
// Fetch bot profile to get bot's userId for mention detection
|
||||||
|
if err := c.fetchBotInfo(); err != nil {
|
||||||
|
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
|
||||||
|
"bot_user_id": c.botUserID,
|
||||||
|
"basic_id": c.botBasicID,
|
||||||
|
"display_name": c.botDisplayName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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]interface{}{
|
||||||
|
"addr": addr,
|
||||||
|
"path": path,
|
||||||
|
})
|
||||||
|
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.setRunning(true)
|
||||||
|
logger.InfoC("line", "LINE channel started (Webhook Mode)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API.
|
||||||
|
func (c *LINEChannel) fetchBotInfo() error {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("bot info API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
BasicID string `json:"basicId"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.botUserID = info.UserID
|
||||||
|
c.botBasicID = info.BasicID
|
||||||
|
c.botDisplayName = info.DisplayName
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully shuts down the HTTP server.
|
||||||
|
func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("line", "Stopping LINE channel")
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
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]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(false)
|
||||||
|
logger.InfoC("line", "LINE channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookHandler handles incoming LINE webhook requests.
|
||||||
|
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
signature := r.Header.Get("X-Line-Signature")
|
||||||
|
if !c.verifySignature(body, signature) {
|
||||||
|
logger.WarnC("line", "Invalid webhook signature")
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Events []lineEvent `json:"events"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return 200 immediately, process events asynchronously
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
for _, event := range payload.Events {
|
||||||
|
go c.processEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySignature validates the X-Line-Signature using HMAC-SHA256.
|
||||||
|
func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
|
||||||
|
if signature == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret))
|
||||||
|
mac.Write(body)
|
||||||
|
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
return hmac.Equal([]byte(expected), []byte(signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LINE webhook event types
|
||||||
|
type lineEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ReplyToken string `json:"replyToken"`
|
||||||
|
Source lineSource `json:"source"`
|
||||||
|
Message json.RawMessage `json:"message"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lineSource struct {
|
||||||
|
Type string `json:"type"` // "user", "group", "room"
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
GroupID string `json:"groupId"`
|
||||||
|
RoomID string `json:"roomId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lineMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker"
|
||||||
|
Text string `json:"text"`
|
||||||
|
QuoteToken string `json:"quoteToken"`
|
||||||
|
Mention *struct {
|
||||||
|
Mentionees []lineMentionee `json:"mentionees"`
|
||||||
|
} `json:"mention"`
|
||||||
|
ContentProvider struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"contentProvider"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lineMentionee struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Length int `json:"length"`
|
||||||
|
Type string `json:"type"` // "user", "all"
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
|
if event.Type != "message" {
|
||||||
|
logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
|
||||||
|
"type": event.Type,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := event.Source.UserID
|
||||||
|
chatID := c.resolveChatID(event.Source)
|
||||||
|
isGroup := event.Source.Type == "group" || event.Source.Type == "room"
|
||||||
|
|
||||||
|
var msg lineMessage
|
||||||
|
if err := json.Unmarshal(event.Message, &msg); err != nil {
|
||||||
|
logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
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]interface{}{
|
||||||
|
"chat_id": chatID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store reply token for later use
|
||||||
|
if event.ReplyToken != "" {
|
||||||
|
c.replyTokens.Store(chatID, replyTokenEntry{
|
||||||
|
token: event.ReplyToken,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store quote token for quoting the original message in reply
|
||||||
|
if msg.QuoteToken != "" {
|
||||||
|
c.quoteTokens.Store(chatID, msg.QuoteToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
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]interface{}{
|
||||||
|
"file": file,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case "text":
|
||||||
|
content = msg.Text
|
||||||
|
// Strip bot mention from text in group chats
|
||||||
|
if isGroup {
|
||||||
|
content = c.stripBotMention(content, msg)
|
||||||
|
}
|
||||||
|
case "image":
|
||||||
|
localPath := c.downloadContent(msg.ID, "image.jpg")
|
||||||
|
if localPath != "" {
|
||||||
|
localFiles = append(localFiles, localPath)
|
||||||
|
mediaPaths = append(mediaPaths, localPath)
|
||||||
|
content = "[image]"
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
||||||
|
if localPath != "" {
|
||||||
|
localFiles = append(localFiles, localPath)
|
||||||
|
mediaPaths = append(mediaPaths, localPath)
|
||||||
|
content = "[audio]"
|
||||||
|
}
|
||||||
|
case "video":
|
||||||
|
localPath := c.downloadContent(msg.ID, "video.mp4")
|
||||||
|
if localPath != "" {
|
||||||
|
localFiles = append(localFiles, localPath)
|
||||||
|
mediaPaths = append(mediaPaths, localPath)
|
||||||
|
content = "[video]"
|
||||||
|
}
|
||||||
|
case "file":
|
||||||
|
content = "[file]"
|
||||||
|
case "sticker":
|
||||||
|
content = "[sticker]"
|
||||||
|
default:
|
||||||
|
content = fmt.Sprintf("[%s]", msg.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"platform": "line",
|
||||||
|
"source_type": event.Source.Type,
|
||||||
|
"message_id": msg.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("line", "Received message", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"message_type": msg.Type,
|
||||||
|
"is_group": isGroup,
|
||||||
|
"preview": utils.Truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show typing/loading indicator (requires user ID, not group ID)
|
||||||
|
c.sendLoading(senderID)
|
||||||
|
|
||||||
|
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBotMentioned checks if the bot is mentioned in the message.
|
||||||
|
// It first checks the mention metadata (userId match), then falls back
|
||||||
|
// to text-based detection using the bot's display name, since LINE may
|
||||||
|
// not include userId in mentionees for Official Accounts.
|
||||||
|
func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
|
||||||
|
// Check mention metadata
|
||||||
|
if msg.Mention != nil {
|
||||||
|
for _, m := range msg.Mention.Mentionees {
|
||||||
|
if m.Type == "all" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c.botUserID != "" && m.UserID == c.botUserID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mention metadata exists with mentionees but bot not matched by userId.
|
||||||
|
// The bot IS likely mentioned (LINE includes mention struct when bot is @-ed),
|
||||||
|
// so check if any mentionee overlaps with bot display name in text.
|
||||||
|
if c.botDisplayName != "" {
|
||||||
|
for _, m := range msg.Mention.Mentionees {
|
||||||
|
if m.Index >= 0 && m.Length > 0 {
|
||||||
|
runes := []rune(msg.Text)
|
||||||
|
end := m.Index + m.Length
|
||||||
|
if end <= len(runes) {
|
||||||
|
mentionText := string(runes[m.Index:end])
|
||||||
|
if strings.Contains(mentionText, c.botDisplayName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: text-based detection with display name
|
||||||
|
if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripBotMention removes the @BotName mention text from the message.
|
||||||
|
func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
|
||||||
|
stripped := false
|
||||||
|
|
||||||
|
// Try to strip using mention metadata indices
|
||||||
|
if msg.Mention != nil {
|
||||||
|
runes := []rune(text)
|
||||||
|
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
|
||||||
|
m := msg.Mention.Mentionees[i]
|
||||||
|
// Strip if userId matches OR if the mention text contains the bot display name
|
||||||
|
shouldStrip := false
|
||||||
|
if c.botUserID != "" && m.UserID == c.botUserID {
|
||||||
|
shouldStrip = true
|
||||||
|
} else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 {
|
||||||
|
end := m.Index + m.Length
|
||||||
|
if end <= len(runes) {
|
||||||
|
mentionText := string(runes[m.Index:end])
|
||||||
|
if strings.Contains(mentionText, c.botDisplayName) {
|
||||||
|
shouldStrip = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shouldStrip {
|
||||||
|
start := m.Index
|
||||||
|
end := m.Index + m.Length
|
||||||
|
if start >= 0 && end <= len(runes) {
|
||||||
|
runes = append(runes[:start], runes[end:]...)
|
||||||
|
stripped = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stripped {
|
||||||
|
return strings.TrimSpace(string(runes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: strip @DisplayName from text
|
||||||
|
if c.botDisplayName != "" {
|
||||||
|
text = strings.ReplaceAll(text, "@"+c.botDisplayName, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveChatID determines the chat ID from the event source.
|
||||||
|
// For group/room messages, use the group/room ID; for 1:1, use the user ID.
|
||||||
|
func (c *LINEChannel) resolveChatID(source lineSource) string {
|
||||||
|
switch source.Type {
|
||||||
|
case "group":
|
||||||
|
return source.GroupID
|
||||||
|
case "room":
|
||||||
|
return source.RoomID
|
||||||
|
default:
|
||||||
|
return source.UserID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to LINE. It first tries the Reply API (free)
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and consume quote token for this chat
|
||||||
|
var quoteToken string
|
||||||
|
if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {
|
||||||
|
quoteToken = qt.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try reply token first (free, valid for ~25 seconds)
|
||||||
|
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
||||||
|
tokenEntry := entry.(replyTokenEntry)
|
||||||
|
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
||||||
|
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
|
||||||
|
logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"quoted": quoteToken != "",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger.DebugC("line", "Reply API failed, falling back to Push API")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Push API
|
||||||
|
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTextMessage creates a text message object, optionally with quoteToken.
|
||||||
|
func buildTextMessage(content, quoteToken string) map[string]string {
|
||||||
|
msg := map[string]string{
|
||||||
|
"type": "text",
|
||||||
|
"text": content,
|
||||||
|
}
|
||||||
|
if quoteToken != "" {
|
||||||
|
msg["quoteToken"] = quoteToken
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendReply sends a message using the LINE Reply API.
|
||||||
|
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"replyToken": replyToken,
|
||||||
|
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.callAPI(ctx, lineReplyEndpoint, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPush sends a message using the LINE Push API.
|
||||||
|
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"to": to,
|
||||||
|
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.callAPI(ctx, linePushEndpoint, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendLoading sends a loading animation indicator to the chat.
|
||||||
|
func (c *LINEChannel) sendLoading(chatID string) {
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"chatId": chatID,
|
||||||
|
"loadingSeconds": 60,
|
||||||
|
}
|
||||||
|
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
|
||||||
|
logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// callAPI makes an authenticated POST request to the LINE API.
|
||||||
|
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("API request failed: %w", 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 nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadContent downloads media content from the LINE API.
|
||||||
|
func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
||||||
|
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
||||||
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
|
LoggerPrefix: "line",
|
||||||
|
ExtraHeaders: map[string]string{
|
||||||
|
"Authorization": "Bearer " + c.config.ChannelAccessToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -150,6 +150,19 @@ func (m *Manager) initChannels() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["line"] = line
|
||||||
|
logger.InfoC("channels", "LINE 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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ type ChannelsConfig struct {
|
|||||||
QQ QQConfig `json:"qq"`
|
QQ QQConfig `json:"qq"`
|
||||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
|
LINE LINEConfig `json:"line"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
@@ -134,6 +135,16 @@ type SlackConfig struct {
|
|||||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -245,6 +256,15 @@ func DefaultConfig() *Config {
|
|||||||
AppToken: "",
|
AppToken: "",
|
||||||
AllowFrom: []string{},
|
AllowFrom: []string{},
|
||||||
},
|
},
|
||||||
|
LINE: LINEConfig{
|
||||||
|
Enabled: false,
|
||||||
|
ChannelSecret: "",
|
||||||
|
ChannelAccessToken: "",
|
||||||
|
WebhookHost: "0.0.0.0",
|
||||||
|
WebhookPort: 18791,
|
||||||
|
WebhookPath: "/webhook/line",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
Anthropic: ProviderConfig{},
|
Anthropic: ProviderConfig{},
|
||||||
|
|||||||
@@ -136,11 +136,14 @@ func TestDefaultConfig_WebTools(t *testing.T) {
|
|||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
// Verify web tools defaults
|
// Verify web tools defaults
|
||||||
if cfg.Tools.Web.Search.MaxResults != 5 {
|
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
||||||
t.Error("Expected MaxResults 5, got ", cfg.Tools.Web.Search.MaxResults)
|
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.Search.APIKey != "" {
|
if cfg.Tools.Web.Brave.APIKey != "" {
|
||||||
t.Error("Search API key should be empty by default")
|
t.Error("Brave API key should be empty by default")
|
||||||
|
}
|
||||||
|
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
||||||
|
t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou
|
|||||||
func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) {
|
func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) {
|
||||||
// Simple regex based extraction for DDG HTML
|
// Simple regex based extraction for DDG HTML
|
||||||
// Strategy: Find all result containers or key anchors directly
|
// Strategy: Find all result containers or key anchors directly
|
||||||
|
|
||||||
// Try finding the result links directly first, as they are the most critical
|
// Try finding the result links directly first, as they are the most critical
|
||||||
// Pattern: <a class="result__a" href="...">Title</a>
|
// Pattern: <a class="result__a" href="...">Title</a>
|
||||||
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
|
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
|
||||||
@@ -133,14 +133,14 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
|||||||
// But simple global search for snippets might mismatch order.
|
// But simple global search for snippets might mismatch order.
|
||||||
// Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex)
|
// Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex)
|
||||||
// Or better: Let's assume the snippet follows the link in the HTML
|
// Or better: Let's assume the snippet follows the link in the HTML
|
||||||
|
|
||||||
// A better regex approach: iterate through text and find matches in order
|
// A better regex approach: iterate through text and find matches in order
|
||||||
// But for now, let's grab all snippets too
|
// But for now, let's grab all snippets too
|
||||||
reSnippet := regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
reSnippet := regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
|
||||||
snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5)
|
snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5)
|
||||||
|
|
||||||
maxItems := min(len(matches), count)
|
maxItems := min(len(matches), count)
|
||||||
|
|
||||||
for i := 0; i < maxItems; i++ {
|
for i := 0; i < maxItems; i++ {
|
||||||
urlStr := matches[i][1]
|
urlStr := matches[i][1]
|
||||||
title := stripTags(matches[i][2])
|
title := stripTags(matches[i][2])
|
||||||
@@ -157,7 +157,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr))
|
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr))
|
||||||
|
|
||||||
// Attempt to attach snippet if available and index aligns
|
// Attempt to attach snippet if available and index aligns
|
||||||
if i < len(snippetMatches) {
|
if i < len(snippetMatches) {
|
||||||
snippet := stripTags(snippetMatches[i][1])
|
snippet := stripTags(snippetMatches[i][1])
|
||||||
@@ -171,13 +171,6 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
|
|||||||
return strings.Join(lines, "\n"), nil
|
return strings.Join(lines, "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
func stripTags(content string) string {
|
func stripTags(content string) string {
|
||||||
re := regexp.MustCompile(`<[^>]+>`)
|
re := regexp.MustCompile(`<[^>]+>`)
|
||||||
return re.ReplaceAllString(content, "")
|
return re.ReplaceAllString(content, "")
|
||||||
|
|||||||
@@ -173,30 +173,19 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWebTool_WebSearch_NoApiKey verifies error handling when API key is missing
|
// TestWebTool_WebSearch_NoApiKey verifies that nil is returned when no provider is configured
|
||||||
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
|
||||||
tool := NewWebSearchTool("", 5)
|
tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "", BraveMaxResults: 5})
|
||||||
ctx := context.Background()
|
|
||||||
args := map[string]interface{}{
|
|
||||||
"query": "test",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
// Should return nil when no provider is enabled
|
||||||
|
if tool != nil {
|
||||||
// Should return error result
|
t.Errorf("Expected nil when no search provider is configured")
|
||||||
if !result.IsError {
|
|
||||||
t.Errorf("Expected error when API key is missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should mention missing API key
|
|
||||||
if !strings.Contains(result.ForLLM, "BRAVE_API_KEY") && !strings.Contains(result.ForUser, "BRAVE_API_KEY") {
|
|
||||||
t.Errorf("Expected API key error message, got ForLLM: %s", result.ForLLM)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
|
||||||
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
|
||||||
tool := NewWebSearchTool("test-key", 5)
|
tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "test-key", BraveMaxResults: 5, BraveEnabled: true})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]interface{}{}
|
args := map[string]interface{}{}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user