chore(release): promote development to master #258

Merged
developer merged 18 commits from development into master 2026-07-13 22:22:00 +00:00
5 changed files with 96 additions and 4 deletions
Showing only changes of commit 522beec82e - Show all commits
+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
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
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
+3 -1
View File
@@ -555,7 +555,9 @@ high-rate флага. С карточки пользователя операт
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
сообщения. То есть незарегистрированного новичка, написавшего в чат,
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
в чате** — роль `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
`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
`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
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
+32
View File
@@ -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) {
+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
// 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)
}
}