Files
Memoh/internal/channel/prepared_outbound.go
T
BBQ d3bf6bc90a 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)
2026-04-09 14:36:11 +08:00

110 lines
2.9 KiB
Go

package channel
import (
"context"
"io"
)
// PreparedAttachmentKind identifies how an attachment should be delivered.
type PreparedAttachmentKind string
const (
PreparedAttachmentNativeRef PreparedAttachmentKind = "native_ref"
PreparedAttachmentPublicURL PreparedAttachmentKind = "public_url"
PreparedAttachmentUpload PreparedAttachmentKind = "upload"
)
// PreparedAttachment is the adapter-facing attachment model after preparation.
type PreparedAttachment struct {
Logical Attachment
Kind PreparedAttachmentKind
NativeRef string
PublicURL string
Name string
Mime string
Size int64
// Open must return a fresh reader each time so retries are safe.
Open func(ctx context.Context) (io.ReadCloser, error)
}
// PreparedMessage is the adapter-facing form of a Message.
type PreparedMessage struct {
Message Message
Attachments []PreparedAttachment
}
// LogicalMessage converts the prepared message back to the logical channel message.
func (m PreparedMessage) LogicalMessage() Message {
msg := m.Message
if len(m.Attachments) == 0 {
return msg
}
attachments := make([]Attachment, 0, len(m.Attachments))
for _, att := range m.Attachments {
attachments = append(attachments, att.Logical)
}
msg.Attachments = attachments
return msg
}
// PreparedOutboundMessage is the adapter-facing form of OutboundMessage.
type PreparedOutboundMessage struct {
Target string
Message PreparedMessage
}
// LogicalMessage converts the prepared outbound message back to the logical model.
func (m PreparedOutboundMessage) LogicalMessage() OutboundMessage {
return OutboundMessage{
Target: m.Target,
Message: m.Message.LogicalMessage(),
}
}
// PreparedStreamFinalizePayload is the adapter-facing stream final payload.
type PreparedStreamFinalizePayload struct {
Message PreparedMessage
}
// PreparedStreamEvent is the adapter-facing form of StreamEvent.
type PreparedStreamEvent struct {
Type StreamEventType
Status StreamStatus
Delta string
Final *PreparedStreamFinalizePayload
Error string
ToolCall *StreamToolCall
Phase StreamPhase
Attachments []PreparedAttachment
Reactions []ReactRequest
Speeches []SpeechRequest
Metadata map[string]any
}
// LogicalEvent converts the prepared stream event back to the logical model.
func (e PreparedStreamEvent) LogicalEvent() StreamEvent {
result := StreamEvent{
Type: e.Type,
Status: e.Status,
Delta: e.Delta,
Error: e.Error,
ToolCall: e.ToolCall,
Phase: e.Phase,
Reactions: e.Reactions,
Speeches: e.Speeches,
Metadata: e.Metadata,
}
if len(e.Attachments) > 0 {
result.Attachments = make([]Attachment, 0, len(e.Attachments))
for _, att := range e.Attachments {
result.Attachments = append(result.Attachments, att.Logical)
}
}
if e.Final != nil {
result.Final = &StreamFinalizePayload{
Message: e.Final.Message.LogicalMessage(),
}
}
return result
}