This commit is contained in:
OpenClaw-User 2026-03-14 17:09:38 +08:00
commit 711b58e7b3
5 changed files with 553 additions and 29 deletions

View file

@ -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 |

View 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)
}

View file

@ -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,

View 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(),
})
}
}
}
}
}
}

View file

@ -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 {