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
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:
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -555,7 +555,9 @@ high-rate флага. С карточки пользователя операт
|
|||||||
|
|
||||||
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
||||||
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
||||||
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
|
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
|
||||||
|
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
|
||||||
|
сообщения. То есть незарегистрированного новичка, написавшего в чат,
|
||||||
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
||||||
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
||||||
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user