fix(channel,attachment): channel quality refactor & attachment pipeline fixes (#349)

* feat(channel): add DingTalk channel adapter

- Add DingTalk channel adapter (`internal/channel/adapters/dingtalk/`) using dingtalk-stream-sdk-go, supporting inbound message receiving and outbound text/markdown reply
- Register DingTalk adapter in cmd/agent and cmd/memoh
- Add go.mod dependency: github.com/memohai/dingtalk-stream-sdk-go
- Add Dingtalk and Wecom SVG icons and Vue components to @memohai/icon
- Refactor existing icon components to remove redundant inline wrappers
- Add `channelTypeDisplayName` util for consistent channel label resolution
- Add DingTalk/WeCom i18n entries (en/zh) for types and typesShort
- Extend channel-icon, bot-channels, channel-settings-panel to support dingtalk/wecom
- Use channelTypeDisplayName in profile page to replace ad-hoc i18n lookup

* fix(channel,attachment): channel quality refactor & attachment pipeline fixes

Channel module:
- Fix RemoveAdapter not cleaning connectionMeta (stale status leak)
- Fix preparedAttachmentTypeFromMime misclassifying image/gif
- Fix sleepWithContext time.After goroutine/timer leak
- Export IsDataURL/IsHTTPURL/IsDataPath, dedup across packages
- Cache OutboundPolicy in managerOutboundStream to avoid repeated lookups
- Split OutboundAttachmentStore: extract ContainerAttachmentIngester interface
- Add ManagerOption funcs (WithInboundQueueSize, WithInboundWorkers, WithRefreshInterval)
- Add thread-safety docs on OutboundStream / managerOutboundStream
- Add debug logs on successful send/edit paths
- Expand outbound_prepare_test.go with 21 new cases
- Convert no-receiver adapter helpers to package-level funcs; drop unused params

DingTalk adapter:
- Implement AttachmentResolver: download inbound media via /v1.0/robot/messageFiles/download
- Fix pure-image inbound messages failing due to missing resolver

Attachment pipeline:
- Fix images invisible to LLM in pipeline (DCP) path: inject InlineImages into
  last user message when cfg.Query is empty
- Fix public_url fallback: skip direct URL-to-LLM when ContentHash is set,
  always prefer inlined persisted asset
- Inject path: carry ImageParts through agent.InjectMessage; inline persisted
  attachments in resolver inject goroutine so mid-stream images reach the model
- Fix ResolveMime for images: prefer content-sniffed MIME over platform-declared
  MIME (fixes Feishu sending image/png header for actual JPEG content → API 400)
This commit is contained in:
BBQ
2026-04-09 14:36:11 +08:00
committed by GitHub
parent fffe5ac34f
commit d3bf6bc90a
76 changed files with 4851 additions and 1185 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)
}