From c52ddfd9c7351c22d65f78538e45b47f4c754e41 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 04:33:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E8=B7=A8=E5=B9=B3=E5=8F=B0=20?= =?UTF-8?q?IM=20=E6=8E=A5=E5=85=A5=E6=8A=BD=E8=B1=A1=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=EF=BC=88UnifiedMessage/PlatformAdapter/Capabilities/Actor=20?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/channel/adapter/actor.go | 71 ++++++++ internal/channel/adapter/actor_test.go | 67 ++++++++ internal/channel/adapter/adapter.go | 97 +++++++++++ internal/channel/adapter/adapter_test.go | 107 ++++++++++++ internal/channel/adapter/capabilities.go | 61 +++++++ internal/channel/adapter/capabilities_test.go | 52 ++++++ internal/channel/adapter/message.go | 161 ++++++++++++++++++ internal/channel/adapter/message_test.go | 151 ++++++++++++++++ 8 files changed, 767 insertions(+) create mode 100644 internal/channel/adapter/actor.go create mode 100644 internal/channel/adapter/actor_test.go create mode 100644 internal/channel/adapter/adapter.go create mode 100644 internal/channel/adapter/adapter_test.go create mode 100644 internal/channel/adapter/capabilities.go create mode 100644 internal/channel/adapter/capabilities_test.go create mode 100644 internal/channel/adapter/message.go create mode 100644 internal/channel/adapter/message_test.go diff --git a/internal/channel/adapter/actor.go b/internal/channel/adapter/actor.go new file mode 100644 index 0000000..c5e6b2e --- /dev/null +++ b/internal/channel/adapter/actor.go @@ -0,0 +1,71 @@ +package adapter + +// Role is the strongly typed identity role the channel layer assigns to a +// message sender. It is derived from channel-level actor metadata (login state, +// account binding, group membership) by the strategy engine, never from prompt +// text. This is a project red line: identity resolution must not rely on +// natural-language hints. +type Role string + +const ( + // RoleOwner is the bound owner/primary account. + RoleOwner Role = "owner" + // RoleMember is a non-owner participant (for example a stranger in a group). + RoleMember Role = "member" + // RoleSystem is a system/synthetic sender (platform notice, bot). + RoleSystem Role = "system" + // RoleUnknown is a sender whose identity could not be determined. + RoleUnknown Role = "unknown" +) + +// Valid reports whether r is one of the known roles. +func (r Role) Valid() bool { + switch r { + case RoleOwner, RoleMember, RoleSystem, RoleUnknown: + return true + default: + return false + } +} + +// Actor is the strongly typed identity metadata attached to a message sender. +// The channel layer derives it from channel telemetry (login session, account +// binding, group membership) rather than from the message's free-text sender +// name. Owner detection and group isolation read this field, not prompt text. +type Actor struct { + // Role is the resolved identity role. + Role Role + // UserID is the stable platform user identifier. + UserID string + // DisplayName is the human-readable sender name, used for display only. + DisplayName string + // IsGroup is true when the actor represents a group/room conversation + // rather than a single user. + IsGroup bool + // GroupID is the group identifier, set when IsGroup is true. + GroupID string + // GroupName is the human-readable group name, used for display only. + GroupName string +} + +// IsOwner reports whether the actor is the bound owner account. +func (a Actor) IsOwner() bool { return a.Role == RoleOwner } + +// IsGroupChat reports whether the actor represents a group conversation. +func (a Actor) IsGroupChat() bool { return a.IsGroup } + +// IsSystem reports whether the actor is a system/synthetic sender. +func (a Actor) IsSystem() bool { return a.Role == RoleSystem } + +// IsUnknown reports whether the actor's role could not be determined. +func (a Actor) IsUnknown() bool { return a.Role == RoleUnknown } + +// Resolve is a pure helper that infers IsGroup and the group identity from the +// GroupID when the channel layer has not set them explicitly, so adapters can +// populate a minimal Actor and still get correct group flags. +func (a Actor) Resolve() Actor { + if a.GroupID != "" { + a.IsGroup = true + } + return a +} diff --git a/internal/channel/adapter/actor_test.go b/internal/channel/adapter/actor_test.go new file mode 100644 index 0000000..584493e --- /dev/null +++ b/internal/channel/adapter/actor_test.go @@ -0,0 +1,67 @@ +package adapter + +import "testing" + +func TestRoleValid(t *testing.T) { + t.Parallel() + + valid := []Role{RoleOwner, RoleMember, RoleSystem, RoleUnknown} + for _, r := range valid { + if !r.Valid() { + t.Errorf("Role(%q).Valid() = false, want true", r) + } + } + if Role("").Valid() { + t.Error("empty Role.Valid() = true, want false") + } + if Role("admin").Valid() { + t.Error("bogus Role.Valid() = true, want false") + } +} + +func TestActorOwnerDetection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + actor Actor + wantOwner bool + wantGroup bool + wantSys bool + }{ + {name: "owner private", actor: Actor{Role: RoleOwner, UserID: "u-1"}, wantOwner: true}, + {name: "member group", actor: Actor{Role: RoleMember, IsGroup: true, GroupID: "g-1"}, wantGroup: true}, + {name: "system", actor: Actor{Role: RoleSystem}, wantSys: true}, + {name: "unknown", actor: Actor{Role: RoleUnknown}}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.actor.IsOwner(); got != tt.wantOwner { + t.Errorf("IsOwner() = %v, want %v", got, tt.wantOwner) + } + if got := tt.actor.IsGroupChat(); got != tt.wantGroup { + t.Errorf("IsGroupChat() = %v, want %v", got, tt.wantGroup) + } + if got := tt.actor.IsSystem(); got != tt.wantSys { + t.Errorf("IsSystem() = %v, want %v", got, tt.wantSys) + } + }) + } +} + +func TestActorResolveInfersGroup(t *testing.T) { + t.Parallel() + + actor := Actor{Role: RoleMember, UserID: "u-1", GroupID: "g-1", GroupName: "Team"} + resolved := actor.Resolve() + + if !resolved.IsGroup || !resolved.IsGroupChat() { + t.Errorf("Resolve() = %+v, want IsGroup true", resolved) + } + if resolved.GroupID != "g-1" || resolved.GroupName != "Team" { + t.Errorf("Resolve() = %+v, want group identity preserved", resolved) + } +} diff --git a/internal/channel/adapter/adapter.go b/internal/channel/adapter/adapter.go new file mode 100644 index 0000000..a00272b --- /dev/null +++ b/internal/channel/adapter/adapter.go @@ -0,0 +1,97 @@ +package adapter + +import ( + "context" + "encoding/json" + "errors" + "fmt" +) + +// MessageHandler receives a normalized inbound message. It is the push-style +// counterpart to ReadMessages: adapters call it when the platform delivers a +// message asynchronously, so consumers do not have to run a polling loop. +type MessageHandler func(ctx context.Context, msg UnifiedMessage) + +// ChannelInfo describes a channel the adapter exposes, used by ListChannels. +type ChannelInfo struct { + ChannelID string + ChannelType ChannelType + ChannelName string + GroupID string + GroupName string +} + +// PlatformAdapter is the contract every cross-platform IM adapter implements. +// Implementations translate between a native platform SDK/protocol and the +// normalized UnifiedMessage/UnifiedResponse model. The API is deliberately +// narrow so it is trivial to mock and so a new platform plugs in without +// touching consumers. +// +// All I/O operations accept a context.Context per the project constraint and +// should return promptly when the context is canceled. +type PlatformAdapter interface { + // Name returns the platform identifier (for example "telegram"). + Name() string + // Config returns a snapshot of the adapter's current configuration as a + // plain map, so callers can inspect or persist it without importing the + // concrete type. Use DecodeConfig to read it into a typed struct. + Config() map[string]any + + // Connect establishes the platform session. It is idempotent. + Connect(ctx context.Context) error + // Disconnect tears down the platform session. It is idempotent. + Disconnect(ctx context.Context) error + // IsConnected reports whether the session is currently live. + IsConnected() bool + // HealthCheck verifies the session is usable, returning an error when not. + HealthCheck(ctx context.Context) error + + // ReadMessages runs a blocking read loop, emitting each normalized inbound + // message to emit until ctx is canceled. Adapters that receive push + // notifications should instead deliver through SetMessageHandler. + ReadMessages(ctx context.Context, emit func(UnifiedMessage)) + + // SendMessage delivers a reply (potentially several messages) to the channel. + SendMessage(ctx context.Context, resp UnifiedResponse) error + // SendProactive pushes a message that was not triggered by a recent inbound + // message. Whether that is permitted is governed by Capabilities.Proactive + // and platform-specific limits (for example the weixin-ilink 24h window). + SendProactive(ctx context.Context, msg UnifiedMessage) error + // SetMessageHandler installs the callbacks receiving push-style inbound + // messages. A nil fn clears the handler. + SetMessageHandler(fn MessageHandler) + + // Capabilities declares what the adapter supports. + Capabilities() Capabilities + // ListChannels returns the channels currently known to the adapter. + ListChannels(ctx context.Context) ([]ChannelInfo, error) +} + +// Sentinel errors returned by adapters. Use errors.Is to test for them. +var ( + // ErrNotConnected is returned when an operation requires a live session. + ErrNotConnected = errors.New("adapter: not connected") + // ErrUnsupported is returned when a requested capability is not available + // (for example proactive push on a channel that does not allow it). + ErrUnsupported = errors.New("adapter: operation not supported") + // ErrClosed is returned when an operation is attempted on a closed adapter. + ErrClosed = errors.New("adapter: closed") +) + +// DecodeConfig decodes a raw config map produced by Config into the struct +// pointed to by v. It is a convenience for adapters and tests that model their +// configuration as strongly typed structs. Unknown keys are ignored; missing +// keys leave the corresponding zero value. +func DecodeConfig(raw map[string]any, v any) error { + if raw == nil { + return errors.New("adapter: nil config map") + } + b, err := json.Marshal(raw) + if err != nil { + return fmt.Errorf("adapter: encode config: %w", err) + } + if err := json.Unmarshal(b, v); err != nil { + return fmt.Errorf("adapter: decode config into %T: %w", v, err) + } + return nil +} diff --git a/internal/channel/adapter/adapter_test.go b/internal/channel/adapter/adapter_test.go new file mode 100644 index 0000000..c380d73 --- /dev/null +++ b/internal/channel/adapter/adapter_test.go @@ -0,0 +1,107 @@ +package adapter + +import ( + "context" + "testing" +) + +// mockAdapter is a minimal in-memory PlatformAdapter used to prove the +// interface is satisfiable and to exercise its surface. It mocks the contract, +// not a concrete adapter type. +type mockAdapter struct { + name string + cfg map[string]any + caps Capabilities + handler MessageHandler + sent []UnifiedMessage +} + +func (m *mockAdapter) Name() string { return m.name } +func (m *mockAdapter) Config() map[string]any { return m.cfg } +func (m *mockAdapter) Connect(context.Context) error { + return nil +} +func (m *mockAdapter) Disconnect(context.Context) error { return nil } +func (m *mockAdapter) IsConnected() bool { return true } +func (m *mockAdapter) HealthCheck(context.Context) error { + return nil +} +func (m *mockAdapter) ReadMessages(context.Context, func(UnifiedMessage)) {} +func (m *mockAdapter) SendMessage(_ context.Context, resp UnifiedResponse) error { + m.sent = resp.Messages + return nil +} +func (m *mockAdapter) SendProactive(context.Context, UnifiedMessage) error { return nil } +func (m *mockAdapter) SetMessageHandler(fn MessageHandler) { m.handler = fn } +func (m *mockAdapter) Capabilities() Capabilities { return m.caps } +func (m *mockAdapter) ListChannels(context.Context) ([]ChannelInfo, error) { + return nil, nil +} + +// Interface conformance is enforced at compile time. +var _ PlatformAdapter = (*mockAdapter)(nil) + +func TestMockAdapterSatisfiesContract(t *testing.T) { + t.Parallel() + + ad := &mockAdapter{ + name: "telegram", + cfg: map[string]any{"token": "t-1"}, + caps: DefaultCapabilities(), + } + + if got := ad.Name(); got != "telegram" { + t.Errorf("Name() = %q, want telegram", got) + } + if got := ad.Config()["token"]; got != "t-1" { + t.Errorf("Config() token = %v, want t-1", got) + } + if got := ad.IsConnected(); !got { + t.Error("IsConnected() = false, want true") + } + if got := ad.Capabilities().MaxMessageLength; got != defaultMaxMessageLength { + t.Errorf("Capabilities().MaxMessageLength = %d, want %d", got, defaultMaxMessageLength) + } +} + +func TestMockAdapterSendMessageRecords(t *testing.T) { + t.Parallel() + + ad := &mockAdapter{} + msg := UnifiedMessage{ChannelType: ChannelDirect, ContentType: ContentText, Content: "pong"} + resp := UnifiedResponse{Messages: []UnifiedMessage{msg}, ReplyTo: "m-1", Hint: "ok"} + + if err := ad.SendMessage(context.Background(), resp); err != nil { + t.Fatalf("SendMessage() error = %v", err) + } + if len(ad.sent) != 1 || ad.sent[0].Content != "pong" { + t.Errorf("sent = %+v, want one message with content pong", ad.sent) + } +} + +func TestDecodeConfig(t *testing.T) { + t.Parallel() + + type cfg struct { + Token string + Port int + } + raw := map[string]any{"token": "abc", "port": 8080} + + var c cfg + if err := DecodeConfig(raw, &c); err != nil { + t.Fatalf("DecodeConfig() error = %v", err) + } + if c.Token != "abc" || c.Port != 8080 { + t.Errorf("DecodeConfig() = %+v, want {abc 8080}", c) + } +} + +func TestDecodeConfigNil(t *testing.T) { + t.Parallel() + + var c struct{ Token string } + if err := DecodeConfig(nil, &c); err == nil { + t.Error("DecodeConfig(nil, ...) error = nil, want non-nil") + } +} diff --git a/internal/channel/adapter/capabilities.go b/internal/channel/adapter/capabilities.go new file mode 100644 index 0000000..3fe7460 --- /dev/null +++ b/internal/channel/adapter/capabilities.go @@ -0,0 +1,61 @@ +package adapter + +// Capabilities declares what a concrete platform adapter supports, so the +// runtime can degrade gracefully and the channel layer can enforce limits. +type Capabilities struct { + // MaxMessageLength is the maximum message length the platform accepts. + // Zero means "derive a sensible default". + MaxMessageLength int + // Markdown reports whether the platform renders markdown in outgoing text. + Markdown bool + // Image reports whether the platform supports sending images. + Image bool + // Voice reports whether the platform supports sending voice/audio. + Voice bool + // Reaction reports whether the platform supports reactions. + Reaction bool + // TypingHint reports whether the platform supports typing indicators. + TypingHint bool + // RecommendBurstMax is the recommended maximum number of messages in one + // burst. Zero means "derive a sensible default". + RecommendBurstMax int + // Proactive reports whether the platform allows push without a recent + // inbound message (e.g. weixin-ilink has a 24h window; OneBot needs rate + // limiting). See design/08 section 3. + Proactive bool +} + +const ( + // defaultMaxMessageLength is used when an adapter declares no limit. + defaultMaxMessageLength = 4000 + // defaultRecommendBurstMax avoids flooding a channel by default. + defaultRecommendBurstMax = 1 +) + +// Normalize fills zero-valued numeric fields with sensible defaults so a +// partially declared Capabilities is still usable. Boolean fields are left +// untouched because an unset bool already means "not supported". +func (c Capabilities) Normalize() Capabilities { + if c.MaxMessageLength <= 0 { + c.MaxMessageLength = defaultMaxMessageLength + } + if c.RecommendBurstMax <= 0 { + c.RecommendBurstMax = defaultRecommendBurstMax + } + return c +} + +// DefaultCapabilities returns a conservative baseline for adapters that only +// speak plain text and basic images, leaving richer features off by default. +func DefaultCapabilities() Capabilities { + return Capabilities{ + MaxMessageLength: defaultMaxMessageLength, + Markdown: true, + Image: true, + Voice: false, + Reaction: false, + TypingHint: false, + RecommendBurstMax: defaultRecommendBurstMax, + Proactive: false, + } +} diff --git a/internal/channel/adapter/capabilities_test.go b/internal/channel/adapter/capabilities_test.go new file mode 100644 index 0000000..5cfcfc8 --- /dev/null +++ b/internal/channel/adapter/capabilities_test.go @@ -0,0 +1,52 @@ +package adapter + +import "testing" + +func TestCapabilitiesNormalizeFillsZeroNumerics(t *testing.T) { + t.Parallel() + + caps := Capabilities{Image: true, Proactive: true}.Normalize() + + if caps.MaxMessageLength != defaultMaxMessageLength { + t.Errorf("MaxMessageLength = %d, want %d", caps.MaxMessageLength, defaultMaxMessageLength) + } + if caps.RecommendBurstMax != defaultRecommendBurstMax { + t.Errorf("RecommendBurstMax = %d, want %d", caps.RecommendBurstMax, defaultRecommendBurstMax) + } + // Boolean fields must be preserved, not overwritten by normalization. + if !caps.Image || !caps.Proactive { + t.Errorf("booleans changed by Normalize: %+v", caps) + } +} + +func TestCapabilitiesNormalizePreservesExplicit(t *testing.T) { + t.Parallel() + + caps := Capabilities{MaxMessageLength: 100, RecommendBurstMax: 5}.Normalize() + + if caps.MaxMessageLength != 100 { + t.Errorf("MaxMessageLength = %d, want 100 (explicit preserved)", caps.MaxMessageLength) + } + if caps.RecommendBurstMax != 5 { + t.Errorf("RecommendBurstMax = %d, want 5 (explicit preserved)", caps.RecommendBurstMax) + } +} + +func TestDefaultCapabilities(t *testing.T) { + t.Parallel() + + caps := DefaultCapabilities() + + if caps.MaxMessageLength != defaultMaxMessageLength { + t.Errorf("MaxMessageLength = %d, want %d", caps.MaxMessageLength, defaultMaxMessageLength) + } + if !caps.Markdown || !caps.Image { + t.Errorf("expected markdown+image by default, got %+v", caps) + } + if caps.Voice || caps.Reaction || caps.TypingHint || caps.Proactive { + t.Errorf("expected rich/proactive features off by default, got %+v", caps) + } + if caps.RecommendBurstMax != defaultRecommendBurstMax { + t.Errorf("RecommendBurstMax = %d, want %d", caps.RecommendBurstMax, defaultRecommendBurstMax) + } +} diff --git a/internal/channel/adapter/message.go b/internal/channel/adapter/message.go new file mode 100644 index 0000000..b1bdf47 --- /dev/null +++ b/internal/channel/adapter/message.go @@ -0,0 +1,161 @@ +// Package adapter defines the cross-platform IM channel adaptation contract for +// Cyrene v2. It declares the normalized message model, the per-platform adapter +// interface, the capability declaration, and the strongly typed identity (actor) +// metadata that the channel layer uses for owner detection and group-chat +// isolation. +// +// The contract is intentionally platform-neutral: concrete adapters (qq-obv11, +// weixin-ilink, telegram, webhook) are implemented behind this interface in +// milestone M6, not here. The design authority is DevDocs/design/08-渠道接入.md +// (sections 0-2) and DevDocs/design/02-会话与上下文模型.md (section 3). +package adapter + +import ( + "errors" + "fmt" + "time" +) + +// ChannelType classifies where a message originated or is destined. +type ChannelType string + +const ( + // ChannelDirect is a one-to-one private conversation. + ChannelDirect ChannelType = "direct" + // ChannelGroup is a group / room conversation. + ChannelGroup ChannelType = "group" +) + +// Valid reports whether ct is one of the known channel kinds. +func (ct ChannelType) Valid() bool { + switch ct { + case ChannelDirect, ChannelGroup: + return true + default: + return false + } +} + +// ContentType classifies the primary payload of a message. +type ContentType string + +const ( + // ContentText is a plain-text chat message. + ContentText ContentType = "text" + // ContentImage is an image attachment. + ContentImage ContentType = "image" + // ContentVoice is a voice/audio clip. + ContentVoice ContentType = "voice" + // ContentFile is an arbitrary file attachment. + ContentFile ContentType = "file" + // ContentAction is a non-chat action signal (typing, join/leave, recall). + ContentAction ContentType = "action" +) + +// Valid reports whether ct is one of the known content kinds. +func (ct ContentType) Valid() bool { + switch ct { + case ContentText, ContentImage, ContentVoice, ContentFile, ContentAction: + return true + default: + return false + } +} + +// MediaKind classifies an individual media attachment. It is broader than +// ContentType so attachments can also carry video or other payloads. +type MediaKind string + +const ( + // MediaImage is an image file. + MediaImage MediaKind = "image" + // MediaVoice is an audio/voice file. + MediaVoice MediaKind = "voice" + // MediaVideo is a video file. + MediaVideo MediaKind = "video" + // MediaFile is an arbitrary file. + MediaFile MediaKind = "file" +) + +// Media is a single attachment carried by a UnifiedMessage. URL and Data are +// mutually exclusive representations: prefer URL for remote media to +// keep payloads out of memory. +type Media struct { + // Kind classifies the attachment. + Kind MediaKind + // URL is the remote location of the media when not inlined. + URL string + // Name is the original file name, when known. + Name string + // Size is the media size in bytes. + Size int64 + // MIMEType is the media content type, when known. + MIMEType string + // Data carries inlined bytes for small, locally produced payloads. + Data []byte +} + +// Mention is a reference to a user inside a message body. +type Mention struct { + UserID string + UserName string +} + +// UnifiedMessage is the normalized, platform-neutral representation of an IM +// event or inbound message. Every channel adapter converts its native payload +// into UnifiedMessage so the runtime can reason about them uniformly. +// +// SenderID/SenderName are kept per design/08 section 0; Sender carries the +// strongly typed identity metadata (Role, group flags) used for owner detection +// and group isolation. The strategy engine (M6) decides access from Sender, not +// from the free-text SenderName. +type UnifiedMessage struct { + Platform string + ChannelID string + ChannelType ChannelType + SenderID string + SenderName string + Sender Actor + Content string + ContentType ContentType + Media []Media + Mentions []Mention + ReplyToID string + ReplyToText string + GroupID string + GroupName string + ThreadID string + MessageID string + Timestamp time.Time + // RawData is the opaque native payload the message was converted from. + RawData []byte +} + +// UnifiedResponse is the normalized outbound payload for SendMessage. It may +// carry several messages so an adapter can burst a reply. +type UnifiedResponse struct { + // Messages are the reply messages to deliver. + Messages []UnifiedMessage + // ReplyTo is an optional context identifier the messages reply to. + ReplyTo string + // Hint is an optional human hint surfaced by the runtime. + Hint string +} + +// Validate reports whether the message is well-formed for downstream handling. +// It checks the enum fields and requires a group id on group messages. +func (m UnifiedMessage) Validate() error { + if !m.ChannelType.Valid() { + return fmt.Errorf("adapter: invalid channel type %q", m.ChannelType) + } + if !m.ContentType.Valid() { + return fmt.Errorf("adapter: invalid content type %q", m.ContentType) + } + if m.ChannelType == ChannelGroup && m.GroupID == "" { + return errors.New("adapter: group message missing group id") + } + return nil +} + +// IsGroup reports whether the message belongs to a group conversation. +func (m UnifiedMessage) IsGroup() bool { return m.ChannelType == ChannelGroup } diff --git a/internal/channel/adapter/message_test.go b/internal/channel/adapter/message_test.go new file mode 100644 index 0000000..eaf4adb --- /dev/null +++ b/internal/channel/adapter/message_test.go @@ -0,0 +1,151 @@ +package adapter + +import ( + "testing" + "time" +) + +func TestUnifiedMessageConstruction(t *testing.T) { + t.Parallel() + + ts := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC) + msg := UnifiedMessage{ + Platform: "telegram", + ChannelID: "ch-100", + ChannelType: ChannelGroup, + SenderID: "u-1", + SenderName: "Alice", + Sender: Actor{ + Role: RoleOwner, + UserID: "u-1", + DisplayName: "Alice", + GroupID: "g-42", + }, + Content: "hello @bob", + ContentType: ContentText, + Media: []Media{ + {Kind: MediaImage, URL: "https://example.com/a.jpg", Name: "a.jpg", Size: 1024, MIMEType: "image/jpeg"}, + }, + Mentions: []Mention{ + {UserID: "u-2", UserName: "Bob"}, + }, + ReplyToID: "m-9", + ReplyToText: "earlier", + GroupID: "g-42", + GroupName: "Team", + ThreadID: "t-7", + MessageID: "m-10", + Timestamp: ts, + RawData: []byte(`{"id":"m-10"}`), + } + + if !msg.IsGroup() { + t.Errorf("IsGroup() = false, want true") + } + if msg.ChannelType != ChannelGroup { + t.Errorf("ChannelType = %q, want %q", msg.ChannelType, ChannelGroup) + } + if msg.ContentType != ContentText { + t.Errorf("ContentType = %q, want %q", msg.ContentType, ContentText) + } + if len(msg.Media) != 1 || msg.Media[0].Kind != MediaImage { + t.Errorf("Media = %+v, want one image", msg.Media) + } + if len(msg.Mentions) != 1 || msg.Mentions[0].UserID != "u-2" { + t.Errorf("Mentions = %+v, want one user u-2", msg.Mentions) + } + if msg.ThreadID != "t-7" { + t.Errorf("ThreadID = %q, want t-7", msg.ThreadID) + } + if msg.Sender.Role != RoleOwner || !msg.Sender.IsOwner() { + t.Errorf("Sender = %+v, want owner role", msg.Sender) + } + if !msg.Timestamp.Equal(ts) { + t.Errorf("Timestamp = %v, want %v", msg.Timestamp, ts) + } +} + +func TestUnifiedMessageValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + msg UnifiedMessage + wantErr bool + }{ + { + name: "valid direct text", + msg: UnifiedMessage{ChannelType: ChannelDirect, ContentType: ContentText, Content: "hi"}, + wantErr: false, + }, + { + name: "valid group text", + msg: UnifiedMessage{ + ChannelType: ChannelGroup, ContentType: ContentText, GroupID: "g-1", Content: "hi", + }, + wantErr: false, + }, + { + name: "group missing id", + msg: UnifiedMessage{ + ChannelType: ChannelGroup, ContentType: ContentText, Content: "hi", + }, + wantErr: true, + }, + { + name: "invalid channel type", + msg: UnifiedMessage{ChannelType: ChannelType("bogus"), ContentType: ContentText}, + wantErr: true, + }, + { + name: "invalid content type", + msg: UnifiedMessage{ChannelType: ChannelDirect, ContentType: ContentType("bogus")}, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.msg.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestChannelTypeValid(t *testing.T) { + t.Parallel() + + valid := []ChannelType{ChannelDirect, ChannelGroup} + for _, ct := range valid { + if !ct.Valid() { + t.Errorf("ChannelType(%q).Valid() = false, want true", ct) + } + } + if ChannelType("").Valid() { + t.Error("empty ChannelType.Valid() = true, want false") + } + if ChannelType("bogus").Valid() { + t.Error("bogus ChannelType.Valid() = true, want false") + } +} + +func TestContentTypeValid(t *testing.T) { + t.Parallel() + + valid := []ContentType{ContentText, ContentImage, ContentVoice, ContentFile, ContentAction} + for _, ct := range valid { + if !ct.Valid() { + t.Errorf("ContentType(%q).Valid() = false, want true", ct) + } + } + if ContentType("").Valid() { + t.Error("empty ContentType.Valid() = true, want false") + } + if ContentType("bogus").Valid() { + t.Error("bogus ContentType.Valid() = true, want false") + } +}