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
@@ -13,9 +13,11 @@ import (
// fakeSender records the delivery calls the executor makes.
type fakeSender struct {
notify []notifyCall
text []textCall
err error
notify []notifyCall
text []textCall
gate []gateCall
applied bool // ApplyChatGate's reported result
err error
}
type notifyCall struct {
@@ -26,6 +28,10 @@ type textCall struct {
chatID int64
text string
}
type gateCall struct {
userID int64
allow bool
}
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
@@ -37,6 +43,11 @@ func (f *fakeSender) SendText(_ context.Context, chatID int64, text string) erro
return f.err
}
func (f *fakeSender) ApplyChatGate(_ context.Context, userID int64, allow bool) (bool, error) {
f.gate = append(f.gate, gateCall{userID, allow})
return f.applied, f.err
}
func yourTurnPayload(gameID string) []byte {
b := flatbuffers.NewBuilder(0)
gid := b.CreateString(gameID)
@@ -106,6 +117,46 @@ func TestExecutorSendToUser(t *testing.T) {
}
}
func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
return &botlinkv1.Command{Payload: &botlinkv1.Command_ChatGate{ChatGate: &botlinkv1.ChatGateCommand{
ExternalId: externalID, Allow: allow,
}}}
}
func TestExecutorChatGateApplied(t *testing.T) {
sender := &fakeSender{applied: true}
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), chatGateCmd("777", true))
if err != nil {
t.Fatalf("handle: %v", err)
}
if !delivered || len(sender.gate) != 1 || sender.gate[0].userID != 777 || !sender.gate[0].allow {
t.Errorf("chat gate = %v / calls %+v", delivered, sender.gate)
}
}
func TestExecutorChatGateNotInChat(t *testing.T) {
sender := &fakeSender{applied: false} // user not in the chat
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), chatGateCmd("888", false))
if err != nil {
t.Fatalf("handle: %v", err)
}
if delivered {
t.Error("expected delivered=false when the user is not in the chat")
}
if len(sender.gate) != 1 {
t.Errorf("gate calls = %d, want 1", len(sender.gate))
}
}
func TestExecutorChatGateInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
t.Error("expected an error for a non-numeric external_id")
}
}
func TestExecutorSendToChannel(t *testing.T) {
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}