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
This commit is contained in:
BBQ
2026-04-08 09:45:06 +08:00
parent 48da4e026e
commit 9e9cd73dd9
25 changed files with 1541 additions and 31 deletions
@@ -14,12 +14,14 @@
<script setup lang="ts">
import { computed, type Component } from 'vue'
import {
Dingtalk,
Qq,
Telegram,
Discord,
Slack,
Feishu,
Wechat,
Wecom,
Matrix,
} from '@memohai/icon'
@@ -31,7 +33,9 @@ const channelIcons: Record<string, Component> = {
feishu: Feishu,
wechat: Wechat,
weixin: Wechat,
wecom: Wecom,
matrix: Matrix,
dingtalk: Dingtalk,
}
const props = withDefaults(defineProps<{
+9
View File
@@ -993,6 +993,7 @@
"feishuWebhookSecurityHint": "For security, webhook mode requires either an Encrypt Key or a Verification Token; an unprotected public callback URL should not be exposed.",
"feishuWebhookSecretRequired": "For security, configure at least one of Encrypt Key or Verification Token.",
"noAvailableTypes": "All platform types have been configured",
"platformKey": "Platform ID: {key}",
"weixinQr": {
"title": "QR Code Login",
"description": "Scan the QR code with WeChat to connect your account.",
@@ -1011,6 +1012,10 @@
"matrix": "Matrix",
"telegram": "Telegram",
"weixin": "WeChat",
"wecom": "WeCom",
"dingtalk": "DingTalk",
"web": "Web",
"cli": "CLI",
"local": "Local"
},
"typesShort": {
@@ -1020,6 +1025,10 @@
"matrix": "MX",
"telegram": "TG",
"weixin": "WX",
"wecom": "WC",
"dingtalk": "DT",
"web": "Web",
"cli": "CLI",
"local": "LC"
}
},
+9
View File
@@ -989,6 +989,7 @@
"feishuWebhookSecurityHint": "出于安全考虑,Webhook 模式必须配置 Encrypt Key 或 Verification Token 之一;未受保护的回调地址不应直接暴露在公网上。",
"feishuWebhookSecretRequired": "出于安全考虑,请至少配置 Encrypt Key 或 Verification Token 之一。",
"noAvailableTypes": "所有平台类型均已配置",
"platformKey": "平台标识:{key}",
"weixinQr": {
"title": "扫码登录",
"description": "使用微信扫描二维码以连接微信账号。",
@@ -1007,6 +1008,10 @@
"matrix": "Matrix",
"telegram": "Telegram",
"weixin": "微信",
"wecom": "企业微信",
"dingtalk": "钉钉",
"web": "Web",
"cli": "本地 CLI",
"local": "本地"
},
"typesShort": {
@@ -1016,6 +1021,10 @@
"matrix": "MX",
"telegram": "TG",
"weixin": "WX",
"wecom": "企微",
"dingtalk": "钉",
"web": "Web",
"cli": "CLI",
"local": "本地"
}
},
@@ -48,7 +48,7 @@
</span>
<div class="flex-1 text-left">
<div class="font-medium">
{{ item.meta.display_name }}
{{ channelTitle(item.meta) }}
</div>
<div class="text-xs">
<span
@@ -107,7 +107,7 @@
size="1em"
/>
</span>
<span>{{ item.meta.display_name }}</span>
<span>{{ channelTitle(item.meta) }}</span>
</button>
</PopoverContent>
</Popover>
@@ -136,6 +136,7 @@
<script setup lang="ts">
import { LoaderCircle, Plus } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Button,
Popover,
@@ -148,6 +149,7 @@ import { getChannels, getBotsByIdChannelByPlatform } from '@memohai/sdk'
import type { HandlersChannelMeta, ChannelChannelConfig } from '@memohai/sdk'
import ChannelSettingsPanel from './channel-settings-panel.vue'
import ChannelIcon from '@/components/channel-icon/index.vue'
import { channelTypeDisplayName } from '@/utils/channel-type-label'
export interface BotChannelItem {
meta: HandlersChannelMeta
@@ -159,6 +161,12 @@ const props = defineProps<{
botId: string
}>()
const { t } = useI18n()
function channelTitle(meta: HandlersChannelMeta) {
return channelTypeDisplayName(t, meta.type, meta.display_name)
}
const botIdRef = computed(() => props.botId)
const { data: channels, isLoading, refetch } = useQuery({
@@ -11,10 +11,10 @@
</span>
<div>
<h3 class="text-sm font-semibold">
{{ channelItem.meta.display_name }}
{{ channelTitle }}
</h3>
<p class="text-xs text-muted-foreground">
{{ channelItem.meta.type }}
{{ platformKeyLine }}
</p>
</div>
</div>
@@ -273,6 +273,7 @@ import { client } from '@memohai/sdk/client'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import ChannelIcon from '@/components/channel-icon/index.vue'
import WeixinQrLogin from './weixin-qr-login.vue'
import { channelTypeDisplayName } from '@/utils/channel-type-label'
interface BotChannelItem {
meta: HandlersChannelMeta
@@ -292,6 +293,13 @@ const emit = defineEmits<{
const { t } = useI18n()
const botIdRef = computed(() => props.botId)
const platformType = computed(() => String(props.channelItem.meta.type || '').trim())
const channelTitle = computed(() =>
channelTypeDisplayName(t, props.channelItem.meta.type, props.channelItem.meta.display_name),
)
const platformKeyLine = computed(() =>
t('bots.channels.platformKey', { key: platformType.value }),
)
const queryCache = useQueryCache()
const { mutateAsync: upsertChannel, isLoading } = useMutation({
mutation: async ({ platform, data }: { platform: string; data: ChannelUpsertConfigRequest }) => {
+2 -4
View File
@@ -228,6 +228,7 @@ import { resolveApiErrorMessage } from '@/utils/api-error'
import { formatDateTime } from '@/utils/date-time'
import { useClipboard } from '@/composables/useClipboard'
import { useAvatarInitials } from '@/composables/useAvatarInitials'
import { channelTypeDisplayName } from '@/utils/channel-type-label'
interface IssueBindCodeResponse {
token: string
@@ -287,10 +288,7 @@ const avatarFallback = useAvatarInitials(() => displayTitle.value, 'U')
function platformLabel(platformKey: string): string {
if (!platformKey?.trim()) return platformKey ?? ''
const key = platformKey.trim().toLowerCase()
const i18nKey = `bots.channels.types.${key}`
const out = t(i18nKey)
return out !== i18nKey ? out : platformKey
return channelTypeDisplayName(t, platformKey, null) || platformKey
}
const platformOptions = computed(() => {
+20
View File
@@ -0,0 +1,20 @@
/**
* Localized channel platform title for UI.
* Prefer bots.channels.types.{type}; fall back to server display_name, then raw type.
*/
export function channelTypeDisplayName(
t: (key: string, ...args: unknown[]) => string,
channelType: string | undefined | null,
serverDisplayName?: string | null,
): string {
const raw = (channelType ?? '').trim().toLowerCase()
if (!raw) {
return (serverDisplayName ?? '').trim() || ''
}
const i18nKey = `bots.channels.types.${raw}`
const out = t(i18nKey)
if (out !== i18nKey) return out
const fb = (serverDisplayName ?? '').trim()
if (fb) return fb
return raw.charAt(0).toUpperCase() + raw.slice(1)
}
+3
View File
@@ -30,6 +30,7 @@ import (
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/browsercontexts"
"github.com/memohai/memoh/internal/channel"
"github.com/memohai/memoh/internal/channel/adapters/dingtalk"
"github.com/memohai/memoh/internal/channel/adapters/discord"
"github.com/memohai/memoh/internal/channel/adapters/feishu"
"github.com/memohai/memoh/internal/channel/adapters/local"
@@ -522,6 +523,8 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
feishuAdapter.SetAssetOpener(mediaService)
registry.MustRegister(feishuAdapter)
registry.MustRegister(wecom.NewWeComAdapter(log))
dingTalkAdapter := dingtalk.NewDingTalkAdapter(log)
registry.MustRegister(dingTalkAdapter)
weixinAdapter := weixin.NewWeixinAdapter(log)
weixinAdapter.SetAssetOpener(mediaService)
registry.MustRegister(weixinAdapter)
+3
View File
@@ -31,6 +31,7 @@ import (
"github.com/memohai/memoh/internal/bots"
"github.com/memohai/memoh/internal/browsercontexts"
"github.com/memohai/memoh/internal/channel"
"github.com/memohai/memoh/internal/channel/adapters/dingtalk"
"github.com/memohai/memoh/internal/channel/adapters/discord"
"github.com/memohai/memoh/internal/channel/adapters/feishu"
"github.com/memohai/memoh/internal/channel/adapters/local"
@@ -444,6 +445,8 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
feishuAdapter.SetAssetOpener(mediaService)
registry.MustRegister(feishuAdapter)
registry.MustRegister(wecom.NewWeComAdapter(log))
dingTalkAdapter := dingtalk.NewDingTalkAdapter(log)
registry.MustRegister(dingTalkAdapter)
weixinAdapter := weixin.NewWeixinAdapter(log)
weixinAdapter.SetAssetOpener(mediaService)
registry.MustRegister(weixinAdapter)
+23 -23
View File
@@ -130,29 +130,29 @@ services:
condition: service_healthy
restart: unless-stopped
browser:
build:
context: ..
dockerfile: devenv/Dockerfile.browser
args:
BROWSER_CORES: ${BROWSER_CORES:-chromium,firefox}
container_name: memoh-dev-browser
working_dir: /workspace/apps/browser
command: ["bun", "run", "--watch", "src/index.ts"]
environment:
- MEMOH_CONFIG_PATH=/workspace/devenv/app.dev.toml
- BROWSER_CORES=${BROWSER_CORES:-chromium,firefox}
volumes:
- ..:/workspace
- node_modules:/workspace/node_modules
ports:
- "${MEMOH_DEV_BROWSER_PORT:-18083}:8083"
depends_on:
deps:
condition: service_completed_successfully
server:
condition: service_healthy
restart: unless-stopped
# browser:
# build:
# context: ..
# dockerfile: devenv/Dockerfile.browser
# args:
# BROWSER_CORES: ${BROWSER_CORES:-chromium,firefox}
# container_name: memoh-dev-browser
# working_dir: /workspace/apps/browser
# command: ["bun", "run", "--watch", "src/index.ts"]
# environment:
# - MEMOH_CONFIG_PATH=/workspace/devenv/app.dev.toml
# - BROWSER_CORES=${BROWSER_CORES:-chromium,firefox}
# volumes:
# - ..:/workspace
# - node_modules:/workspace/node_modules
# ports:
# - "${MEMOH_DEV_BROWSER_PORT:-18083}:8083"
# depends_on:
# deps:
# condition: service_completed_successfully
# server:
# condition: service_healthy
# restart: unless-stopped
# sparse:
# build:
+1
View File
@@ -26,6 +26,7 @@ require (
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mailgun/mailgun-go/v5 v5.14.0
github.com/memohai/acgo v0.0.0-20260221232113-babac0d6acd7
github.com/memohai/dingtalk-stream-sdk-go v0.0.0-20260405113102-87e23096b978
github.com/memohai/twilight-ai v0.3.4-0.20260402160505-00db38ee4442
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/opencontainers/image-spec v1.1.1
+2
View File
@@ -228,6 +228,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/memohai/acgo v0.0.0-20260221232113-babac0d6acd7 h1:beehwOQperqGWj4m4EhcPhnSZKtDiuHK/7ZMoTPaQjw=
github.com/memohai/acgo v0.0.0-20260221232113-babac0d6acd7/go.mod h1:OvmxM7JmnXBmwJWWVqtreL3HSHSKuzPbtbhlg5MvBg0=
github.com/memohai/dingtalk-stream-sdk-go v0.0.0-20260405113102-87e23096b978 h1:6gD8DvZkimGmU0e3PjlusJPyw55SyeoE12CZQoYUa8g=
github.com/memohai/dingtalk-stream-sdk-go v0.0.0-20260405113102-87e23096b978/go.mod h1:2LMgK5QYFlTSvrGY+sI/j+jK2WK+YGHv4IMuiW+iPSc=
github.com/memohai/twilight-ai v0.3.4-0.20260402160505-00db38ee4442 h1:mTy+OSkMCOvF1S6D5asKRdKx0A+icQvnu6A/f7aZolg=
github.com/memohai/twilight-ai v0.3.4-0.20260402160505-00db38ee4442/go.mod h1:GZTT9GUT3uSs6zram/FcF24GLTZMFSpiybbYmjr+gH8=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -0,0 +1,219 @@
package dingtalk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"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"`
}
// 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
}
@@ -0,0 +1,157 @@
package dingtalk
import (
"errors"
"strings"
"github.com/memohai/memoh/internal/channel"
)
type adapterConfig struct {
AppKey string
AppSecret string
}
// UserConfig holds per-user delivery target data for DingTalk.
type UserConfig struct {
// UserID is the recipient's DingTalk userId, used for single/private chat.
UserID string
// OpenConversationID is the group's openConversationId, used for group chat.
OpenConversationID string
// DisplayName is an optional human-readable label.
DisplayName string
}
func normalizeConfig(raw map[string]any) (map[string]any, error) {
cfg, err := parseConfig(raw)
if err != nil {
return nil, err
}
out := map[string]any{
"appKey": cfg.AppKey,
"appSecret": cfg.AppSecret,
}
return out, nil
}
func normalizeUserConfig(raw map[string]any) (map[string]any, error) {
cfg, err := parseUserConfig(raw)
if err != nil {
return nil, err
}
out := map[string]any{}
if cfg.UserID != "" {
out["user_id"] = cfg.UserID
}
if cfg.OpenConversationID != "" {
out["open_conversation_id"] = cfg.OpenConversationID
}
if cfg.DisplayName != "" {
out["display_name"] = cfg.DisplayName
}
return out, nil
}
func parseConfig(raw map[string]any) (adapterConfig, error) {
cfg := adapterConfig{
AppKey: strings.TrimSpace(channel.ReadString(raw, "appKey", "app_key")),
AppSecret: strings.TrimSpace(channel.ReadString(raw, "appSecret", "app_secret")),
}
if cfg.AppKey == "" || cfg.AppSecret == "" {
return adapterConfig{}, errors.New("dingtalk appKey and appSecret are required")
}
return cfg, nil
}
func parseUserConfig(raw map[string]any) (UserConfig, error) {
cfg := UserConfig{
UserID: strings.TrimSpace(channel.ReadString(raw, "userId", "user_id")),
OpenConversationID: strings.TrimSpace(channel.ReadString(raw, "openConversationId", "open_conversation_id")),
DisplayName: strings.TrimSpace(channel.ReadString(raw, "displayName", "display_name")),
}
if cfg.UserID == "" && cfg.OpenConversationID == "" {
return UserConfig{}, errors.New("dingtalk user config requires user_id or open_conversation_id")
}
return cfg, nil
}
// resolveTarget converts a UserConfig to the canonical target string.
func resolveTarget(raw map[string]any) (string, error) {
cfg, err := parseUserConfig(raw)
if err != nil {
return "", err
}
if cfg.OpenConversationID != "" {
return "group:" + cfg.OpenConversationID, nil
}
return "user:" + cfg.UserID, nil
}
// normalizeTarget normalizes a raw target string to canonical form.
func normalizeTarget(raw string) string {
kind, id, ok := parseTarget(raw)
if !ok {
return ""
}
return kind + ":" + id
}
// parseTarget parses a target string into (kind, id).
// kind is "user" or "group".
func parseTarget(raw string) (kind, id string, ok bool) {
v := strings.TrimSpace(raw)
if v == "" {
return "", "", false
}
lv := strings.ToLower(v)
switch {
case strings.HasPrefix(lv, "user:"):
id = strings.TrimSpace(v[len("user:"):])
return "user", id, id != ""
case strings.HasPrefix(lv, "user_id:"):
id = strings.TrimSpace(v[len("user_id:"):])
return "user", id, id != ""
case strings.HasPrefix(lv, "group:"):
id = strings.TrimSpace(v[len("group:"):])
return "group", id, id != ""
case strings.HasPrefix(lv, "open_conversation_id:"):
id = strings.TrimSpace(v[len("open_conversation_id:"):])
return "group", id, id != ""
default:
// Bare value: treat as userId (private chat)
return "user", v, true
}
}
func matchBinding(raw map[string]any, criteria channel.BindingCriteria) bool {
cfg, err := parseUserConfig(raw)
if err != nil {
return false
}
if v := strings.TrimSpace(criteria.Attribute("user_id")); v != "" && v == cfg.UserID {
return true
}
if v := strings.TrimSpace(criteria.Attribute("open_conversation_id")); v != "" && v == cfg.OpenConversationID {
return true
}
if criteria.SubjectID != "" {
if criteria.SubjectID == cfg.UserID || criteria.SubjectID == cfg.OpenConversationID {
return true
}
}
return false
}
func buildUserConfig(identity channel.Identity) map[string]any {
out := map[string]any{}
if v := strings.TrimSpace(identity.Attribute("user_id")); v != "" {
out["user_id"] = v
}
if v := strings.TrimSpace(identity.Attribute("open_conversation_id")); v != "" {
out["open_conversation_id"] = v
}
if v := strings.TrimSpace(identity.DisplayName); v != "" {
out["display_name"] = v
}
return out
}
@@ -0,0 +1,7 @@
// Package dingtalk implements the DingTalk channel adapter.
package dingtalk
import "github.com/memohai/memoh/internal/channel"
// Type is the registered ChannelType identifier for DingTalk.
const Type channel.ChannelType = "dingtalk"
@@ -0,0 +1,333 @@
package dingtalk
import (
"context"
"errors"
"log/slog"
"strings"
"sync"
"time"
"github.com/memohai/dingtalk-stream-sdk-go/chatbot"
dtsdk "github.com/memohai/dingtalk-stream-sdk-go/client"
"github.com/memohai/memoh/internal/channel"
)
// DingTalkAdapter implements the Memoh channel adapter for DingTalk bots.
// It uses the DingTalk Stream SDK (WebSocket) for inbound messages and
// the DingTalk OpenAPI (HTTP) for outbound messages.
type DingTalkAdapter struct {
logger *slog.Logger
// mu guards the clients and apiClients maps.
mu sync.RWMutex
clients map[string]*dtsdk.StreamClient
apiClients map[string]*apiClient
// webhookCache stores recent sessionWebhook contexts keyed by msgId.
webhookCache *sessionWebhookCache
}
// NewDingTalkAdapter creates a new DingTalkAdapter.
func NewDingTalkAdapter(log *slog.Logger) *DingTalkAdapter {
if log == nil {
log = slog.Default()
}
return &DingTalkAdapter{
logger: log.With(slog.String("adapter", "dingtalk")),
clients: make(map[string]*dtsdk.StreamClient),
apiClients: make(map[string]*apiClient),
webhookCache: newSessionWebhookCache(30 * time.Minute),
}
}
// SetAssetOpener is a no-op placeholder to match the adapter registration pattern.
func (*DingTalkAdapter) SetAssetOpener(_ any) {}
func (*DingTalkAdapter) Type() channel.ChannelType { return Type }
func (*DingTalkAdapter) Descriptor() channel.Descriptor {
return channel.Descriptor{
Type: Type,
DisplayName: "DingTalk",
Capabilities: channel.ChannelCapabilities{
Text: true,
Markdown: true,
Attachments: true,
Media: true,
ChatTypes: []string{channel.ConversationTypePrivate, channel.ConversationTypeGroup},
},
ConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"appKey": {Type: channel.FieldString, Required: true, Title: "App Key (Client ID)"},
"appSecret": {Type: channel.FieldSecret, Required: true, Title: "App Secret (Client Secret)"},
},
},
UserConfigSchema: channel.ConfigSchema{
Version: 1,
Fields: map[string]channel.FieldSchema{
"user_id": {Type: channel.FieldString, Title: "User ID (single chat)"},
"open_conversation_id": {Type: channel.FieldString, Title: "Open Conversation ID (group chat)"},
"display_name": {Type: channel.FieldString, Title: "Display Name"},
},
},
TargetSpec: channel.TargetSpec{
Format: "user:{userId} | group:{openConversationId}",
Hints: []channel.TargetHint{
{Label: "User", Example: "user:user123"},
{Label: "Group", Example: "group:cidXXXXXXXX"},
},
},
}
}
// NormalizeConfig validates and normalizes a DingTalk channel config map.
func (*DingTalkAdapter) NormalizeConfig(raw map[string]any) (map[string]any, error) {
return normalizeConfig(raw)
}
// NormalizeUserConfig validates and normalizes a DingTalk user binding config map.
func (*DingTalkAdapter) NormalizeUserConfig(raw map[string]any) (map[string]any, error) {
return normalizeUserConfig(raw)
}
// NormalizeTarget normalizes a raw target string to canonical form.
func (*DingTalkAdapter) NormalizeTarget(raw string) string { return normalizeTarget(raw) }
// ResolveTarget converts a user config map to a canonical delivery target string.
func (*DingTalkAdapter) ResolveTarget(userConfig map[string]any) (string, error) {
return resolveTarget(userConfig)
}
// MatchBinding checks whether a user binding config matches the given criteria.
func (*DingTalkAdapter) MatchBinding(config map[string]any, criteria channel.BindingCriteria) bool {
return matchBinding(config, criteria)
}
// BuildUserConfig constructs a user config map from a channel Identity.
func (*DingTalkAdapter) BuildUserConfig(identity channel.Identity) map[string]any {
return buildUserConfig(identity)
}
// DiscoverSelf retrieves the bot's own identity from DingTalk.
// DiscoverSelf uses AppKey as the OpenAPI robotCode parameter (钉钉统一应用下二者一致)。
func (a *DingTalkAdapter) DiscoverSelf(ctx context.Context, credentials map[string]any) (map[string]any, string, error) {
cfg, err := parseConfig(credentials)
if err != nil {
return nil, "", err
}
cli := newAPIClient(cfg.AppKey, cfg.AppSecret)
info, err := cli.getBotInfo(ctx, cfg.AppKey)
if err != nil {
a.logger.Warn("dingtalk: getBotInfo failed, using appKey as identity",
slog.String("app_key", cfg.AppKey),
slog.Any("error", err),
)
return map[string]any{"app_key": cfg.AppKey}, cfg.AppKey, nil
}
externalID := strings.TrimSpace(info.Result.RobotCode)
if externalID == "" {
externalID = cfg.AppKey
}
return map[string]any{
"app_key": cfg.AppKey,
"name": strings.TrimSpace(info.Result.Name),
}, externalID, nil
}
// Connect establishes a DingTalk Stream WebSocket connection and begins receiving messages.
func (a *DingTalkAdapter) Connect(ctx context.Context, cfg channel.ChannelConfig, handler channel.InboundHandler) (channel.Connection, error) {
parsed, err := parseConfig(cfg.Credentials)
if err != nil {
return nil, err
}
apiCli := newAPIClient(parsed.AppKey, parsed.AppSecret)
streamCli := dtsdk.NewStreamClient(
dtsdk.WithAppCredential(dtsdk.NewAppCredentialConfig(parsed.AppKey, parsed.AppSecret)),
dtsdk.WithAutoReconnect(true),
)
streamCli.RegisterChatBotCallbackRouter(a.newChatBotHandler(cfg, handler))
key := cfg.ID
a.mu.Lock()
a.clients[key] = streamCli
a.apiClients[key] = apiCli
a.mu.Unlock()
if err := streamCli.Start(ctx); err != nil {
a.mu.Lock()
delete(a.clients, key)
delete(a.apiClients, key)
a.mu.Unlock()
return nil, err
}
stop := func(context.Context) error {
// Disable reconnect before closing to prevent the reconnect loop from restarting.
streamCli.AutoReconnect = false
streamCli.Close()
a.mu.Lock()
if current, ok := a.clients[key]; ok && current == streamCli {
delete(a.clients, key)
}
delete(a.apiClients, key)
a.mu.Unlock()
return nil
}
return channel.NewConnection(cfg, stop), nil
}
// Send delivers an outbound message to a DingTalk user or group.
// It first tries the session webhook (if cached and valid), then falls back to the OpenAPI.
func (a *DingTalkAdapter) Send(ctx context.Context, cfg channel.ChannelConfig, msg channel.OutboundMessage) error {
target := strings.TrimSpace(msg.Target)
if target == "" {
return errors.New("dingtalk: target is required")
}
if msg.Message.IsEmpty() {
return errors.New("dingtalk: message is empty")
}
// Session webhook fast path: immediate reply without access_token round-trip.
if whCtx, ok := a.lookupWebhook(msg.Message.Reply); ok && whCtx.isValid() {
body, bodyErr := buildWebhookBody(msg.Message)
if bodyErr == nil {
apiCli := a.getOrNewAPIClient(cfg)
if webhookErr := apiCli.sendViaWebhook(ctx, whCtx.SessionWebhook, body); webhookErr == nil {
return nil
}
// Webhook failed (possibly expired mid-flight); fall through to OpenAPI.
}
}
return a.sendViaAPI(ctx, cfg, msg)
}
// sendViaAPI sends a message through the DingTalk OpenAPI.
func (a *DingTalkAdapter) sendViaAPI(ctx context.Context, cfg channel.ChannelConfig, msg channel.OutboundMessage) error {
parsed, err := parseConfig(cfg.Credentials)
if err != nil {
return err
}
apiCli := a.getOrNewAPIClient(cfg)
msgKey, msgParam, err := buildAPIPayload(msg.Message)
if err != nil {
return err
}
kind, id, ok := parseTarget(msg.Target)
if !ok {
return errors.New("dingtalk: invalid target")
}
switch kind {
case "user":
return apiCli.sendToUser(ctx, parsed.AppKey, []string{id}, msgKey, msgParam)
case "group":
return apiCli.sendToGroup(ctx, parsed.AppKey, id, msgKey, msgParam)
default:
return errors.New("dingtalk: unknown target kind: " + kind)
}
}
// OpenStream creates a new accumulating outbound stream for the given target.
func (a *DingTalkAdapter) OpenStream(ctx context.Context, cfg channel.ChannelConfig, target string, opts channel.StreamOptions) (channel.OutboundStream, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
target = strings.TrimSpace(target)
if target == "" {
return nil, errors.New("dingtalk: target is required")
}
reply := opts.Reply
if reply == nil && strings.TrimSpace(opts.SourceMessageID) != "" {
reply = &channel.ReplyRef{
Target: target,
MessageID: strings.TrimSpace(opts.SourceMessageID),
}
}
return &dingtalkOutboundStream{
adapter: a,
cfg: cfg,
target: target,
reply: reply,
}, nil
}
// newChatBotHandler returns the DingTalk SDK chatbot callback for the given channel config.
func (a *DingTalkAdapter) newChatBotHandler(cfg channel.ChannelConfig, handler channel.InboundHandler) chatbot.IChatBotMessageHandler {
return func(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
if data == nil {
return nil, nil
}
// Cache the session webhook so that Send can use the fast-reply path.
if strings.TrimSpace(data.MsgId) != "" && strings.TrimSpace(data.SessionWebhook) != "" {
a.rememberWebhook(data.MsgId, sessionWebhookContext{
SessionWebhook: data.SessionWebhook,
ExpiredTime: data.SessionWebhookExpiredTime,
ConversationID: data.ConversationId,
SenderID: data.SenderId,
})
}
if handler == nil {
return nil, nil
}
msg, ok := buildInboundMessage(data)
if !ok {
return nil, nil
}
msg.BotID = cfg.BotID
if err := handler(ctx, cfg, msg); err != nil {
a.logger.Error("dingtalk: inbound handler error",
slog.String("config_id", cfg.ID),
slog.Any("error", err),
)
}
return nil, nil
}
}
func (a *DingTalkAdapter) getOrNewAPIClient(cfg channel.ChannelConfig) *apiClient {
a.mu.RLock()
cli := a.apiClients[cfg.ID]
a.mu.RUnlock()
if cli != nil {
return cli
}
parsed, err := parseConfig(cfg.Credentials)
if err != nil {
return newAPIClient("", "")
}
return newAPIClient(parsed.AppKey, parsed.AppSecret)
}
func (a *DingTalkAdapter) lookupWebhook(reply *channel.ReplyRef) (sessionWebhookContext, bool) {
if reply == nil {
return sessionWebhookContext{}, false
}
msgID := strings.TrimSpace(reply.MessageID)
if msgID == "" {
return sessionWebhookContext{}, false
}
return a.webhookCache.get(msgID)
}
func (a *DingTalkAdapter) rememberWebhook(msgID string, whCtx sessionWebhookContext) {
msgID = strings.TrimSpace(msgID)
if msgID == "" || strings.TrimSpace(whCtx.SessionWebhook) == "" {
return
}
if whCtx.CreatedAt.IsZero() {
whCtx.CreatedAt = time.Now().UTC()
}
a.webhookCache.put(msgID, whCtx)
}
@@ -0,0 +1,372 @@
package dingtalk
import (
"encoding/json"
"strings"
"sync"
"time"
"github.com/memohai/dingtalk-stream-sdk-go/chatbot"
"github.com/memohai/memoh/internal/channel"
)
// sessionWebhookContext holds a cached session webhook for a received message.
type sessionWebhookContext struct {
SessionWebhook string
ExpiredTime int64 // unix milliseconds; 0 means never set
ConversationID string
SenderID string
CreatedAt time.Time
}
// isValid reports whether the session webhook is still within its validity window.
func (w sessionWebhookContext) isValid() bool {
if strings.TrimSpace(w.SessionWebhook) == "" {
return false
}
if w.ExpiredTime <= 0 {
return false
}
return time.Now().UnixMilli() < w.ExpiredTime
}
// sessionWebhookCache stores recent sessionWebhook contexts keyed by msgId.
type sessionWebhookCache struct {
mu sync.RWMutex
items map[string]sessionWebhookContext
ttl time.Duration
}
func newSessionWebhookCache(ttl time.Duration) *sessionWebhookCache {
if ttl <= 0 {
ttl = 30 * time.Minute
}
return &sessionWebhookCache{
items: make(map[string]sessionWebhookContext),
ttl: ttl,
}
}
func (c *sessionWebhookCache) put(msgID string, ctx sessionWebhookContext) {
key := strings.TrimSpace(msgID)
if key == "" {
return
}
if ctx.CreatedAt.IsZero() {
ctx.CreatedAt = time.Now().UTC()
}
c.mu.Lock()
c.items[key] = ctx
c.gcLocked()
c.mu.Unlock()
}
func (c *sessionWebhookCache) get(msgID string) (sessionWebhookContext, bool) {
key := strings.TrimSpace(msgID)
if key == "" {
return sessionWebhookContext{}, false
}
c.mu.RLock()
item, ok := c.items[key]
c.mu.RUnlock()
if !ok {
return sessionWebhookContext{}, false
}
if time.Since(item.CreatedAt) > c.ttl {
c.mu.Lock()
delete(c.items, key)
c.mu.Unlock()
return sessionWebhookContext{}, false
}
return item, true
}
func (c *sessionWebhookCache) gcLocked() {
if len(c.items) < 512 {
return
}
now := time.Now().UTC()
for key, item := range c.items {
if now.Sub(item.CreatedAt) > c.ttl {
delete(c.items, key)
}
}
}
// richTextItem is an element within a DingTalk richText message payload.
type richTextItem struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
DownloadCode string `json:"downloadCode,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// pictureContent is the payload for msgtype="picture".
type pictureContent struct {
DownloadCode string `json:"downloadCode"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// fileContent is the payload for msgtype="file".
type fileContent struct {
DownloadCode string `json:"downloadCode"`
FileName string `json:"fileName,omitempty"`
FileSize string `json:"fileSize,omitempty"`
FileType string `json:"fileType,omitempty"`
}
// audioContent is the payload for msgtype="audio".
type audioContent struct {
DownloadCode string `json:"downloadCode,omitempty"`
Duration int `json:"duration,omitempty"`
Recognition string `json:"recognition,omitempty"`
}
// videoContent is the payload for msgtype="video".
type videoContent struct {
VideoMediaId string `json:"videoMediaId"`
Duration int `json:"duration,omitempty"`
VideoType string `json:"videoType,omitempty"`
}
// buildInboundMessage converts a DingTalk BotCallbackDataModel to a channel.InboundMessage.
// Returns false when the message should be silently ignored (e.g. empty content).
func buildInboundMessage(data *chatbot.BotCallbackDataModel) (channel.InboundMessage, bool) {
text, format, attachments := extractContent(data)
if strings.TrimSpace(text) == "" && len(attachments) == 0 {
return channel.InboundMessage{}, false
}
convType := normalizeDingTalkConversationType(data.ConversationType)
convID := strings.TrimSpace(data.ConversationId)
if convID == "" {
convID = strings.TrimSpace(data.SenderId)
}
// ReplyTarget: for private chat use user:{senderId}, for group chat use group:{conversationId}.
replyTarget := buildReplyTarget(convType, data.ConversationId, data.SenderId)
// Sanitize @ mentions from text body (DingTalk prepends "@botNick " in group messages).
if strings.TrimSpace(text) != "" {
text = strings.TrimSpace(text)
}
return channel.InboundMessage{
Channel: Type,
Message: channel.Message{
ID: strings.TrimSpace(data.MsgId),
Format: format,
Text: text,
Attachments: attachments,
},
ReplyTarget: replyTarget,
Sender: channel.Identity{
SubjectID: strings.TrimSpace(data.SenderId),
DisplayName: strings.TrimSpace(data.SenderNick),
Attributes: map[string]string{
"user_id": strings.TrimSpace(data.SenderId),
"staff_id": strings.TrimSpace(data.SenderStaffId),
"corp_id": strings.TrimSpace(data.SenderCorpId),
"chatbot_user_id": strings.TrimSpace(data.ChatbotUserId),
},
},
Conversation: channel.Conversation{
ID: convID,
Type: convType,
Name: strings.TrimSpace(data.ConversationTitle),
Metadata: map[string]any{
"open_conversation_id": strings.TrimSpace(data.ConversationId),
},
},
ReceivedAt: parseCreateAt(data.CreateAt),
Source: "dingtalk",
Metadata: map[string]any{
"msg_id": strings.TrimSpace(data.MsgId),
"conversation_id": strings.TrimSpace(data.ConversationId),
"conversation_type": strings.TrimSpace(data.ConversationType),
"chatbot_corp_id": strings.TrimSpace(data.ChatbotCorpId),
"is_admin": data.IsAdmin,
"session_webhook": strings.TrimSpace(data.SessionWebhook),
"session_webhook_exp": data.SessionWebhookExpiredTime,
},
}, true
}
// extractContent parses the msgtype-specific payload and returns text, format, and attachments.
func extractContent(data *chatbot.BotCallbackDataModel) (string, channel.MessageFormat, []channel.Attachment) {
msgtype := strings.ToLower(strings.TrimSpace(data.Msgtype))
switch msgtype {
case "text":
return strings.TrimSpace(data.Text.Content), channel.MessageFormatPlain, nil
case "markdown":
content := extractStringField(data.Content, "text")
if content == "" {
content = strings.TrimSpace(data.Text.Content)
}
return content, channel.MessageFormatMarkdown, nil
case "picture", "image":
raw, _ := json.Marshal(data.Content)
var pic pictureContent
if err := json.Unmarshal(raw, &pic); err != nil || strings.TrimSpace(pic.DownloadCode) == "" {
return "", channel.MessageFormatPlain, nil
}
att := channel.Attachment{
Type: channel.AttachmentImage,
PlatformKey: strings.TrimSpace(pic.DownloadCode),
SourcePlatform: Type.String(),
Width: pic.Width,
Height: pic.Height,
}
return "", channel.MessageFormatPlain, []channel.Attachment{channel.NormalizeInboundChannelAttachment(att)}
case "file":
raw, _ := json.Marshal(data.Content)
var f fileContent
if err := json.Unmarshal(raw, &f); err != nil || strings.TrimSpace(f.DownloadCode) == "" {
return "", channel.MessageFormatPlain, nil
}
att := channel.Attachment{
Type: channel.AttachmentFile,
PlatformKey: strings.TrimSpace(f.DownloadCode),
SourcePlatform: Type.String(),
Name: strings.TrimSpace(f.FileName),
}
return "", channel.MessageFormatPlain, []channel.Attachment{channel.NormalizeInboundChannelAttachment(att)}
case "audio", "voice":
raw, _ := json.Marshal(data.Content)
var a audioContent
_ = json.Unmarshal(raw, &a)
// Prefer voice-recognition text; fall back to attachment if code present.
if rec := strings.TrimSpace(a.Recognition); rec != "" {
return rec, channel.MessageFormatPlain, nil
}
if code := strings.TrimSpace(a.DownloadCode); code != "" {
att := channel.Attachment{
Type: channel.AttachmentVoice,
PlatformKey: code,
SourcePlatform: Type.String(),
DurationMs: int64(a.Duration) * 1000,
}
return "", channel.MessageFormatPlain, []channel.Attachment{channel.NormalizeInboundChannelAttachment(att)}
}
return "", channel.MessageFormatPlain, nil
case "video":
raw, _ := json.Marshal(data.Content)
var v videoContent
if err := json.Unmarshal(raw, &v); err != nil || strings.TrimSpace(v.VideoMediaId) == "" {
return "", channel.MessageFormatPlain, nil
}
att := channel.Attachment{
Type: channel.AttachmentVideo,
PlatformKey: strings.TrimSpace(v.VideoMediaId),
SourcePlatform: Type.String(),
DurationMs: int64(v.Duration) * 1000,
}
return "", channel.MessageFormatPlain, []channel.Attachment{channel.NormalizeInboundChannelAttachment(att)}
case "richtext":
return extractRichText(data.Content)
default:
// Try to extract a text field from Content as a best-effort fallback.
if text := extractStringField(data.Content, "content", "text"); text != "" {
return text, channel.MessageFormatPlain, nil
}
return "", channel.MessageFormatPlain, nil
}
}
// extractRichText parses a richText message content into text and attachments.
func extractRichText(raw any) (string, channel.MessageFormat, []channel.Attachment) {
rawJSON, _ := json.Marshal(raw)
var payload struct {
RichText []richTextItem `json:"richText"`
}
if err := json.Unmarshal(rawJSON, &payload); err != nil {
return "", channel.MessageFormatPlain, nil
}
var textParts []string
var attachments []channel.Attachment
for _, item := range payload.RichText {
switch strings.ToLower(strings.TrimSpace(item.Type)) {
case "text":
if v := strings.TrimSpace(item.Text); v != "" {
textParts = append(textParts, v)
}
case "picture", "image":
if code := strings.TrimSpace(item.DownloadCode); code != "" {
att := channel.Attachment{
Type: channel.AttachmentImage,
PlatformKey: code,
SourcePlatform: Type.String(),
Width: item.Width,
Height: item.Height,
}
attachments = append(attachments, channel.NormalizeInboundChannelAttachment(att))
}
}
}
return strings.Join(textParts, "\n"), channel.MessageFormatPlain, attachments
}
// extractStringField attempts to read a string value from an interface{} map by trying keys in order.
func extractStringField(raw any, keys ...string) string {
m, ok := raw.(map[string]any)
if !ok {
// Try JSON round-trip for non-map types.
b, err := json.Marshal(raw)
if err != nil {
return ""
}
if err := json.Unmarshal(b, &m); err != nil {
return ""
}
}
for _, key := range keys {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
}
}
return ""
}
// normalizeDingTalkConversationType maps DingTalk conversationType values to Memoh constants.
// "1" = single/private; "2" = group.
func normalizeDingTalkConversationType(raw string) string {
switch strings.TrimSpace(raw) {
case "2", "group":
return channel.ConversationTypeGroup
default:
return channel.ConversationTypePrivate
}
}
// buildReplyTarget produces the canonical reply target for a DingTalk inbound message.
func buildReplyTarget(convType, conversationID, senderID string) string {
if channel.IsPrivateConversationType(convType) {
if v := strings.TrimSpace(senderID); v != "" {
return "user:" + v
}
}
if v := strings.TrimSpace(conversationID); v != "" {
return "group:" + v
}
return ""
}
// parseCreateAt converts a DingTalk createAt unix millisecond timestamp to time.Time.
func parseCreateAt(ms int64) time.Time {
if ms <= 0 {
return time.Now().UTC()
}
return time.UnixMilli(ms).UTC()
}
@@ -0,0 +1,164 @@
package dingtalk
import (
"encoding/json"
"errors"
"strings"
"github.com/memohai/memoh/internal/channel"
)
// buildAPIPayload converts a channel.Message to a DingTalk OpenAPI msgKey + msgParam pair.
// Markdown messages map to sampleMarkdown; all others fall back to sampleText.
// Attachments with a URL map to the corresponding media message type.
func buildAPIPayload(msg channel.Message) (msgKey, msgParam string, err error) {
// Attachment-only message: use the first attachment to determine message type.
if strings.TrimSpace(msg.PlainText()) == "" && len(msg.Attachments) > 0 {
return buildAttachmentPayload(msg.Attachments[0])
}
text := strings.TrimSpace(msg.PlainText())
if text == "" {
return "", "", errors.New("dingtalk: outbound message text is empty")
}
if msg.Format == channel.MessageFormatMarkdown {
return buildMarkdownAPIPayload(text)
}
return buildTextAPIPayload(text)
}
func buildTextAPIPayload(text string) (string, string, error) {
param, _ := json.Marshal(map[string]string{"content": text})
return "sampleText", string(param), nil
}
func buildMarkdownAPIPayload(text string) (string, string, error) {
param, _ := json.Marshal(map[string]string{
"title": extractMarkdownTitle(text),
"text": text,
})
return "sampleMarkdown", string(param), nil
}
func buildAttachmentPayload(att channel.Attachment) (string, string, error) {
switch att.Type {
case channel.AttachmentImage, channel.AttachmentGIF:
url := strings.TrimSpace(att.URL)
if url == "" {
return "", "", errors.New("dingtalk: image attachment requires URL")
}
param, _ := json.Marshal(map[string]string{"photoURL": url})
return "sampleImageMsg", string(param), nil
case channel.AttachmentFile:
fileType := resolveFileType(att)
param, _ := json.Marshal(map[string]string{
"mediaId": strings.TrimSpace(att.PlatformKey),
"fileName": strings.TrimSpace(att.Name),
"fileType": fileType,
})
return "sampleFile", string(param), nil
case channel.AttachmentAudio, channel.AttachmentVoice:
param, _ := json.Marshal(map[string]string{
"mediaId": strings.TrimSpace(att.PlatformKey),
"duration": "0",
})
return "sampleAudio", string(param), nil
case channel.AttachmentVideo:
param, _ := json.Marshal(map[string]string{
"mediaId": strings.TrimSpace(att.PlatformKey),
"videoType": "mp4",
})
return "sampleVideo", string(param), nil
default:
return "", "", errors.New("dingtalk: unsupported attachment type for outbound")
}
}
// buildWebhookBody converts a channel.Message to a DingTalk session webhook payload.
// session webhook uses msgtype/text style instead of msgKey/msgParam.
func buildWebhookBody(msg channel.Message) (map[string]any, error) {
if strings.TrimSpace(msg.PlainText()) == "" && len(msg.Attachments) > 0 {
// Webhooks only support text and markdown; fall back to text describing the attachment.
return map[string]any{
"msgtype": "text",
"text": map[string]string{
"content": "[attachment]",
},
}, nil
}
text := strings.TrimSpace(msg.PlainText())
if text == "" {
return nil, errors.New("dingtalk: webhook message text is empty")
}
if msg.Format == channel.MessageFormatMarkdown {
return map[string]any{
"msgtype": "markdown",
"markdown": map[string]string{
"title": extractMarkdownTitle(text),
"text": text,
},
}, nil
}
return map[string]any{
"msgtype": "text",
"text": map[string]string{
"content": text,
},
}, nil
}
// extractMarkdownTitle tries to extract the first heading from markdown text.
// Falls back to "消息" if no heading is present.
func extractMarkdownTitle(text string) string {
for _, line := range strings.SplitN(text, "\n", 5) {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
title := strings.TrimLeft(trimmed, "# ")
if t := strings.TrimSpace(title); t != "" {
if len([]rune(t)) > 20 {
r := []rune(t)
return string(r[:20])
}
return t
}
}
}
return "消息"
}
// resolveFileType returns a DingTalk-compatible file type string for file attachments.
func resolveFileType(att channel.Attachment) string {
if ext := fileExtFromName(att.Name); ext != "" {
return ext
}
switch att.Type {
case channel.AttachmentImage:
return "jpg"
case channel.AttachmentVideo:
return "mp4"
case channel.AttachmentAudio, channel.AttachmentVoice:
return "mp3"
default:
return "doc"
}
}
// fileExtFromName extracts a lowercase extension (without the dot) from a filename.
func fileExtFromName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
idx := strings.LastIndex(name, ".")
if idx < 0 || idx == len(name)-1 {
return ""
}
return strings.ToLower(name[idx+1:])
}
@@ -0,0 +1,147 @@
package dingtalk
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"github.com/memohai/memoh/internal/channel"
)
// dingtalkOutboundStream accumulates streaming events and flushes the final message
// to DingTalk when closed. DingTalk has no native streaming API, so the stream
// is buffered and sent as a single message on Close.
type dingtalkOutboundStream struct {
adapter *DingTalkAdapter
cfg channel.ChannelConfig
target string
reply *channel.ReplyRef
mu sync.Mutex
closed atomic.Bool
finalSent atomic.Bool
textBuilder strings.Builder
attachments []channel.Attachment
final *channel.Message
}
func (s *dingtalkOutboundStream) Push(ctx context.Context, event channel.StreamEvent) error {
if s.closed.Load() {
return errors.New("dingtalk stream is closed")
}
if s.finalSent.Load() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
switch event.Type {
case channel.StreamEventStatus,
channel.StreamEventPhaseStart,
channel.StreamEventPhaseEnd,
channel.StreamEventToolCallStart,
channel.StreamEventToolCallEnd,
channel.StreamEventAgentStart,
channel.StreamEventAgentEnd,
channel.StreamEventProcessingStarted,
channel.StreamEventProcessingCompleted,
channel.StreamEventProcessingFailed:
// Non-content events: no-op.
return nil
case channel.StreamEventDelta:
if strings.TrimSpace(event.Delta) == "" || event.Phase == channel.StreamPhaseReasoning {
return nil
}
s.mu.Lock()
s.textBuilder.WriteString(event.Delta)
s.mu.Unlock()
return nil
case channel.StreamEventAttachment:
if len(event.Attachments) == 0 {
return nil
}
s.mu.Lock()
s.attachments = append(s.attachments, event.Attachments...)
s.mu.Unlock()
return nil
case channel.StreamEventFinal:
if event.Final == nil {
return nil
}
s.mu.Lock()
final := event.Final.Message
s.final = &final
s.mu.Unlock()
return s.flush(ctx)
case channel.StreamEventError:
text := strings.TrimSpace(event.Error)
if text == "" {
return nil
}
s.mu.Lock()
s.final = &channel.Message{Format: channel.MessageFormatPlain, Text: "Error: " + text}
s.mu.Unlock()
return s.flush(ctx)
}
return nil
}
func (s *dingtalkOutboundStream) Close(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
s.closed.Store(true)
if s.finalSent.Load() {
return nil
}
return s.flush(ctx)
}
func (s *dingtalkOutboundStream) flush(ctx context.Context) error {
if s.finalSent.Load() {
return nil
}
msg := s.snapshotMessage()
if msg.IsEmpty() {
return nil
}
if err := s.adapter.Send(ctx, s.cfg, channel.OutboundMessage{
Target: s.target,
Message: msg,
}); err != nil {
return err
}
s.finalSent.Store(true)
return nil
}
func (s *dingtalkOutboundStream) snapshotMessage() channel.Message {
s.mu.Lock()
defer s.mu.Unlock()
msg := channel.Message{}
if s.final != nil {
msg = *s.final
}
if strings.TrimSpace(msg.Text) == "" {
msg.Text = strings.TrimSpace(s.textBuilder.String())
}
if len(msg.Attachments) == 0 && len(s.attachments) > 0 {
msg.Attachments = append(msg.Attachments, s.attachments...)
}
if msg.Reply == nil && s.reply != nil {
msg.Reply = s.reply
}
return msg
}
+1
View File
@@ -0,0 +1 @@
<svg t="1775466278875" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1655" width="200" height="200"><path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#3296FA" p-id="1656"></path><path d="M772.064 431.2c-1.376 5.44-4.096 10.944-8.192 20.48v1.376c-23.264 47.872-83.424 140.864-83.424 140.864l-17.792 30.08h84.8l-161.376 207.872 36.928-142.208h-67.008l23.232-94.4c-19.136 4.128-41.024 10.976-67.008 19.2 0 0-35.52 20.48-101.184-38.304 0 0-45.12-38.304-19.136-47.872 10.944-4.096 53.312-9.6 87.52-13.696 45.12-5.44 73.856-9.568 73.856-9.568s-139.52 1.376-172.32-2.72c-32.832-5.472-75.2-58.816-84.8-106.656 0 0-13.664-25.984 30.08-13.696 43.776 12.32 224.32 47.872 224.32 47.872S313.92 358.72 298.88 340.928c-16.416-16.416-46.496-92.992-42.4-139.488 0 0 1.376-12.288 13.664-8.192 0 0 173.696 77.952 292.672 120.32 118.976 41.024 222.88 62.912 209.216 117.632z" fill="#FFFFFF" p-id="1657"></path></svg>

After

Width:  |  Height:  |  Size: 967 B

+1
View File
@@ -0,0 +1 @@
<svg t="1775466349847" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3541" width="200" height="200"><path d="M512 2c281.7 0 510 228.3 510 510s-228.3 510-510 510S2 793.7 2 512 230.3 2 512 2z m159.8 680.3c-4 3.9-4 10.2-0.2 14.1 0.4 0.5 0.9 0.9 1.5 1.2 22.1 20.4 36.4 47.9 40.4 77.7 6.2 22.4 29.7 35.7 52.4 29.5 22.5-5.9 35.9-28.9 30-51.3 0-0.1-0.1-0.2-0.1-0.4-4.7-16.8-19.3-29.1-36.7-30.8-28-5.1-53.5-19.2-72.8-40.1-4.1-3.8-10.5-3.8-14.5 0.1z m-225.7-483c-76.4 8.3-145.8 40.6-195.6 91-19.4 19.4-35.5 41.8-47.8 66.3-37.7 74.9-31.4 164.4 16.5 233.2 13.5 20.2 35.8 45.4 56.1 63.3l-9.2 71.3-1 3c-0.3 0.9-0.3 1.9-0.4 2.8l-0.2 2.3 0.2 2.3c1.2 12.7 12.5 22 25.2 20.9 3.5-0.3 6.8-1.4 9.8-3.1h0.4l1.4-1 22-10.8 65.5-32.5c31.1 8.8 63.4 13.2 95.8 13 40 0.1 79.8-6.7 117.5-20.2-18.8-6-30.9-24.3-29-44-39 12.4-80.2 16.4-120.8 11.9l-6.5-0.9c-14.7-1.9-29.2-4.9-43.4-8.9-7.8-2.4-16.1-1.5-23.3 2.4l-1.8 0.9-53.9 31.3-2.3 1.4c-1.3 0.7-1.9 1-2.6 1-2-0.1-3.5-1.8-3.4-3.8l2-8.2 2.4-8.9 3.9-14.7 4.5-16.4c3-9.2-0.3-19.2-8.2-24.8-21.1-15.5-39.5-34.4-54.4-56-37.9-54.2-43-124.8-13.3-183.8 9.9-19.5 22.9-37.4 38.4-52.9 40.9-41.6 98.3-68.1 161.9-74.9 22-2.4 44.2-2.4 66.2 0 63.2 7.2 120.4 34 161.1 75.4 15.4 15.7 28.2 33.6 37.9 53.2 12.5 24.8 19 52.3 19.1 80.1 0 2.9-0.3 5.8-0.4 8.6 16.8-10.2 38.4-7.7 52.4 6.1l1.9 2.3c3.3-41.2-4.8-82.5-23.3-119.5-12.1-24.5-28.1-46.8-47.3-66.3-52.5-52-121.4-84.3-194.9-91.7-26.4-3.4-52.9-3.5-79.1-0.7z m418.2 405.4c-7.2 1.9-13.8 5.7-19.2 11h-0.1c-6.9 6.8-11.2 15.7-12.2 25.3-5.2 27.8-19.5 53.1-40.5 72-4 3.8-4.1 10.1-0.3 14.1l0.1 0.1c4 4 10.5 4.1 14.6 0.1 0.5-0.5 0.9-0.9 1.2-1.5 20.9-21.9 48.7-36 78.7-39.8 22.8-6.1 36.2-29.2 30.1-51.6-6.2-22.5-29.6-35.8-52.4-29.7z m-160.4-42l-0.7 0.7c-20.9 22.7-49.2 37.3-79.9 41.2-22.6 5.9-36.2 28.7-30.2 51.1 1.9 7.3 5.9 14 11.3 19.3 16.7 16.4 43.7 16.4 60.4-0.1 6.8-6.8 11.1-15.7 12.2-25.2 5.3-27.8 19.6-53.1 40.7-72 4.1-3.7 4.5-10 0.9-14.1l-0.1-0.1c-4-4.2-10.4-4.5-14.6-0.8z m39.6-76.6c-7.1 1.9-13.6 5.7-18.7 10.8-16.4 16.2-16.6 42.6-0.4 59l0.5 0.5c6.9 6.8 15.9 11 25.5 12.1 28 5.1 53.6 19.1 72.9 39.9 4 4 10.4 4 14.4 0.1 4-3.8 4.1-10.1 0.3-14.1-0.5-0.5-1.1-1-1.7-1.4-22.1-20.4-36.4-47.8-40.4-77.6-6.1-22.4-29.6-35.5-52.4-29.3z" fill="#0082EF" p-id="3542"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+2
View File
@@ -82,7 +82,9 @@ const channelPlatforms: string[] = [
'slack',
'feishu',
'wechat',
'wecom',
'matrix',
'dingtalk',
]
// ---------------------------------------------------------------------------
+22
View File
@@ -0,0 +1,22 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 1024 1024"
v-bind="$attrs"
><path
d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z"
fill="#3296FA"
p-id="1656"
/><path
d="M772.064 431.2c-1.376 5.44-4.096 10.944-8.192 20.48v1.376c-23.264 47.872-83.424 140.864-83.424 140.864l-17.792 30.08h84.8l-161.376 207.872 36.928-142.208h-67.008l23.232-94.4c-19.136 4.128-41.024 10.976-67.008 19.2 0 0-35.52 20.48-101.184-38.304 0 0-45.12-38.304-19.136-47.872 10.944-4.096 53.312-9.6 87.52-13.696 45.12-5.44 73.856-9.568 73.856-9.568s-139.52 1.376-172.32-2.72c-32.832-5.472-75.2-58.816-84.8-106.656 0 0-13.664-25.984 30.08-13.696 43.776 12.32 224.32 47.872 224.32 47.872S313.92 358.72 298.88 340.928c-16.416-16.416-46.496-92.992-42.4-139.488 0 0 1.376-12.288 13.664-8.192 0 0 173.696 77.952 292.672 120.32 118.976 41.024 222.88 62.912 209.216 117.632z"
fill="#FFFFFF"
p-id="1657"
/></svg>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ size?: string | number }>(), { size: '1em' })
defineOptions({ inheritAttrs: false })
</script>
+18
View File
@@ -0,0 +1,18 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="size"
:height="size"
viewBox="0 0 1024 1024"
v-bind="$attrs"
><path
d="M512 2c281.7 0 510 228.3 510 510s-228.3 510-510 510S2 793.7 2 512 230.3 2 512 2z m159.8 680.3c-4 3.9-4 10.2-0.2 14.1 0.4 0.5 0.9 0.9 1.5 1.2 22.1 20.4 36.4 47.9 40.4 77.7 6.2 22.4 29.7 35.7 52.4 29.5 22.5-5.9 35.9-28.9 30-51.3 0-0.1-0.1-0.2-0.1-0.4-4.7-16.8-19.3-29.1-36.7-30.8-28-5.1-53.5-19.2-72.8-40.1-4.1-3.8-10.5-3.8-14.5 0.1z m-225.7-483c-76.4 8.3-145.8 40.6-195.6 91-19.4 19.4-35.5 41.8-47.8 66.3-37.7 74.9-31.4 164.4 16.5 233.2 13.5 20.2 35.8 45.4 56.1 63.3l-9.2 71.3-1 3c-0.3 0.9-0.3 1.9-0.4 2.8l-0.2 2.3 0.2 2.3c1.2 12.7 12.5 22 25.2 20.9 3.5-0.3 6.8-1.4 9.8-3.1h0.4l1.4-1 22-10.8 65.5-32.5c31.1 8.8 63.4 13.2 95.8 13 40 0.1 79.8-6.7 117.5-20.2-18.8-6-30.9-24.3-29-44-39 12.4-80.2 16.4-120.8 11.9l-6.5-0.9c-14.7-1.9-29.2-4.9-43.4-8.9-7.8-2.4-16.1-1.5-23.3 2.4l-1.8 0.9-53.9 31.3-2.3 1.4c-1.3 0.7-1.9 1-2.6 1-2-0.1-3.5-1.8-3.4-3.8l2-8.2 2.4-8.9 3.9-14.7 4.5-16.4c3-9.2-0.3-19.2-8.2-24.8-21.1-15.5-39.5-34.4-54.4-56-37.9-54.2-43-124.8-13.3-183.8 9.9-19.5 22.9-37.4 38.4-52.9 40.9-41.6 98.3-68.1 161.9-74.9 22-2.4 44.2-2.4 66.2 0 63.2 7.2 120.4 34 161.1 75.4 15.4 15.7 28.2 33.6 37.9 53.2 12.5 24.8 19 52.3 19.1 80.1 0 2.9-0.3 5.8-0.4 8.6 16.8-10.2 38.4-7.7 52.4 6.1l1.9 2.3c3.3-41.2-4.8-82.5-23.3-119.5-12.1-24.5-28.1-46.8-47.3-66.3-52.5-52-121.4-84.3-194.9-91.7-26.4-3.4-52.9-3.5-79.1-0.7z m418.2 405.4c-7.2 1.9-13.8 5.7-19.2 11h-0.1c-6.9 6.8-11.2 15.7-12.2 25.3-5.2 27.8-19.5 53.1-40.5 72-4 3.8-4.1 10.1-0.3 14.1l0.1 0.1c4 4 10.5 4.1 14.6 0.1 0.5-0.5 0.9-0.9 1.2-1.5 20.9-21.9 48.7-36 78.7-39.8 22.8-6.1 36.2-29.2 30.1-51.6-6.2-22.5-29.6-35.8-52.4-29.7z m-160.4-42l-0.7 0.7c-20.9 22.7-49.2 37.3-79.9 41.2-22.6 5.9-36.2 28.7-30.2 51.1 1.9 7.3 5.9 14 11.3 19.3 16.7 16.4 43.7 16.4 60.4-0.1 6.8-6.8 11.1-15.7 12.2-25.2 5.3-27.8 19.6-53.1 40.7-72 4.1-3.7 4.5-10 0.9-14.1l-0.1-0.1c-4-4.2-10.4-4.5-14.6-0.8z m39.6-76.6c-7.1 1.9-13.6 5.7-18.7 10.8-16.4 16.2-16.6 42.6-0.4 59l0.5 0.5c6.9 6.8 15.9 11 25.5 12.1 28 5.1 53.6 19.1 72.9 39.9 4 4 10.4 4 14.4 0.1 4-3.8 4.1-10.1 0.3-14.1-0.5-0.5-1.1-1-1.7-1.4-22.1-20.4-36.4-47.8-40.4-77.6-6.1-22.4-29.6-35.5-52.4-29.3z"
fill="#0082EF"
p-id="3542"
/></svg>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ size?: string | number }>(), { size: '1em' })
defineOptions({ inheritAttrs: false })
</script>
+2
View File
@@ -17,6 +17,7 @@ export { default as Cohere } from './icons/Cohere.vue'
export { default as CohereColor } from './icons/CohereColor.vue'
export { default as Deepseek } from './icons/Deepseek.vue'
export { default as DeepseekColor } from './icons/DeepseekColor.vue'
export { default as Dingtalk } from './icons/Dingtalk.vue'
export { default as Discord } from './icons/Discord.vue'
export { default as Doubao } from './icons/Doubao.vue'
export { default as DoubaoColor } from './icons/DoubaoColor.vue'
@@ -80,6 +81,7 @@ export { default as VertexaiColor } from './icons/VertexaiColor.vue'
export { default as Volcengine } from './icons/Volcengine.vue'
export { default as VolcengineColor } from './icons/VolcengineColor.vue'
export { default as Wechat } from './icons/Wechat.vue'
export { default as Wecom } from './icons/Wecom.vue'
export { default as Xai } from './icons/Xai.vue'
export { default as Yandex } from './icons/Yandex.vue'
export { default as Yi } from './icons/Yi.vue'