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
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:
@@ -147,7 +147,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// fire-and-forget; the backend admin relay (plaintext, internal) awaits the Ack.
|
||||
var botHub *botlink.Hub
|
||||
if cfg.BotLinkEnabled() {
|
||||
botHub = botlink.NewHub(logger, tel.MeterProvider().Meter("scrabble/gateway/botlink"))
|
||||
botHub = botlink.NewHub(logger, tel.MeterProvider().Meter("scrabble/gateway/botlink"),
|
||||
func(ctx context.Context, externalID string) (bool, bool, error) {
|
||||
r, rerr := backend.ChatEligibility(ctx, externalID)
|
||||
return r.Registered, r.Eligible, rerr
|
||||
})
|
||||
tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile)
|
||||
if terr != nil {
|
||||
return terr
|
||||
@@ -361,6 +365,15 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
|
||||
}
|
||||
break
|
||||
}
|
||||
// A chat-access-changed event is an infra signal, not an in-app event:
|
||||
// resolve the recipient's Telegram identity and current eligibility and
|
||||
// push the chat-gate command to the bot, without fanning it out to clients.
|
||||
if ev.GetKind() == chatAccessChangedKind {
|
||||
if bot != nil {
|
||||
go deliverChatGate(ctx, backend, bot, ev.GetUserId(), logger)
|
||||
}
|
||||
continue
|
||||
}
|
||||
hub.Publish(push.Event{
|
||||
UserID: ev.GetUserId(),
|
||||
Kind: ev.GetKind(),
|
||||
@@ -398,6 +411,27 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, bot *bo
|
||||
bot.Send(botlink.NotifyCommand(target.ExternalID, kind, payload, target.Language))
|
||||
}
|
||||
|
||||
// chatAccessChangedKind is the backend event signalling that a player's moderated-chat
|
||||
// write eligibility may have changed; the gateway turns it into a bot-link chat-gate
|
||||
// command rather than an in-app event (it mirrors notify.KindChatAccessChanged).
|
||||
const chatAccessChangedKind = "chat_access_changed"
|
||||
|
||||
// deliverChatGate resolves a chat-access-changed event to the recipient's Telegram
|
||||
// identity and current eligibility and pushes the chat-gate command to the bot. It is
|
||||
// best-effort: a recipient with no Telegram identity is skipped, and a resolve failure
|
||||
// is logged and dropped (the next moderation action, or a re-join, re-applies the gate).
|
||||
func deliverChatGate(ctx context.Context, backend *backendclient.Client, bot *botlink.Hub, userID string, logger *zap.Logger) {
|
||||
res, err := backend.ChatAccessByUser(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("chat-gate resolve failed", zap.String("user_id", userID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if res.ExternalID == "" {
|
||||
return // no Telegram identity, nothing to gate
|
||||
}
|
||||
bot.Send(botlink.ChatGateCommand(res.ExternalID, res.Eligible))
|
||||
}
|
||||
|
||||
// sleep waits for d or until ctx is cancelled, reporting whether it waited the
|
||||
// full duration.
|
||||
func sleep(ctx context.Context, d time.Duration) bool {
|
||||
|
||||
@@ -215,6 +215,34 @@ func (c *Client) PushTarget(ctx context.Context, userID string) (PushTargetResp,
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ChatAccessResp is a user's moderated-chat write eligibility: ExternalID is their
|
||||
// Telegram identity (empty when they have none, so the gateway has nothing to gate),
|
||||
// Registered whether an account was found, and Eligible the final gate the bot applies
|
||||
// (registered and neither admin-suspended nor chat-muted).
|
||||
type ChatAccessResp struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
Registered bool `json:"registered"`
|
||||
Eligible bool `json:"eligible"`
|
||||
}
|
||||
|
||||
// ChatEligibility resolves a Telegram identity to its moderated-chat write
|
||||
// eligibility — the join path, when the bot sees a user enter the chat.
|
||||
func (c *Client) ChatEligibility(ctx context.Context, externalID string) (ChatAccessResp, error) {
|
||||
var out ChatAccessResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/chat-access", "", "",
|
||||
map[string]string{"external_id": externalID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ChatAccessByUser resolves an account id to its Telegram identity and current
|
||||
// moderated-chat write eligibility — the change path, for a chat-access-changed event.
|
||||
func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAccessResp, error) {
|
||||
var out ChatAccessResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/chat-access", "", "",
|
||||
map[string]string{"user_id": userID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GuestAuth provisions a guest account and mints a session.
|
||||
func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
|
||||
@@ -36,3 +36,15 @@ func SendToGameChannelCommand(text string) *botlinkv1.Command {
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// ChatGateCommand builds a chat-gate command that sets whether the Telegram user
|
||||
// identified by externalID may write in the moderated discussion chat. The bot
|
||||
// applies it only to a member currently in the chat (guarded on getChatMember).
|
||||
func ChatGateCommand(externalID string, allow bool) *botlinkv1.Command {
|
||||
return &botlinkv1.Command{
|
||||
Payload: &botlinkv1.Command_ChatGate{ChatGate: &botlinkv1.ChatGateCommand{
|
||||
ExternalId: externalID,
|
||||
Allow: allow,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
|
||||
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
||||
@@ -23,12 +25,18 @@ type fakeBot struct {
|
||||
received chan *botlinkv1.Command
|
||||
}
|
||||
|
||||
// startHub registers a Hub on an in-memory gRPC server and returns the hub plus a
|
||||
// dialer for fake bots.
|
||||
// startHub registers a Hub (no chat-eligibility resolver) on an in-memory gRPC
|
||||
// server and returns the hub plus a dialer for fake bots.
|
||||
func startHub(t *testing.T) (*Hub, func(t *testing.T) botlinkv1.BotLinkClient) {
|
||||
return startHubWith(t, nil)
|
||||
}
|
||||
|
||||
// startHubWith is startHub with an explicit chat-eligibility resolver, for the
|
||||
// ResolveChatEligibility tests.
|
||||
func startHubWith(t *testing.T, resolve EligibilityResolver) (*Hub, func(t *testing.T) botlinkv1.BotLinkClient) {
|
||||
t.Helper()
|
||||
lis := bufconn.Listen(1 << 20)
|
||||
hub := NewHub(nil, nil)
|
||||
hub := NewHub(nil, nil, resolve)
|
||||
srv := grpc.NewServer()
|
||||
botlinkv1.RegisterBotLinkServer(srv, hub)
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
@@ -162,6 +170,62 @@ func TestHubSendAsync(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubSendChatGate(t *testing.T) {
|
||||
hub, dial := startHub(t)
|
||||
ctx := t.Context()
|
||||
bot := &fakeBot{ack: false}
|
||||
bot.connect(t, ctx, hub, dial(t))
|
||||
|
||||
hub.Send(ChatGateCommand("42", true))
|
||||
select {
|
||||
case cmd := <-bot.received:
|
||||
cg := cmd.GetChatGate()
|
||||
if cg.GetExternalId() != "42" || !cg.GetAllow() {
|
||||
t.Errorf("chat_gate = %+v, want external_id=42 allow=true", cg)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("bot received no command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubResolveChatEligibility(t *testing.T) {
|
||||
var gotExt string
|
||||
_, dial := startHubWith(t, func(_ context.Context, ext string) (bool, bool, error) {
|
||||
gotExt = ext
|
||||
return true, ext == "good", nil
|
||||
})
|
||||
client := dial(t)
|
||||
ctx := t.Context()
|
||||
|
||||
resp, err := client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: "good"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveChatEligibility: %v", err)
|
||||
}
|
||||
if gotExt != "good" {
|
||||
t.Errorf("resolver external_id = %q, want good", gotExt)
|
||||
}
|
||||
if !resp.GetRegistered() || !resp.GetEligible() {
|
||||
t.Errorf("resp = %+v, want registered+eligible", resp)
|
||||
}
|
||||
|
||||
resp, err = client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: "muted"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveChatEligibility(muted): %v", err)
|
||||
}
|
||||
if !resp.GetRegistered() || resp.GetEligible() {
|
||||
t.Errorf("resp = %+v, want registered but not eligible", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubResolveChatEligibilityUnconfigured(t *testing.T) {
|
||||
_, dial := startHub(t) // nil resolver
|
||||
client := dial(t)
|
||||
_, err := client.ResolveChatEligibility(t.Context(), &botlinkv1.ChatEligibilityRequest{ExternalId: "x"})
|
||||
if status.Code(err) != codes.Unavailable {
|
||||
t.Fatalf("err = %v, want Unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayServerNoBot(t *testing.T) {
|
||||
hub, _ := startHub(t)
|
||||
relay := NewRelayServer(hub, 200*time.Millisecond)
|
||||
|
||||
@@ -94,7 +94,7 @@ func startMTLSHub(t *testing.T) (hub *Hub, addr, caFile, cliCert, cliKey string)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
hub = NewHub(nil, nil)
|
||||
hub = NewHub(nil, nil, nil)
|
||||
srv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg)))
|
||||
botlinkv1.RegisterBotLinkServer(srv, hub)
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
|
||||
Reference in New Issue
Block a user