mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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<{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -82,7 +82,9 @@ const channelPlatforms: string[] = [
|
||||
'slack',
|
||||
'feishu',
|
||||
'wechat',
|
||||
'wecom',
|
||||
'matrix',
|
||||
'dingtalk',
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user