feat(telegram): inbound parts rendered as one prompt per batch

This commit is contained in:
EugeneTes
2026-09-26 19:52:59 +02:00
parent d8c698b326
commit b82dd859c6
3 changed files with 150 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
using ImageContent = Volcanic.AgenticCore.Abstractions.Messages.ImageContent;
namespace Volcanic.AgenticCore.Telegram.Inbound;
/// <summary>
/// What one Telegram message contributes to a prompt. Parts stay structured until the batch is flushed, so an album's
/// repeated headers can be collapsed.
/// </summary>
/// <param name="MessageId">The Telegram message id, used to react to the message.</param>
/// <param name="SentAt">The message's <c>Date</c>, for the batch's timestamp header.</param>
/// <param name="MediaGroupId">The album id, when the message is one item of an album.</param>
/// <param name="ReplyHeader">From <see cref="MessageContext.ReplyHeader"/>, brackets included.</param>
/// <param name="ForwardLabel">From <see cref="MessageContext.ForwardLabel"/>, without the brackets.</param>
/// <param name="Body">Text or caption, then lines added by content steps (<c>[File received: …]</c>, <c>[Voice message] …</c>).</param>
/// <param name="Images">Images the message carries (its largest photo).</param>
public sealed record InboundPart(
int MessageId,
DateTimeOffset SentAt,
string? MediaGroupId,
string? ReplyHeader,
string? ForwardLabel,
IReadOnlyList<string> Body,
IReadOnlyList<ImageContent> Images);
/// <summary>Turns a flushed batch of parts into one prompt.</summary>
public static class InboundBatch
{
/// <summary>
/// One timestamp line (the first part's <see cref="InboundPart.SentAt"/> in <paramref name="zone"/>), then each part:
/// its header lines, then its body lines separated by a blank line. Parts are separated by a blank line. Consecutive
/// parts of one media group show the reply header and the forward label once. An image-only batch is just the
/// timestamp line.
/// </summary>
public static (string Text, IReadOnlyList<ImageContent> Images) Render(IReadOnlyList<InboundPart> parts, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(parts);
ArgumentNullException.ThrowIfNull(zone);
ArgumentOutOfRangeException.ThrowIfZero(parts.Count);
var blocks = new List<string>();
var images = new List<ImageContent>();
string? group = null;
bool replyShown = false, forwardShown = false;
foreach (var part in parts)
{
if (part.MediaGroupId is null || part.MediaGroupId != group)
{
group = part.MediaGroupId;
replyShown = forwardShown = false;
}
var lines = new List<string>();
if (part.ReplyHeader is { } reply && !replyShown)
{
lines.Add(reply);
replyShown = group is not null;
}
if (part.ForwardLabel is { } forward && !forwardShown)
{
lines.Add($"[Forwarded from {forward}]");
forwardShown = group is not null;
}
var body = part.Body.Where(line => line.Length > 0).ToList();
if (body.Count > 0) lines.Add(string.Join("\n\n", body));
if (lines.Count > 0) blocks.Add(string.Join('\n', lines));
images.AddRange(part.Images);
}
var header = MessageContext.TimestampHeader(parts[0].SentAt, zone);
return (blocks.Count == 0 ? header : header + "\n" + string.Join("\n\n", blocks), images);
}
}

View File

@@ -0,0 +1,62 @@
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Telegram.Inbound;
namespace Volcanic.AgenticCore.Telegram.Tests;
public sealed class ChatInboxTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly DateTimeOffset At = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero);
private const string Header = "[Sat 2026-09-26 14:03 Europe/Berlin]";
private static InboundPart P(int id, string? group = null, string? reply = null, string? forward = null, string[]? body = null, int images = 0) =>
new(id, At.AddSeconds(id - 1), group, reply, forward, body ?? [],
Enumerable.Range(0, images).Select(_ => new ImageContent("", "image/jpeg")).ToList());
[Fact]
public void Render_starts_with_one_timestamp_header_from_the_first_part()
{
var (text, images) = InboundBatch.Render([P(1, body: ["look at this"]), P(2, body: ["and this"])], Berlin);
Assert.Equal($"{Header}\nlook at this\n\nand this", text);
Assert.Empty(images);
}
[Fact]
public void Render_puts_headers_before_the_body_and_keeps_body_lines_apart()
{
var part = P(1, reply: "[Reply to your message: \"x\"]", forward: "Daily News (Anna K.)", body: ["post", "[Voice message] hi"]);
var (text, _) = InboundBatch.Render([part], Berlin);
Assert.Equal($"{Header}\n[Reply to your message: \"x\"]\n[Forwarded from Daily News (Anna K.)]\npost\n\n[Voice message] hi", text);
}
[Fact]
public void Album_headers_collapse_to_the_first_part_that_has_them()
{
var (text, images) = InboundBatch.Render(
[P(1, "g", forward: "Daily News", body: ["three views"], images: 1), P(2, "g", forward: "Daily News", images: 1), P(3, "g", forward: "Daily News", images: 1)],
Berlin);
Assert.Equal($"{Header}\n[Forwarded from Daily News]\nthree views", text);
Assert.Equal(3, images.Count);
}
[Fact]
public void Parts_outside_a_media_group_keep_their_own_headers()
{
var (text, _) = InboundBatch.Render([P(1, forward: "A", body: ["x"]), P(2, forward: "A", body: ["y"])], Berlin);
Assert.Equal($"{Header}\n[Forwarded from A]\nx\n\n[Forwarded from A]\ny", text);
}
[Fact]
public void An_image_only_batch_is_just_the_timestamp_header()
{
var (text, images) = InboundBatch.Render([P(1, images: 1)], Berlin);
Assert.Equal(Header, text);
Assert.Single(images);
}
}

View File

@@ -215,4 +215,23 @@ public sealed class MessageContextTests
CultureInfo.CurrentCulture = culture;
}
}
[Fact]
public void Reply_header_comes_before_the_forward_label_and_the_body()
{
var m = Msg("Geothermal plant opens", Me);
m.ReplyToMessage = Msg("Which plant do you mean?", Bot);
m.ForwardOrigin = Channel("Daily News", signature: "Anna K.");
var part = new InboundPart(m.Id, new DateTimeOffset(2026, 9, 26, 12, 3, 0, TimeSpan.Zero), null,
MessageContext.ReplyHeader(m, BotId), MessageContext.ForwardLabel(m.ForwardOrigin), [m.Text!], []);
var (text, _) = InboundBatch.Render([part], TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"));
Assert.Equal("""
[Sat 2026-09-26 14:03 Europe/Berlin]
[Reply to your message: "Which plant do you mean?"]
[Forwarded from Daily News (Anna K.)]
Geothermal plant opens
""".ReplaceLineEndings("\n"), text);
}
}