// 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"` }