mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
d3bf6bc90a
* feat(channel): add DingTalk channel adapter - Add DingTalk channel adapter (`internal/channel/adapters/dingtalk/`) using dingtalk-stream-sdk-go, supporting inbound message receiving and outbound text/markdown reply - Register DingTalk adapter in cmd/agent and cmd/memoh - Add go.mod dependency: github.com/memohai/dingtalk-stream-sdk-go - Add Dingtalk and Wecom SVG icons and Vue components to @memohai/icon - Refactor existing icon components to remove redundant inline wrappers - Add `channelTypeDisplayName` util for consistent channel label resolution - Add DingTalk/WeCom i18n entries (en/zh) for types and typesShort - Extend channel-icon, bot-channels, channel-settings-panel to support dingtalk/wecom - Use channelTypeDisplayName in profile page to replace ad-hoc i18n lookup * fix(channel,attachment): channel quality refactor & attachment pipeline fixes Channel module: - Fix RemoveAdapter not cleaning connectionMeta (stale status leak) - Fix preparedAttachmentTypeFromMime misclassifying image/gif - Fix sleepWithContext time.After goroutine/timer leak - Export IsDataURL/IsHTTPURL/IsDataPath, dedup across packages - Cache OutboundPolicy in managerOutboundStream to avoid repeated lookups - Split OutboundAttachmentStore: extract ContainerAttachmentIngester interface - Add ManagerOption funcs (WithInboundQueueSize, WithInboundWorkers, WithRefreshInterval) - Add thread-safety docs on OutboundStream / managerOutboundStream - Add debug logs on successful send/edit paths - Expand outbound_prepare_test.go with 21 new cases - Convert no-receiver adapter helpers to package-level funcs; drop unused params DingTalk adapter: - Implement AttachmentResolver: download inbound media via /v1.0/robot/messageFiles/download - Fix pure-image inbound messages failing due to missing resolver Attachment pipeline: - Fix images invisible to LLM in pipeline (DCP) path: inject InlineImages into last user message when cfg.Query is empty - Fix public_url fallback: skip direct URL-to-LLM when ContentHash is set, always prefer inlined persisted asset - Inject path: carry ImageParts through agent.InjectMessage; inline persisted attachments in resolver inject goroutine so mid-stream images reach the model - Fix ResolveMime for images: prefer content-sniffed MIME over platform-declared MIME (fixes Feishu sending image/png header for actual JPEG content → API 400)
327 lines
10 KiB
Go
327 lines
10 KiB
Go
package dingtalk
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultAPIBase = "https://api.dingtalk.com"
|
|
// tokenTTL is slightly under 2h to avoid races near the expiry boundary.
|
|
tokenTTL = 110 * time.Minute
|
|
)
|
|
|
|
// apiClient handles DingTalk OpenAPI authentication and message delivery.
|
|
type apiClient struct {
|
|
appKey string
|
|
appSecret string
|
|
base string
|
|
|
|
mu sync.RWMutex
|
|
token string
|
|
tokenExp time.Time
|
|
|
|
http *http.Client
|
|
}
|
|
|
|
func newAPIClient(appKey, appSecret string) *apiClient {
|
|
return &apiClient{
|
|
appKey: appKey,
|
|
appSecret: appSecret,
|
|
base: defaultAPIBase,
|
|
http: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
type tokenResponse struct {
|
|
AccessToken string `json:"accessToken"` //nolint:gosec // G117: DingTalk API response field, not a credential stored by us
|
|
ExpireIn int `json:"expireIn"`
|
|
}
|
|
|
|
// getToken returns a valid access token, refreshing it when necessary.
|
|
func (c *apiClient) getToken(ctx context.Context) (string, error) {
|
|
c.mu.RLock()
|
|
if c.token != "" && time.Now().Before(c.tokenExp) {
|
|
token := c.token
|
|
c.mu.RUnlock()
|
|
return token, nil
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
// Double-check after acquiring write lock.
|
|
if c.token != "" && time.Now().Before(c.tokenExp) {
|
|
return c.token, nil
|
|
}
|
|
|
|
body, _ := json.Marshal(map[string]string{
|
|
"appKey": c.appKey,
|
|
"appSecret": c.appSecret,
|
|
})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/v1.0/oauth2/accessToken", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("dingtalk token request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req) //nolint:gosec // G704: URL is the DingTalk OpenAPI endpoint, operator-configured
|
|
if err != nil {
|
|
return "", fmt.Errorf("dingtalk token: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("dingtalk token read: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("dingtalk token: status %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
var tr tokenResponse
|
|
if err := json.Unmarshal(data, &tr); err != nil {
|
|
return "", fmt.Errorf("dingtalk token parse: %w", err)
|
|
}
|
|
if strings.TrimSpace(tr.AccessToken) == "" {
|
|
return "", errors.New("dingtalk token: empty in response")
|
|
}
|
|
c.token = tr.AccessToken
|
|
c.tokenExp = time.Now().Add(tokenTTL)
|
|
return c.token, nil
|
|
}
|
|
|
|
// doPost executes an authenticated POST to a DingTalk API path.
|
|
func (c *apiClient) doPost(ctx context.Context, path string, body any) ([]byte, error) {
|
|
token, err := c.getToken(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, _ := json.Marshal(body)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(raw))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("x-acs-dingtalk-access-token", token)
|
|
|
|
resp, err := c.http.Do(req) //nolint:gosec // G704: URL is the DingTalk OpenAPI endpoint, operator-configured
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("dingtalk api %s: status %d: %s", path, resp.StatusCode, string(data))
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
type sendUserMsgReq struct {
|
|
RobotCode string `json:"robotCode"`
|
|
UserIds []string `json:"userIds"`
|
|
MsgKey string `json:"msgKey"`
|
|
MsgParam string `json:"msgParam"`
|
|
}
|
|
|
|
// sendToUser sends a message to one or more DingTalk users via OpenAPI.
|
|
// userIds can contain at most 20 entries per request.
|
|
func (c *apiClient) sendToUser(ctx context.Context, robotCode string, userIds []string, msgKey, msgParam string) error {
|
|
if len(userIds) == 0 {
|
|
return errors.New("dingtalk: userIds is required")
|
|
}
|
|
_, err := c.doPost(ctx, "/v1.0/robot/oToMessages/batchSend", sendUserMsgReq{
|
|
RobotCode: robotCode,
|
|
UserIds: userIds,
|
|
MsgKey: msgKey,
|
|
MsgParam: msgParam,
|
|
})
|
|
return err
|
|
}
|
|
|
|
type sendGroupMsgReq struct {
|
|
RobotCode string `json:"robotCode"`
|
|
OpenConversationId string `json:"openConversationId"`
|
|
MsgKey string `json:"msgKey"`
|
|
MsgParam string `json:"msgParam"`
|
|
}
|
|
|
|
// sendToGroup sends a message to a DingTalk group via OpenAPI.
|
|
func (c *apiClient) sendToGroup(ctx context.Context, robotCode, openConversationId, msgKey, msgParam string) error {
|
|
if strings.TrimSpace(openConversationId) == "" {
|
|
return errors.New("dingtalk: openConversationId is required")
|
|
}
|
|
_, err := c.doPost(ctx, "/v1.0/robot/groupMessages/send", sendGroupMsgReq{
|
|
RobotCode: robotCode,
|
|
OpenConversationId: openConversationId,
|
|
MsgKey: msgKey,
|
|
MsgParam: msgParam,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// sendViaWebhook posts a reply through a session webhook URL.
|
|
// The webhook URL already contains auth; no access_token is required.
|
|
func (c *apiClient) sendViaWebhook(ctx context.Context, webhookURL string, body map[string]any) error {
|
|
raw, _ := json.Marshal(body)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(raw))
|
|
if err != nil {
|
|
return fmt.Errorf("dingtalk webhook request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req) //nolint:gosec // G704: webhook URL is received from DingTalk platform callback, not user-supplied
|
|
if err != nil {
|
|
return fmt.Errorf("dingtalk webhook: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
data, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("dingtalk webhook: status %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getBotInfo queries basic info for the robot itself (used by DiscoverSelf).
|
|
type botInfoResponse struct {
|
|
Result struct {
|
|
Name string `json:"name"`
|
|
RobotCode string `json:"robotCode"`
|
|
} `json:"result"`
|
|
RequestID string `json:"requestId"`
|
|
}
|
|
|
|
type uploadMediaResponse struct {
|
|
MediaID string `json:"media_id"`
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
|
|
// uploadMedia uploads a media file to DingTalk and returns the resulting mediaId.
|
|
// mediaType must be one of: "image", "voice", "video", "file".
|
|
// filename is used as the multipart filename; Content-Type is determined by the multipart writer.
|
|
func (c *apiClient) uploadMedia(ctx context.Context, mediaType, filename string, data io.Reader) (string, error) {
|
|
token, err := c.getToken(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
|
|
// The "type" field tells DingTalk which media category this is.
|
|
if err := mw.WriteField("type", mediaType); err != nil {
|
|
return "", fmt.Errorf("dingtalk upload: write type field: %w", err)
|
|
}
|
|
|
|
// The file part must be named "media".
|
|
part, err := mw.CreateFormFile("media", filename)
|
|
if err != nil {
|
|
return "", fmt.Errorf("dingtalk upload: create form file: %w", err)
|
|
}
|
|
if _, err := io.Copy(part, data); err != nil {
|
|
return "", fmt.Errorf("dingtalk upload: copy media: %w", err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return "", fmt.Errorf("dingtalk upload: close multipart writer: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
c.base+"/media/upload?access_token="+token, &buf)
|
|
if err != nil {
|
|
return "", fmt.Errorf("dingtalk upload: build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
resp, err := c.http.Do(req) //nolint:gosec // URL is the DingTalk OpenAPI media endpoint
|
|
if err != nil {
|
|
return "", fmt.Errorf("dingtalk upload: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("dingtalk upload: read response: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("dingtalk upload: status %d: %s", resp.StatusCode, string(raw))
|
|
}
|
|
var result uploadMediaResponse
|
|
if err := json.Unmarshal(raw, &result); err != nil {
|
|
return "", fmt.Errorf("dingtalk upload: parse response: %w", err)
|
|
}
|
|
if result.ErrCode != 0 {
|
|
return "", fmt.Errorf("dingtalk upload: errcode %d: %s", result.ErrCode, result.ErrMsg)
|
|
}
|
|
if strings.TrimSpace(result.MediaID) == "" {
|
|
return "", fmt.Errorf("dingtalk upload: empty media_id in response: %s", string(raw))
|
|
}
|
|
return result.MediaID, nil
|
|
}
|
|
|
|
type downloadFileResponse struct {
|
|
DownloadURL string `json:"downloadUrl"`
|
|
}
|
|
|
|
// downloadMessageFile resolves a downloadCode received in an inbound message to a
|
|
// temporary download URL, then streams the file content to the caller.
|
|
// The caller is responsible for closing the returned ReadCloser.
|
|
func (c *apiClient) downloadMessageFile(ctx context.Context, robotCode, downloadCode string) (io.ReadCloser, string, error) {
|
|
data, err := c.doPost(ctx, "/v1.0/robot/messageFiles/download", map[string]string{
|
|
"downloadCode": downloadCode,
|
|
"robotCode": robotCode,
|
|
})
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("dingtalk download file: %w", err)
|
|
}
|
|
var result downloadFileResponse
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, "", fmt.Errorf("dingtalk download file: parse response: %w", err)
|
|
}
|
|
downloadURL := strings.TrimSpace(result.DownloadURL)
|
|
if downloadURL == "" {
|
|
return nil, "", fmt.Errorf("dingtalk download file: empty downloadUrl in response: %s", string(data))
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("dingtalk download file: build request: %w", err)
|
|
}
|
|
resp, err := c.http.Do(req) //nolint:gosec // G107: URL is returned by DingTalk API, not user-supplied
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("dingtalk download file: fetch: %w", err)
|
|
}
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
_ = resp.Body.Close()
|
|
return nil, "", fmt.Errorf("dingtalk download file: status %d", resp.StatusCode)
|
|
}
|
|
mimeType := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
|
return resp.Body, mimeType, nil
|
|
}
|
|
|
|
// getBotInfo retrieves the bot's own profile via the OpenAPI.
|
|
func (c *apiClient) getBotInfo(ctx context.Context, robotCode string) (botInfoResponse, error) {
|
|
type req struct {
|
|
RobotCode string `json:"robotCode"`
|
|
}
|
|
data, err := c.doPost(ctx, "/v1.0/robot/robotInfo", req{RobotCode: robotCode})
|
|
if err != nil {
|
|
return botInfoResponse{}, err
|
|
}
|
|
var info botInfoResponse
|
|
if err := json.Unmarshal(data, &info); err != nil {
|
|
return botInfoResponse{}, fmt.Errorf("dingtalk getBotInfo parse: %w", err)
|
|
}
|
|
return info, nil
|
|
}
|