From 6a12a0b24cfdcb061e928ba867ea8a386b17396b Mon Sep 17 00:00:00 2001
From: ZenonEl <165126589+ZenonEl@users.noreply.github.com>
Date: Mon, 30 Mar 2026 06:42:53 +0400
Subject: [PATCH] fix(sessions): implement per-message sessions with captions
and cancel closes #30
Replace single-URL-per-user state (ProcessVideoDC) with per-link MediaSession
keyed by status message ID. Each link now gets its own session with independent
URL, caption, and cancellation token, so pressing a button always processes the
correct link regardless of how many were sent.
Changes:
- Add MediaSession and MediaSessionManager (ConcurrentDictionary-based)
- Encode session ID in keyboard callback data (send_to_all_contacts:{id}, etc.)
- Add parameterized callback handlers for all distribution actions + cancel
- Route media session callbacks through factory before UserSession check
- Add Cancel button to distribution keyboard
- Handle expired sessions with user-friendly alert
- Add resource strings for Cancel, SessionExpired, Cancelled (EN + RU)
---
Resources/texts.resx | 9 +
Resources/texts.ru-RU.resx | 9 +
.../Handlers/ICallBackQuery/CallbackNames.cs | 8 +
.../IMediaSessionCallbackQuery.cs | 312 ++++++++++++++++++
TelegramBot/Handlers/PrivateUpdateHandler.cs | 12 +-
TelegramBot/Handlers/Utils.cs | 41 +--
TelegramBot/MediaDownloader.cs | 28 ++
TelegramBot/Scheduler.cs | 2 +
TelegramBot/Sessions/MediaSession.cs | 31 ++
TelegramBot/Sessions/MediaSessionManager.cs | 101 ++++++
TelegramBot/Utils/KeyboardUtils.cs | 49 +--
11 files changed, 543 insertions(+), 59 deletions(-)
create mode 100644 TelegramBot/Handlers/ICallBackQuery/IMediaSessionCallbackQuery.cs
create mode 100644 TelegramBot/Sessions/MediaSession.cs
create mode 100644 TelegramBot/Sessions/MediaSessionManager.cs
diff --git a/Resources/texts.resx b/Resources/texts.resx
index 60c6c5a..46e774b 100644
--- a/Resources/texts.resx
+++ b/Resources/texts.resx
@@ -632,4 +632,13 @@ If you'd like to get access, you can contact <a href="{0}">me here</a&g
The mailing list is over. Sent {0}/{1}
+
+ Cancel
+
+
+ Session expired. Please send the link again.
+
+
+ Cancelled
+
\ No newline at end of file
diff --git a/Resources/texts.ru-RU.resx b/Resources/texts.ru-RU.resx
index b2749b0..812852b 100644
--- a/Resources/texts.ru-RU.resx
+++ b/Resources/texts.ru-RU.resx
@@ -676,4 +676,13 @@ PS: квадратные скобки не нужны :)
Рассылка окончена. Отправлено {0}/{1}
+
+ Отмена
+
+
+ Сессия истекла. Отправьте ссылку заново.
+
+
+ Отменено
+
\ No newline at end of file
diff --git a/TelegramBot/Handlers/ICallBackQuery/CallbackNames.cs b/TelegramBot/Handlers/ICallBackQuery/CallbackNames.cs
index 862afdc..0e602fa 100644
--- a/TelegramBot/Handlers/ICallBackQuery/CallbackNames.cs
+++ b/TelegramBot/Handlers/ICallBackQuery/CallbackNames.cs
@@ -66,4 +66,12 @@ public static class CallbackNames
public const string UserSetAutoSendVideoTimeTo = "user_set_auto_send_video_time_to:";
public const string UserSetVideoSendUsersParameterized = "user_set_video_send_users:";
public const string UserSetSiteStopList = "user_set_site_stop_list:";
+
+ // Media session parameterized callbacks
+ public const string SendToAllContactsSession = "send_to_all_contacts:";
+ public const string SendToDefaultGroupsSession = "send_to_default_groups:";
+ public const string SendToSpecifiedGroupsSession = "send_to_specified_groups:";
+ public const string SendToSpecifiedUsersSession = "send_to_specified_users:";
+ public const string SendOnlyToMeSession = "send_only_to_me:";
+ public const string CancelMediaSession = "cancel_media:";
}
diff --git a/TelegramBot/Handlers/ICallBackQuery/IMediaSessionCallbackQuery.cs b/TelegramBot/Handlers/ICallBackQuery/IMediaSessionCallbackQuery.cs
new file mode 100644
index 0000000..31675bc
--- /dev/null
+++ b/TelegramBot/Handlers/ICallBackQuery/IMediaSessionCallbackQuery.cs
@@ -0,0 +1,312 @@
+// Copyright (C) 2024-2025 ZenonEl
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+
+
+using TelegramMediaRelayBot.TelegramBot.Sessions;
+using TelegramMediaRelayBot.Database;
+using TelegramMediaRelayBot.Database.Interfaces;
+
+namespace TelegramMediaRelayBot.TelegramBot.Handlers.ICallBackQuery;
+
+
+public class SendToAllContactsSessionCommand : IBotCallbackQueryHandlers
+{
+ private readonly TGBot _tgBot;
+ private readonly IContactGetter _contactGetterRepository;
+ private readonly IUserGetter _userGetter;
+
+ public SendToAllContactsSessionCommand(
+ TGBot tgBot,
+ IContactGetter contactGetterRepository,
+ IUserGetter userGetter)
+ {
+ _tgBot = tgBot;
+ _contactGetterRepository = contactGetterRepository;
+ _userGetter = userGetter;
+ }
+
+ public string Name => "send_to_all_contacts:";
+
+ public async Task ExecuteAsync(Update update, ITelegramBotClient botClient, CancellationToken ct)
+ {
+ string sessionId = update.CallbackQuery!.Data!.Split(':')[1];
+ if (!MediaSessionManager.TryGet(sessionId, out var session) || session == null)
+ {
+ await botClient.AnswerCallbackQuery(update.CallbackQuery.Id, Config.GetResourceString("SessionExpiredMessage"), showAlert: true);
+ return;
+ }
+
+ // Cancel default action timeout
+ try { session.Cts.Cancel(); } catch (ObjectDisposedException) { }
+
+ long chatId = session.ChatId;
+ int userId = _userGetter.GetUserIDbyTelegramID(chatId);
+ List mutedByUserIds = _userGetter.GetUsersIdForMuteContactId(userId);
+ List contactUserTGIds = await _contactGetterRepository.GetAllContactUserTGIds(userId);
+ List targetUserIds = contactUserTGIds.Except(mutedByUserIds).ToList();
+
+ int messageId = int.Parse(sessionId);
+ var statusMessage = update.CallbackQuery!.Message!;
+
+ MediaSessionManager.Remove(sessionId);
+
+ await botClient.EditMessageText(
+ chatId,
+ messageId,
+ Config.GetResourceString("WaitDownloadingVideo"),
+ cancellationToken: ct
+ );
+
+ _ = _tgBot.HandleMediaRequest(botClient, session.Url, chatId, statusMessage, targetUserIds, caption: session.Caption ?? "");
+ }
+}
+
+public class SendToDefaultGroupsSessionCommand : IBotCallbackQueryHandlers
+{
+ private readonly TGBot _tgBot;
+ private readonly IUserGetter _userGetter;
+ private readonly IGroupGetter _groupGetter;
+
+ public SendToDefaultGroupsSessionCommand(
+ TGBot tgBot,
+ IUserGetter userGetter,
+ IGroupGetter groupGetter)
+ {
+ _tgBot = tgBot;
+ _userGetter = userGetter;
+ _groupGetter = groupGetter;
+ }
+
+ public string Name => "send_to_default_groups:";
+
+ public async Task ExecuteAsync(Update update, ITelegramBotClient botClient, CancellationToken ct)
+ {
+ string sessionId = update.CallbackQuery!.Data!.Split(':')[1];
+ if (!MediaSessionManager.TryGet(sessionId, out var session) || session == null)
+ {
+ await botClient.AnswerCallbackQuery(update.CallbackQuery.Id, Config.GetResourceString("SessionExpiredMessage"), showAlert: true);
+ return;
+ }
+
+ try { session.Cts.Cancel(); } catch (ObjectDisposedException) { }
+
+ long chatId = session.ChatId;
+ int userId = _userGetter.GetUserIDbyTelegramID(chatId);
+ List mutedByUserIds = _userGetter.GetUsersIdForMuteContactId(userId);
+ List userIds = await _groupGetter.GetAllUsersInDefaultEnabledGroups(userId);
+
+ List targetUserIds = userIds
+ .Where(contactId => !mutedByUserIds.Contains(_userGetter.GetTelegramIDbyUserID(contactId)))
+ .Select(_userGetter.GetTelegramIDbyUserID)
+ .ToList();
+
+ int messageId = int.Parse(sessionId);
+ var statusMessage = update.CallbackQuery!.Message!;
+
+ MediaSessionManager.Remove(sessionId);
+
+ await botClient.EditMessageText(
+ chatId,
+ messageId,
+ Config.GetResourceString("WaitDownloadingVideo"),
+ cancellationToken: ct
+ );
+
+ _ = _tgBot.HandleMediaRequest(botClient, session.Url, chatId, statusMessage, targetUserIds, caption: session.Caption ?? "");
+ }
+}
+
+public class SendOnlyToMeSessionCommand : IBotCallbackQueryHandlers
+{
+ private readonly TGBot _tgBot;
+
+ public SendOnlyToMeSessionCommand(TGBot tgBot)
+ {
+ _tgBot = tgBot;
+ }
+
+ public string Name => "send_only_to_me:";
+
+ public async Task ExecuteAsync(Update update, ITelegramBotClient botClient, CancellationToken ct)
+ {
+ string sessionId = update.CallbackQuery!.Data!.Split(':')[1];
+ if (!MediaSessionManager.TryGet(sessionId, out var session) || session == null)
+ {
+ await botClient.AnswerCallbackQuery(update.CallbackQuery.Id, Config.GetResourceString("SessionExpiredMessage"), showAlert: true);
+ return;
+ }
+
+ try { session.Cts.Cancel(); } catch (ObjectDisposedException) { }
+
+ long chatId = session.ChatId;
+ int messageId = int.Parse(sessionId);
+ var statusMessage = update.CallbackQuery!.Message!;
+
+ MediaSessionManager.Remove(sessionId);
+
+ await botClient.EditMessageText(
+ chatId,
+ messageId,
+ Config.GetResourceString("WaitDownloadingVideo"),
+ cancellationToken: ct
+ );
+
+ _ = _tgBot.HandleMediaRequest(botClient, session.Url, chatId, statusMessage, caption: session.Caption ?? "");
+ }
+}
+
+public class SendToSpecifiedGroupsSessionCommand : IBotCallbackQueryHandlers
+{
+ private readonly TGBot _tgBot;
+ private readonly IUserGetter _userGetter;
+ private readonly IGroupGetter _groupGetter;
+ private readonly IDefaultActionGetter _defaultActionGetter;
+
+ public SendToSpecifiedGroupsSessionCommand(
+ TGBot tgBot,
+ IUserGetter userGetter,
+ IGroupGetter groupGetter,
+ IDefaultActionGetter defaultActionGetter)
+ {
+ _tgBot = tgBot;
+ _userGetter = userGetter;
+ _groupGetter = groupGetter;
+ _defaultActionGetter = defaultActionGetter;
+ }
+
+ public string Name => "send_to_specified_groups:";
+
+ public async Task ExecuteAsync(Update update, ITelegramBotClient botClient, CancellationToken ct)
+ {
+ string sessionId = update.CallbackQuery!.Data!.Split(':')[1];
+ if (!MediaSessionManager.TryGet(sessionId, out var session) || session == null)
+ {
+ await botClient.AnswerCallbackQuery(update.CallbackQuery.Id, Config.GetResourceString("SessionExpiredMessage"), showAlert: true);
+ return;
+ }
+
+ try { session.Cts.Cancel(); } catch (ObjectDisposedException) { }
+
+ long chatId = session.ChatId;
+ int userId = _userGetter.GetUserIDbyTelegramID(chatId);
+ List mutedByUserIds = _userGetter.GetUsersIdForMuteContactId(userId);
+ int actionId = _defaultActionGetter.GetDefaultActionId(userId, UsersActionTypes.DEFAULT_MEDIA_DISTRIBUTION);
+ List groupIds = _defaultActionGetter.GetAllDefaultUsersActionTargets(userId, TargetTypes.GROUP, actionId);
+ List userIds = new List();
+
+ foreach (int groupId in groupIds)
+ {
+ userIds.AddRange(await _groupGetter.GetAllUsersIdsInGroup(groupId));
+ }
+
+ List targetUserIds = userIds
+ .Where(contactId => !mutedByUserIds.Contains(_userGetter.GetTelegramIDbyUserID(contactId)))
+ .Select(_userGetter.GetTelegramIDbyUserID)
+ .ToList();
+
+ int messageId = int.Parse(sessionId);
+ var statusMessage = update.CallbackQuery!.Message!;
+
+ MediaSessionManager.Remove(sessionId);
+
+ await botClient.EditMessageText(
+ chatId,
+ messageId,
+ Config.GetResourceString("WaitDownloadingVideo"),
+ cancellationToken: ct
+ );
+
+ _ = _tgBot.HandleMediaRequest(botClient, session.Url, chatId, statusMessage, targetUserIds, caption: session.Caption ?? "");
+ }
+}
+
+public class SendToSpecifiedUsersSessionCommand : IBotCallbackQueryHandlers
+{
+ private readonly TGBot _tgBot;
+ private readonly IUserGetter _userGetter;
+ private readonly IDefaultActionGetter _defaultActionGetter;
+
+ public SendToSpecifiedUsersSessionCommand(
+ TGBot tgBot,
+ IUserGetter userGetter,
+ IDefaultActionGetter defaultActionGetter)
+ {
+ _tgBot = tgBot;
+ _userGetter = userGetter;
+ _defaultActionGetter = defaultActionGetter;
+ }
+
+ public string Name => "send_to_specified_users:";
+
+ public async Task ExecuteAsync(Update update, ITelegramBotClient botClient, CancellationToken ct)
+ {
+ string sessionId = update.CallbackQuery!.Data!.Split(':')[1];
+ if (!MediaSessionManager.TryGet(sessionId, out var session) || session == null)
+ {
+ await botClient.AnswerCallbackQuery(update.CallbackQuery.Id, Config.GetResourceString("SessionExpiredMessage"), showAlert: true);
+ return;
+ }
+
+ try { session.Cts.Cancel(); } catch (ObjectDisposedException) { }
+
+ long chatId = session.ChatId;
+ int userId = _userGetter.GetUserIDbyTelegramID(chatId);
+ List mutedByUserIds = _userGetter.GetUsersIdForMuteContactId(userId);
+ int actionId = _defaultActionGetter.GetDefaultActionId(userId, UsersActionTypes.DEFAULT_MEDIA_DISTRIBUTION);
+ List userIds = _defaultActionGetter.GetAllDefaultUsersActionTargets(userId, TargetTypes.USER, actionId);
+
+ List targetUserIds = userIds
+ .Where(contactId => !mutedByUserIds.Contains(_userGetter.GetTelegramIDbyUserID(contactId)))
+ .Select(_userGetter.GetTelegramIDbyUserID)
+ .ToList();
+
+ int messageId = int.Parse(sessionId);
+ var statusMessage = update.CallbackQuery!.Message!;
+
+ MediaSessionManager.Remove(sessionId);
+
+ await botClient.EditMessageText(
+ chatId,
+ messageId,
+ Config.GetResourceString("WaitDownloadingVideo"),
+ cancellationToken: ct
+ );
+
+ _ = _tgBot.HandleMediaRequest(botClient, session.Url, chatId, statusMessage, targetUserIds, caption: session.Caption ?? "");
+ }
+}
+
+public class CancelMediaSessionCommand : IBotCallbackQueryHandlers
+{
+ public string Name => "cancel_media:";
+
+ public async Task ExecuteAsync(Update update, ITelegramBotClient botClient, CancellationToken ct)
+ {
+ string sessionId = update.CallbackQuery!.Data!.Split(':')[1];
+ if (!MediaSessionManager.TryGet(sessionId, out var session) || session == null)
+ {
+ await botClient.AnswerCallbackQuery(update.CallbackQuery.Id, Config.GetResourceString("SessionExpiredMessage"), showAlert: true);
+ return;
+ }
+
+ long chatId = session.ChatId;
+ int messageId = int.Parse(sessionId);
+
+ MediaSessionManager.Remove(sessionId);
+
+ await botClient.EditMessageText(
+ chatId,
+ messageId,
+ Config.GetResourceString("CancelledMessage"),
+ cancellationToken: ct
+ );
+ }
+}
diff --git a/TelegramBot/Handlers/PrivateUpdateHandler.cs b/TelegramBot/Handlers/PrivateUpdateHandler.cs
index e573d3b..97477f5 100644
--- a/TelegramBot/Handlers/PrivateUpdateHandler.cs
+++ b/TelegramBot/Handlers/PrivateUpdateHandler.cs
@@ -11,6 +11,7 @@
using TelegramMediaRelayBot.TelegramBot.Utils;
+using TelegramMediaRelayBot.TelegramBot.Sessions;
using TelegramMediaRelayBot.Database;
using TelegramMediaRelayBot.Database.Interfaces;
@@ -71,20 +72,21 @@ public async Task ProcessMessage(ITelegramBotClient botClient, Update update, Ca
cancellationToken: cancellationToken
);
+ string sessionId = statusMessage.MessageId.ToString();
+ string? caption = string.IsNullOrWhiteSpace(text) ? null : text;
+ MediaSessionManager.Create(sessionId, chatId, link, caption);
+
await botClient.EditMessageText(
statusMessage.Chat.Id,
statusMessage.MessageId,
Config.GetResourceString("VideoDistributionQuestion"),
- replyMarkup: KeyboardUtils.GetVideoDistributionKeyboardMarkup(),
+ replyMarkup: KeyboardUtils.GetVideoDistributionKeyboardMarkup(sessionId),
cancellationToken: cancellationToken
);
int userId = _userGetter.GetUserIDbyTelegramID(chatId);
string defaultActionData = _defaultActionGetter.GetDefaultActionByUserIDAndType(userId, UsersActionTypes.DEFAULT_MEDIA_DISTRIBUTION);
- CancellationTokenSource timeoutCTS = new CancellationTokenSource();
- UserSessionManager.Set(chatId, new ProcessVideoDC(link, statusMessage, text, timeoutCTS, _tgBot, _contactGetterRepository, _userGetter, _groupGetter));
-
if (defaultActionData == UsersAction.NO_VALUE) return;
string defaultAction = defaultActionData.Split(';')[0];
@@ -93,7 +95,7 @@ await botClient.EditMessageText(
if (defaultAction == UsersAction.OFF) return;
var privateUtils = new PrivateUtils(_tgBot, _contactGetterRepository, _defaultActionGetter, _userGetter, _groupGetter);
privateUtils.ProcessDefaultSendAction(botClient, chatId, statusMessage, defaultAction, cancellationToken,
- userId, defaultCondition, timeoutCTS, link, text);
+ userId, defaultCondition, sessionId, link, text);
}
else if (update.Message.Text == "/start")
{
diff --git a/TelegramBot/Handlers/Utils.cs b/TelegramBot/Handlers/Utils.cs
index 6643064..bbc4248 100644
--- a/TelegramBot/Handlers/Utils.cs
+++ b/TelegramBot/Handlers/Utils.cs
@@ -12,6 +12,7 @@
using TelegramMediaRelayBot.Database;
using TelegramMediaRelayBot.Database.Interfaces;
+using TelegramMediaRelayBot.TelegramBot.Sessions;
namespace TelegramMediaRelayBot.TelegramBot.Handlers;
@@ -40,14 +41,20 @@ IGroupGetter groupGetter
}
public void ProcessDefaultSendAction(ITelegramBotClient botClient, long chatId, Message statusMessage, string defaultAction,
- CancellationToken cancellationToken, int userId, int defaultCondition, CancellationTokenSource timeoutCTS,
+ CancellationToken cancellationToken, int userId, int defaultCondition, string sessionId,
string link, string text)
{
_ = Task.Run(async () =>
{
try
{
- await Task.Delay(TimeSpan.FromSeconds(defaultCondition), timeoutCTS.Token);
+ var session = MediaSessionManager.Get(sessionId);
+ if (session == null) return;
+
+ await Task.Delay(TimeSpan.FromSeconds(defaultCondition), session.Cts.Token);
+
+ // Check session still exists (not cancelled by user pressing a button)
+ if (MediaSessionManager.Get(sessionId) == null) return;
List targetUserIds = new List();
List mutedByUserIds = new List();
@@ -96,7 +103,8 @@ public void ProcessDefaultSendAction(ITelegramBotClient botClient, long chatId,
break;
}
- if (UserSessionManager.TryGetValue(chatId, out var state) && state is ProcessVideoDC videoState)
+ // Remove session before processing (user didn't press any button in time)
+ if (MediaSessionManager.Remove(sessionId))
{
await botClient.EditMessageText(
statusMessage.Chat.Id,
@@ -105,33 +113,6 @@ await botClient.EditMessageText(
cancellationToken: cancellationToken
);
_ = _tgBot.HandleMediaRequest(botClient, link, chatId, statusMessage, targetUserIds, caption: text);
-
- if (videoState.linkQueue.Count > 0)
- {
- var nextLink = videoState.linkQueue.Dequeue();
- statusMessage = await botClient.EditMessageText(
- chatId,
- nextLink.MessageId,
- Config.GetResourceString("WaitDownloadingVideo"),
- cancellationToken: cancellationToken
- );
- ProcessDefaultSendAction(
- botClient,
- chatId,
- statusMessage,
- defaultAction,
- cancellationToken,
- userId,
- defaultCondition,
- timeoutCTS,
- nextLink.Link,
- nextLink.Text
- );
- }
- else
- {
- UserSessionManager.Remove(chatId, out _);
- }
}
}
catch (TaskCanceledException) { }
diff --git a/TelegramBot/MediaDownloader.cs b/TelegramBot/MediaDownloader.cs
index e59b524..107b466 100644
--- a/TelegramBot/MediaDownloader.cs
+++ b/TelegramBot/MediaDownloader.cs
@@ -15,6 +15,7 @@
using Telegram.Bot.Types.Enums;
using TelegramMediaRelayBot.TelegramBot.Utils;
using TelegramMediaRelayBot.TelegramBot.Handlers;
+using TelegramMediaRelayBot.TelegramBot.Sessions;
using TelegramMediaRelayBot.Database.Interfaces;
using TelegramMediaRelayBot.Database;
using TelegramMediaRelayBot.TelegramBot.SiteFilter;
@@ -103,6 +104,13 @@ private async Task UpdateHandler(ITelegramBotClient botClient, Update update, Ca
if (CommonUtilities.CheckPrivateChatType(update))
{
+ // Media session callbacks are handled by the factory, not by user state
+ if (update.CallbackQuery != null && IsMediaSessionCallback(update.CallbackQuery.Data))
+ {
+ await _updateHandler.ProcessCallbackQuery(botClient, update, cancellationToken);
+ return;
+ }
+
if (UserSessionManager.ContainsKey(chatId))
{
await ProcessState(botClient, update);
@@ -376,6 +384,26 @@ public static void LogEvent(Update update, long chatId)
Log.Information($"Event: {logMessageType}, UserId: {userId}, ChatId: {chatId}, {logMessageType}: {logMessageData}, State: {currentUserStatus}");
}
+ private static readonly string[] _mediaSessionPrefixes = new[]
+ {
+ "send_to_all_contacts:",
+ "send_to_default_groups:",
+ "send_to_specified_groups:",
+ "send_to_specified_users:",
+ "send_only_to_me:",
+ "cancel_media:",
+ };
+
+ private static bool IsMediaSessionCallback(string? data)
+ {
+ if (string.IsNullOrEmpty(data)) return false;
+ foreach (var prefix in _mediaSessionPrefixes)
+ {
+ if (data.StartsWith(prefix)) return true;
+ }
+ return false;
+ }
+
[GeneratedRegex(@"[^a-zA-Zа-яА-Я0-9]")]
private static partial Regex MyRegex();
}
\ No newline at end of file
diff --git a/TelegramBot/Scheduler.cs b/TelegramBot/Scheduler.cs
index 8dd9211..aaa5986 100644
--- a/TelegramBot/Scheduler.cs
+++ b/TelegramBot/Scheduler.cs
@@ -11,6 +11,7 @@
using System.Net;
using TelegramMediaRelayBot.Database.Interfaces;
+using TelegramMediaRelayBot.TelegramBot.Sessions;
namespace TelegramMediaRelayBot.TelegramBot;
@@ -36,6 +37,7 @@ public void Init()
_unMuteTimer = new Timer(async _ => await CheckForUnmuteContacts(), null, TimeSpan.Zero, TimeSpan.FromSeconds(Config.userUnMuteCheckInterval));
if (Config.torEnabled) _torChangingChainTimer = new Timer(async _ => await TorChangingChain(), null, TimeSpan.Zero, TimeSpan.FromMinutes(Config.torChangingChainInterval));
UserSessionManager.StartCleanupTimer();
+ MediaSessionManager.StartCleanupTimer();
Log.Information("Scheduler started");
}
diff --git a/TelegramBot/Sessions/MediaSession.cs b/TelegramBot/Sessions/MediaSession.cs
new file mode 100644
index 0000000..ae581d4
--- /dev/null
+++ b/TelegramBot/Sessions/MediaSession.cs
@@ -0,0 +1,31 @@
+// Copyright (C) 2024-2025 ZenonEl
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+
+
+namespace TelegramMediaRelayBot.TelegramBot.Sessions;
+
+public class MediaSession
+{
+ public string SessionId { get; set; }
+ public long ChatId { get; set; }
+ public string Url { get; set; }
+ public string? Caption { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public CancellationTokenSource Cts { get; set; } = new();
+
+ public MediaSession(string sessionId, long chatId, string url, string? caption = null)
+ {
+ SessionId = sessionId;
+ ChatId = chatId;
+ Url = url;
+ Caption = caption;
+ }
+}
diff --git a/TelegramBot/Sessions/MediaSessionManager.cs b/TelegramBot/Sessions/MediaSessionManager.cs
new file mode 100644
index 0000000..d97c728
--- /dev/null
+++ b/TelegramBot/Sessions/MediaSessionManager.cs
@@ -0,0 +1,101 @@
+// Copyright (C) 2024-2025 ZenonEl
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+
+
+using System.Collections.Concurrent;
+
+
+namespace TelegramMediaRelayBot.TelegramBot.Sessions;
+
+public static class MediaSessionManager
+{
+ private static readonly ConcurrentDictionary _sessions = new();
+ private static Timer? _cleanupTimer;
+
+ public static void StartCleanupTimer()
+ {
+ _cleanupTimer = new Timer(
+ _ => CleanupExpired(),
+ null,
+ TimeSpan.FromMinutes(Config.sessionCleanupIntervalMinutes),
+ TimeSpan.FromMinutes(Config.sessionCleanupIntervalMinutes)
+ );
+ Log.Information("MediaSession cleanup timer started (interval: {Interval} min, TTL: {TTL} min)",
+ Config.sessionCleanupIntervalMinutes, Config.sessionTtlMinutes);
+ }
+
+ public static MediaSession Create(string sessionId, long chatId, string url, string? caption = null)
+ {
+ var session = new MediaSession(sessionId, chatId, url, caption);
+ _sessions[sessionId] = session;
+ return session;
+ }
+
+ public static MediaSession? Get(string sessionId)
+ {
+ _sessions.TryGetValue(sessionId, out var session);
+ return session;
+ }
+
+ public static bool TryGet(string sessionId, out MediaSession? session)
+ {
+ return _sessions.TryGetValue(sessionId, out session);
+ }
+
+ public static bool Remove(string sessionId)
+ {
+ if (_sessions.TryRemove(sessionId, out var session))
+ {
+ try { session.Cts.Cancel(); }
+ catch (ObjectDisposedException) { }
+ return true;
+ }
+ return false;
+ }
+
+ public static bool Remove(string sessionId, out MediaSession? session)
+ {
+ if (_sessions.TryRemove(sessionId, out session))
+ {
+ try { session.Cts.Cancel(); }
+ catch (ObjectDisposedException) { }
+ return true;
+ }
+ session = null;
+ return false;
+ }
+
+ private static void CleanupExpired()
+ {
+ var now = DateTime.UtcNow;
+ var ttl = TimeSpan.FromMinutes(Config.sessionTtlMinutes);
+ int cleaned = 0;
+
+ foreach (var kvp in _sessions)
+ {
+ if (now - kvp.Value.CreatedAt <= ttl) continue;
+
+ if (_sessions.TryRemove(kvp.Key, out var session))
+ {
+ try { session.Cts.Cancel(); }
+ catch (ObjectDisposedException) { }
+ cleaned++;
+ Log.Debug("Expired media session removed: {SessionId}", kvp.Key);
+ }
+ }
+
+ if (cleaned > 0)
+ {
+ Log.Information("MediaSession cleanup: removed {Count} expired session(s), {Remaining} active",
+ cleaned, _sessions.Count);
+ }
+ }
+}
diff --git a/TelegramBot/Utils/KeyboardUtils.cs b/TelegramBot/Utils/KeyboardUtils.cs
index be83501..9d2b0fb 100644
--- a/TelegramBot/Utils/KeyboardUtils.cs
+++ b/TelegramBot/Utils/KeyboardUtils.cs
@@ -113,29 +113,30 @@ public static Task SendInlineKeyboardMenu(ITelegramBotClient botClient, Update u
return CommonUtilities.SendMessage(botClient, update, inlineKeyboard, cancellationToken, text);
}
- public static InlineKeyboardMarkup GetVideoDistributionKeyboardMarkup()
-{
- var inlineKeyboard = new InlineKeyboardMarkup(new[]
- {
- new[]
- {
- InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendToAllContactsButtonText"), "send_to_all_contacts"),
- InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendToDefaultGroupsButtonText"), "send_to_default_groups"),
- },
- new[]
- {
- InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendToSpecifiedGroupsButtonText"), "send_to_specified_groups"),
- InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendToSpecifiedUsersButtonText"), "send_to_specified_users"),
- },
- new[]
- {
- InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendOnlyToMeButtonText"), "send_only_to_me"),
- },
- new[]
+ public static InlineKeyboardMarkup GetVideoDistributionKeyboardMarkup(string? sessionId = null)
+ {
+ string suffix = sessionId != null ? $":{sessionId}" : "";
+ var inlineKeyboard = new InlineKeyboardMarkup(new[]
{
- GetReturnButton()
- },
- });
- return inlineKeyboard;
-}
+ new[]
+ {
+ InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendToAllContactsButtonText"), $"send_to_all_contacts{suffix}"),
+ InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendToDefaultGroupsButtonText"), $"send_to_default_groups{suffix}"),
+ },
+ new[]
+ {
+ InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendToSpecifiedGroupsButtonText"), $"send_to_specified_groups{suffix}"),
+ InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendToSpecifiedUsersButtonText"), $"send_to_specified_users{suffix}"),
+ },
+ new[]
+ {
+ InlineKeyboardButton.WithCallbackData(Config.GetResourceString("SendOnlyToMeButtonText"), $"send_only_to_me{suffix}"),
+ },
+ new[]
+ {
+ InlineKeyboardButton.WithCallbackData(Config.GetResourceString("CancelButtonText"), $"cancel_media{suffix}"),
+ },
+ });
+ return inlineKeyboard;
+ }
}
\ No newline at end of file