feat(telegram): promo bot + channel-chat moderation gate
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

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.
This commit is contained in:
Ilia Denisov
2026-06-21 14:46:51 +02:00
parent 41d21f3f6f
commit e71e40eef5
42 changed files with 2082 additions and 68 deletions
+33 -6
View File
@@ -31,13 +31,21 @@ var ErrNoBot = errors.New("botlink: no bot connected")
// (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
log *zap.Logger
eligibility EligibilityResolver
mu sync.Mutex
links map[*link]struct{}
@@ -56,15 +64,18 @@ type link struct {
out chan *botlinkv1.ToBot
}
// NewHub builds a Hub. A nil meter disables metrics; a nil logger is tolerated.
func NewHub(log *zap.Logger, meter metric.Meter) *Hub {
// 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,
links: make(map[*link]struct{}),
pending: make(map[string]chan *botlinkv1.Ack),
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",
@@ -120,6 +131,22 @@ func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.
}
}
// 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()