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:
@@ -285,6 +285,12 @@ func (t *Bot) logChatAdminStatus(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
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
|
||||
@@ -405,6 +411,14 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
t.handleSuccessfulPayment(ctx, update.Message)
|
||||
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 —
|
||||
// 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.
|
||||
@@ -421,6 +435,24 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
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
|
||||
// bot-link client backing it is built after the bot).
|
||||
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
|
||||
// scripted getChatMember status, and records restrictChatMember calls.
|
||||
// scripted getChatMember status, and records restrictChatMember and unpinChatMessage calls.
|
||||
type chatAPI struct {
|
||||
memberStatus string // the status getChatMember reports (default "left")
|
||||
restricts []restrictCall
|
||||
unpins []unpinCall
|
||||
}
|
||||
|
||||
type restrictCall struct {
|
||||
@@ -30,6 +31,13 @@ type restrictCall struct {
|
||||
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) {
|
||||
switch {
|
||||
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)
|
||||
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
|
||||
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:
|
||||
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