Files
Memoh/internal/channel/adapters/dingtalk/stream.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

154 lines
3.6 KiB
Go

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.PreparedAttachment
final *channel.PreparedMessage
}
func (s *dingtalkOutboundStream) Push(ctx context.Context, event channel.PreparedStreamEvent) 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.PreparedMessage{
Message: 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
}
prepared := s.snapshotPrepared()
if prepared.Message.IsEmpty() && len(prepared.Attachments) == 0 {
return nil
}
if err := s.adapter.Send(ctx, s.cfg, channel.PreparedOutboundMessage{
Target: s.target,
Message: prepared,
}); err != nil {
return err
}
s.finalSent.Store(true)
return nil
}
func (s *dingtalkOutboundStream) snapshotPrepared() channel.PreparedMessage {
s.mu.Lock()
defer s.mu.Unlock()
var prepared channel.PreparedMessage
if s.final != nil {
prepared = *s.final
}
if strings.TrimSpace(prepared.Message.Text) == "" {
prepared.Message.Text = strings.TrimSpace(s.textBuilder.String())
}
if len(prepared.Attachments) == 0 && len(s.attachments) > 0 {
prepared.Attachments = append(prepared.Attachments, s.attachments...)
prepared.Message.Attachments = make([]channel.Attachment, 0, len(s.attachments))
for _, att := range s.attachments {
prepared.Message.Attachments = append(prepared.Message.Attachments, att.Logical)
}
}
if prepared.Message.Reply == nil && s.reply != nil {
prepared.Message.Reply = s.reply
}
return prepared
}