Merge PR #1414
This commit is contained in:
commit
711b58e7b3
5 changed files with 553 additions and 29 deletions
|
|
@ -11,23 +11,83 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"proactive_send": false,
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": false,
|
||||||
|
"prefixes": []
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 描述 |
|
| 字段 | 类型 | 必填 | 描述 |
|
||||||
| ------------- | ------ | ---- | -------------------------------- |
|
| ------------------- | ------ | ---- | -------------------------------------------- |
|
||||||
| enabled | bool | 是 | 是否启用钉钉频道 |
|
| enabled | bool | 是 | 是否启用钉钉频道 |
|
||||||
| client_id | string | 是 | 钉钉应用的 Client ID |
|
| client_id | string | 是 | 钉钉应用的 Client ID |
|
||||||
| client_secret | string | 是 | 钉钉应用的 Client Secret |
|
| client_secret | string | 是 | 钉钉应用的 Client Secret |
|
||||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||||
|
| proactive_send | bool | 否 | 是否启用主动消息发送(通过机器人API) |
|
||||||
|
| group_trigger | object | 否 | 群聊触发配置 |
|
||||||
|
| reasoning_channel_id| string | 否 | 推理消息发送的目标频道ID |
|
||||||
|
|
||||||
|
### 群聊触发配置
|
||||||
|
|
||||||
|
| 字段 | 类型 | 描述 |
|
||||||
|
| ----------- | -------- | ------------------------------------------ |
|
||||||
|
| mention_only| bool | 是否仅在@提及时响应 |
|
||||||
|
| prefixes | []string | 触发前缀列表,如 `["/ai", "机器人"]` |
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 健康监控与自动恢复
|
||||||
|
|
||||||
|
钉钉频道内置了连接健康监控机制,确保流式连接的稳定性:
|
||||||
|
|
||||||
|
- **定期健康检查**:每 5 分钟检查一次连接状态
|
||||||
|
- **自动恢复**:当连接超过 30 分钟未收到消息时,自动触发重连
|
||||||
|
- **重试机制**:恢复失败时每 30 秒重试一次,直到成功
|
||||||
|
|
||||||
|
### 主动消息发送
|
||||||
|
|
||||||
|
启用 `proactive_send` 后,机器人可以主动向用户或群组发送消息:
|
||||||
|
|
||||||
|
- **智能路由**:优先使用会话 webhook 发送,失效时自动切换到机器人 API
|
||||||
|
- **Token 自动刷新**:每 5 分钟自动刷新访问令牌,确保 API 可用性
|
||||||
|
- **支持场景**:
|
||||||
|
- 单聊消息:通过 `oToMessages/batchSend` API
|
||||||
|
- 群聊消息:通过 `groupMessages/send` API
|
||||||
|
|
||||||
|
### 结构化日志
|
||||||
|
|
||||||
|
所有钉钉相关的日志均采用结构化格式输出,便于调试和监控:
|
||||||
|
|
||||||
|
```log
|
||||||
|
INFO dingtalk: DingTalk channel started {"proactive_send": true}
|
||||||
|
DEBUG dingtalk: Received message {"sender_nick": "张三", "sender_id": "user123", "preview": "你好"}
|
||||||
|
INFO dingtalk: Connection recovered successfully {}
|
||||||
|
```
|
||||||
|
|
||||||
## 设置流程
|
## 设置流程
|
||||||
|
|
||||||
1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
|
1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
|
||||||
2. 创建一个企业内部应用
|
2. 创建一个企业内部应用
|
||||||
3. 从应用设置中获取 Client ID 和 Client Secret
|
3. 从应用设置中获取 Client ID 和 Client Secret
|
||||||
4. 配置OAuth和事件订阅(如需要)
|
4. 配置 OAuth 和事件订阅(如需要)
|
||||||
5. 将 Client ID 和 Client Secret 填入配置文件中
|
5. 将 Client ID 和 Client Secret 填入配置文件中
|
||||||
|
6. 如需主动消息功能,设置 `proactive_send: true`
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
所有配置项均可通过环境变量设置:
|
||||||
|
|
||||||
|
| 环境变量 | 对应配置项 |
|
||||||
|
| --------------------------------------------- | ----------------------- |
|
||||||
|
| PICOCLAW_CHANNELS_DINGTALK_ENABLED | enabled |
|
||||||
|
| PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID | client_id |
|
||||||
|
| PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET | client_secret |
|
||||||
|
| PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM | allow_from |
|
||||||
|
| PICOCLAW_CHANNELS_DINGTALK_PROACTIVE_SEND | proactive_send |
|
||||||
|
| PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID| reasoning_channel_id |
|
||||||
|
|
|
||||||
151
pkg/channels/dingtalk/api.go
Normal file
151
pkg/channels/dingtalk/api.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// DingTalk robot API for proactive messaging
|
||||||
|
|
||||||
|
package dingtalk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sendGroupMessage sends a message to a group chat via robot API
|
||||||
|
func (c *DingTalkChannel) sendGroupMessage(ctx context.Context, accessToken, openConversationId, content string) error {
|
||||||
|
url := fmt.Sprintf("%s/v1.0/robot/groupMessages/send", dingtalkAPIBase)
|
||||||
|
|
||||||
|
// Build msgParam for markdown message
|
||||||
|
msgParam, _ := json.Marshal(map[string]string{
|
||||||
|
"title": "PicoClaw",
|
||||||
|
"text": content,
|
||||||
|
})
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"openConversationId": openConversationId,
|
||||||
|
"robotCode": c.clientID,
|
||||||
|
"msgKey": "sampleMarkdown",
|
||||||
|
"msgParam": string(msgParam),
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.sendRobotAPIRequest(ctx, accessToken, url, body, "group")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOToMessage sends a message to a user via robot API (one-to-one)
|
||||||
|
func (c *DingTalkChannel) sendOToMessage(ctx context.Context, accessToken, userID, content string) error {
|
||||||
|
url := fmt.Sprintf("%s/v1.0/robot/oToMessages/batchSend", dingtalkAPIBase)
|
||||||
|
|
||||||
|
// Build msgParam for markdown message
|
||||||
|
msgParam, _ := json.Marshal(map[string]string{
|
||||||
|
"title": "PicoClaw",
|
||||||
|
"text": content,
|
||||||
|
})
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"robotCode": c.clientID,
|
||||||
|
"userIds": []string{userID},
|
||||||
|
"msgKey": "sampleMarkdown",
|
||||||
|
"msgParam": string(msgParam),
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.sendRobotAPIRequest(ctx, accessToken, url, body, "direct")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendRobotAPIRequest sends a request to DingTalk robot API
|
||||||
|
func (c *DingTalkChannel) sendRobotAPIRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
accessToken, url string,
|
||||||
|
body any,
|
||||||
|
msgType string,
|
||||||
|
) error {
|
||||||
|
jsonData, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request body failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("x-acs-dingtalk-access-token", accessToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return channels.ClassifyNetError(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// Parse error response for better error message
|
||||||
|
var errResp struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Msg string `json:"message"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(respBody, &errResp) == nil && errResp.Msg != "" {
|
||||||
|
return fmt.Errorf("dingtalk robot API error: %s (%s)", errResp.Msg, errResp.Code)
|
||||||
|
}
|
||||||
|
return channels.ClassifySendError(resp.StatusCode,
|
||||||
|
fmt.Errorf("dingtalk robot API error (status %d): %s", resp.StatusCode, string(respBody)))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("dingtalk", "Robot API message sent successfully", map[string]any{
|
||||||
|
"type": msgType,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendViaRobotAPI sends a message via the robot API
|
||||||
|
// For proactive messaging: chatID is used directly as staffId (direct) or openConversationId (group)
|
||||||
|
// Group IDs start with "cid", everything else is treated as direct message staffId
|
||||||
|
func (c *DingTalkChannel) sendViaRobotAPI(ctx context.Context, chatID, content string) error {
|
||||||
|
accessToken, err := c.ensureValidToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, check if we have session info (from prior conversation)
|
||||||
|
session := c.getSession(chatID)
|
||||||
|
if session != nil {
|
||||||
|
if session.ConversationType == "1" {
|
||||||
|
// Direct message with session info
|
||||||
|
if session.SenderStaffId != "" {
|
||||||
|
return c.sendOToMessage(ctx, accessToken, session.SenderStaffId, content)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Group message with session info
|
||||||
|
if session.OpenConversationId != "" {
|
||||||
|
return c.sendGroupMessage(ctx, accessToken, session.OpenConversationId, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No session info - detect type based on chatID format
|
||||||
|
// Group IDs start with "cid" (openConversationId format)
|
||||||
|
isGroup := len(chatID) > 3 && chatID[:3] == "cid"
|
||||||
|
|
||||||
|
if isGroup {
|
||||||
|
logger.DebugCF("dingtalk", "Sending proactive group message", map[string]any{
|
||||||
|
"open_conversation_id": chatID,
|
||||||
|
})
|
||||||
|
return c.sendGroupMessage(ctx, accessToken, chatID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct message - chatID is the staffId
|
||||||
|
logger.DebugCF("dingtalk", "Sending proactive direct message", map[string]any{
|
||||||
|
"staff_id": chatID,
|
||||||
|
})
|
||||||
|
return c.sendOToMessage(ctx, accessToken, chatID, content)
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,9 @@ package dingtalk
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
||||||
|
|
@ -20,6 +22,24 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Health check constants
|
||||||
|
const (
|
||||||
|
healthCheckInterval = 5 * time.Minute // Check every 5 minutes
|
||||||
|
maxSilenceDuration = 30 * time.Minute // Max time without messages before recovery
|
||||||
|
recoveryDelay = 2 * time.Second // Delay before reconnecting
|
||||||
|
recoveryRetryDelay = 30 * time.Second // Delay before retrying failed recovery
|
||||||
|
)
|
||||||
|
|
||||||
|
// DingTalkSession stores complete session information for proactive messaging
|
||||||
|
type DingTalkSession struct {
|
||||||
|
SessionWebhook string `json:"session_webhook"`
|
||||||
|
SessionWebhookExpiredTime int64 `json:"session_webhook_expired_time"` // milliseconds timestamp
|
||||||
|
OpenConversationId string `json:"open_conversation_id"` // Group chat ID
|
||||||
|
SenderStaffId string `json:"sender_staff_id"` // User ID for direct chat
|
||||||
|
ConversationType string `json:"conversation_type"` // "1" = direct, else = group
|
||||||
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
|
}
|
||||||
|
|
||||||
// DingTalkChannel implements the Channel interface for DingTalk (钉钉)
|
// DingTalkChannel implements the Channel interface for DingTalk (钉钉)
|
||||||
// It uses WebSocket for receiving messages via stream mode and API for sending
|
// It uses WebSocket for receiving messages via stream mode and API for sending
|
||||||
type DingTalkChannel struct {
|
type DingTalkChannel struct {
|
||||||
|
|
@ -30,8 +50,16 @@ type DingTalkChannel struct {
|
||||||
streamClient *client.StreamClient
|
streamClient *client.StreamClient
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
// Map to store session webhooks for each chat
|
// Map to store session info for each chat
|
||||||
sessionWebhooks sync.Map // chatID -> sessionWebhook
|
sessions sync.Map // chatID -> *DingTalkSession
|
||||||
|
// Token management for proactive messaging via robot API
|
||||||
|
accessToken string
|
||||||
|
tokenExpiry time.Time
|
||||||
|
tokenMu sync.RWMutex
|
||||||
|
httpClient *http.Client
|
||||||
|
// Health monitoring
|
||||||
|
lastMessageTime time.Time
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDingTalkChannel creates a new DingTalk channel instance
|
// NewDingTalkChannel creates a new DingTalk channel instance
|
||||||
|
|
@ -62,7 +90,38 @@ func (c *DingTalkChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("dingtalk", "Starting DingTalk channel (Stream Mode)...")
|
logger.InfoC("dingtalk", "Starting DingTalk channel (Stream Mode)...")
|
||||||
|
|
||||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
c.lastMessageTime = time.Now() // Initialize on start
|
||||||
|
|
||||||
|
// Initialize HTTP client for robot API
|
||||||
|
c.httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
|
// If proactive send is enabled, get initial token and start refresh loop
|
||||||
|
if c.config.ProactiveSend {
|
||||||
|
if err := c.refreshAccessToken(); err != nil {
|
||||||
|
logger.WarnCF("dingtalk", "Failed to get initial access token, proactive send may not work", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
go c.tokenRefreshLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the stream client
|
||||||
|
if err := c.startStreamClient(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start health monitoring goroutine
|
||||||
|
go c.healthMonitor()
|
||||||
|
|
||||||
|
c.SetRunning(true)
|
||||||
|
logger.InfoCF("dingtalk", "DingTalk channel started", map[string]any{
|
||||||
|
"proactive_send": c.config.ProactiveSend,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startStreamClient creates and starts the stream client
|
||||||
|
func (c *DingTalkChannel) startStreamClient() error {
|
||||||
// Create credential config
|
// Create credential config
|
||||||
cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret)
|
cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret)
|
||||||
|
|
||||||
|
|
@ -72,7 +131,7 @@ func (c *DingTalkChannel) Start(ctx context.Context) error {
|
||||||
client.WithAutoReconnect(true),
|
client.WithAutoReconnect(true),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register chatbot callback handler (IChatBotMessageHandler is a function type)
|
// Register chatbot callback handler
|
||||||
c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived)
|
c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived)
|
||||||
|
|
||||||
// Start the stream client
|
// Start the stream client
|
||||||
|
|
@ -80,11 +139,84 @@ func (c *DingTalkChannel) Start(ctx context.Context) error {
|
||||||
return fmt.Errorf("failed to start stream client: %w", err)
|
return fmt.Errorf("failed to start stream client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.SetRunning(true)
|
|
||||||
logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// healthMonitor periodically checks connection health and triggers recovery if needed
|
||||||
|
func (c *DingTalkChannel) healthMonitor() {
|
||||||
|
ticker := time.NewTicker(healthCheckInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.checkAndRecover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAndRecover checks if connection is stale and triggers recovery
|
||||||
|
func (c *DingTalkChannel) checkAndRecover() {
|
||||||
|
c.mu.RLock()
|
||||||
|
silenceDuration := time.Since(c.lastMessageTime)
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
logger.DebugCF("dingtalk", "Health check: silence duration", map[string]any{"duration": silenceDuration})
|
||||||
|
|
||||||
|
if silenceDuration >= maxSilenceDuration {
|
||||||
|
logger.InfoCF(
|
||||||
|
"dingtalk",
|
||||||
|
"Connection appears stale (no messages for configured duration), triggering recovery",
|
||||||
|
map[string]any{"silenceDuration": silenceDuration},
|
||||||
|
)
|
||||||
|
c.recoverConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverConnection attempts to recover the stream connection
|
||||||
|
func (c *DingTalkChannel) recoverConnection() {
|
||||||
|
// Close old client
|
||||||
|
if c.streamClient != nil {
|
||||||
|
c.streamClient.Close()
|
||||||
|
time.Sleep(recoveryDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to reconnect
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
logger.InfoC("dingtalk", "Recovery aborted: context cancelled")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.startStreamClient()
|
||||||
|
if err == nil {
|
||||||
|
logger.InfoCF("dingtalk", "Connection recovered successfully", map[string]any{})
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastMessageTime = time.Now()
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.WarnCF(
|
||||||
|
"dingtalk",
|
||||||
|
"Recovery failed, retrying",
|
||||||
|
map[string]any{"error": err, "retryDelay": recoveryRetryDelay},
|
||||||
|
)
|
||||||
|
time.Sleep(recoveryRetryDelay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLastMessageTime updates the last message timestamp
|
||||||
|
func (c *DingTalkChannel) updateLastMessageTime() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastMessageTime = time.Now()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// Stop gracefully stops the DingTalk channel
|
// Stop gracefully stops the DingTalk channel
|
||||||
func (c *DingTalkChannel) Stop(ctx context.Context) error {
|
func (c *DingTalkChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("dingtalk", "Stopping DingTalk channel...")
|
logger.InfoC("dingtalk", "Stopping DingTalk channel...")
|
||||||
|
|
@ -102,30 +234,60 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send sends a message to DingTalk via the chatbot reply API
|
// Send sends a message to DingTalk
|
||||||
|
// Priority: 1) session_webhook (if valid), 2) robot API (if proactive_send enabled)
|
||||||
func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
if !c.IsRunning() {
|
if !c.IsRunning() {
|
||||||
return channels.ErrNotRunning
|
return channels.ErrNotRunning
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get session webhook from storage
|
|
||||||
sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionWebhook, ok := sessionWebhookRaw.(string)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.DebugCF("dingtalk", "Sending message", map[string]any{
|
logger.DebugCF("dingtalk", "Sending message", map[string]any{
|
||||||
"chat_id": msg.ChatID,
|
"chat_id": msg.ChatID,
|
||||||
"preview": utils.Truncate(msg.Content, 100),
|
"preview": utils.Truncate(msg.Content, 100),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use the session webhook to send the reply
|
// 1. Try session webhook first
|
||||||
return c.SendDirectReply(ctx, sessionWebhook, msg.Content)
|
session := c.getSession(msg.ChatID)
|
||||||
|
if session != nil && c.isSessionWebhookValid(session) {
|
||||||
|
err := c.SendDirectReply(ctx, session.SessionWebhook, msg.Content)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Session webhook failed, log and try fallback
|
||||||
|
logger.WarnCF("dingtalk", "Session webhook send failed, trying fallback", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to robot API if proactive send is enabled
|
||||||
|
if !c.config.ProactiveSend {
|
||||||
|
return fmt.Errorf("no valid session_webhook for chat %s and proactive_send is disabled", msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.sendViaRobotAPI(ctx, msg.ChatID, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSession retrieves session info for a chat
|
||||||
|
func (c *DingTalkChannel) getSession(chatID string) *DingTalkSession {
|
||||||
|
if v, ok := c.sessions.Load(chatID); ok {
|
||||||
|
if session, ok := v.(*DingTalkSession); ok {
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSessionWebhookValid checks if the session webhook is still valid
|
||||||
|
func (c *DingTalkChannel) isSessionWebhookValid(session *DingTalkSession) bool {
|
||||||
|
if session.SessionWebhook == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Check expiry if available (SessionWebhookExpiredTime is in milliseconds)
|
||||||
|
if session.SessionWebhookExpiredTime > 0 {
|
||||||
|
return time.Now().UnixMilli() < session.SessionWebhookExpiredTime
|
||||||
|
}
|
||||||
|
return true // No expiry info, assume valid
|
||||||
}
|
}
|
||||||
|
|
||||||
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
|
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
|
||||||
|
|
@ -135,6 +297,9 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
data *chatbot.BotCallbackDataModel,
|
data *chatbot.BotCallbackDataModel,
|
||||||
) ([]byte, error) {
|
) ([]byte, error) {
|
||||||
|
// Update last message time for health monitoring
|
||||||
|
c.updateLastMessageTime()
|
||||||
|
|
||||||
// Extract message content from Text field
|
// Extract message content from Text field
|
||||||
content := data.Text.Content
|
content := data.Text.Content
|
||||||
if content == "" {
|
if content == "" {
|
||||||
|
|
@ -158,8 +323,16 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||||
chatID = data.ConversationId
|
chatID = data.ConversationId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the session webhook for this chat so we can reply later
|
// Store complete session info for proactive messaging
|
||||||
c.sessionWebhooks.Store(chatID, data.SessionWebhook)
|
session := &DingTalkSession{
|
||||||
|
SessionWebhook: data.SessionWebhook,
|
||||||
|
SessionWebhookExpiredTime: data.SessionWebhookExpiredTime,
|
||||||
|
OpenConversationId: data.ConversationId,
|
||||||
|
SenderStaffId: data.SenderStaffId,
|
||||||
|
ConversationType: data.ConversationType,
|
||||||
|
LastUpdated: time.Now(),
|
||||||
|
}
|
||||||
|
c.sessions.Store(chatID, session)
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"sender_name": senderNick,
|
"sender_name": senderNick,
|
||||||
|
|
|
||||||
139
pkg/channels/dingtalk/token.go
Normal file
139
pkg/channels/dingtalk/token.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// DingTalk access token management
|
||||||
|
|
||||||
|
package dingtalk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dingtalkAPIBase = "https://api.dingtalk.com"
|
||||||
|
tokenRefreshInterval = 5 * time.Minute
|
||||||
|
tokenExpireBuffer = 5 * time.Minute // Refresh token 5 minutes before expiry
|
||||||
|
)
|
||||||
|
|
||||||
|
// accessTokenResponse 钉钉 access_token 响应
|
||||||
|
type accessTokenResponse struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ExpireIn int64 `json:"expireIn"` // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshAccessToken refreshes the access token from DingTalk API
|
||||||
|
func (c *DingTalkChannel) refreshAccessToken() error {
|
||||||
|
url := fmt.Sprintf("%s/v1.0/oauth2/accessToken", dingtalkAPIBase)
|
||||||
|
|
||||||
|
body := map[string]string{
|
||||||
|
"appKey": c.clientID,
|
||||||
|
"appSecret": c.clientSecret,
|
||||||
|
}
|
||||||
|
jsonData, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal request body failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("dingtalk API error (status %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp accessTokenResponse
|
||||||
|
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
||||||
|
return fmt.Errorf("parse response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.AccessToken == "" {
|
||||||
|
return fmt.Errorf("empty access token in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.tokenMu.Lock()
|
||||||
|
c.accessToken = tokenResp.AccessToken
|
||||||
|
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpireIn) * time.Second)
|
||||||
|
c.tokenMu.Unlock()
|
||||||
|
|
||||||
|
logger.DebugCF("dingtalk", "Access token refreshed successfully", map[string]any{
|
||||||
|
"expires_in": tokenResp.ExpireIn,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAccessToken returns the current valid access token
|
||||||
|
// Returns empty string if token is expired or about to expire
|
||||||
|
func (c *DingTalkChannel) getAccessToken() string {
|
||||||
|
c.tokenMu.RLock()
|
||||||
|
defer c.tokenMu.RUnlock()
|
||||||
|
|
||||||
|
if c.accessToken == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is about to expire (within buffer time)
|
||||||
|
if time.Now().After(c.tokenExpiry.Add(-tokenExpireBuffer)) {
|
||||||
|
return "" // Token expired or about to expire
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureValidToken ensures we have a valid access token, refreshing if necessary
|
||||||
|
func (c *DingTalkChannel) ensureValidToken(ctx context.Context) (string, error) {
|
||||||
|
token := c.getAccessToken()
|
||||||
|
if token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to refresh
|
||||||
|
if err := c.refreshAccessToken(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to refresh access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.getAccessToken(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenRefreshLoop runs periodically to refresh the access token
|
||||||
|
func (c *DingTalkChannel) tokenRefreshLoop() {
|
||||||
|
ticker := time.NewTicker(tokenRefreshInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if c.config.ProactiveSend {
|
||||||
|
// Check if token needs refresh
|
||||||
|
if c.getAccessToken() == "" {
|
||||||
|
if err := c.refreshAccessToken(); err != nil {
|
||||||
|
logger.ErrorCF("dingtalk", "Failed to refresh access token", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -362,6 +362,7 @@ type DingTalkConfig struct {
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
||||||
|
ProactiveSend bool `json:"proactive_send" env:"PICOCLAW_CHANNELS_DINGTALK_PROACTIVE_SEND"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlackConfig struct {
|
type SlackConfig struct {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue