Files
scrabble-game/gateway/internal/botlink/hub.go
T
Ilia Denisov e71e40eef5
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
feat(telegram): promo bot + channel-chat moderation gate
Add a second standalone promo bot to the bot container (answers /start with a
localized message + a URL button into the main bot's Mini App) and gate write
access in a channel's linked discussion chat: grant on join when the Telegram
user is registered and neither admin-suspended nor holding a new chat_muted
role, and revoke/grant on the matching moderation change for a member currently
in the chat.

Eligibility (registered AND NOT suspended AND NOT chat_muted; the game
suspension dominates) is resolved once in the backend and reached two ways: the
bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link,
and a backend chat_access_changed event -> gateway -> ChatGate command
(idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the
block/unblock path with getChatMember, since bots cannot list members.

A web_app button cannot open another bot's Mini App (it signs initData with the
sending bot's token), so the promo button is a t.me ?startapp URL reusing the
UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members
right and chat_member in its allowed updates.

No schema change: chat_muted reuses the data-driven account_roles table.
2026-06-21 14:46:51 +02:00

287 lines
8.3 KiB
Go

// Package botlink is the gateway side of the reverse Telegram bot channel: a remote
// bot dials the gateway and opens one long-lived mTLS gRPC stream
// (pkg/proto/botlink/v1), over which the gateway pushes send Commands and the bot
// returns an Ack per command. The Hub registers connected bots and routes commands:
// out-of-app push is fire-and-forget (Send), admin sends await the bot Ack
// (SendAwait). Delivery is best-effort, at-most-once — a command lost across a
// reconnect is not replayed. See docs/ARCHITECTURE.md.
package botlink
import (
"context"
"errors"
"strconv"
"sync"
"sync/atomic"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
)
// ErrNoBot is returned by SendAwait when no bot is currently connected.
var ErrNoBot = errors.New("botlink: no bot connected")
// outboundBuffer is the per-link command queue depth; a full queue drops commands
// (at-most-once under backpressure).
const outboundBuffer = 64
// EligibilityResolver answers a Telegram identity's moderated-chat write eligibility
// for the bot's join-time ResolveChatEligibility query: registered reports whether the
// identity maps to an account, eligible is the final gate the bot acts on (registered
// and neither admin-suspended nor chat-muted). The gateway backs it with the backend
// chat-access endpoint.
type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error)
// Hub registers connected bots and routes send commands to them. A single bot is
// expected today; the registry already holds a set so adding more later needs no
// rewrite.
type Hub struct {
botlinkv1.UnimplementedBotLinkServer
log *zap.Logger
eligibility EligibilityResolver
mu sync.Mutex
links map[*link]struct{}
pending map[string]chan *botlinkv1.Ack
seq atomic.Uint64
connected metric.Int64UpDownCounter
commands metric.Int64Counter
}
// link is one connected bot's outbound queue and identity.
type link struct {
instanceID string
ownsUpdates bool
out chan *botlinkv1.ToBot
}
// NewHub builds a Hub. resolve answers the bot's join-time chat-eligibility query
// (nil rejects it as unavailable). A nil meter disables metrics; a nil logger is
// tolerated.
func NewHub(log *zap.Logger, meter metric.Meter, resolve EligibilityResolver) *Hub {
if log == nil {
log = zap.NewNop()
}
h := &Hub{
log: log,
eligibility: resolve,
links: make(map[*link]struct{}),
pending: make(map[string]chan *botlinkv1.Ack),
}
if meter != nil {
h.connected, _ = meter.Int64UpDownCounter("botlink_connected_bots",
metric.WithDescription("Number of Telegram bots currently connected to the gateway bot-link."))
h.commands, _ = meter.Int64Counter("botlink_commands_total",
metric.WithDescription("Bot-link send commands by result (delivered, not_delivered, dropped, error)."))
}
return h
}
// Link implements botlinkv1.BotLinkServer: it registers the dialing bot, drains
// queued commands to it, and resolves Acks until the stream ends.
func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.ToBot]) error {
first, err := stream.Recv()
if err != nil {
return err
}
hello := first.GetHello()
if hello == nil {
return status.Error(codes.InvalidArgument, "first bot-link message must be Hello")
}
l := &link{
instanceID: hello.GetInstanceId(),
ownsUpdates: hello.GetOwnsUpdates(),
out: make(chan *botlinkv1.ToBot, outboundBuffer),
}
h.register(l)
defer h.unregister(l)
ctx := stream.Context()
// A dedicated goroutine owns stream.Send; the Recv loop below owns stream.Recv.
go func() {
for {
select {
case <-ctx.Done():
return
case msg := <-l.out:
if err := stream.Send(msg); err != nil {
return
}
}
}
}()
for {
msg, err := stream.Recv()
if err != nil {
return err
}
if ack := msg.GetAck(); ack != nil {
h.resolve(ack)
}
}
}
// ResolveChatEligibility serves the bot's join-time query: whether the Telegram user
// identified in the request may write in the moderated discussion chat. It delegates
// to the configured resolver (the backend chat-access endpoint), unlike the streamed
// Commands it is a plain request/response over the same mTLS channel.
func (h *Hub) ResolveChatEligibility(ctx context.Context, req *botlinkv1.ChatEligibilityRequest) (*botlinkv1.ChatEligibilityResponse, error) {
if h.eligibility == nil {
return nil, status.Error(codes.Unavailable, "chat eligibility resolver not configured")
}
registered, eligible, err := h.eligibility(ctx, req.GetExternalId())
if err != nil {
h.log.Warn("resolve chat eligibility failed", zap.String("external_id", req.GetExternalId()), zap.Error(err))
return nil, status.Error(codes.Internal, "resolve chat eligibility")
}
return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil
}
// register adds a connected bot.
func (h *Hub) register(l *link) {
h.mu.Lock()
h.links[l] = struct{}{}
n := len(h.links)
h.mu.Unlock()
if h.connected != nil {
h.connected.Add(context.Background(), 1)
}
h.log.Info("bot connected",
zap.String("instance_id", l.instanceID),
zap.Bool("owns_updates", l.ownsUpdates),
zap.Int("connected", n))
}
// unregister removes a bot. l.out is left for the GC; its sender goroutine has
// already exited via the stream context, and stale enqueues simply never send
// (at-most-once).
func (h *Hub) unregister(l *link) {
h.mu.Lock()
delete(h.links, l)
n := len(h.links)
h.mu.Unlock()
if h.connected != nil {
h.connected.Add(context.Background(), -1)
}
h.log.Info("bot disconnected", zap.String("instance_id", l.instanceID), zap.Int("connected", n))
}
// pick returns one connected bot.
func (h *Hub) pick() (*link, bool) {
h.mu.Lock()
defer h.mu.Unlock()
for l := range h.links {
return l, true
}
return nil, false
}
// Send enqueues a fire-and-forget command to a connected bot, dropping it (with a
// warning) when no bot is connected or the queue is full.
func (h *Hub) Send(cmd *botlinkv1.Command) {
l, ok := h.pick()
if !ok {
h.count("dropped")
h.log.Warn("bot-link send dropped: no bot connected")
return
}
cmd.CommandId = h.nextID()
select {
case l.out <- &botlinkv1.ToBot{Command: cmd}:
default:
h.count("dropped")
h.log.Warn("bot-link send dropped: outbound queue full", zap.String("instance_id", l.instanceID))
}
}
// SendAwait enqueues a command and waits for the bot's Ack or until ctx is done.
// It returns ErrNoBot when no bot is connected, the delivered flag on a clean Ack,
// or (false, nil) when ctx (the deadline) fires before an Ack arrives.
func (h *Hub) SendAwait(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
l, ok := h.pick()
if !ok {
h.count("dropped")
return false, ErrNoBot
}
id := h.nextID()
cmd.CommandId = id
ackc := make(chan *botlinkv1.Ack, 1)
h.mu.Lock()
h.pending[id] = ackc
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.pending, id)
h.mu.Unlock()
}()
select {
case l.out <- &botlinkv1.ToBot{Command: cmd}:
case <-ctx.Done():
h.count("dropped")
return false, ctx.Err()
}
select {
case ack := <-ackc:
if e := ack.GetError(); e != "" {
h.count("error")
return false, errors.New(e)
}
h.count(deliveredLabel(ack.GetDelivered()))
return ack.GetDelivered(), nil
case <-ctx.Done():
h.count("error")
return false, nil
}
}
// resolve routes an Ack to its waiting SendAwait, if any.
func (h *Hub) resolve(ack *botlinkv1.Ack) {
h.mu.Lock()
c, ok := h.pending[ack.GetCommandId()]
h.mu.Unlock()
if ok {
select {
case c <- ack:
default:
}
}
}
// nextID returns a process-unique command id.
func (h *Hub) nextID() string {
return strconv.FormatUint(h.seq.Add(1), 10)
}
// count records one command result, when metrics are enabled.
func (h *Hub) count(result string) {
if h.commands == nil {
return
}
h.commands.Add(context.Background(), 1, metric.WithAttributes(resultAttr(result)))
}
// deliveredLabel maps the delivered flag to a metric result label.
func deliveredLabel(delivered bool) string {
if delivered {
return "delivered"
}
return "not_delivered"
}
// resultAttr is the metric attribute carrying a command result label.
func resultAttr(result string) attribute.KeyValue {
return attribute.String("result", result)
}