2026-02-04 11:06:13 +00:00
|
|
|
package channels
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-02-12 05:45:45 +00:00
|
|
|
"strings"
|
2026-02-20 16:00:29 +00:00
|
|
|
"sync/atomic"
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/google/uuid"
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-04 11:06:13 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Channel interface {
|
|
|
|
|
Name() string
|
|
|
|
|
Start(ctx context.Context) error
|
|
|
|
|
Stop(ctx context.Context) error
|
|
|
|
|
Send(ctx context.Context, msg bus.OutboundMessage) error
|
|
|
|
|
IsRunning() bool
|
|
|
|
|
IsAllowed(senderID string) bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:46:29 +00:00
|
|
|
// BaseChannelOption is a functional option for configuring a BaseChannel.
|
|
|
|
|
type BaseChannelOption func(*BaseChannel)
|
|
|
|
|
|
|
|
|
|
// WithMaxMessageLength sets the maximum message length (in runes) for a channel.
|
|
|
|
|
// Messages exceeding this limit will be automatically split by the Manager.
|
|
|
|
|
// A value of 0 means no limit.
|
|
|
|
|
func WithMaxMessageLength(n int) BaseChannelOption {
|
|
|
|
|
return func(c *BaseChannel) { c.maxMessageLength = n }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MessageLengthProvider is an opt-in interface that channels implement
|
|
|
|
|
// to advertise their maximum message length. The Manager uses this via
|
|
|
|
|
// type assertion to decide whether to split outbound messages.
|
|
|
|
|
type MessageLengthProvider interface {
|
|
|
|
|
MaxMessageLength() int
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
type BaseChannel struct {
|
2026-02-22 14:46:29 +00:00
|
|
|
config any
|
|
|
|
|
bus *bus.MessageBus
|
|
|
|
|
running atomic.Bool
|
|
|
|
|
name string
|
|
|
|
|
allowList []string
|
|
|
|
|
maxMessageLength int
|
2026-02-22 15:27:55 +00:00
|
|
|
mediaStore media.MediaStore
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:46:29 +00:00
|
|
|
func NewBaseChannel(
|
|
|
|
|
name string,
|
|
|
|
|
config any,
|
|
|
|
|
bus *bus.MessageBus,
|
|
|
|
|
allowList []string,
|
|
|
|
|
opts ...BaseChannelOption,
|
|
|
|
|
) *BaseChannel {
|
|
|
|
|
bc := &BaseChannel{
|
2026-02-04 11:06:13 +00:00
|
|
|
config: config,
|
|
|
|
|
bus: bus,
|
|
|
|
|
name: name,
|
|
|
|
|
allowList: allowList,
|
|
|
|
|
}
|
2026-02-22 14:46:29 +00:00
|
|
|
for _, opt := range opts {
|
|
|
|
|
opt(bc)
|
|
|
|
|
}
|
|
|
|
|
return bc
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MaxMessageLength returns the maximum message length (in runes) for this channel.
|
|
|
|
|
// A value of 0 means no limit.
|
|
|
|
|
func (c *BaseChannel) MaxMessageLength() int {
|
|
|
|
|
return c.maxMessageLength
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *BaseChannel) Name() string {
|
|
|
|
|
return c.name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *BaseChannel) IsRunning() bool {
|
2026-02-20 16:00:29 +00:00
|
|
|
return c.running.Load()
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *BaseChannel) IsAllowed(senderID string) bool {
|
|
|
|
|
if len(c.allowList) == 0 {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 05:45:45 +00:00
|
|
|
// Extract parts from compound senderID like "123456|username"
|
|
|
|
|
idPart := senderID
|
|
|
|
|
userPart := ""
|
|
|
|
|
if idx := strings.Index(senderID, "|"); idx > 0 {
|
|
|
|
|
idPart = senderID[:idx]
|
|
|
|
|
userPart = senderID[idx+1:]
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
for _, allowed := range c.allowList {
|
2026-02-12 05:45:45 +00:00
|
|
|
// Strip leading "@" from allowed value for username matching
|
|
|
|
|
trimmed := strings.TrimPrefix(allowed, "@")
|
2026-02-12 18:09:59 +00:00
|
|
|
allowedID := trimmed
|
|
|
|
|
allowedUser := ""
|
|
|
|
|
if idx := strings.Index(trimmed, "|"); idx > 0 {
|
|
|
|
|
allowedID = trimmed[:idx]
|
|
|
|
|
allowedUser = trimmed[idx+1:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Support either side using "id|username" compound form.
|
|
|
|
|
// This keeps backward compatibility with legacy Telegram allowlist entries.
|
|
|
|
|
if senderID == allowed ||
|
|
|
|
|
idPart == allowed ||
|
|
|
|
|
senderID == trimmed ||
|
|
|
|
|
idPart == trimmed ||
|
|
|
|
|
idPart == allowedID ||
|
|
|
|
|
(allowedUser != "" && senderID == allowedUser) ||
|
|
|
|
|
(userPart != "" && (userPart == allowed || userPart == trimmed || userPart == allowedUser)) {
|
2026-02-04 11:06:13 +00:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 13:57:12 +00:00
|
|
|
func (c *BaseChannel) HandleMessage(
|
|
|
|
|
peer bus.Peer,
|
|
|
|
|
messageID, senderID, chatID, content string,
|
|
|
|
|
media []string,
|
|
|
|
|
metadata map[string]string,
|
|
|
|
|
) {
|
2026-02-04 11:06:13 +00:00
|
|
|
if !c.IsAllowed(senderID) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 15:27:55 +00:00
|
|
|
scope := BuildMediaScope(c.name, chatID, messageID)
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
msg := bus.InboundMessage{
|
2026-02-22 15:27:55 +00:00
|
|
|
Channel: c.name,
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
ChatID: chatID,
|
|
|
|
|
Content: content,
|
|
|
|
|
Media: media,
|
|
|
|
|
Peer: peer,
|
|
|
|
|
MessageID: messageID,
|
|
|
|
|
MediaScope: scope,
|
|
|
|
|
Metadata: metadata,
|
2026-02-11 10:43:21 +00:00
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
|
refactor(bus): fix deadlock and concurrency issues in MessageBus
PublishInbound/PublishOutbound held RLock during blocking channel sends,
deadlocking against Close() which needs a write lock when the buffer is
full. ConsumeInbound/SubscribeOutbound used bare receives instead of
comma-ok, causing zero-value processing or busy loops after close.
Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish
methods use a lock-free 3-way select (send / done / ctx.Done). Add
context.Context parameter to both Publish methods so callers can cancel
or timeout blocked sends. Close() now only sets the atomic flag and
closes the done channel—never closes the data channels—eliminating
send-on-closed-channel panics.
- Remove dead code: RegisterHandler, GetHandler, handlers map,
MessageHandler type (zero callers across the whole repo)
- Add ErrBusClosed sentinel error
- Update all 10 caller sites to pass context
- Add msgBus.Close() to gateway and agent shutdown flows
- Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip,
context cancellation, closed-bus behavior, concurrent publish+close,
full-buffer timeout, and idempotent Close
2026-02-22 16:44:45 +00:00
|
|
|
c.bus.PublishInbound(context.TODO(), msg)
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:18:46 +00:00
|
|
|
func (c *BaseChannel) SetRunning(running bool) {
|
2026-02-20 16:00:29 +00:00
|
|
|
c.running.Store(running)
|
2026-02-20 15:18:46 +00:00
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
|
|
|
|
|
// SetMediaStore injects a MediaStore into the channel.
|
|
|
|
|
func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s }
|
|
|
|
|
|
|
|
|
|
// GetMediaStore returns the injected MediaStore (may be nil).
|
|
|
|
|
func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore }
|
|
|
|
|
|
|
|
|
|
// BuildMediaScope constructs a scope key for media lifecycle tracking.
|
|
|
|
|
func BuildMediaScope(channel, chatID, messageID string) string {
|
|
|
|
|
id := messageID
|
|
|
|
|
if id == "" {
|
|
|
|
|
id = uuid.New().String()
|
|
|
|
|
}
|
|
|
|
|
return channel + ":" + chatID + ":" + id
|
|
|
|
|
}
|