diff --git a/pkg/message_gateway/channel.go b/pkg/message_gateway/channel.go new file mode 100644 index 00000000..85e31a2c --- /dev/null +++ b/pkg/message_gateway/channel.go @@ -0,0 +1,21 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import "context" + +// Handler processes one inbound message. +type Handler func(ctx context.Context, msg InboundMessage) error + +// Factory constructs a Channel from decrypted config. +type Factory func(cfg ChannelConfig, onInbound Handler) (Channel, error) + +// Channel is one connected messaging adapter. +type Channel interface { + Type() string + Connect(ctx context.Context) error + Disconnect(ctx context.Context) error + Send(ctx context.Context, to Recipient, msg OutboundMessage) error + Capabilities() Capability +} diff --git a/pkg/message_gateway/pairing.go b/pkg/message_gateway/pairing.go new file mode 100644 index 00000000..12115567 --- /dev/null +++ b/pkg/message_gateway/pairing.go @@ -0,0 +1,50 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "crypto/rand" + "strings" + "unicode" +) + +// CodeAlphabet excludes easily confused runes 0/O/1/I. +const CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// CodeLength is the raw pairing code size. +const CodeLength = 8 + +// GenerateCode returns an 8-character pairing code. +func GenerateCode() (string, error) { + buf := make([]byte, CodeLength) + if _, err := rand.Read(buf); err != nil { + return "", err + } + out := make([]byte, CodeLength) + for i, b := range buf { + out[i] = CodeAlphabet[int(b)%len(CodeAlphabet)] + } + return string(out), nil +} + +// NormalizeCode strips separators and uppercases. +func NormalizeCode(s string) string { + var b strings.Builder + for _, r := range s { + if r == '-' || unicode.IsSpace(r) { + continue + } + b.WriteRune(unicode.ToUpper(r)) + } + return b.String() +} + +// FormatCode renders ABCD-EFGH. +func FormatCode(s string) string { + s = NormalizeCode(s) + if len(s) != CodeLength { + return s + } + return s[:4] + "-" + s[4:] +} diff --git a/pkg/message_gateway/pairing_test.go b/pkg/message_gateway/pairing_test.go new file mode 100644 index 00000000..c442c30d --- /dev/null +++ b/pkg/message_gateway/pairing_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "strings" + "testing" +) + +func TestGenerateCode_AlphabetAndLength(t *testing.T) { + code, err := GenerateCode() + if err != nil { + t.Fatal(err) + } + if len(code) != 8 { + t.Fatalf("len=%d", len(code)) + } + for _, r := range code { + if !strings.ContainsRune(CodeAlphabet, r) { + t.Fatalf("bad rune %q", r) + } + } +} + +func TestNormalizeAndFormat(t *testing.T) { + if got := NormalizeCode("ab-cd-ef-gh"); got != "ABCDEFGH" { + t.Fatalf("got %q", got) + } + if got := FormatCode("ABCDEFGH"); got != "ABCD-EFGH" { + t.Fatalf("got %q", got) + } +} diff --git a/pkg/message_gateway/registry.go b/pkg/message_gateway/registry.go new file mode 100644 index 00000000..350c3d32 --- /dev/null +++ b/pkg/message_gateway/registry.go @@ -0,0 +1,26 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import "sync" + +var ( + factoriesMu sync.RWMutex + factories = map[string]Factory{} +) + +// Register stores a channel factory under typ. +func Register(typ string, fn Factory) { + factoriesMu.Lock() + defer factoriesMu.Unlock() + factories[typ] = fn +} + +// Lookup returns a previously registered factory. +func Lookup(typ string) (Factory, bool) { + factoriesMu.RLock() + defer factoriesMu.RUnlock() + fn, ok := factories[typ] + return fn, ok +} diff --git a/pkg/message_gateway/registry_test.go b/pkg/message_gateway/registry_test.go new file mode 100644 index 00000000..ad86d322 --- /dev/null +++ b/pkg/message_gateway/registry_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "context" + "testing" +) + +type stubChannel struct{} + +func (stubChannel) Type() string { return "stub" } +func (stubChannel) Connect(context.Context) error { + return nil +} +func (stubChannel) Disconnect(context.Context) error { return nil } +func (stubChannel) Send(context.Context, Recipient, OutboundMessage) error { + return nil +} +func (stubChannel) Capabilities() Capability { return Capability{Text: true} } + +func TestRegisterLookup(t *testing.T) { + Register("stub", func(ChannelConfig, Handler) (Channel, error) { + return stubChannel{}, nil + }) + fn, ok := Lookup("stub") + if !ok { + t.Fatal("expected factory") + } + ch, err := fn(ChannelConfig{}, nil) + if err != nil { + t.Fatal(err) + } + if ch.Type() != "stub" { + t.Fatalf("type=%s", ch.Type()) + } +} diff --git a/pkg/message_gateway/types.go b/pkg/message_gateway/types.go new file mode 100644 index 00000000..bd4d81e1 --- /dev/null +++ b/pkg/message_gateway/types.go @@ -0,0 +1,61 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +// ChannelTypeTelegram is the Telegram private-chat adapter type. +const ChannelTypeTelegram = "telegram" + +// ChannelTypeQQ is the official QQ Bot C2C adapter type. +const ChannelTypeQQ = "qq" + +// Capability describes what an adapter can send and receive. +type Capability struct { + Text bool + Image bool + File bool + Reply bool + Group bool +} + +// ChannelConfig is the decrypted runtime config passed to a factory. +type ChannelConfig struct { + ID uint64 + Type string + Name string + Credentials map[string]string + Extra map[string]string +} + +// Recipient is the outbound destination on a platform. +type Recipient struct { + ChatID string + PlatformUserID string +} + +// Attachment is a downloaded inbound file sitting on local disk. +type Attachment struct { + Path string + FileName string + MIME string + Error string +} + +// InboundMessage is a normalized private-chat message. +type InboundMessage struct { + ChannelID uint64 + ChannelType string + PlatformUserID string + ChatID string + MessageID string + Text string + Attachments []Attachment + BindingUserID *uint64 +} + +// OutboundMessage is a reply or probe send. +type OutboundMessage struct { + Text string + ReplyToID string + Attachments []Attachment +}