Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 21.
В предыдущей статье мы разобрали историю изменений задач. Незадолго до неё в веб-приложении появилась возможность прикреплять к задачам картинки.
Краткое содержание
Как мы сделали вложения в веб-приложении:
- Файлы, которые пользователь загружает в веб-приложении, бот отправляет в отдельный чат Telegram. Оригинал остаётся в Telegram, а маленькое превью сохраняется в локальное хранилище.
- Так мы смогли отложить настройку S3 и пока бесплатно хранить файлы в Telegram, используя тот же путь, что и для медиа из бота.
- Файлы из бота и из веба теперь описывает одна модель: вложение, файл и ссылка на файл в Telegram.
- В первой версии можно загружать только JPEG и PNG размером до 3 МБ.
- В ближайшее время мы перейдём на S3, чтобы не зависеть от правил и инфраструктуры Telegram.
Откуда взялась идея
В части 11 мы разбирали, как бот сохраняет фото и видео из сообщений. Файлы при этом не копируются к нам целиком: маленькое превью мы храним в локальном хранилище, а оригинал по запросу стримим прямо из Telegram.
Когда пришло время загружать картинки из веб-приложения, у нас уже был готовый путь для файлов, которые лежат в Telegram. Оставалось сделать так, чтобы файл из браузера тоже оказался в Telegram. Тогда настройку S3 можно отложить, файлы пока бесплатно хранить в Telegram, а показывать их так же, как медиа из бота.
Как файл из браузера попадает в Telegram
Для загруженных файлов у бота есть отдельный чат, его идентификатор хранится в настройках:
public class TelegramOptions
{
/// <summary>
/// Telegram bot token.
/// </summary>
public required string Token { get; set; }
/// <summary>
/// The chat to store uploaded files originals.
/// </summary>
public required long FilesChatId { get; set; }
}
Чат мы создали вручную: добавили в него бота, сделали его администратором, а потом сами из чата вышли. Теперь в чате остался только бот, и доступ к загруженным файлам есть только у него — у людей, включая нас, его нет.
Когда пользователь прикрепляет картинку к задаче, бэкенд отправляет её в этот чат как обычное фото. Telegram сам сохраняет оригинал и делает несколько уменьшенных копий. Из ответа мы берём самую маленькую копию как превью и самую большую как оригинал:
var message = await botClient.SendPhoto(
options.Value.FilesChatId,
new InputFileStream(stream, fileName),
cancellationToken: cancellationToken);
var thumbnailPhoto = message.Photo![0];
var thumbnail = ToFile(thumbnailPhoto, fileName, contentType);
var originalPhoto = message.Photo![3];
var original = ToFile(originalPhoto, fileName, contentType);
// превью сохраняем в локальное хранилище
await DownloadToLocalStorage(thumbnail.FileId, contentType, cancellationToken);
// идентификаторы файлов — в базу
var thumbnailFileId = await UpsertDbFile(thumbnail, cancellationToken);
var originalFileId = await UpsertDbFile(original, cancellationToken);
Генерировать превью самим не пришлось: уменьшенные копии делает Telegram. Дальше всё работает так же, как с медиа из бота: превью отдаётся из локального хранилища, а оригинал стримится из Telegram, когда пользователь его открывает.
Если такой файл уже есть в базе (у Telegram для этого есть уникальный идентификатор файла), новая запись не создаётся, а используется существующая.
Одна модель для файлов из бота и из веба
До этого у медиа из Telegram были свои таблицы: отдельно для фото и для видео из сообщений. Файлы из веба в них не вписывались, поэтому мы переделали модель так, чтобы она не зависела от того, откуда пришёл файл.
Файл — это просто файл: имя, размер и MIME-тип. Где он хранится, файл не знает:
public class File
{
public Guid Id { get; set; }
public long? Size { get; set; }
[MaxLength(255)]
public string? Name { get; set; }
[MaxLength(32)]
public required string? MimeType { get; set; }
public TelegramFile? TelegramFile { get; set; }
}
Где лежит файл, описывает отдельная таблица. Сейчас это только Telegram:
public class TelegramFile
{
public long Id { get; set; }
public Guid FileId { get; set; }
public File? File { get; set; }
[MaxLength(255)]
public required string ExternalFileId { get; set; }
[MaxLength(64)]
public required string ExternalFileUniqueId { get; set; }
}
Вложение — то, что видит пользователь: картинка или видео, у которых есть превью и оригинал:
public class Attachment
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; }
public Guid OwnerId { get; set; }
public User? Owner { get; set; }
public Guid? PreviewFileId { get; set; }
public File? PreviewFile { get; set; }
public Guid FileId { get; set; }
public File? File { get; set; }
public AttachmentType Type { get; set; }
}
Вложение привязывается к задаче через отдельную таблицу IssueAttachment. Задача ничего не знает о том, пришла картинка из Telegram-сообщения или из браузера.
Когда мы перейдём на S3, рядом с TelegramFile появится таблица для файлов в S3, а модель вложений и всё, что на неё опирается, останется прежней.
Проверка загрузок
В первой версии мы ограничили загрузки: только JPEG и PNG и не больше 3 МБ на файл. Это не ограничение Telegram, а наш предел для первой итерации. Проверка выполняется в сервисе хоста ещё до того, как файл уходит в Telegram:
private static bool FilesHasError(IEnumerable<IFormFile> files, [NotNullWhen(true)] out string? error)
{
foreach (var file in files)
{
if (file.Length > 3_000_000)
{
error = "File size is limited to 3MB";
return true;
}
if (!SystemMimeTypes.Supported.Contains(file.ContentType))
{
error = $"Supported mime types are: {string.Join(", ", SystemMimeTypes.Supported)}";
return true;
}
}
error = null;
return false;
}
Файлы приходят в обычном запросе multipart/form-data вместе с текстом задачи, поэтому задачу с картинками можно создать или изменить одним запросом.
Чем рискуем
Хранение файлов в Telegram работает, но мы рассматриваем его как временное. Мы зависим от правил Telegram и его инфраструктуры: если что-то изменится, файлы могут стать недоступны. Пока нагрузка небольшая — в день загружается всего несколько файлов, — и это оправданно. Следующий шаг — перейти на S3 и перенести туда все файлы. Благодаря общей модели вложений для этого понадобится новое место хранения, а не переделка всего, что работает с файлами.
Итоги
- Telegram как временное бесплатное хранилище. Файлы из веб-приложения бот отправляет в отдельный чат, а превью для них делает сам Telegram.
- Один путь для всех файлов. Файлы из бота и из веба хранятся и показываются одинаково.
- Модель не зависит от места хранения. Файл, место его хранения и вложение — это разные таблицы.
- Ограничения первой итерации. Только JPEG и PNG до 3 МБ.
- Решение временное. В ближайшее время перейдём на S3 и перенесём туда все файлы.
Что дальше
Почти одновременно с вложениями у задач появились комментарии, и у них тоже есть вложения. О комментариях — следующая статья.