feat(tg-bot): unpin auto-forwarded channel posts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s

Telegram non-disableably auto-pins each channel post it auto-forwards
into the linked discussion group. The bot now detects that message by
Message.is_automatic_forward in the moderated chat (TELEGRAM_CHAT_ID)
and unpins it by id, so a pin set by a human admin — or by the bot for
another message — is never touched (no unpinAllChatMessages).

Needs the can_pin_messages right in the chat; the startup self-check
now also warns when it is missing. Bot-only; no wire/schema/DB change.
This commit is contained in:
Ilia Denisov
2026-07-14 00:07:46 +02:00
parent efeed17abc
commit 3b485883ee
5 changed files with 96 additions and 4 deletions
+2 -1
View File
@@ -542,7 +542,8 @@ at any time (games already lost stay lost).
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the or are unblocked. It also clears the automatic pin Telegram puts on each channel post that is
auto-forwarded into the chat, so only deliberately pinned messages stay pinned. So an unregistered newcomer who comments is muted (the promo bot points them at the
game to register, after which the bot restores their voice), and a registered, unblocked player simply game to register, after which the bot restores their voice), and a registered, unblocked player simply
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card — writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
+3 -1
View File
@@ -555,7 +555,9 @@ high-rate флага. С карточки пользователя операт
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот **глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат, зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
сообщения. То есть незарегистрированного новичка, написавшего в чат,
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
+6 -1
View File
@@ -70,7 +70,12 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
the bot applies it — but only to a member currently in the chat (it probes one user with the bot applies it — but only to a member currently in the chat (it probes one user with
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there `getChatMember`, since bots cannot list members). The bot must be an **administrator** there
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
`chat_member` updates, which Telegram delivers only to a chat admin. `chat_member` updates, which Telegram delivers only to a chat admin. The bot also **removes
the automatic pin** Telegram places on every channel post auto-forwarded into this linked
chat (the `Message.is_automatic_forward` marker): it unpins only that one message
(`unpinChatMessage` by id), so a pin set by a human admin — or by the bot for another
message — is never touched. This needs the **pin-messages** right (`can_pin_messages`) in
addition to "Ban users"; a startup self-check warns when it is missing.
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum - **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
supergroup**, the bot runs a direct support channel for users who message it. A user's first supergroup**, the bot runs a direct support channel for users who message it. A user's first
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
+32
View File
@@ -285,6 +285,12 @@ func (t *Bot) logChatAdminStatus(ctx context.Context) {
return return
} }
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID)) t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
// The same moderated chat also gets the linked channel's auto-forwarded posts un-pinned,
// which needs the pin-messages right; warn (separately from gating) when it is missing.
if !m.Administrator.CanPinMessages {
t.log.Warn("auto-unpin of linked channel posts WILL NOT WORK: the bot lacks the pin-messages right",
zap.Int64("chat_id", t.chatID))
}
} }
// Notify sends a notification message with a Mini App launch button that opens the // Notify sends a notification message with a Mini App launch button that opens the
@@ -405,6 +411,14 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleSuccessfulPayment(ctx, update.Message) t.handleSuccessfulPayment(ctx, update.Message)
return return
} }
// A channel post automatically forwarded into the moderated discussion chat is
// auto-pinned by Telegram (a non-disableable behaviour). Unpin that one message so only
// deliberate pins remain; it targets this exact message id, so a pin set by a human admin
// — or by the bot for another message — is never touched.
if m := update.Message; m != nil && m.IsAutomaticForward && t.chatID != 0 && m.Chat.ID == t.chatID {
t.handleLinkedChannelPin(ctx, m)
return
}
// Support relay (when enabled): a non-/start message — /start has its own handler — // Support relay (when enabled): a non-/start message — /start has its own handler —
// is either an operator's reply in the support chat or a user's direct message to // is either an operator's reply in the support chat or a user's direct message to
// relay. Everything else falls through to the Mini App launch reply. // relay. Everything else falls through to the Mini App launch reply.
@@ -421,6 +435,24 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleStart(ctx, api, update) t.handleStart(ctx, api, update)
} }
// handleLinkedChannelPin removes Telegram's automatic pin from a linked channel's post
// that was auto-forwarded into the moderated discussion chat. It unpins only that exact
// message (by id), so a pin set by a human admin — or by the bot for another message — is
// left untouched. It needs the bot to hold the pin-messages right in the chat; a missing
// right surfaces as a warning (the unpin then no-ops), not a crash.
func (t *Bot) handleLinkedChannelPin(ctx context.Context, m *models.Message) {
if _, err := t.api.UnpinChatMessage(ctx, &tgbot.UnpinChatMessageParams{
ChatID: m.Chat.ID,
MessageID: m.ID,
}); err != nil {
t.log.Warn("could not unpin an auto-forwarded channel post; does the bot have the pin-messages right?",
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID), zap.Error(err))
return
}
t.log.Debug("unpinned an auto-forwarded channel post",
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID))
}
// SetEligibilityResolver wires the chat-eligibility resolver after construction (the // SetEligibilityResolver wires the chat-eligibility resolver after construction (the
// bot-link client backing it is built after the bot). // bot-link client backing it is built after the bot).
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) { func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
+53 -1
View File
@@ -19,10 +19,11 @@ const (
) )
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a // chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
// scripted getChatMember status, and records restrictChatMember calls. // scripted getChatMember status, and records restrictChatMember and unpinChatMessage calls.
type chatAPI struct { type chatAPI struct {
memberStatus string // the status getChatMember reports (default "left") memberStatus string // the status getChatMember reports (default "left")
restricts []restrictCall restricts []restrictCall
unpins []unpinCall
} }
type restrictCall struct { type restrictCall struct {
@@ -30,6 +31,13 @@ type restrictCall struct {
canSend bool // can_send_messages in the applied permissions canSend bool // can_send_messages in the applied permissions
} }
// unpinCall records a single unpinChatMessage request (raw form values, mirroring the
// restrictCall style).
type unpinCall struct {
chatID string
messageID string
}
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch { switch {
case strings.HasSuffix(r.URL.Path, "/getMe"): case strings.HasSuffix(r.URL.Path, "/getMe"):
@@ -47,6 +55,9 @@ func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms) _ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages}) a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
io.WriteString(w, `{"ok":true,"result":true}`) io.WriteString(w, `{"ok":true,"result":true}`)
case strings.HasSuffix(r.URL.Path, "/unpinChatMessage"):
a.unpins = append(a.unpins, unpinCall{chatID: r.FormValue("chat_id"), messageID: r.FormValue("message_id")})
io.WriteString(w, `{"ok":true,"result":true}`)
default: default:
io.WriteString(w, `{"ok":true,"result":true}`) io.WriteString(w, `{"ok":true,"result":true}`)
} }
@@ -237,3 +248,44 @@ func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) {
}) })
} }
} }
// autoForwardUpdate builds a message update as it lands in the discussion chat: a post in
// chat chatID with id msgID and is_automatic_forward set to auto.
func autoForwardUpdate(chatID int64, msgID int, auto bool) *models.Update {
return &models.Update{Message: &models.Message{
ID: msgID,
Chat: models.Chat{ID: chatID, Type: models.ChatTypeSupergroup},
IsAutomaticForward: auto,
}}
}
func TestAutoForwardUnpinned(t *testing.T) {
// A channel post auto-forwarded into the moderated chat is unpinned by its exact id.
api := &chatAPI{}
b := newChatBot(t, api)
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, true))
if len(api.unpins) != 1 || api.unpins[0].chatID != "555" || api.unpins[0].messageID != "42" {
t.Fatalf("unpins = %+v, want one {555, 42}", api.unpins)
}
}
func TestNonAutoForwardNotUnpinned(t *testing.T) {
// A normal message in the moderated chat (e.g. another admin's pinned message) is never
// unpinned — the is_automatic_forward filter is what guards every other pin.
api := &chatAPI{}
b := newChatBot(t, api)
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, false))
if len(api.unpins) != 0 {
t.Fatalf("unpins = %+v, want none for a non-auto-forward message", api.unpins)
}
}
func TestAutoForwardOtherChatIgnored(t *testing.T) {
// An auto-forward in some other chat (not the configured moderated one) is ignored.
api := &chatAPI{}
b := newChatBot(t, api)
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(999, 42, true))
if len(api.unpins) != 0 {
t.Fatalf("unpins = %+v, want none for a foreign chat", api.unpins)
}
}