mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
d3bf6bc90a
* 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)
306 lines
7.9 KiB
Go
306 lines
7.9 KiB
Go
package inbound
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/memohai/memoh/internal/channel"
|
|
"github.com/memohai/memoh/internal/conversation"
|
|
)
|
|
|
|
// InjectMessage is an alias for conversation.InjectMessage, re-exported so
|
|
// callers within this package do not need to import the conversation package
|
|
// directly for inject-related types.
|
|
type InjectMessage = conversation.InjectMessage
|
|
|
|
// InboundMode determines how a new inbound message is handled when an agent
|
|
// stream is already active for the same route.
|
|
type InboundMode int
|
|
|
|
const (
|
|
// ModeInject (default, command /btw) injects the message into the active
|
|
// agent stream via the PrepareStep hook so the LLM sees it between tool
|
|
// rounds. When no stream is active, starts one normally.
|
|
ModeInject InboundMode = iota
|
|
// ModeParallel (command /now) starts a new agent stream immediately,
|
|
// running concurrently with any existing stream.
|
|
ModeParallel
|
|
// ModeQueue (command /next) queues the message and processes it after the
|
|
// current agent stream completes.
|
|
ModeQueue
|
|
)
|
|
|
|
// QueuedTask holds everything needed to start an agent stream for a queued message.
|
|
type QueuedTask struct {
|
|
Ctx context.Context
|
|
Cfg channel.ChannelConfig
|
|
Msg channel.InboundMessage
|
|
Sender channel.StreamReplySender
|
|
Ident InboundIdentity
|
|
Text string
|
|
Attachs []conversation.ChatAttachment
|
|
}
|
|
|
|
// PersistFunc is a deferred persistence closure called after the active stream
|
|
// completes (and its storeRound has run), ensuring correct created_at ordering.
|
|
type PersistFunc func(ctx context.Context)
|
|
|
|
// routeState tracks in-flight agent activity for a single route.
|
|
type routeState struct {
|
|
mu sync.Mutex
|
|
active bool
|
|
injectCh chan InjectMessage
|
|
queue []QueuedTask
|
|
pendingPersists []PersistFunc
|
|
lastUsed time.Time
|
|
}
|
|
|
|
// RouteDispatcher manages per-route concurrency for inbound message processing.
|
|
// It decides whether a new message should be injected into an active stream,
|
|
// run in parallel, or be queued.
|
|
type RouteDispatcher struct {
|
|
mu sync.RWMutex
|
|
routes map[string]*routeState
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewRouteDispatcher creates a dispatcher with background cleanup.
|
|
func NewRouteDispatcher(logger *slog.Logger) *RouteDispatcher {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &RouteDispatcher{
|
|
routes: make(map[string]*routeState),
|
|
logger: logger.With(slog.String("component", "route_dispatcher")),
|
|
}
|
|
}
|
|
|
|
const injectChBuffer = 16
|
|
|
|
func (d *RouteDispatcher) getOrCreate(routeID string) *routeState {
|
|
d.mu.RLock()
|
|
rs, ok := d.routes[routeID]
|
|
d.mu.RUnlock()
|
|
if ok {
|
|
return rs
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if rs, ok = d.routes[routeID]; ok {
|
|
return rs
|
|
}
|
|
rs = &routeState{
|
|
injectCh: make(chan InjectMessage, injectChBuffer),
|
|
lastUsed: time.Now(),
|
|
}
|
|
d.routes[routeID] = rs
|
|
return rs
|
|
}
|
|
|
|
// IsActive reports whether the given route has an active agent stream.
|
|
func (d *RouteDispatcher) IsActive(routeID string) bool {
|
|
routeID = strings.TrimSpace(routeID)
|
|
if routeID == "" {
|
|
return false
|
|
}
|
|
rs := d.getOrCreate(routeID)
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
return rs.active
|
|
}
|
|
|
|
// MarkActive marks a route as having an active stream and returns the inject
|
|
// channel that the agent should drain via PrepareStep.
|
|
func (d *RouteDispatcher) MarkActive(routeID string) <-chan InjectMessage {
|
|
routeID = strings.TrimSpace(routeID)
|
|
if routeID == "" {
|
|
return nil
|
|
}
|
|
rs := d.getOrCreate(routeID)
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
rs.active = true
|
|
rs.lastUsed = time.Now()
|
|
return rs.injectCh
|
|
}
|
|
|
|
// MarkDoneResult holds the data returned when a route transitions from active to idle.
|
|
type MarkDoneResult struct {
|
|
PendingPersists []PersistFunc
|
|
QueuedTasks []QueuedTask
|
|
}
|
|
|
|
// MarkDone marks a route as idle and returns pending persist functions (to be
|
|
// called now that storeRound has completed) and any queued tasks.
|
|
func (d *RouteDispatcher) MarkDone(routeID string) MarkDoneResult {
|
|
routeID = strings.TrimSpace(routeID)
|
|
if routeID == "" {
|
|
return MarkDoneResult{}
|
|
}
|
|
rs := d.getOrCreate(routeID)
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
rs.active = false
|
|
rs.lastUsed = time.Now()
|
|
|
|
drainInjectCh(rs.injectCh)
|
|
|
|
var persists []PersistFunc
|
|
if len(rs.pendingPersists) > 0 {
|
|
persists = rs.pendingPersists
|
|
rs.pendingPersists = nil
|
|
}
|
|
|
|
var tasks []QueuedTask
|
|
if len(rs.queue) > 0 {
|
|
tasks = rs.queue
|
|
rs.queue = nil
|
|
}
|
|
|
|
return MarkDoneResult{PendingPersists: persists, QueuedTasks: tasks}
|
|
}
|
|
|
|
// AddPendingPersist records a deferred persist closure to be executed after the
|
|
// active stream completes. This ensures injected messages get a created_at
|
|
// timestamp after the triggering message's round.
|
|
func (d *RouteDispatcher) AddPendingPersist(routeID string, fn PersistFunc) {
|
|
routeID = strings.TrimSpace(routeID)
|
|
if routeID == "" || fn == nil {
|
|
return
|
|
}
|
|
rs := d.getOrCreate(routeID)
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
rs.pendingPersists = append(rs.pendingPersists, fn)
|
|
}
|
|
|
|
// Inject sends a message to the inject channel of an active route.
|
|
// Returns true if the message was accepted (route is active and channel not full).
|
|
func (d *RouteDispatcher) Inject(routeID string, msg InjectMessage) bool {
|
|
routeID = strings.TrimSpace(routeID)
|
|
if routeID == "" {
|
|
return false
|
|
}
|
|
rs := d.getOrCreate(routeID)
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
if !rs.active {
|
|
return false
|
|
}
|
|
select {
|
|
case rs.injectCh <- msg:
|
|
if d.logger != nil {
|
|
d.logger.Info("message injected into active stream",
|
|
slog.String("route_id", routeID),
|
|
)
|
|
}
|
|
return true
|
|
default:
|
|
if d.logger != nil {
|
|
d.logger.Warn("inject channel full, message dropped",
|
|
slog.String("route_id", routeID),
|
|
)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Enqueue adds a task to the route's queue for later processing.
|
|
func (d *RouteDispatcher) Enqueue(routeID string, task QueuedTask) {
|
|
routeID = strings.TrimSpace(routeID)
|
|
if routeID == "" {
|
|
return
|
|
}
|
|
rs := d.getOrCreate(routeID)
|
|
rs.mu.Lock()
|
|
defer rs.mu.Unlock()
|
|
rs.queue = append(rs.queue, task)
|
|
rs.lastUsed = time.Now()
|
|
if d.logger != nil {
|
|
d.logger.Info("message queued",
|
|
slog.String("route_id", routeID),
|
|
slog.Int("queue_size", len(rs.queue)),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Cleanup removes idle route states older than maxAge.
|
|
func (d *RouteDispatcher) Cleanup(maxAge time.Duration) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
cutoff := time.Now().Add(-maxAge)
|
|
for id, rs := range d.routes {
|
|
rs.mu.Lock()
|
|
idle := !rs.active && rs.lastUsed.Before(cutoff) && len(rs.queue) == 0
|
|
rs.mu.Unlock()
|
|
if idle {
|
|
delete(d.routes, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func drainInjectCh(ch chan InjectMessage) {
|
|
for {
|
|
select {
|
|
case <-ch:
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// DetectMode parses a message prefix to determine the inbound mode.
|
|
// Returns the mode and the text with the prefix stripped.
|
|
func DetectMode(text string) (InboundMode, string) {
|
|
trimmed := strings.TrimSpace(text)
|
|
if trimmed == "" {
|
|
return ModeInject, trimmed
|
|
}
|
|
|
|
type modePrefix struct {
|
|
prefix string
|
|
mode InboundMode
|
|
}
|
|
prefixes := []modePrefix{
|
|
{"/now ", ModeParallel},
|
|
{"/next ", ModeQueue},
|
|
{"/btw ", ModeInject},
|
|
}
|
|
lower := strings.ToLower(trimmed)
|
|
for _, mp := range prefixes {
|
|
if strings.HasPrefix(lower, mp.prefix) {
|
|
return mp.mode, strings.TrimSpace(trimmed[len(mp.prefix):])
|
|
}
|
|
}
|
|
// Exact match without trailing text (bare command)
|
|
barePrefixes := []modePrefix{
|
|
{"/now", ModeParallel},
|
|
{"/next", ModeQueue},
|
|
{"/btw", ModeInject},
|
|
}
|
|
for _, mp := range barePrefixes {
|
|
if lower == mp.prefix {
|
|
return mp.mode, ""
|
|
}
|
|
}
|
|
return ModeInject, trimmed
|
|
}
|
|
|
|
// IsModeCommand reports whether the text is a mode-prefix command
|
|
// (/btw, /now, /next), so the generic command handler should skip it.
|
|
func IsModeCommand(text string) bool {
|
|
trimmed := strings.ToLower(strings.TrimSpace(text))
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
for _, prefix := range []string{"/now", "/next", "/btw"} {
|
|
if trimmed == prefix || strings.HasPrefix(trimmed, prefix+" ") || strings.HasPrefix(trimmed, prefix+"\t") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|