feat(channel): 跨平台 IM 接入抽象契约(UnifiedMessage/PlatformAdapter/Capabilities/Actor 身份)

This commit is contained in:
Aska committed 2026-09-06 04:33:59 +08:00
1 parent a21176ec0a
commit c52ddfd9c7
8 files changed
+767

No files matched your search

+71
View File
@@ -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
}
+67
View File
@@ -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)
}
}
+97
View File
@@ -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
}
+107
View File
@@ -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")
}
}
+61
View File
@@ -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)
}
}
+161
View File
@@ -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 }
+151
View File
@@ -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")
}
}