Files
Playa/hosts/Volcanic.AgenticCore.Telegram/Inbound/InboxFiles.cs
EugeneTes a0cd2975b8 feat(telegram): save documents, videos and audio to the chat inbox
Content step 2 (InboundMedia.AddFilesAsync, InboxFiles): files land in <cwd>/inbox/<date>-<safe name>[-N], image documents within Telegram:InlineImageMaxBytes are attached inline, oversize files and failed downloads get a notice and no run. Documents, videos, audio and voice are accepted; the unsupported reply is now 'I can read text, photos, files and voice messages.'

Spec: Updates helpers are named DocumentMessage/VideoMessage/VoiceMessage/AudioMessage; the getFile 'file is too big' notice reads 'over 20 MB'; the too-large inline note is a parenthesised suffix after the size; size formats spelled out.
2026-09-26 22:09:16 +02:00

66 lines
3.1 KiB
C#

using System.Globalization;
using System.Text;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>Where received files go: <c>&lt;workspace&gt;/inbox/&lt;yyyy-MM-dd&gt;-&lt;safe name&gt;</c>, never overwriting.</summary>
public static class InboxFiles
{
/// <summary>The inbox directory under the chat's workspace.</summary>
public const string DirectoryName = "inbox";
/// <summary>Longest stem kept from a received file name, in characters.</summary>
public const int MaxStemLength = 80;
/// <summary>Longest extension kept, dot included.</summary>
public const int MaxExtensionLength = 16;
/// <summary>
/// Drops any directory part, replaces everything but Unicode letters, digits, <c>.</c>, <c>-</c> and <c>_</c> with
/// <c>_</c>, trims leading dots and underscores, caps stem (80) and extension (16, dot included); <c>file</c> when empty.
/// </summary>
public static string SafeName(string? fileName)
{
var name = Path.GetFileName((fileName ?? "").Replace('\\', '/'));
var cleaned = new StringBuilder(name.Length);
foreach (var rune in name.EnumerateRunes())
cleaned.Append(Rune.IsLetterOrDigit(rune) || rune.Value is '.' or '-' or '_' ? rune.ToString() : "_");
var safe = cleaned.ToString().TrimStart('.', '_');
var extension = Path.GetExtension(safe);
var stem = extension.Length == 0 ? safe : safe[..^extension.Length];
extension = Cap(extension, MaxExtensionLength);
stem = Cap(stem, MaxStemLength);
if (stem.Length == 0) stem = "file";
return stem + extension;
}
/// <summary>Creates the next free <c>inbox/&lt;date&gt;-&lt;stem&gt;[-N]&lt;ext&gt;</c> with <see cref="FileMode.CreateNew"/>.</summary>
public static (string Path, FileStream Stream) Create(string workspace, DateTimeOffset localNow, string? fileName)
{
var directory = Path.Combine(workspace, DirectoryName);
Directory.CreateDirectory(directory);
var safe = SafeName(fileName);
var extension = Path.GetExtension(safe);
var prefix = localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + "-" + safe[..^extension.Length];
for (var n = 0; ; n++)
{
var path = Path.Combine(directory, prefix + (n == 0 ? "" : "-" + n.ToString(CultureInfo.InvariantCulture)) + extension);
try
{
return (path, new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, useAsync: true));
}
catch (IOException) when (File.Exists(path) && n < 10_000)
{
// taken: try the next suffix
}
}
}
/// <summary>Workspace-relative with <c>/</c>, e.g. <c>inbox/2026-09-26-report.pdf</c>.</summary>
public static string Relative(string workspace, string path) => Path.GetRelativePath(workspace, path).Replace('\\', '/');
/// <summary>At most <paramref name="max"/> characters, never splitting a surrogate pair.</summary>
private static string Cap(string text, int max) =>
text.Length <= max ? text : text[..(char.IsHighSurrogate(text[max - 1]) ? max - 1 : max)];
}