Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 41 additions & 19 deletions src/VahterBanBot/Bot.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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<unit> =
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
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/VahterBanBot/BotCommandsSetup.fs
Original file line number Diff line number Diff line change
@@ -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<BotConfiguration>,
logger: ILogger<BotCommandsSetupService>
) =
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
7 changes: 6 additions & 1 deletion src/VahterBanBot/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -185,6 +189,7 @@ WebhookHost.configureSharedServices webhookCfg builder
let cs = sp.GetRequiredService<CleanupService>()
{ new IForcedCleanup with member _.Run() = cs.ForceCleanup() :> Task })
.AddHostedService<StartupMessage>()
.AddHostedService<BotCommandsSetupService>()
.AddHostedService<UpdateChatAdmins>()

// OCR: register shared IBotOcr (Azure AI Vision SDK; SDK-native retry), then the VahterBanBot
Expand Down
5 changes: 5 additions & 0 deletions src/VahterBanBot/TgMessage.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/VahterBanBot/Types.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 |})

Expand Down
1 change: 1 addition & 0 deletions src/VahterBanBot/VahterBanBot.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<Compile Include="Bot.fs" />
<Compile Include="Cleanup.fs" />
<Compile Include="StartupMessage.fs" />
<Compile Include="BotCommandsSetup.fs" />
<Compile Include="Program.fs" />
</ItemGroup>

Expand Down
10 changes: 6 additions & 4 deletions tests/BotTestInfra/TgMessageUtils.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -147,7 +147,8 @@ type Tg() =
?quote = quote,
?externalReply = externalReply,
?replyMarkup = replyMarkup,
?sticker = sticker
?sticker = sticker,
?ephemeralMessageId = ephemeralMessageId
),
?editedMessage =
(editedText
Expand Down Expand Up @@ -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 =
Expand All @@ -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
)
)

Expand Down
Loading
Loading