forked from admin/VolcanicAgenticCore
107 lines
5.6 KiB
C#
107 lines
5.6 KiB
C#
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)";
|
|
}
|