diff --git a/src/VahterBanBot/Bot.fs b/src/VahterBanBot/Bot.fs index 1b5595a..3528769 100644 --- a/src/VahterBanBot/Bot.fs +++ b/src/VahterBanBot/Bot.fs @@ -70,12 +70,17 @@ let banInAllChats (botConfig: BotConfiguration) (botClient: ITelegramBotClient) let aggregateBanResultInLogMsg (logger: ILogger) (message: Message) + (deletedUserMessages: int) (banResults: Result []) = + let vahterUserId = message.From.Id + let vahterUsername = message.From.Username + let targetUserId = message.ReplyToMessage.From.Id let targetUsername = message.ReplyToMessage.From.Username let logMsgBuilder = StringBuilder() - %logMsgBuilder.AppendLine($"Result of ban {targetUsername} ({targetUserId}) in chats:") + %logMsgBuilder.AppendLine($"Vahter {vahterUsername}({vahterUserId}) banned {targetUsername} ({targetUserId})") + %logMsgBuilder.AppendLine($"Deleted {deletedUserMessages} messages in chats:") (logMsgBuilder, banResults) ||> Array.fold (fun (sb: StringBuilder) result -> @@ -94,11 +99,18 @@ let onUpdate (logger: ILogger) (message: Message) = task { + // early return if if we can't process it if isNull message || isNull message.From then logger.LogWarning "Received update without message" + else + + // upserting user to DB + let! _ = + DbUser.newUser message.From + |> DB.upsertUser // check if message comes from channel, we should delete it immediately - elif botConfig.ShouldDeleteChannelMessages && isChannelMessage message then + if botConfig.ShouldDeleteChannelMessages && isChannelMessage message then do! botClient.DeleteMessageAsync(ChatId(message.Chat.Id), message.MessageId) let probablyChannelName = @@ -116,17 +128,41 @@ let onUpdate let deleteCmdTask = botClient.DeleteMessageAsync(ChatId(message.Chat.Id), message.MessageId) // delete message that was replied to let deleteReplyTask = botClient.DeleteMessageAsync(ChatId(message.Chat.Id), message.ReplyToMessage.MessageId) + // update user in DB + let banUserInDb = + message.ReplyToMessage.From + |> DbUser.newUser + |> DbUser.banUser message.From.Id (Option.ofObj message.Text) + |> DB.upsertUser + + let deletedUserMessagesTask = task { + let fromUserId = message.ReplyToMessage.From.Id + + // delete all recorded messages from user in all chats + let! allUserMessages = DB.getUserMessages fromUserId + for msg in allUserMessages do + // try to delete each message separately + try + do! botClient.DeleteMessageAsync(ChatId(msg.Chat_Id), msg.Message_Id) + with e -> + logger.LogError ($"Failed to delete message {msg.Message_Id} from chat {msg.Chat_Id}", e) + + // delete recorded messages from DB + return! DB.deleteUserMessages fromUserId + } // try ban user in all monitored chats let! banResults = banInAllChats botConfig botClient message.ReplyToMessage.From.Id + let! deletedUserMessages = deletedUserMessagesTask // produce aggregated log message - let logMsg = aggregateBanResultInLogMsg logger message banResults + let logMsg = aggregateBanResultInLogMsg logger message deletedUserMessages banResults // log both to logger and to logs channel let! _ = botClient.SendTextMessageAsync(ChatId(botConfig.LogsChannelId), logMsg) logger.LogInformation logMsg + let! _ = banUserInDb do! deleteCmdTask do! deleteReplyTask @@ -136,4 +172,12 @@ let onUpdate let deleteCmdTask = botClient.DeleteMessageAsync(ChatId(message.Chat.Id), message.MessageId) let! _ = botClient.SendTextMessageAsync(ChatId(message.Chat.Id), "pong") do! deleteCmdTask + + // if message is not a command, just save it ID to DB + else + let! _ = + message + |> DbMessage.newMessage + |> DB.insertMessage + () } diff --git a/src/VahterBanBot/DB.fs b/src/VahterBanBot/DB.fs index b439484..d941b51 100644 --- a/src/VahterBanBot/DB.fs +++ b/src/VahterBanBot/DB.fs @@ -1,5 +1,6 @@ module VahterBanBot.DB +open System.Threading.Tasks open Npgsql open VahterBanBot.Types open Dapper @@ -7,34 +8,122 @@ open VahterBanBot.Utils let private connString = getEnv "DATABASE_URL" -let upsertUser (user: User) = +let upsertUser (user: DbUser): Task = task { use conn = new NpgsqlConnection(connString) //language=postgresql let sql = """ -INSERT INTO "user" (id, username) -VALUES (@id, @username) +INSERT INTO "user" (id, username, ban_reason, banned_at, banned_by, created_at, updated_at) +VALUES (@id, @username, @banReason, @bannedAt, @bannedBy, @createdAt, @updatedAt) ON CONFLICT (id) DO UPDATE SET username = CASE - WHEN EXCLUDED.username != "user".username THEN EXCLUDED.username + WHEN EXCLUDED.username != "user".username THEN COALESCE(EXCLUDED.username, "user".username) ELSE "user".username END, updated_at = CASE - WHEN EXCLUDED.username != "user".username THEN timezone('utc'::TEXT, NOW()) + WHEN EXCLUDED.updated_at != "user".updated_at THEN EXCLUDED.username + ELSE "user".updated_at + END, + ban_reason = + CASE + WHEN EXCLUDED.ban_reason != "user".ban_reason THEN EXCLUDED.ban_reason + ELSE "user".ban_reason + END, + banned_at = + CASE + WHEN EXCLUDED.banned_at != "user".banned_at THEN EXCLUDED.ban_reason + ELSE "user".banned_at + END, + banned_by = + CASE + WHEN EXCLUDED.banned_by != "user".banned_by THEN EXCLUDED.ban_reason + ELSE "user".banned_by + END, + created_at = + CASE + WHEN EXCLUDED.created_at != "user".created_at THEN EXCLUDED.ban_reason + ELSE "user".created_at + END, + updated_at = + CASE + WHEN EXCLUDED.updated_at != "user".updated_at THEN EXCLUDED.ban_reason ELSE "user".updated_at END RETURNING *; """ let! insertedUser = - conn.QueryAsync( + conn.QueryAsync( sql, - {| id = user.Id; username = user.Username |} + {| id = user.Id + username = user.Username + banReason = user.Ban_Reason + bannedAt = user.Banned_At + bannedBy = user.Banned_By + createdAt = user.Created_At + updatedAt = user.Updated_At |} ) return insertedUser |> Seq.head } + +let insertMessage (message: DbMessage): Task = + task { + use conn = new NpgsqlConnection(connString) + + //language=postgresql + let sql = + """ +INSERT INTO message (chat_id, message_id, user_id, created_at) +VALUES (@chatId, @messageId, @userId, @createdAt) +ON CONFLICT (chat_id, message_id) DO NOTHING RETURNING *; + """ + + let! insertedMessage = + conn.QueryAsync( + sql, + {| chatId = message.Chat_Id + messageId = message.Message_Id + userId = message.User_Id + createdAt = message.Created_At |} + ) + + return + insertedMessage + |> Seq.tryHead + |> Option.defaultValue message +} + +let getUserMessages (userId: int64): Task = + task { + use conn = new NpgsqlConnection(connString) + + //language=postgresql + let sql = "SELECT * FROM message WHERE user_id = @userId" + + let! messages = + conn.QueryAsync( + sql, + {| userId = userId |} + ) + return Array.ofSeq messages + } + +let deleteUserMessages (userId: int64): Task = + task { + use conn = new NpgsqlConnection(connString) + + //language=postgresql + let sql = "DELETE FROM message WHERE user_id = @userId" + + let! messagesDeleted = + conn.ExecuteAsync( + sql, + {| userId = userId |} + ) + return messagesDeleted + } diff --git a/src/VahterBanBot/Program.fs b/src/VahterBanBot/Program.fs index caca51a..e31f4ef 100644 --- a/src/VahterBanBot/Program.fs +++ b/src/VahterBanBot/Program.fs @@ -72,19 +72,6 @@ let webApp = choose [ logger.LogError(e, "Unexpected error while processing update") return! Successful.OK() next ctx }) - - GET >=> routef "/user/%d" (fun id next ctx -> task { - let logger = ctx.GetLogger() - try - let! upsertedUser = - User.newUser id - |> DB.upsertUser - - return! json upsertedUser next ctx - with e -> - logger.LogError(e, "Unexpected error while processing user") - return! ServerErrors.INTERNAL_ERROR() next ctx - }) ] ] diff --git a/src/VahterBanBot/Types.fs b/src/VahterBanBot/Types.fs index afabd6f..a012279 100644 --- a/src/VahterBanBot/Types.fs +++ b/src/VahterBanBot/Types.fs @@ -14,7 +14,7 @@ type BotConfiguration = ShouldDeleteChannelMessages: bool } [] -type User = +type DbUser = { Id: int64 Username: string option Banned_By: int64 option @@ -31,3 +31,27 @@ type User = Ban_Reason = None Updated_At = DateTime.UtcNow Created_At = DateTime.UtcNow } + + static member newUser(user: Telegram.Bot.Types.User) = + DbUser.newUser (id = user.Id, ?username = Option.ofObj user.Username) + + member this.Ban(vahter: int64, ?reason: String) = + { this with + Banned_By = Some vahter + Banned_At = Some DateTime.UtcNow + Ban_Reason = reason + Updated_At = DateTime.UtcNow } + +module DbUser = + let banUser vahter reason (user: DbUser) = user.Ban(vahter, ?reason = reason) + +type DbMessage = + { Chat_Id: int64 + Message_Id: int + User_Id: int64 + Created_At: DateTime } + static member newMessage(message: Telegram.Bot.Types.Message) = + { Chat_Id = message.Chat.Id + Message_Id = message.MessageId + User_Id = message.From.Id + Created_At = DateTime.UtcNow } diff --git a/src/migrations/V2__added-chat-id.sql b/src/migrations/V2__added-chat-id.sql new file mode 100644 index 0000000..e369274 --- /dev/null +++ b/src/migrations/V2__added-chat-id.sql @@ -0,0 +1,20 @@ +DROP TABLE message; + +CREATE TABLE "message" +( + id BIGSERIAL NOT NULL PRIMARY KEY, + chat_id BIGINT NOT NULL, + message_id INT NOT NULL, + user_id BIGINT NOT NULL + CONSTRAINT message_user_id_fkey + REFERENCES "user" (id) + ON DELETE CASCADE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT timezone('utc'::TEXT, NOW()) +); + +CREATE UNIQUE INDEX message_chat_id_message_id_uindex + ON "message" (chat_id, message_id); + +CREATE INDEX message_user_id_index + ON "message" (user_id);