From e3d3bea0809d38707d2be42c45f7bc2ffdf6ccfb Mon Sep 17 00:00:00 2001 From: Ayrat Hudaygulov Date: Thu, 16 Jul 2026 21:48:11 +0100 Subject: [PATCH] Ephemeral commands + vahter confirmations (Bot API 10.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public-chat moderation commands (/ban, /sban, /unban) can now be registered with is_ephemeral so clients send them invisibly to other chat members, and the issuing vahter can get a short self-dismissing ephemeral confirmation in the same chat. Both behind default-off DB-backed flags: - EPHEMERAL_COMMANDS_ENABLED: new BotCommandsSetupService registers the commands with is_ephemeral in the all_group_chats scope on startup; when off, deletes the registration so clients revert to plain visible commands. - EPHEMERAL_CONFIRMATION_ENABLED: after /ban, /sban, /unban the issuing vahter receives an ephemeral confirmation (receiver_user_id, CallIgnore — best-effort, never fails the command). Lives in the AdminCommand dispatch so ML auto-bans never confirm; logs-channel reporting unchanged. Incoming ephemeral commands (TgMessage.IsEphemeral) skip the delete-command-message step — they are invisible and auto-expire; the delete path stays for old clients. Ephemeral /ban ping gets an ephemeral pong. Known risk (accepted): Telegram docs don't say whether an ephemeral command can be a reply (required by /ban and /sban). If broken in practice, flip EPHEMERAL_COMMANDS_ENABLED off. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019QJX5JAiRcLoPsyK7naF8E --- src/VahterBanBot/Bot.fs | 60 ++++--- src/VahterBanBot/BotCommandsSetup.fs | 43 +++++ src/VahterBanBot/Program.fs | 7 +- src/VahterBanBot/TgMessage.fs | 5 + src/VahterBanBot/Types.fs | 9 +- src/VahterBanBot/VahterBanBot.fsproj | 1 + tests/BotTestInfra/TgMessageUtils.fs | 10 +- tests/VahterBanBot.Tests/EphemeralTests.fs | 149 ++++++++++++++++++ .../VahterBanBot.Tests.fsproj | 1 + 9 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 src/VahterBanBot/BotCommandsSetup.fs create mode 100644 tests/VahterBanBot.Tests/EphemeralTests.fs diff --git a/src/VahterBanBot/Bot.fs b/src/VahterBanBot/Bot.fs index 6d962d4..72034ca 100644 --- a/src/VahterBanBot/Bot.fs +++ b/src/VahterBanBot/Bot.fs @@ -68,6 +68,12 @@ module private BotHelpers = isBanCommand msg && msg.ReplyToMessage.IsSome + /// Soft-ban duration in hours from "/sban [hours]"; defaults to 24. + let softBanDuration (msg: TgMessage) = + match Int32.TryParse(msg.Text.Split " " |> Seq.last) with + | true, x -> x + | _ -> 24 // 1 day should be enough + let isMessageFromAllowedChats (cfg: BotConfiguration) (msg: TgMessage) = cfg.ChatsToMonitor.ContainsValue msg.ChatId @@ -332,7 +338,12 @@ type BotService( member private _.Ping(msg: TgMessage) = task { use _ = botActivity.StartActivity("ping") - do! tg.CallExn(Req.SendMessage.Make(msg.ChatId, "pong")) |> taskIgnore + // an ephemeral /ban ping gets an ephemeral pong — a public reply to an + // invisible command would look like noise to the rest of the chat + if msg.IsEphemeral then + do! tg.CallExn(Req.SendMessage.Make(msg.ChatId, "pong", receiverUserId = msg.SenderId)) |> taskIgnore + else + do! tg.CallExn(Req.SendMessage.Make(msg.ChatId, "pong")) |> taskIgnore } member private this.TotalBan(msg: TgMessage, actor: Actor) = task { @@ -496,12 +507,7 @@ type BotService( |> safeTaskAwait (fun e -> logger.LogWarning ($"Failed to delete reply message {messageToRemove.MessageId} from chat {messageToRemove.ChatId}", e)) } - let maybeDurationString = commandMessage.Text.Split " " |> Seq.last - // use last value as soft ban duration - let duration = - match Int32.TryParse maybeDurationString with - | true, x -> x - | _ -> 24 // 1 day should be enough + let duration = softBanDuration commandMessage let logText = softBanResultInLogMsg messageToRemove vahter duration (utcNow()) @@ -1271,16 +1277,26 @@ type BotService( // ----------------------------------------------------------------------- member private this.AdminCommand(vahter: User, msg: TgMessage) = + // Self-dismissing ephemeral confirmation in the same chat, visible only to the + // issuing vahter (Bot API 10.2). Best-effort: delivery is not guaranteed and a + // failure must never fail the command itself, hence CallIgnore. + let confirm (text: string) : Task = + if botConfig.Value.EphemeralConfirmationEnabled then + tg.CallIgnore(Req.SendMessage.Make(msg.ChatId, text, receiverUserId = vahter.Id)) + else Task.FromResult () + // aux functions to overcome annoying FS3511: This state machine is not statically compilable. let banOnReplyAux() = task { + let target = msg.ReplyToMessage.Value let authed = isBanAuthorized botConfig.Value - msg.ReplyToMessage.Value + target vahter logger if authed then do! this.BanOnReply(msg, vahter) + do! confirm $"✅ Banned {prependUsername target.SenderUsername}({target.SenderId})" } let unbanAux() = task { if isUserVahter botConfig.Value vahter then @@ -1289,33 +1305,39 @@ type BotService( match userToUnban with | None -> logger.LogWarning $"User {vahter.Username} ({vahter.Id}) tried to unban non-existing user {targetUserId}" + do! confirm $"⚠️ User {targetUserId} not found" | Some userToUnban -> do! this.Unban(msg, vahter, userToUnban) + do! confirm $"✅ Unbanned {prependUsername (defaultArg userToUnban.Username null)}({userToUnban.Id})" } let softBanOnReplyAux() = task { + let target = msg.ReplyToMessage.Value let authed = isBanAuthorized botConfig.Value - msg.ReplyToMessage.Value + target vahter logger if authed then do! this.SoftBanMsg(msg, vahter) + do! confirm $"✅ Soft-banned {prependUsername target.SenderUsername}({target.SenderId}) for {softBanDuration msg}h" } task { use _ = botActivity.StartActivity("adminCommand") - // delete command message + // delete command message; an ephemeral command is invisible to the chat and + // auto-expires — there is no regular message to delete let deleteCmdTask = task { - use _ = - botActivity - .StartActivity("deleteCmdMsg") - .SetTag("msgId", msg.MessageId) - .SetTag("chatId", msg.ChatId) - .SetTag("chatUsername", msg.ChatUsername) - recordDeletedMessage msg.ChatId msg.ChatUsername "adminCommand" - do! tg.CallExn(Req.DeleteMessage.Make(msg.ChatId, msg.MessageId)) - |> safeTaskAwait (fun e -> logger.LogWarning ($"Failed to delete command message {msg.MessageId} from chat {msg.ChatId}", e)) + if not msg.IsEphemeral then + use _ = + botActivity + .StartActivity("deleteCmdMsg") + .SetTag("msgId", msg.MessageId) + .SetTag("chatId", msg.ChatId) + .SetTag("chatUsername", msg.ChatUsername) + recordDeletedMessage msg.ChatId msg.ChatUsername "adminCommand" + do! tg.CallExn(Req.DeleteMessage.Make(msg.ChatId, msg.MessageId)) + |> safeTaskAwait (fun e -> logger.LogWarning ($"Failed to delete command message {msg.MessageId} from chat {msg.ChatId}", e)) } // check that user is allowed to (un)ban others if isBanOnReplyCommand msg then diff --git a/src/VahterBanBot/BotCommandsSetup.fs b/src/VahterBanBot/BotCommandsSetup.fs new file mode 100644 index 0000000..b6b4375 --- /dev/null +++ b/src/VahterBanBot/BotCommandsSetup.fs @@ -0,0 +1,43 @@ +module VahterBanBot.BotCommandsSetup + +open System.Threading.Tasks +open Microsoft.Extensions.Hosting +open Microsoft.Extensions.Logging +open Microsoft.Extensions.Options +open Funogram.Telegram.Types +open VahterBanBot.Types +open BotInfra + +/// At startup, registers the public-chat moderation commands (/ban, /sban, /unban) in Telegram. +/// With EphemeralCommandsEnabled they are marked is_ephemeral (Bot API 10.2), so clients send +/// them invisibly to other chat members. When the flag is off, the group-scope registration is +/// removed so clients fall back to plain visible commands (the send-then-delete flow still +/// covers those, as it does for old clients that ignore is_ephemeral). +type BotCommandsSetupService( + tg: ITelegramApi, + botConf: IOptions, + logger: ILogger +) = + interface IHostedService with + member _.StartAsync _ = + task { + let cfg = botConf.Value + if not cfg.IgnoreSideEffects then + try + let scope = BotCommandScope.AllGroupChats(BotCommandScopeAllGroupChats.Create("all_group_chats")) + if cfg.EphemeralCommandsEnabled then + let commands = + [| BotCommand.Create("ban", "Забанить (ответом на сообщение спамера)", isEphemeral = true) + BotCommand.Create("sban", "Софтбан (ответом на сообщение спамера)", isEphemeral = true) + BotCommand.Create("unban", "Разбанить юзера по ID", isEphemeral = true) |] + do! tg.CallExn(Funogram.Telegram.Req.SetMyCommands.Make(commands, scope = scope)) |> taskIgnore + logger.LogInformation "Registered ephemeral group commands (/ban, /sban, /unban)" + else + do! tg.CallExn(Funogram.Telegram.Req.DeleteMyCommands.Make(scope = scope)) |> taskIgnore + logger.LogInformation "Ephemeral commands disabled; removed group-scope command registration" + with ex -> + logger.LogWarning(ex, "Could not update bot command registration in Telegram") + } + :> Task + + member _.StopAsync _ = Task.CompletedTask diff --git a/src/VahterBanBot/Program.fs b/src/VahterBanBot/Program.fs index 5aec73c..bdb2304 100644 --- a/src/VahterBanBot/Program.fs +++ b/src/VahterBanBot/Program.fs @@ -19,6 +19,7 @@ open VahterBanBot.ProfileFetcher open VahterBanBot.Telemetry open VahterBanBot.Types open VahterBanBot.StartupMessage +open VahterBanBot.BotCommandsSetup open VahterBanBot.UpdateChatAdmins open BotInfra open BotInfra.JsonSetup @@ -133,7 +134,10 @@ let buildBotConf () = LlmReactionTriageShadowDisable = getSettingOr "LLM_REACTION_TRIAGE_SHADOW_DISABLE" "false" |> bool.Parse ReactionNotSpamCooldownDays = getSettingOr "REACTION_NOT_SPAM_COOLDOWN_DAYS" "30" |> int ReactionTriageDebounce = getSettingOr "REACTION_TRIAGE_DEBOUNCE_SECONDS" "5" |> int64 |> TimeSpan.FromSeconds - BanExpiryDays = getSettingOr "BAN_EXPIRY_DAYS" "7" |> int } + BanExpiryDays = getSettingOr "BAN_EXPIRY_DAYS" "7" |> int + // Ephemeral commands & confirmations (Bot API 10.2) + EphemeralCommandsEnabled = getSettingOr "EPHEMERAL_COMMANDS_ENABLED" "false" |> bool.Parse + EphemeralConfirmationEnabled = getSettingOr "EPHEMERAL_CONFIRMATION_ENABLED" "false" |> bool.Parse } let ocrConfigOf (c: BotConfiguration) = { OcrEnabled = c.OcrEnabled @@ -185,6 +189,7 @@ WebhookHost.configureSharedServices webhookCfg builder let cs = sp.GetRequiredService() { new IForcedCleanup with member _.Run() = cs.ForceCleanup() :> Task }) .AddHostedService() + .AddHostedService() .AddHostedService() // OCR: register shared IBotOcr (Azure AI Vision SDK; SDK-native retry), then the VahterBanBot diff --git a/src/VahterBanBot/TgMessage.fs b/src/VahterBanBot/TgMessage.fs index 9ef87dd..2b06fd8 100644 --- a/src/VahterBanBot/TgMessage.fs +++ b/src/VahterBanBot/TgMessage.fs @@ -71,6 +71,11 @@ type TgMessage private (raw: Message, isEdit: bool) = // ── Message identity ─────────────────────────────────────────── member _.MessageId = raw.MessageId + /// Set for ephemeral messages (Bot API 10.2) instead of a regular MessageId. + member _.EphemeralMessageId = raw.EphemeralMessageId + /// True for ephemeral messages (e.g. commands registered with is_ephemeral) — + /// invisible to other chat members, auto-expiring, not deletable via DeleteMessage. + member _.IsEphemeral = raw.EphemeralMessageId.IsSome member _.ChatId = raw.Chat.Id member _.ChatUsername : string = raw.Chat.Username |> Option.toObj member _.Chat = raw.Chat diff --git a/src/VahterBanBot/Types.fs b/src/VahterBanBot/Types.fs index bb8512d..749cd49 100644 --- a/src/VahterBanBot/Types.fs +++ b/src/VahterBanBot/Types.fs @@ -371,7 +371,14 @@ type BotConfiguration = /// reaction, but we run triage at most once per this window. Default: 5s. ReactionTriageDebounce: TimeSpan /// Bans older than this many days are considered expired. Default: 7. - BanExpiryDays: int } + BanExpiryDays: int + // Ephemeral commands & confirmations (Bot API 10.2) + /// When true, public commands (/ban, /unban, /sban) are registered with is_ephemeral, + /// so clients send them invisibly to other chat members. Re-registration happens on restart. + EphemeralCommandsEnabled: bool + /// When true, the issuing vahter gets a short self-dismissing ephemeral confirmation + /// in the chat after /ban, /sban, /unban. Visible only to the issuer. + EphemeralConfirmationEnabled: bool } member this.BotActor = Actor.Bot (Some {| botUserId = this.BotUserId; botUsername = this.BotUserName |}) diff --git a/src/VahterBanBot/VahterBanBot.fsproj b/src/VahterBanBot/VahterBanBot.fsproj index 9a89de5..e1e4dd8 100644 --- a/src/VahterBanBot/VahterBanBot.fsproj +++ b/src/VahterBanBot/VahterBanBot.fsproj @@ -21,6 +21,7 @@ + diff --git a/tests/BotTestInfra/TgMessageUtils.fs b/tests/BotTestInfra/TgMessageUtils.fs index 92ab07a..6a61223 100644 --- a/tests/BotTestInfra/TgMessageUtils.fs +++ b/tests/BotTestInfra/TgMessageUtils.fs @@ -125,7 +125,7 @@ type Tg() = // ── Message factories (VahterBanBot-style) ─────────────────────────────── - static member quickMsg (?text: string, ?chat: Chat, ?from: User, ?date: DateTime, ?caption: string, ?editedText: string, ?entities: MessageEntity[], ?photos: PhotoSize[], ?isAutomaticForward: bool, ?senderChat: Chat, ?quote: TextQuote, ?externalReply: ExternalReplyInfo, ?replyMarkup: InlineKeyboardMarkup, ?sticker: Sticker) = + static member quickMsg (?text: string, ?chat: Chat, ?from: User, ?date: DateTime, ?caption: string, ?editedText: string, ?entities: MessageEntity[], ?photos: PhotoSize[], ?isAutomaticForward: bool, ?senderChat: Chat, ?quote: TextQuote, ?externalReply: ExternalReplyInfo, ?replyMarkup: InlineKeyboardMarkup, ?sticker: Sticker, ?ephemeralMessageId: int64) = let msgId = next() let msgChat = chat |> Option.defaultWith (fun () -> Tg.chat()) let msgFrom = from |> Option.defaultWith (fun () -> Tg.user()) @@ -147,7 +147,8 @@ type Tg() = ?quote = quote, ?externalReply = externalReply, ?replyMarkup = replyMarkup, - ?sticker = sticker + ?sticker = sticker, + ?ephemeralMessageId = ephemeralMessageId ), ?editedMessage = (editedText @@ -176,7 +177,7 @@ type Tg() = ) ) - static member replyMsg (msg: Message, ?text: string, ?from: User, ?date: DateTime) = + static member replyMsg (msg: Message, ?text: string, ?from: User, ?date: DateTime, ?ephemeralMessageId: int64) = Update.Create( updateId = next(), message = @@ -186,7 +187,8 @@ type Tg() = chat = msg.Chat, from = (from |> Option.defaultWith (fun () -> Tg.user())), text = (text |> Option.defaultValue (Guid.NewGuid().ToString())), - replyToMessage = msg + replyToMessage = msg, + ?ephemeralMessageId = ephemeralMessageId ) ) diff --git a/tests/VahterBanBot.Tests/EphemeralTests.fs b/tests/VahterBanBot.Tests/EphemeralTests.fs new file mode 100644 index 0000000..c14d9da --- /dev/null +++ b/tests/VahterBanBot.Tests/EphemeralTests.fs @@ -0,0 +1,149 @@ +module VahterBanBot.Tests.EphemeralTests + +open System +open System.Net +open System.Threading.Tasks +open VahterBanBot.Tests.ContainerTestBase +open BotTestInfra +open Xunit + +/// Ephemeral commands & confirmations (Bot API 10.2). +/// EPHEMERAL_CONFIRMATION_ENABLED defaults to false; tests that need it flip the +/// bot_setting and DisposeAsync restores it after every test (xunit creates a new +/// class instance per test). +type EphemeralTests(fixture: MlDisabledVahterTestContainers) = + + let setConfirmation (enabled: bool) = task { + do! fixture.SetBotSetting("EPHEMERAL_CONFIRMATION_ENABLED", if enabled then "true" else "false") + do! fixture.ReloadSettings() + } + + [] + let ``Ban confirmation is sent ephemerally to the issuing vahter when enabled`` () = task { + do! setConfirmation true + + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0]) + let! _ = fixture.SendMessage msgUpdate + + do! fixture.ClearFakeCalls() + let! banResp = + Tg.replyMsg(msgUpdate.Message.Value, "/ban", fixture.Vahters[0]) + |> fixture.SendMessage + Assert.Equal(HttpStatusCode.OK, banResp.StatusCode) + + let! msgBanned = fixture.MessageBanned msgUpdate.Message.Value + Assert.True msgBanned + + // confirmation goes into the same chat, targeted at the issuing vahter only + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.True(calls |> Array.exists (fun c -> + c.Body.Contains $"\"chat_id\":{fixture.ChatsToMonitor[0].Id}" + && c.Body.Contains $"\"receiver_user_id\":{fixture.Vahters[0].Id}" + && c.Body.Contains "Banned")) + } + + [] + let ``No ephemeral confirmation when the flag is off`` () = task { + do! setConfirmation false + + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0]) + let! _ = fixture.SendMessage msgUpdate + + do! fixture.ClearFakeCalls() + let! banResp = + Tg.replyMsg(msgUpdate.Message.Value, "/ban", fixture.Vahters[0]) + |> fixture.SendMessage + Assert.Equal(HttpStatusCode.OK, banResp.StatusCode) + + let! msgBanned = fixture.MessageBanned msgUpdate.Message.Value + Assert.True msgBanned + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.False(calls |> Array.exists (fun c -> c.Body.Contains "receiver_user_id")) + } + + [] + let ``Unban confirmation is sent ephemerally when enabled`` () = task { + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0]) + let! _ = fixture.SendMessage msgUpdate + let! _ = + Tg.replyMsg(msgUpdate.Message.Value, "/ban", fixture.Vahters[0]) + |> fixture.SendMessage + + do! setConfirmation true + do! fixture.ClearFakeCalls() + let! unbanResp = + Tg.quickMsg($"/unban {msgUpdate.Message.Value.From.Value.Id}", chat = fixture.ChatsToMonitor[0], from = fixture.Vahters[1]) + |> fixture.SendMessage + Assert.Equal(HttpStatusCode.OK, unbanResp.StatusCode) + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.True(calls |> Array.exists (fun c -> + c.Body.Contains $"\"receiver_user_id\":{fixture.Vahters[1].Id}" + && c.Body.Contains "Unbanned")) + } + + [] + let ``Softban confirmation carries the duration`` () = task { + do! setConfirmation true + + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0]) + let! _ = fixture.SendMessage msgUpdate + + do! fixture.ClearFakeCalls() + let! sbanResp = + Tg.replyMsg(msgUpdate.Message.Value, "/sban 12", fixture.Vahters[0]) + |> fixture.SendMessage + Assert.Equal(HttpStatusCode.OK, sbanResp.StatusCode) + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.True(calls |> Array.exists (fun c -> + c.Body.Contains $"\"receiver_user_id\":{fixture.Vahters[0].Id}" + && c.Body.Contains "Soft-banned" + && c.Body.Contains "for 12h")) + } + + [] + let ``Ephemeral command message is not deleted but the ban still happens`` () = task { + let msgUpdate = Tg.quickMsg(chat = fixture.ChatsToMonitor[0]) + let! _ = fixture.SendMessage msgUpdate + + do! fixture.ClearFakeCalls() + // command arrives as an ephemeral message — invisible to the chat, auto-expiring + let banUpdate = Tg.replyMsg(msgUpdate.Message.Value, "/ban", fixture.Vahters[0], ephemeralMessageId = 777L) + let! banResp = fixture.SendMessage banUpdate + Assert.Equal(HttpStatusCode.OK, banResp.StatusCode) + + let! msgBanned = fixture.MessageBanned msgUpdate.Message.Value + Assert.True msgBanned + + let! deleteCalls = fixture.GetFakeCalls "deleteMessage" + // the spam message is deleted... + Assert.True(deleteCalls |> Array.exists (fun c -> + c.Body.Contains $"\"message_id\":{msgUpdate.Message.Value.MessageId}")) + // ...but no DeleteMessage is issued for the ephemeral command message + Assert.False(deleteCalls |> Array.exists (fun c -> + c.Body.Contains $"\"message_id\":{banUpdate.Message.Value.MessageId}")) + } + + [] + let ``Ephemeral ping gets an ephemeral pong`` () = task { + do! fixture.ClearFakeCalls() + let! pingResp = + Tg.quickMsg("/ban ping", chat = fixture.ChatsToMonitor[0], from = fixture.Vahters[0], ephemeralMessageId = 778L) + |> fixture.SendMessage + Assert.Equal(HttpStatusCode.OK, pingResp.StatusCode) + + let! calls = fixture.GetFakeCalls "sendMessage" + Assert.True(calls |> Array.exists (fun c -> + c.Body.Contains "pong" + && c.Body.Contains $"\"receiver_user_id\":{fixture.Vahters[0].Id}")) + } + + // Restore the confirmation flag to its default after every test. + interface IAsyncDisposable with + member _.DisposeAsync() = + ValueTask(task { + do! fixture.SetBotSetting("EPHEMERAL_CONFIRMATION_ENABLED", "false") + do! fixture.ReloadSettings() + } :> Task) diff --git a/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj b/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj index c875aae..6e19393 100644 --- a/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj +++ b/tests/VahterBanBot.Tests/VahterBanBot.Tests.fsproj @@ -18,6 +18,7 @@ +