feat(telegram): chat-gate observability + grant on first registration
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
Two follow-ups from a contour test where a user joined the chat, then
registered, and got no write access — with silent logs.
Observability: log every chat_member update (chat id, configured id, user,
old->new status), the eligibility result and the grant outcome; plus a startup
self-check that warns loudly when the bot is not an administrator in the chat
with the restrict-members ("Ban users") right — the common misconfiguration,
previously invisible in the logs.
Grant on first registration: a user who joins the moderated chat BEFORE
registering is covered by no chat_member event, so the join-time grant never
fires for them. ProvisionTelegram now reports first contact, and the Telegram
auth handler emits chat_access_changed on it, so the gateway re-evaluates and
grants write access if the user is already in the chat.
This commit is contained in:
@@ -117,9 +117,38 @@ func (t *Bot) Run(ctx context.Context) {
|
||||
}); err != nil {
|
||||
t.log.Warn("set menu button failed", zap.Error(err))
|
||||
}
|
||||
if t.chatID != 0 {
|
||||
t.logChatAdminStatus(ctx)
|
||||
}
|
||||
t.api.Start(ctx)
|
||||
}
|
||||
|
||||
// logChatAdminStatus checks, at startup, whether the bot can actually gate the
|
||||
// moderated chat — it must be an administrator there with the restrict-members
|
||||
// ("Ban users") right, or Telegram delivers no chat_member updates and restricts
|
||||
// fail. It logs a prominent warning when the prerequisite is missing (the common
|
||||
// misconfiguration), so the cause is visible without reproducing a join.
|
||||
func (t *Bot) logChatAdminStatus(ctx context.Context) {
|
||||
me, err := t.api.GetMe(ctx)
|
||||
if err != nil {
|
||||
t.log.Warn("chat self-check: getMe failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
m, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: me.ID})
|
||||
if err != nil {
|
||||
t.log.Warn("chat gating self-check failed: the bot cannot read the chat — is it added and is TELEGRAM_CHAT_ID the discussion group id?",
|
||||
zap.Int64("chat_id", t.chatID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
canRestrict := m.Type == models.ChatMemberTypeAdministrator && m.Administrator.CanRestrictMembers
|
||||
if !canRestrict {
|
||||
t.log.Warn(`chat gating WILL NOT WORK: the bot must be an administrator with the restrict-members ("Ban users") right`,
|
||||
zap.Int64("chat_id", t.chatID), zap.String("bot_status", string(m.Type)))
|
||||
return
|
||||
}
|
||||
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
|
||||
}
|
||||
|
||||
// Notify sends a notification message with a Mini App launch button that opens the
|
||||
// app at startParam (empty opens the lobby).
|
||||
func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error {
|
||||
@@ -226,6 +255,20 @@ func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
|
||||
// defaults to no-send), and a resolve failure fails closed (also left muted). Other
|
||||
// status changes (leaves, restrictions, admin edits) are ignored.
|
||||
func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated) {
|
||||
user := chatMemberUser(cm.NewChatMember)
|
||||
var uid int64
|
||||
if user != nil {
|
||||
uid = user.ID
|
||||
}
|
||||
// Log every chat_member update the bot receives: this is the one place to see
|
||||
// whether Telegram is delivering joins at all, for which chat, and the transition.
|
||||
t.log.Info("chat_member update",
|
||||
zap.Int64("chat_id", cm.Chat.ID),
|
||||
zap.Int64("configured_chat_id", t.chatID),
|
||||
zap.Int64("user_id", uid),
|
||||
zap.String("old_status", string(cm.OldChatMember.Type)),
|
||||
zap.String("new_status", string(cm.NewChatMember.Type)))
|
||||
|
||||
if t.chatID == 0 || cm.Chat.ID != t.chatID {
|
||||
return
|
||||
}
|
||||
@@ -233,24 +276,27 @@ func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated
|
||||
if cm.NewChatMember.Type != models.ChatMemberTypeMember || cm.OldChatMember.Type == models.ChatMemberTypeMember {
|
||||
return
|
||||
}
|
||||
user := chatMemberUser(cm.NewChatMember)
|
||||
if user == nil || user.IsBot {
|
||||
return
|
||||
}
|
||||
if t.eligibility == nil {
|
||||
return // resolver not wired: leave muted (fail closed)
|
||||
t.log.Warn("chat join: eligibility resolver not wired", zap.Int64("user_id", uid))
|
||||
return
|
||||
}
|
||||
eligible, err := t.eligibility(ctx, strconv.FormatInt(user.ID, 10))
|
||||
if err != nil {
|
||||
t.log.Warn("chat join eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
t.log.Info("chat join evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible))
|
||||
if !eligible {
|
||||
return // not registered or blocked: leave muted
|
||||
}
|
||||
if err := t.setChatWrite(ctx, user.ID, true); err != nil {
|
||||
t.log.Warn("grant chat write failed", zap.Int64("user_id", user.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
t.log.Info("chat write granted", zap.Int64("user_id", user.ID))
|
||||
}
|
||||
|
||||
// ApplyChatGate applies a chat-gate command (an admin block/unblock or chat_muted
|
||||
@@ -272,8 +318,10 @@ func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool
|
||||
if err := t.setChatWrite(ctx, userID, allow); err != nil {
|
||||
return false, err
|
||||
}
|
||||
t.log.Info("chat gate applied", zap.Int64("user_id", userID), zap.Bool("allow", allow))
|
||||
return true, nil
|
||||
default:
|
||||
t.log.Info("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type)))
|
||||
return false, nil // absent, or an admin/owner who cannot be restricted
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user