Files
Memoh/internal/channel/types.go
T
BBQ bc374fe8cd refactor: content-addressed assets, cross-channel multimodal, infra simplification (#63)
* refactor(attachment): multimodal attachment refactor with snapshot schema and storage layer

- Add snapshot schema migration (0008) and update init/versions/snapshots
- Add internal/attachment and internal/channel normalize for unified attachment handling
- Move containerfs provider from internal/media to internal/storage
- Update agent types, channel adapters (Telegram/Feishu), inbound and handlers
- Add containerd snapshot lineage and local_channel tests
- Regenerate sqlc, swagger and SDK

* refactor(media): content-addressed asset system with unified naming

- Replace asset_id foreign key with content_hash as sole identifier
  for bot_history_message_assets (pure soft-link model)
- Remove mime, size_bytes, storage_key from DB; derive at read time
  via media.Resolve from actual storage
- Merge migrations 0008/0009 into single 0008; keep 0001 as canonical schema
- Add Docker initdb script for deterministic migration execution order
- Fix cross-channel real-time image display (Telegram → WebUI SSE)
- Fix message disappearing on refresh (null assets fallback)
- Fix file icon instead of image preview (mime derivation from storage)
- Unify AssetID → ContentHash naming across Go, Agent, and Frontend
- Change storage key prefix from 4-char to 2-char for directory sharding
- Add server-entrypoint.sh for Docker deployment migration handling

* refactor(infra): embedded migrations, Docker simplification, and config consolidation

- Embed SQL migrations into Go binary, removing shell-based migration scripts
- Consolidate config files into conf/ directory (app.example.toml, app.docker.toml, app.dev.toml)
- Simplify Docker setup: remove initdb.d scripts, streamline nginx config and entrypoint
- Remove legacy CLI, feishu-echo commands, and obsolete incremental migration files
- Update install script and docs to require sudo for one-click install
- Add mise tasks for dev environment orchestration

* chore: recover migrations

---------

Co-authored-by: Acbox <acbox0328@gmail.com>
2026-02-19 00:20:27 +08:00

399 lines
14 KiB
Go

// Package channel provides a unified abstraction for multi-platform messaging channels.
// It defines types, interfaces, and a registry for channel adapters such as Telegram and Feishu.
package channel
import (
"strings"
"time"
)
// ChannelType identifies a messaging platform (e.g., "telegram", "feishu").
type ChannelType string
// String returns the channel type as a plain string.
func (c ChannelType) String() string {
return string(c)
}
// Identity represents a sender's identity on a channel.
type Identity struct {
SubjectID string
DisplayName string
Attributes map[string]string
}
// Attribute returns the trimmed value for the given key, or empty string if absent.
func (i Identity) Attribute(key string) string {
if i.Attributes == nil {
return ""
}
return strings.TrimSpace(i.Attributes[key])
}
// Conversation holds metadata about the chat or group context.
type Conversation struct {
ID string
Type string
Name string
ThreadID string
Metadata map[string]any
}
// InboundMessage is a message received from an external channel.
type InboundMessage struct {
Channel ChannelType
Message Message
BotID string
ReplyTarget string
RouteKey string
Sender Identity
Conversation Conversation
ReceivedAt time.Time
Source string
Metadata map[string]any
}
// RoutingKey returns a stable identifier used for reply routing.
// Format: platform:bot_id:conversation_id[:sender_id].
func (m InboundMessage) RoutingKey() string {
if strings.TrimSpace(m.RouteKey) != "" {
return strings.TrimSpace(m.RouteKey)
}
senderID := strings.TrimSpace(m.Sender.SubjectID)
if senderID == "" {
senderID = strings.TrimSpace(m.Sender.DisplayName)
}
return GenerateRoutingKey(string(m.Channel), m.BotID, m.Conversation.ID, m.Conversation.Type, senderID)
}
// GenerateRoutingKey builds a route key from platform, bot, conversation, and sender info.
// For group chats, the sender ID is appended to provide per-user context.
func GenerateRoutingKey(platform, botID, conversationID, conversationType, senderID string) string {
parts := []string{platform, botID, conversationID}
ct := strings.ToLower(strings.TrimSpace(conversationType))
if ct != "" && ct != "p2p" && ct != "private" {
senderID = strings.TrimSpace(senderID)
if senderID != "" {
parts = append(parts, senderID)
}
}
return strings.Join(parts, ":")
}
// OutboundMessage pairs a delivery target with the message content.
type OutboundMessage struct {
Target string `json:"target"`
Message Message `json:"message"`
}
// StreamEventType defines the kind of outbound stream event.
type StreamEventType string
const (
StreamEventStatus StreamEventType = "status"
StreamEventDelta StreamEventType = "delta"
StreamEventFinal StreamEventType = "final"
StreamEventError StreamEventType = "error"
StreamEventToolCallStart StreamEventType = "tool_call_start"
StreamEventToolCallEnd StreamEventType = "tool_call_end"
StreamEventPhaseStart StreamEventType = "phase_start"
StreamEventPhaseEnd StreamEventType = "phase_end"
StreamEventAttachment StreamEventType = "attachment"
StreamEventAgentStart StreamEventType = "agent_start"
StreamEventAgentEnd StreamEventType = "agent_end"
StreamEventProcessingStarted StreamEventType = "processing_started"
StreamEventProcessingCompleted StreamEventType = "processing_completed"
StreamEventProcessingFailed StreamEventType = "processing_failed"
)
// StreamStatus indicates the lifecycle state of a streaming reply.
type StreamStatus string
const (
StreamStatusStarted StreamStatus = "started"
StreamStatusCompleted StreamStatus = "completed"
StreamStatusFailed StreamStatus = "failed"
)
// StreamFinalizePayload carries the final reply message emitted by a stream.
type StreamFinalizePayload struct {
Message Message `json:"message"`
}
// StreamToolCall carries tool invocation data for tool_call_start / tool_call_end events.
type StreamToolCall struct {
Name string `json:"name"`
CallID string `json:"call_id,omitempty"`
Input any `json:"input,omitempty"`
Result any `json:"result,omitempty"`
}
// StreamPhase labels a processing stage within a stream (e.g., reasoning, text).
type StreamPhase string
const (
StreamPhaseReasoning StreamPhase = "reasoning"
StreamPhaseText StreamPhase = "text"
)
// StreamEvent represents a unified stream event routed through the channel layer.
type StreamEvent struct {
Type StreamEventType `json:"type"`
Status StreamStatus `json:"status,omitempty"`
Delta string `json:"delta,omitempty"`
Final *StreamFinalizePayload `json:"final,omitempty"`
Error string `json:"error,omitempty"`
ToolCall *StreamToolCall `json:"tool_call,omitempty"`
Phase StreamPhase `json:"phase,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// StreamOptions configures how an outbound stream is initialized.
type StreamOptions struct {
Reply *ReplyRef `json:"reply,omitempty"`
SourceMessageID string `json:"source_message_id,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// MessageFormat indicates how the message text should be rendered.
type MessageFormat string
const (
MessageFormatPlain MessageFormat = "plain"
MessageFormatMarkdown MessageFormat = "markdown"
MessageFormatRich MessageFormat = "rich"
)
// MessagePartType identifies the kind of a rich-text message part.
type MessagePartType string
const (
MessagePartText MessagePartType = "text"
MessagePartLink MessagePartType = "link"
MessagePartCodeBlock MessagePartType = "code_block"
MessagePartMention MessagePartType = "mention"
MessagePartEmoji MessagePartType = "emoji"
)
// MessageTextStyle describes inline formatting for a text part.
type MessageTextStyle string
const (
MessageStyleBold MessageTextStyle = "bold"
MessageStyleItalic MessageTextStyle = "italic"
MessageStyleStrikethrough MessageTextStyle = "strikethrough"
MessageStyleCode MessageTextStyle = "code"
)
// MessagePart is a single element within a rich-text message.
type MessagePart struct {
Type MessagePartType `json:"type"`
Text string `json:"text,omitempty"`
URL string `json:"url,omitempty"`
Styles []MessageTextStyle `json:"styles,omitempty"`
Language string `json:"language,omitempty"`
ChannelIdentityID string `json:"channel_identity_id,omitempty"`
Emoji string `json:"emoji,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// AttachmentType classifies the kind of binary attachment.
type AttachmentType string
const (
AttachmentImage AttachmentType = "image"
AttachmentAudio AttachmentType = "audio"
AttachmentVideo AttachmentType = "video"
AttachmentVoice AttachmentType = "voice"
AttachmentFile AttachmentType = "file"
AttachmentGIF AttachmentType = "gif"
)
// Attachment represents a binary file attached to a message.
type Attachment struct {
Type AttachmentType `json:"type"`
URL string `json:"url,omitempty"`
PlatformKey string `json:"platform_key,omitempty"`
SourcePlatform string `json:"source_platform,omitempty"`
ContentHash string `json:"content_hash,omitempty"`
Base64 string `json:"base64,omitempty"` // data URL for agent delivery
Name string `json:"name,omitempty"`
Size int64 `json:"size,omitempty"`
Mime string `json:"mime,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
ThumbnailURL string `json:"thumbnail_url,omitempty"`
Caption string `json:"caption,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// Reference returns the strongest available attachment reference.
// URL is preferred for cross-platform portability, then platform key.
func (a Attachment) Reference() string {
if strings.TrimSpace(a.URL) != "" {
return strings.TrimSpace(a.URL)
}
return strings.TrimSpace(a.PlatformKey)
}
// HasReference reports whether URL or platform key is available.
func (a Attachment) HasReference() bool {
return a.Reference() != ""
}
// Action describes an interactive button or link in a message.
type Action struct {
Type string `json:"type"`
Label string `json:"label,omitempty"`
Value string `json:"value,omitempty"`
URL string `json:"url,omitempty"`
}
// ThreadRef references a conversation thread by ID.
type ThreadRef struct {
ID string `json:"id"`
}
// ReplyRef points to a message being replied to.
type ReplyRef struct {
Target string `json:"target,omitempty"`
MessageID string `json:"message_id,omitempty"`
}
// Message is the unified message structure used across all channels.
type Message struct {
ID string `json:"id,omitempty"`
Format MessageFormat `json:"format,omitempty"`
Text string `json:"text,omitempty"`
Parts []MessagePart `json:"parts,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Actions []Action `json:"actions,omitempty"`
Thread *ThreadRef `json:"thread,omitempty"`
Reply *ReplyRef `json:"reply,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// IsEmpty reports whether the message carries no content.
func (m Message) IsEmpty() bool {
return strings.TrimSpace(m.Text) == "" &&
len(m.Parts) == 0 &&
len(m.Attachments) == 0 &&
len(m.Actions) == 0
}
// PlainText extracts the plain text representation of the message.
func (m Message) PlainText() string {
if strings.TrimSpace(m.Text) != "" {
return strings.TrimSpace(m.Text)
}
if len(m.Parts) == 0 {
return ""
}
lines := make([]string, 0, len(m.Parts))
for _, part := range m.Parts {
switch part.Type {
case MessagePartText, MessagePartLink, MessagePartCodeBlock, MessagePartMention, MessagePartEmoji:
value := strings.TrimSpace(part.Text)
if value == "" && part.Type == MessagePartLink {
value = strings.TrimSpace(part.URL)
}
if value == "" && part.Type == MessagePartEmoji {
value = strings.TrimSpace(part.Emoji)
}
if value == "" {
continue
}
lines = append(lines, value)
default:
continue
}
}
return strings.Join(lines, "\n")
}
// BindingCriteria specifies conditions for matching a user-channel binding.
type BindingCriteria struct {
SubjectID string
Attributes map[string]string
}
// Attribute returns the trimmed value for the given key, or empty string if absent.
func (c BindingCriteria) Attribute(key string) string {
if c.Attributes == nil {
return ""
}
return strings.TrimSpace(c.Attributes[key])
}
// BindingCriteriaFromIdentity creates BindingCriteria from a channel Identity.
func BindingCriteriaFromIdentity(identity Identity) BindingCriteria {
return BindingCriteria{
SubjectID: strings.TrimSpace(identity.SubjectID),
Attributes: identity.Attributes,
}
}
// ChannelConfig holds the configuration for a bot's channel integration.
// Disabled: true means the channel is stopped (not connected); false means enabled.
type ChannelConfig struct {
ID string `json:"id"`
BotID string `json:"bot_id"`
ChannelType ChannelType `json:"channel_type"`
Credentials map[string]any `json:"credentials"`
ExternalIdentity string `json:"external_identity"`
SelfIdentity map[string]any `json:"self_identity"`
Routing map[string]any `json:"routing"`
Disabled bool `json:"disabled"`
VerifiedAt time.Time `json:"verified_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ChannelIdentityBinding represents a channel identity's binding to a specific channel type.
type ChannelIdentityBinding struct {
ID string `json:"id"`
ChannelType ChannelType `json:"channel_type"`
ChannelIdentityID string `json:"channel_identity_id"`
Config map[string]any `json:"config"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UpsertConfigRequest is the input for creating or updating a channel configuration.
// Disabled: true to stop the channel, false to enable it. Omitted is treated as false (enabled).
type UpsertConfigRequest struct {
Credentials map[string]any `json:"credentials"`
ExternalIdentity string `json:"external_identity,omitempty"`
SelfIdentity map[string]any `json:"self_identity,omitempty"`
Routing map[string]any `json:"routing,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
}
// UpsertChannelIdentityConfigRequest is the input for creating or updating a channel-identity binding.
type UpsertChannelIdentityConfigRequest struct {
Config map[string]any `json:"config"`
}
// UpdateChannelStatusRequest is the input for enabling/disabling a bot channel config.
type UpdateChannelStatusRequest struct {
Disabled bool `json:"disabled"`
}
// SendRequest is the input for sending an outbound message through a channel.
type SendRequest struct {
Target string `json:"target,omitempty"`
ChannelIdentityID string `json:"channel_identity_id,omitempty"`
Message Message `json:"message"`
}
// ReactRequest is the input for adding or removing an emoji reaction on a message.
type ReactRequest struct {
Target string `json:"target"`
MessageID string `json:"message_id"`
Emoji string `json:"emoji"`
Remove bool `json:"remove,omitempty"`
}