feat(telegram): reply, forward and timestamp header text

This commit is contained in:
EugeneTes
2026-09-26 19:51:36 +02:00
parent 651924544a
commit d8c698b326
2 changed files with 324 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
using System.Globalization;
using System.Text;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>
/// The header lines the agent reads before a message: what it replies to or quotes, where a forward came from, and the
/// local time it was sent. Wording matches the TypeScript reference bot, so both read the same to the model.
/// </summary>
public static class MessageContext
{
/// <summary>Quoted or replied text is cut to this many Unicode scalars (the last one being <c>…</c>).</summary>
public const int MaxQuoted = 500;
/// <summary>
/// <c>[Reply to …: "…"]</c> or <c>[Reply quoting …: "…"]</c> for a reply, a quote or an external reply; null when the
/// message replies to nothing. Never throws: an unknown shape is described as <c>(message)</c>.
/// </summary>
public static string? ReplyHeader(Message message, long botId)
{
ArgumentNullException.ThrowIfNull(message);
var quote = message.Quote?.Text is { Length: > 0 } q ? Truncate(q) : null;
if (message.ExternalReply is { } external)
{
var origin = external.Origin is { } o ? ForwardLabel(o) : "unknown";
return quote is not null
? $"[Reply quoting a message from {origin}: \"{quote}\"]"
: $"[Reply to a message from {origin}: \"{Truncate(Describe(external))}\"]";
}
if (message.ReplyToMessage is not { } replied) return null;
var who = replied.From is null ? "the earlier message"
: replied.From.Id == botId ? "your message"
: "your own earlier message"; // private chats only, so any other sender is the user
return quote is not null
? $"[Reply quoting {who}: \"{quote}\"]"
: $"[Reply to {who}: \"{Truncate(Describe(replied))}\"]";
}
/// <summary>Who a forward (or an external reply) came from, without the brackets.</summary>
public static string ForwardLabel(MessageOrigin origin) => origin switch
{
MessageOriginUser { SenderUser: { } user } => UserLabel(user),
MessageOriginUser => "unknown user",
MessageOriginHiddenUser hidden => hidden.SenderUserName is { Length: > 0 } n ? n : "hidden user",
MessageOriginChat chat => WithSignature(Named(chat.SenderChat?.Title, chat.SenderChat?.Username, "chat"), chat.AuthorSignature),
MessageOriginChannel channel => WithSignature(Named(channel.Chat?.Title, channel.Chat?.Username, "channel"), channel.AuthorSignature),
_ => "unknown",
};
/// <summary>The replied message's text, else its caption, else a media descriptor such as <c>(photo)</c>.</summary>
public static string Describe(Message replied)
{
ArgumentNullException.ThrowIfNull(replied);
return replied.Text is { Length: > 0 } text ? text
: replied.Caption is { Length: > 0 } caption ? caption
: Media(replied.Photo, replied.Video, replied.Voice, replied.VideoNote, replied.Audio, replied.Document, replied.Sticker);
}
internal static string Describe(ExternalReplyInfo external) =>
Media(external.Photo, external.Video, external.Voice, external.VideoNote, external.Audio, external.Document, external.Sticker);
/// <summary>Cut to <paramref name="max"/> Unicode scalars (the last one <c>…</c>), never splitting a surrogate pair.</summary>
public static string Truncate(string text, int max = MaxQuoted)
{
ArgumentNullException.ThrowIfNull(text);
ArgumentOutOfRangeException.ThrowIfLessThan(max, 1);
var runes = text.EnumerateRunes().Take(max + 1).ToList();
if (runes.Count <= max) return text;
var sb = new StringBuilder();
foreach (var rune in runes.Take(max - 1)) sb.Append(rune.ToString());
return sb.Append('…').ToString();
}
/// <summary><c>[Sat 2026-09-26 14:03 Europe/Berlin]</c>: <paramref name="at"/> in <paramref name="zone"/>, invariant culture.</summary>
public static string TimestampHeader(DateTimeOffset at, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
var local = TimeZoneInfo.ConvertTime(at, zone);
return string.Create(CultureInfo.InvariantCulture, $"[{local:ddd yyyy-MM-dd HH:mm} {TimeZoneIds.ToIana(zone) ?? zone.Id}]");
}
private static string UserLabel(User user)
{
if (user.Username is { Length: > 0 } name) return "@" + name;
var full = string.Join(' ', new[] { user.FirstName, user.LastName }.Where(s => !string.IsNullOrWhiteSpace(s)));
return full.Length > 0 ? full : "unknown user";
}
private static string Named(string? title, string? username, string fallback) =>
title is { Length: > 0 } ? title : username is { Length: > 0 } ? "@" + username : fallback;
private static string WithSignature(string label, string? signature) =>
signature is { Length: > 0 } ? $"{label} ({signature})" : label;
private static string Media(PhotoSize[]? photo, Video? video, Voice? voice, VideoNote? videoNote, Audio? audio, Document? document, Sticker? sticker) =>
photo is { Length: > 0 } ? "(photo)"
: video is not null ? "(video)"
: voice is not null ? "(voice message)"
: videoNote is not null ? "(video message)"
: audio is not null ? audio.FileName is { Length: > 0 } a ? $"(audio: {a})" : "(audio)"
: document is not null ? document.FileName is { Length: > 0 } d ? $"(document: {d})" : "(document)"
: sticker is not null ? sticker.Emoji is { Length: > 0 } e ? $"(sticker {e})" : "(sticker)"
: "(message)";
}