Fix voice message forwarding and playback
This commit is contained in:
@@ -680,6 +680,8 @@ public sealed class ChatsController(
|
||||
Direction = MessageDirection.Outgoing,
|
||||
Text = source.Text,
|
||||
ForwardedFrom = ResolveForwardedFrom(source),
|
||||
ForwardedFromExternalChatId = source.ExternalId is null ? null : source.Chat?.ExternalId,
|
||||
ForwardedFromExternalMessageId = source.ExternalId,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
DeliveryState = MessageDeliveryState.Sending,
|
||||
SenderName = "You"
|
||||
|
||||
@@ -15,6 +15,8 @@ public sealed class Message
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
public Guid? ReplyToMessageId { get; set; }
|
||||
public string? ForwardedFrom { get; set; }
|
||||
public string? ForwardedFromExternalChatId { get; set; }
|
||||
public string? ForwardedFromExternalMessageId { get; set; }
|
||||
public MessageDeliveryState DeliveryState { get; set; } = MessageDeliveryState.Sent;
|
||||
public string? Error { get; set; }
|
||||
public string? MediaAlbumId { get; set; }
|
||||
|
||||
@@ -80,7 +80,24 @@ public static class QMaxDatabaseCleanup
|
||||
WHERE message.Direction = 'Outgoing'
|
||||
AND localAttachment.Id <> remoteAttachment.Id
|
||||
AND localAttachment.SortOrder = remoteAttachment.SortOrder
|
||||
AND localAttachment.Kind = remoteAttachment.Kind
|
||||
AND (
|
||||
localAttachment.Kind = remoteAttachment.Kind OR
|
||||
(
|
||||
localAttachment.Kind = 'VoiceNote'
|
||||
AND remoteAttachment.Kind = 'File'
|
||||
AND localAttachment.FileSizeBytes = remoteAttachment.FileSizeBytes
|
||||
AND (
|
||||
lower(remoteAttachment.ContentType) LIKE 'audio/%' OR
|
||||
lower(remoteAttachment.OriginalFileName) LIKE '%.m4a' OR
|
||||
lower(remoteAttachment.OriginalFileName) LIKE '%.ogg' OR
|
||||
lower(remoteAttachment.OriginalFileName) LIKE '%.opus' OR
|
||||
lower(remoteAttachment.OriginalFileName) LIKE '%.aac' OR
|
||||
lower(remoteAttachment.OriginalFileName) LIKE '%.mp3' OR
|
||||
lower(remoteAttachment.OriginalFileName) LIKE '%.wav' OR
|
||||
lower(remoteAttachment.OriginalFileName) LIKE '%.flac'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (localAttachment.ExternalId IS NULL OR trim(localAttachment.ExternalId) = '')
|
||||
AND (localAttachment.RemoteUrl IS NULL OR trim(localAttachment.RemoteUrl) = '')
|
||||
AND localAttachment.StorageFileName NOT LIKE 'remote-%'
|
||||
|
||||
@@ -32,6 +32,14 @@ public interface IMaxBridgeClient
|
||||
Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||
Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken);
|
||||
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken);
|
||||
Task<MaxSendResult> ForwardMessageAsync(
|
||||
string externalChatId,
|
||||
string sourceExternalChatId,
|
||||
string sourceExternalMessageId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxSendResult(false, null, "Native forwarding is not supported."));
|
||||
}
|
||||
Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken);
|
||||
Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
|
||||
|
||||
@@ -106,6 +106,15 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
||||
return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId)));
|
||||
}
|
||||
|
||||
public Task<MaxSendResult> ForwardMessageAsync(
|
||||
string externalChatId,
|
||||
string sourceExternalChatId,
|
||||
string sourceExternalMessageId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxSendResult(true, $"mock-forward-{Guid.NewGuid():N}", null, MockChatUrl(externalChatId)));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
|
||||
@@ -132,6 +132,20 @@ public sealed class WorkerMaxBridgeClient(
|
||||
?? new MaxSendResult(false, null, "Worker returned an empty attachment result.");
|
||||
}
|
||||
|
||||
public async Task<MaxSendResult> ForwardMessageAsync(
|
||||
string externalChatId,
|
||||
string sourceExternalChatId,
|
||||
string sourceExternalMessageId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxSendResult>(
|
||||
HttpMethod.Post,
|
||||
"/message/forward",
|
||||
new { externalChatId, sourceExternalChatId, sourceExternalMessageId },
|
||||
cancellationToken)
|
||||
?? new MaxSendResult(false, null, "Worker returned an empty forward result.");
|
||||
}
|
||||
|
||||
public async Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxActionResult>(
|
||||
|
||||
@@ -128,8 +128,9 @@ public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IA
|
||||
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
||||
}
|
||||
|
||||
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||
var resolvedKind = preferredKind ?? GuessKind(resolvedContentType, extension);
|
||||
var declaredContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||
var resolvedKind = preferredKind ?? GuessKind(declaredContentType, extension);
|
||||
var resolvedContentType = NormalizeContentType(declaredContentType, safeOriginalName, resolvedKind);
|
||||
var hash = await ComputeSha256Async(partPath, cancellationToken);
|
||||
File.Move(partPath, finalPath, false);
|
||||
|
||||
@@ -167,6 +168,32 @@ public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IA
|
||||
return AttachmentKind.File;
|
||||
}
|
||||
|
||||
public static string NormalizeContentType(string? contentType, string? fileName, AttachmentKind kind)
|
||||
{
|
||||
var declared = contentType?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(declared) &&
|
||||
!declared.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase) &&
|
||||
!declared.Equals("binary/octet-stream", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return declared;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(fileName ?? "").ToLowerInvariant();
|
||||
var inferred = extension switch
|
||||
{
|
||||
".m4a" => "audio/mp4",
|
||||
".ogg" or ".oga" => "audio/ogg",
|
||||
".opus" => "audio/opus",
|
||||
".aac" => "audio/aac",
|
||||
".mp3" => "audio/mpeg",
|
||||
".wav" => "audio/wav",
|
||||
".flac" => "audio/flac",
|
||||
_ => null
|
||||
};
|
||||
|
||||
return inferred ?? (kind == AttachmentKind.VoiceNote ? "audio/ogg" : declared ?? "application/octet-stream");
|
||||
}
|
||||
|
||||
public static string NormalizeRemoteFileName(string? fileName, string? contentType, AttachmentKind? preferredKind)
|
||||
{
|
||||
var original = string.IsNullOrWhiteSpace(fileName) ? "" : Path.GetFileName(fileName.Trim());
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Data;
|
||||
using QMax.Api.Data.Entities;
|
||||
using QMax.Api.Infrastructure.Auth;
|
||||
using QMax.Api.Infrastructure.Hubs;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
@@ -234,6 +235,27 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionError TEXT;");
|
||||
}
|
||||
|
||||
var messageColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA table_info(Messages);";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
messageColumns.Add(reader.GetString(1));
|
||||
}
|
||||
}
|
||||
|
||||
if (!messageColumns.Contains("ForwardedFromExternalChatId"))
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Messages ADD COLUMN ForwardedFromExternalChatId TEXT;");
|
||||
}
|
||||
|
||||
if (!messageColumns.Contains("ForwardedFromExternalMessageId"))
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Messages ADD COLUMN ForwardedFromExternalMessageId TEXT;");
|
||||
}
|
||||
|
||||
var attachmentColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
@@ -385,6 +407,22 @@ static async Task RemoveGenericMediaLabelsAsync(QMaxDbContext db)
|
||||
var attachments = await db.MessageAttachments.ToListAsync();
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
if (attachment.Kind == AttachmentKind.File)
|
||||
{
|
||||
var inferredKind = AttachmentStorageService.GuessKind(
|
||||
attachment.ContentType,
|
||||
Path.GetExtension(attachment.OriginalFileName));
|
||||
if (inferredKind != AttachmentKind.File)
|
||||
{
|
||||
attachment.Kind = inferredKind;
|
||||
}
|
||||
}
|
||||
|
||||
attachment.ContentType = AttachmentStorageService.NormalizeContentType(
|
||||
attachment.ContentType,
|
||||
attachment.OriginalFileName,
|
||||
attachment.Kind);
|
||||
|
||||
var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(
|
||||
attachment.OriginalFileName,
|
||||
attachment.ContentType,
|
||||
|
||||
@@ -409,7 +409,13 @@ public sealed class MaxBridgeSyncService(
|
||||
|
||||
foreach (var incomingAttachment in DistinctAttachments(incoming.Attachments ?? []))
|
||||
{
|
||||
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
|
||||
var attachment = await CreateAttachmentAsync(
|
||||
db,
|
||||
incoming,
|
||||
incomingAttachment,
|
||||
maxBridgeClient,
|
||||
storage,
|
||||
cancellationToken);
|
||||
if (attachment is not null)
|
||||
{
|
||||
message.Attachments.Add(attachment);
|
||||
@@ -1024,6 +1030,35 @@ public sealed class MaxBridgeSyncService(
|
||||
|
||||
foreach (var incomingAttachment in incomingAttachments)
|
||||
{
|
||||
var existingAttachment = FindMatchingAttachment(knownAttachments, incomingAttachment);
|
||||
if (existingAttachment is not null)
|
||||
{
|
||||
if (IsUncachedRemotePlaceholder(existingAttachment) &&
|
||||
RequiresCachedRemoteMedia(existingAttachment.Kind))
|
||||
{
|
||||
var hydrated = await TryHydrateRemoteAttachmentAsync(
|
||||
existingAttachment,
|
||||
incomingAttachment,
|
||||
maxBridgeClient,
|
||||
storage,
|
||||
cancellationToken);
|
||||
if (!hydrated)
|
||||
{
|
||||
hydrated = await TryHydrateFromCachedOutgoingAsync(
|
||||
db,
|
||||
existingAttachment,
|
||||
incoming,
|
||||
incomingAttachment,
|
||||
storage,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
changed |= hydrated;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryMergeOutgoingRemoteEchoIntoLocalAttachment(
|
||||
message,
|
||||
incoming,
|
||||
@@ -1035,20 +1070,13 @@ public sealed class MaxBridgeSyncService(
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingAttachment = FindMatchingAttachment(knownAttachments, incomingAttachment);
|
||||
if (existingAttachment is not null)
|
||||
{
|
||||
if (IsUncachedRemotePlaceholder(existingAttachment) &&
|
||||
RequiresCachedRemoteMedia(existingAttachment.Kind) &&
|
||||
await TryHydrateRemoteAttachmentAsync(existingAttachment, incomingAttachment, maxBridgeClient, storage, cancellationToken))
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
|
||||
var attachment = await CreateAttachmentAsync(
|
||||
db,
|
||||
incoming,
|
||||
incomingAttachment,
|
||||
maxBridgeClient,
|
||||
storage,
|
||||
cancellationToken);
|
||||
if (attachment is null)
|
||||
{
|
||||
continue;
|
||||
@@ -1148,6 +1176,8 @@ public sealed class MaxBridgeSyncService(
|
||||
}
|
||||
|
||||
private static async Task<MessageAttachment?> CreateAttachmentAsync(
|
||||
QMaxDbContext db,
|
||||
MaxMessageUpdate incomingMessage,
|
||||
MaxAttachmentUpdate incoming,
|
||||
IMaxBridgeClient maxBridgeClient,
|
||||
IAttachmentStorageService storage,
|
||||
@@ -1221,20 +1251,62 @@ public sealed class MaxBridgeSyncService(
|
||||
// Non-visual files can stay as deferred downloads; inline media must be cached before it is shown.
|
||||
}
|
||||
|
||||
var cachedAttachment = CreateRemotePlaceholder(incoming, kind, remoteUrl);
|
||||
if (await TryHydrateFromCachedOutgoingAsync(
|
||||
db,
|
||||
cachedAttachment,
|
||||
incomingMessage,
|
||||
incoming,
|
||||
storage,
|
||||
cancellationToken))
|
||||
{
|
||||
return cachedAttachment;
|
||||
}
|
||||
|
||||
if (RequiresCachedRemoteMedia(kind))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return cachedAttachment;
|
||||
}
|
||||
|
||||
var placeholder = CreateRemotePlaceholder(incoming, kind, remoteUrl);
|
||||
if (await TryHydrateFromCachedOutgoingAsync(
|
||||
db,
|
||||
placeholder,
|
||||
incomingMessage,
|
||||
incoming,
|
||||
storage,
|
||||
cancellationToken))
|
||||
{
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
private static MessageAttachment CreateRemotePlaceholder(
|
||||
MaxAttachmentUpdate incoming,
|
||||
AttachmentKind? kind,
|
||||
string? remoteUrl)
|
||||
{
|
||||
var resolvedKind = kind ?? AttachmentKind.File;
|
||||
var originalFileName = AttachmentStorageService.NormalizeRemoteFileName(
|
||||
incoming.FileName,
|
||||
incoming.ContentType,
|
||||
resolvedKind);
|
||||
return new MessageAttachment
|
||||
{
|
||||
ExternalId = incoming.ExternalId,
|
||||
OriginalFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind),
|
||||
OriginalFileName = originalFileName,
|
||||
StorageFileName = $"remote-{Guid.NewGuid():N}",
|
||||
ContentType = string.IsNullOrWhiteSpace(incoming.ContentType) ? "application/octet-stream" : incoming.ContentType,
|
||||
ContentType = AttachmentStorageService.NormalizeContentType(
|
||||
incoming.ContentType,
|
||||
originalFileName,
|
||||
resolvedKind),
|
||||
FileSizeBytes = incoming.FileSizeBytes ?? 0,
|
||||
Kind = kind ?? AttachmentKind.File,
|
||||
Kind = resolvedKind,
|
||||
SortOrder = incoming.SortOrder,
|
||||
RemoteUrl = remoteUrl
|
||||
};
|
||||
@@ -1281,11 +1353,93 @@ public sealed class MaxBridgeSyncService(
|
||||
|
||||
private static bool IsUncachedRemotePlaceholder(MessageAttachment attachment)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(attachment.RemoteUrl) &&
|
||||
attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) &&
|
||||
return attachment.StorageFileName.StartsWith("remote-", StringComparison.Ordinal) &&
|
||||
(attachment.FileSizeBytes <= 0 || string.IsNullOrWhiteSpace(attachment.Sha256));
|
||||
}
|
||||
|
||||
private static async Task<bool> TryHydrateFromCachedOutgoingAsync(
|
||||
QMaxDbContext db,
|
||||
MessageAttachment attachment,
|
||||
MaxMessageUpdate incomingMessage,
|
||||
MaxAttachmentUpdate incomingAttachment,
|
||||
IAttachmentStorageService storage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(incomingMessage.ExternalId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var kind = ResolveKind(incomingAttachment) ?? attachment.Kind;
|
||||
var expectedSize = incomingAttachment.FileSizeBytes.GetValueOrDefault();
|
||||
var messages = await db.Messages
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Attachments)
|
||||
.Where(x =>
|
||||
x.ExternalId == incomingMessage.ExternalId &&
|
||||
x.Direction == MessageDirection.Outgoing &&
|
||||
x.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var cached = messages
|
||||
.SelectMany(x => x.Attachments)
|
||||
.Where(x =>
|
||||
x.Kind == kind &&
|
||||
x.SortOrder == incomingAttachment.SortOrder &&
|
||||
(string.IsNullOrWhiteSpace(incomingAttachment.ExternalId) ||
|
||||
x.ExternalId == incomingAttachment.ExternalId) &&
|
||||
(expectedSize <= 0 || x.FileSizeBytes == expectedSize) &&
|
||||
x.FileSizeBytes > 0 &&
|
||||
!string.IsNullOrWhiteSpace(x.Sha256) &&
|
||||
!x.StorageFileName.StartsWith("remote-", StringComparison.Ordinal))
|
||||
.Select(x => new { Attachment = x, Path = storage.GetPath(x.StorageFileName) })
|
||||
.FirstOrDefault(x =>
|
||||
File.Exists(x.Path) &&
|
||||
new FileInfo(x.Path).Length == x.Attachment.FileSizeBytes);
|
||||
if (cached is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var source = new FileStream(
|
||||
cached.Path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
128 * 1024,
|
||||
useAsync: true);
|
||||
var stored = await storage.SaveRemoteAsync(
|
||||
incomingAttachment.FileName,
|
||||
cached.Attachment.ContentType,
|
||||
source,
|
||||
expectedSize > 0 ? expectedSize : cached.Attachment.FileSizeBytes,
|
||||
kind,
|
||||
cancellationToken);
|
||||
|
||||
attachment.ExternalId = string.IsNullOrWhiteSpace(incomingAttachment.ExternalId)
|
||||
? attachment.ExternalId
|
||||
: incomingAttachment.ExternalId;
|
||||
attachment.OriginalFileName = stored.OriginalFileName;
|
||||
attachment.StorageFileName = stored.StorageFileName;
|
||||
attachment.ContentType = stored.ContentType;
|
||||
attachment.FileSizeBytes = stored.FileSizeBytes;
|
||||
attachment.Sha256 = stored.Sha256;
|
||||
attachment.Kind = stored.Kind;
|
||||
attachment.SortOrder = incomingAttachment.SortOrder;
|
||||
attachment.RemoteUrl = string.IsNullOrWhiteSpace(incomingAttachment.RemoteUrl)
|
||||
? attachment.RemoteUrl
|
||||
: incomingAttachment.RemoteUrl;
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> TryHydrateRemoteAttachmentAsync(
|
||||
MessageAttachment attachment,
|
||||
MaxAttachmentUpdate incoming,
|
||||
@@ -1342,7 +1496,15 @@ public sealed class MaxBridgeSyncService(
|
||||
if (!string.IsNullOrWhiteSpace(attachment.Kind) &&
|
||||
Enum.TryParse<AttachmentKind>(attachment.Kind, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
if (parsed != AttachmentKind.File)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
var inferred = AttachmentStorageService.GuessKind(
|
||||
attachment.ContentType,
|
||||
Path.GetExtension(attachment.FileName));
|
||||
return inferred == AttachmentKind.File ? parsed : inferred;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(attachment.FileName);
|
||||
|
||||
@@ -278,6 +278,16 @@ public sealed class MaxOutboxService(
|
||||
{
|
||||
var externalChatId = chat.ExternalId!;
|
||||
var chatUrl = chat.WebUrl;
|
||||
if (!string.IsNullOrWhiteSpace(message.ForwardedFromExternalChatId) &&
|
||||
!string.IsNullOrWhiteSpace(message.ForwardedFromExternalMessageId))
|
||||
{
|
||||
return await maxBridge.ForwardMessageAsync(
|
||||
externalChatId,
|
||||
message.ForwardedFromExternalChatId,
|
||||
message.ForwardedFromExternalMessageId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var attachments = message.Attachments.OrderBy(attachment => attachment.SortOrder).ToArray();
|
||||
if (attachments.Length == 0)
|
||||
{
|
||||
|
||||
@@ -46,7 +46,9 @@ public static class MessageTextSanitizer
|
||||
"\u0430\u0443\u0434\u0438\u043e" => true,
|
||||
"audio" => true,
|
||||
"\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435" => true,
|
||||
"\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" => true,
|
||||
"voice" => true,
|
||||
"voice message" => true,
|
||||
"\u0441\u0442\u0438\u043a\u0435\u0440" => true,
|
||||
"sticker" => true,
|
||||
"\u044d\u043c\u043e\u0434\u0437\u0438" => true,
|
||||
|
||||
Reference in New Issue
Block a user