forked from admin/VolcanicAgenticCore
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.
66 lines
3.1 KiB
C#
66 lines
3.1 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
|
|
namespace Volcanic.AgenticCore.Telegram.Inbound;
|
|
|
|
/// <summary>Where received files go: <c><workspace>/inbox/<yyyy-MM-dd>-<safe name></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/<date>-<stem>[-N]<ext></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)];
|
|
}
|