feat(dingtalk): add proactive messaging support with OpenAPI fallback
- Add access token management with auto-refresh (every 5 minutes) - Implement proactive messaging via DingTalk OpenAPI: - Single chat: /v1.0/robot/oToMessages/batchSend - Group chat: /v1.0/robot/groupMessages/send - Add fallback from session_webhook to proactive API - Enable proactive messaging without requiring prior user interaction - Add unit tests for token refresh and proactive send - Update documentation with API references and usage examples This enables heartbeat notifications, device alerts, and other proactive message scenarios even when session_webhook expires. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cff85cfe5c
commit
81e9f0b77a
3 changed files with 747 additions and 28 deletions
|
|
@ -11,7 +11,11 @@
|
||||||
"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": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": false,
|
||||||
|
"prefixes": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -20,14 +24,105 @@
|
||||||
| 字段 | 类型 | 必填 | 描述 |
|
| 字段 | 类型 | 必填 | 描述 |
|
||||||
| ------------- | ------ | ---- | -------------------------------- |
|
| ------------- | ------ | ---- | -------------------------------- |
|
||||||
| enabled | bool | 是 | 是否启用钉钉频道 |
|
| enabled | bool | 是 | 是否启用钉钉频道 |
|
||||||
| client_id | string | 是 | 钉钉应用的 Client ID |
|
| client_id | string | 是 | 钉钉应用的 AppKey(也作为 robotCode 使用) |
|
||||||
| client_secret | string | 是 | 钉钉应用的 Client Secret |
|
| client_secret | string | 是 | 钉钉应用的 AppSecret |
|
||||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||||
|
| group_trigger | object | 否 | 群聊触发配置 |
|
||||||
|
|
||||||
## 设置流程
|
## 设置流程
|
||||||
|
|
||||||
1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
|
1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
|
||||||
2. 创建一个企业内部应用
|
2. 创建一个企业内部应用
|
||||||
3. 从应用设置中获取 Client ID 和 Client Secret
|
3. 从应用设置中获取 AppKey(Client ID)和 AppSecret(Client Secret)
|
||||||
4. 配置OAuth和事件订阅(如需要)
|
4. 配置机器人的回调模式和事件订阅
|
||||||
5. 将 Client ID 和 Client Secret 填入配置文件中
|
5. 将 AppKey 和 AppSecret 填入配置文件中
|
||||||
|
|
||||||
|
## 消息发送机制
|
||||||
|
|
||||||
|
钉钉频道支持两种消息发送方式:
|
||||||
|
|
||||||
|
### 1. Session Webhook 回复(优先)
|
||||||
|
|
||||||
|
当用户发送消息给机器人时,钉钉会提供一个临时的 `session_webhook`,有效期约 2 小时。系统优先使用此方式回复消息,因为它更简单且不需要额外的 API 调用。
|
||||||
|
|
||||||
|
### 2. 主动消息(Proactive Messaging)
|
||||||
|
|
||||||
|
当 `session_webhook` 不可用或已过期时,系统会自动切换到钉钉 OpenAPI 发送主动消息:
|
||||||
|
|
||||||
|
- **单聊消息**: 使用 `/v1.0/robot/oToMessages/batchSend` API
|
||||||
|
- **群聊消息**: 使用 `/v1.0/robot/groupMessages/send` API
|
||||||
|
|
||||||
|
主动消息功能使得以下场景成为可能:
|
||||||
|
- 心跳通知(Heartbeat)
|
||||||
|
- 设备告警
|
||||||
|
- 定时任务提醒
|
||||||
|
- 其他无需用户先发起对话的消息推送
|
||||||
|
|
||||||
|
### 主动消息的工作原理
|
||||||
|
|
||||||
|
1. **首次交互**: 用户发送消息后,系统会存储用户的 `staffId`(单聊)或 `openConversationId`(群聊)
|
||||||
|
2. **后续推送**: 即使 `session_webhook` 过期,系统仍可通过 OpenAPI 主动发送消息
|
||||||
|
3. **无历史交互**: 如果用户从未发送过消息,只要知道用户的 `staffId`,系统也可以主动发送单聊消息
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
| API | 用途 |
|
||||||
|
|-----|------|
|
||||||
|
| `POST /v1.0/oauth2/accessToken` | 获取访问令牌 |
|
||||||
|
| `POST /v1.0/robot/oToMessages/batchSend` | 发送单聊消息 |
|
||||||
|
| `POST /v1.0/robot/groupMessages/send` | 发送群聊消息 |
|
||||||
|
|
||||||
|
### 获取访问令牌
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://api.dingtalk.com/v1.0/oauth2/accessToken
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"appKey": "YOUR_APP_KEY",
|
||||||
|
"appSecret": "YOUR_APP_SECRET"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessToken": "xxx",
|
||||||
|
"expireIn": 7200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送单聊消息
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Acs-Dingtalk-Access-Token: ACCESS_TOKEN
|
||||||
|
|
||||||
|
{
|
||||||
|
"robotCode": "YOUR_APP_KEY",
|
||||||
|
"userIds": ["STAFF_ID"],
|
||||||
|
"msgKey": "sampleMarkdown",
|
||||||
|
"msgParam": "{\"title\":\"标题\",\"text\":\"内容\"}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送群聊消息
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST https://api.dingtalk.com/v1.0/robot/groupMessages/send
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Acs-Dingtalk-Access-Token: ACCESS_TOKEN
|
||||||
|
|
||||||
|
{
|
||||||
|
"robotCode": "YOUR_APP_KEY",
|
||||||
|
"openConversationId": "CONVERSATION_ID",
|
||||||
|
"msgKey": "sampleMarkdown",
|
||||||
|
"msgParam": "{\"title\":\"标题\",\"text\":\"内容\"}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 官方文档
|
||||||
|
|
||||||
|
- [钉钉机器人开发文档](https://open.dingtalk.com/document/orgapp/the-robot-sends-a-group-message)
|
||||||
|
- [获取访问令牌](https://open.dingtalk.com/document/development/obtain-the-access-token-of-an-internal-app)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
// DingTalk channel implementation using Stream Mode
|
// DingTalk channel implementation using Stream Mode with proactive messaging support
|
||||||
|
|
||||||
package dingtalk
|
package dingtalk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"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,18 +25,43 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const dingtalkAPIBase = "https://api.dingtalk.com"
|
||||||
|
|
||||||
|
// chatInfo stores information needed for proactive messaging
|
||||||
|
type chatInfo struct {
|
||||||
|
sessionWebhook string
|
||||||
|
sessionWebhookExp time.Time // From sessionWebhookExpiredTime
|
||||||
|
senderStaffId string // For single chat proactive send
|
||||||
|
openConversationId string // For group chat proactive send (ConversationId)
|
||||||
|
conversationType string // "1" = single, "2" = group
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchSendResponse represents the batch send API response
|
||||||
|
type BatchSendResponse struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ProcessQueryKeys map[string]string `json:"processQueryKeys"`
|
||||||
|
InvalidStaffIdList []string `json:"invalidStaffIdList"`
|
||||||
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config config.DingTalkConfig
|
config config.DingTalkConfig
|
||||||
clientID string
|
clientID string // AppKey (also used as robotCode for proactive messaging)
|
||||||
clientSecret string
|
clientSecret string // AppSecret
|
||||||
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 chat info for each chat (includes session webhook and proactive send info)
|
||||||
sessionWebhooks sync.Map // chatID -> sessionWebhook
|
chatInfos sync.Map // chatID -> *chatInfo
|
||||||
|
|
||||||
|
// HTTP client for proactive API calls
|
||||||
|
httpClient *http.Client
|
||||||
|
accessToken string
|
||||||
|
tokenExpiry time.Time
|
||||||
|
tokenMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDingTalkChannel creates a new DingTalk channel instance
|
// NewDingTalkChannel creates a new DingTalk channel instance
|
||||||
|
|
@ -52,8 +82,9 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
|
||||||
return &DingTalkChannel{
|
return &DingTalkChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
clientID: cfg.ClientID,
|
clientID: cfg.ClientID, // Also used as robotCode for proactive messaging
|
||||||
clientSecret: cfg.ClientSecret,
|
clientSecret: cfg.ClientSecret,
|
||||||
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +111,16 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get initial access token for proactive messaging
|
||||||
|
if err := c.refreshAccessToken(); err != nil {
|
||||||
|
logger.WarnCF("dingtalk", "Failed to get initial access token", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start token refresh goroutine
|
||||||
|
go c.tokenRefreshLoop()
|
||||||
|
|
||||||
c.SetRunning(true)
|
c.SetRunning(true)
|
||||||
logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)")
|
logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)")
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -102,30 +143,171 @@ 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 with fallback from session_webhook to proactive API
|
||||||
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 (if available and not expired)
|
||||||
return c.SendDirectReply(ctx, sessionWebhook, msg.Content)
|
if info, ok := c.getChatInfo(msg.ChatID); ok {
|
||||||
|
if info.sessionWebhook != "" && time.Now().Before(info.sessionWebhookExp) {
|
||||||
|
err := c.SendDirectReply(ctx, info.sessionWebhook, msg.Content)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Log error and fall through to proactive API
|
||||||
|
logger.DebugCF("dingtalk", "session_webhook failed, trying proactive API", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to proactive API
|
||||||
|
return c.sendProactive(ctx, msg.ChatID, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getChatInfo safely retrieves chat info
|
||||||
|
func (c *DingTalkChannel) getChatInfo(chatID string) (*chatInfo, bool) {
|
||||||
|
raw, ok := c.chatInfos.Load(chatID)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
info, ok := raw.(*chatInfo)
|
||||||
|
return info, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendProactive sends a message using the proactive API
|
||||||
|
// If chatInfo exists (from prior user message), use stored info for conversation type.
|
||||||
|
// If not, assume single chat and use chatID directly as staffId - this allows
|
||||||
|
// proactive messaging to users whose staffId is known (e.g., from state/config).
|
||||||
|
func (c *DingTalkChannel) sendProactive(ctx context.Context, chatID, content string) error {
|
||||||
|
accessToken := c.getAccessToken()
|
||||||
|
if accessToken == "" {
|
||||||
|
return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := c.getChatInfo(chatID)
|
||||||
|
if ok {
|
||||||
|
// Use stored info (preferred - we know conversation type)
|
||||||
|
if info.conversationType == "1" {
|
||||||
|
// Single chat - use batch send API
|
||||||
|
return c.sendProactiveSingleChat(ctx, accessToken, info.senderStaffId, content)
|
||||||
|
}
|
||||||
|
// Group chat - use group messages API
|
||||||
|
return c.sendProactiveGroupChat(ctx, accessToken, info.openConversationId, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No stored chatInfo - assume single chat and use chatID directly as staffId
|
||||||
|
// This enables proactive messaging without requiring prior user interaction
|
||||||
|
logger.DebugCF("dingtalk", "No stored chatInfo, assuming single chat", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
})
|
||||||
|
return c.sendProactiveSingleChat(ctx, accessToken, chatID, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendProactiveSingleChat sends message via oToMessages/batchSend API for single chats
|
||||||
|
// robotCode = clientID (AppKey)
|
||||||
|
func (c *DingTalkChannel) sendProactiveSingleChat(ctx context.Context, accessToken, staffId, content string) error {
|
||||||
|
msgParam := buildMarkdownMsgParam("PicoClaw", content)
|
||||||
|
reqBody := map[string]any{
|
||||||
|
"robotCode": c.clientID, // robotCode = AppKey
|
||||||
|
"userIds": []string{staffId},
|
||||||
|
"msgKey": "sampleMarkdown",
|
||||||
|
"msgParam": msgParam,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s/v1.0/robot/oToMessages/batchSend", dingtalkAPIBase)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %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 fmt.Errorf("failed to send request: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result BatchSendResponse
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK || (result.Code != "" && result.Code != "0" && result.Code != "success") {
|
||||||
|
return fmt.Errorf("dingtalk API error: %s (code: %s, status: %d)", result.Message, result.Code, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendProactiveGroupChat sends message via groupMessages API for group chats
|
||||||
|
// robotCode = clientID (AppKey)
|
||||||
|
func (c *DingTalkChannel) sendProactiveGroupChat(
|
||||||
|
ctx context.Context,
|
||||||
|
accessToken, openConversationId, content string,
|
||||||
|
) error {
|
||||||
|
msgParam := buildMarkdownMsgParam("PicoClaw", content)
|
||||||
|
reqBody := map[string]any{
|
||||||
|
"openConversationId": openConversationId,
|
||||||
|
"robotCode": c.clientID, // robotCode = AppKey
|
||||||
|
"msgKey": "sampleMarkdown",
|
||||||
|
"msgParam": msgParam,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s/v1.0/robot/groupMessages/send", dingtalkAPIBase)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %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 fmt.Errorf("failed to send request: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result BatchSendResponse
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK || (result.Code != "" && result.Code != "0" && result.Code != "success") {
|
||||||
|
return fmt.Errorf("dingtalk API error: %s (code: %s, status: %d)", result.Message, result.Code, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
|
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
|
||||||
|
|
@ -158,8 +340,21 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
|
||||||
chatID = data.ConversationId
|
chatID = data.ConversationId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the session webhook for this chat so we can reply later
|
// Parse expiry time from sessionWebhookExpiredTime
|
||||||
c.sessionWebhooks.Store(chatID, data.SessionWebhook)
|
var webhookExpiry time.Time
|
||||||
|
if data.SessionWebhookExpiredTime > 0 {
|
||||||
|
webhookExpiry = time.Unix(data.SessionWebhookExpiredTime/1000, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store extended chat info for proactive messaging
|
||||||
|
info := &chatInfo{
|
||||||
|
sessionWebhook: data.SessionWebhook,
|
||||||
|
sessionWebhookExp: webhookExpiry,
|
||||||
|
senderStaffId: data.SenderStaffId,
|
||||||
|
openConversationId: data.ConversationId,
|
||||||
|
conversationType: data.ConversationType,
|
||||||
|
}
|
||||||
|
c.chatInfos.Store(chatID, info)
|
||||||
|
|
||||||
metadata := map[string]string{
|
metadata := map[string]string{
|
||||||
"sender_name": senderNick,
|
"sender_name": senderNick,
|
||||||
|
|
@ -229,3 +424,103 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshAccessToken fetches a new access token from DingTalk API
|
||||||
|
// API: POST /v1.0/oauth2/accessToken
|
||||||
|
// Body: {"appKey": "...", "appSecret": "..."}
|
||||||
|
// Response: {"accessToken": "...", "expireIn": 7200}
|
||||||
|
func (c *DingTalkChannel) refreshAccessToken() error {
|
||||||
|
reqBody := map[string]string{
|
||||||
|
"appKey": c.clientID,
|
||||||
|
"appSecret": c.clientSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s/v1.0/oauth2/accessToken", dingtalkAPIBase)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(c.ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to request access token: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("access token request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ExpireIn int `json:"expireIn"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.tokenMu.Lock()
|
||||||
|
c.accessToken = tokenResp.AccessToken
|
||||||
|
// Refresh 5 minutes before expiry
|
||||||
|
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpireIn-300) * time.Second)
|
||||||
|
c.tokenMu.Unlock()
|
||||||
|
|
||||||
|
logger.DebugCF("dingtalk", "Access token refreshed successfully", map[string]any{
|
||||||
|
"expire_in": tokenResp.ExpireIn,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenRefreshLoop periodically refreshes the access token
|
||||||
|
func (c *DingTalkChannel) tokenRefreshLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.refreshAccessToken(); err != nil {
|
||||||
|
logger.ErrorCF("dingtalk", "Failed to refresh access token", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAccessToken returns the current valid access token
|
||||||
|
func (c *DingTalkChannel) getAccessToken() string {
|
||||||
|
c.tokenMu.RLock()
|
||||||
|
defer c.tokenMu.RUnlock()
|
||||||
|
|
||||||
|
if time.Now().After(c.tokenExpiry) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMarkdownMsgParam builds the msgParam for markdown messages
|
||||||
|
func buildMarkdownMsgParam(title, content string) string {
|
||||||
|
param := map[string]string{
|
||||||
|
"title": title,
|
||||||
|
"text": content,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(param)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
|
||||||
329
pkg/channels/dingtalk/dingtalk_test.go
Normal file
329
pkg/channels/dingtalk/dingtalk_test.go
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
package dingtalk
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockTransport is a custom http.RoundTripper for testing
|
||||||
|
type mockTransport struct {
|
||||||
|
response *http.Response
|
||||||
|
responseBody any
|
||||||
|
requestErr error
|
||||||
|
requests []*http.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
m.requests = append(m.requests, req)
|
||||||
|
|
||||||
|
if m.requestErr != nil {
|
||||||
|
return nil, m.requestErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.response != nil {
|
||||||
|
return m.response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate response from responseBody
|
||||||
|
bodyBytes, _ := json.Marshal(m.responseBody)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(bodyBytes)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefreshAccessToken tests the access token refresh functionality
|
||||||
|
func TestRefreshAccessToken(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
clientID string
|
||||||
|
clientSecret string
|
||||||
|
serverResponse any
|
||||||
|
serverStatus int
|
||||||
|
expectError bool
|
||||||
|
expectToken string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful token refresh",
|
||||||
|
clientID: "test_app_key",
|
||||||
|
clientSecret: "test_app_secret",
|
||||||
|
serverResponse: map[string]any{
|
||||||
|
"accessToken": "test_access_token_123",
|
||||||
|
"expireIn": 7200,
|
||||||
|
},
|
||||||
|
serverStatus: http.StatusOK,
|
||||||
|
expectError: false,
|
||||||
|
expectToken: "test_access_token_123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid credentials",
|
||||||
|
clientID: "invalid_client",
|
||||||
|
clientSecret: "invalid_secret",
|
||||||
|
serverResponse: map[string]any{
|
||||||
|
"code": "invalid.client",
|
||||||
|
"message": "Invalid client credentials",
|
||||||
|
},
|
||||||
|
serverStatus: http.StatusBadRequest,
|
||||||
|
expectError: true,
|
||||||
|
expectToken: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server error",
|
||||||
|
clientID: "test_app_key",
|
||||||
|
clientSecret: "test_app_secret",
|
||||||
|
serverResponse: map[string]any{
|
||||||
|
"code": "server.error",
|
||||||
|
"message": "Internal server error",
|
||||||
|
},
|
||||||
|
serverStatus: http.StatusInternalServerError,
|
||||||
|
expectError: true,
|
||||||
|
expectToken: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create mock transport
|
||||||
|
mock := &mockTransport{}
|
||||||
|
|
||||||
|
if tt.serverStatus > 0 {
|
||||||
|
bodyBytes, _ := json.Marshal(tt.serverResponse)
|
||||||
|
mock.response = &http.Response{
|
||||||
|
StatusCode: tt.serverStatus,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(bodyBytes)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP client with mock transport
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: mock,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create channel
|
||||||
|
cfg := config.DingTalkConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: tt.clientID,
|
||||||
|
ClientSecret: tt.clientSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := &DingTalkChannel{
|
||||||
|
config: cfg,
|
||||||
|
clientID: tt.clientID,
|
||||||
|
clientSecret: tt.clientSecret,
|
||||||
|
httpClient: httpClient,
|
||||||
|
ctx: context.Background(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the token refresh
|
||||||
|
err := channel.refreshAccessToken()
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected error but got none")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if channel.accessToken != tt.expectToken {
|
||||||
|
t.Errorf("Expected token %s, got %s", tt.expectToken, channel.accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify request format if a request was made
|
||||||
|
if len(mock.requests) > 0 {
|
||||||
|
req := mock.requests[0]
|
||||||
|
|
||||||
|
// Verify method
|
||||||
|
if req.Method != "POST" {
|
||||||
|
t.Errorf("Expected POST request, got %s", req.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify URL path is correct
|
||||||
|
expectedPath := "/v1.0/oauth2/accessToken"
|
||||||
|
if req.URL.Path != expectedPath {
|
||||||
|
t.Errorf("Expected path %s, got %s", expectedPath, req.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Content-Type
|
||||||
|
if req.Header.Get("Content-Type") != "application/json" {
|
||||||
|
t.Errorf("Expected Content-Type: application/json, got %s", req.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and verify request body
|
||||||
|
bodyBytes, _ := io.ReadAll(req.Body)
|
||||||
|
var reqBody map[string]string
|
||||||
|
json.Unmarshal(bodyBytes, &reqBody)
|
||||||
|
|
||||||
|
// Verify new API format: appKey and appSecret
|
||||||
|
if reqBody["appKey"] != tt.clientID {
|
||||||
|
t.Errorf("Expected appKey %s, got %s", tt.clientID, reqBody["appKey"])
|
||||||
|
}
|
||||||
|
if reqBody["appSecret"] != tt.clientSecret {
|
||||||
|
t.Errorf("Expected appSecret %s, got %s", tt.clientSecret, reqBody["appSecret"])
|
||||||
|
}
|
||||||
|
// Verify old fields are NOT present
|
||||||
|
if _, exists := reqBody["client_id"]; exists {
|
||||||
|
t.Error("client_id should not be present in request body")
|
||||||
|
}
|
||||||
|
if _, exists := reqBody["client_secret"]; exists {
|
||||||
|
t.Error("client_secret should not be present in request body")
|
||||||
|
}
|
||||||
|
if _, exists := reqBody["grant_type"]; exists {
|
||||||
|
t.Error("grant_type should not be present in request body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatInfoStorage tests that chat info is properly stored
|
||||||
|
func TestChatInfoStorage(t *testing.T) {
|
||||||
|
channel := &DingTalkChannel{
|
||||||
|
chatInfos: sync.Map{},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
info := &chatInfo{
|
||||||
|
sessionWebhook: "https://webhook.example.com/test",
|
||||||
|
sessionWebhookExp: now.Add(2 * time.Hour),
|
||||||
|
senderStaffId: "staff123",
|
||||||
|
openConversationId: "conv456",
|
||||||
|
conversationType: "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the info
|
||||||
|
channel.chatInfos.Store("test_chat_id", info)
|
||||||
|
|
||||||
|
// Retrieve and verify
|
||||||
|
retrieved, ok := channel.getChatInfo("test_chat_id")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Failed to retrieve chat info")
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.sessionWebhook != info.sessionWebhook {
|
||||||
|
t.Errorf("Expected sessionWebhook %s, got %s", info.sessionWebhook, retrieved.sessionWebhook)
|
||||||
|
}
|
||||||
|
if retrieved.senderStaffId != info.senderStaffId {
|
||||||
|
t.Errorf("Expected senderStaffId %s, got %s", info.senderStaffId, retrieved.senderStaffId)
|
||||||
|
}
|
||||||
|
if retrieved.conversationType != info.conversationType {
|
||||||
|
t.Errorf("Expected conversationType %s, got %s", info.conversationType, retrieved.conversationType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test non-existent chat
|
||||||
|
_, ok = channel.getChatInfo("non_existent")
|
||||||
|
if ok {
|
||||||
|
t.Error("Expected false for non-existent chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildMarkdownMsgParam tests the markdown message parameter builder
|
||||||
|
func TestBuildMarkdownMsgParam(t *testing.T) {
|
||||||
|
result := buildMarkdownMsgParam("Test Title", "Test Content")
|
||||||
|
|
||||||
|
var parsed map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||||
|
t.Fatalf("Failed to parse result: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed["title"] != "Test Title" {
|
||||||
|
t.Errorf("Expected title 'Test Title', got %s", parsed["title"])
|
||||||
|
}
|
||||||
|
if parsed["text"] != "Test Content" {
|
||||||
|
t.Errorf("Expected text 'Test Content', got %s", parsed["text"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTokenExpiry tests the token expiry logic
|
||||||
|
func TestTokenExpiry(t *testing.T) {
|
||||||
|
channel := &DingTalkChannel{
|
||||||
|
accessToken: "test_token",
|
||||||
|
tokenExpiry: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token should be valid
|
||||||
|
token := channel.getAccessToken()
|
||||||
|
if token != "test_token" {
|
||||||
|
t.Errorf("Expected token 'test_token', got %s", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set token as expired
|
||||||
|
channel.tokenExpiry = time.Now().Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
// Token should be empty (expired)
|
||||||
|
token = channel.getAccessToken()
|
||||||
|
if token != "" {
|
||||||
|
t.Errorf("Expected empty token for expired, got %s", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendProactiveWithoutToken tests that proactive send fails gracefully without token
|
||||||
|
func TestSendProactiveWithoutToken(t *testing.T) {
|
||||||
|
channel := &DingTalkChannel{
|
||||||
|
chatInfos: sync.Map{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store some chat info
|
||||||
|
channel.chatInfos.Store("test_chat", &chatInfo{
|
||||||
|
conversationType: "1",
|
||||||
|
senderStaffId: "staff123",
|
||||||
|
})
|
||||||
|
|
||||||
|
// No token set, should fail
|
||||||
|
err := channel.sendProactive(context.Background(), "test_chat", "test message")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error when no access token available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendProactiveWithoutChatInfo tests that proactive send works without stored chat info
|
||||||
|
// When no chatInfo is stored, it assumes single chat and uses chatID as staffId
|
||||||
|
func TestSendProactiveWithoutChatInfo(t *testing.T) {
|
||||||
|
mock := &mockTransport{
|
||||||
|
responseBody: map[string]any{},
|
||||||
|
}
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: mock,
|
||||||
|
}
|
||||||
|
|
||||||
|
channel := &DingTalkChannel{
|
||||||
|
chatInfos: sync.Map{},
|
||||||
|
accessToken: "test_token",
|
||||||
|
tokenExpiry: time.Now().Add(1 * time.Hour),
|
||||||
|
httpClient: httpClient,
|
||||||
|
clientID: "test_app_key", // robotCode = clientID
|
||||||
|
}
|
||||||
|
|
||||||
|
// No chat info stored - should try to send as single chat using clientID as robotCode
|
||||||
|
err := channel.sendProactive(context.Background(), "staff123", "test message")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the request was made to the correct endpoint
|
||||||
|
if len(mock.requests) != 1 {
|
||||||
|
t.Fatalf("Expected 1 request, got %d", len(mock.requests))
|
||||||
|
}
|
||||||
|
|
||||||
|
req := mock.requests[0]
|
||||||
|
expectedPath := "/v1.0/robot/oToMessages/batchSend"
|
||||||
|
if req.URL.Path != expectedPath {
|
||||||
|
t.Errorf("Expected path %s, got %s", expectedPath, req.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the header
|
||||||
|
if req.Header.Get("X-Acs-Dingtalk-Access-Token") != "test_token" {
|
||||||
|
t.Errorf("Expected X-Acs-Dingtalk-Access-Token header, got %s", req.Header.Get("X-Acs-Dingtalk-Access-Token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue