feat(channel): 跨平台 IM 接入抽象契约(UnifiedMessage/PlatformAdapter/Capabilities/Actor 身份)
This commit is contained in:
1 parent
a21176ec0a
commit
c52ddfd9c7
8 files changed
+767
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user