forked from admin/VolcanicAgenticCore
feat(telegram): inbound parts rendered as one prompt per batch
This commit is contained in:
69
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundPart.cs
Normal file
69
hosts/Volcanic.AgenticCore.Telegram/Inbound/InboundPart.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
62
tests/Volcanic.AgenticCore.Telegram.Tests/ChatInboxTests.cs
Normal file
62
tests/Volcanic.AgenticCore.Telegram.Tests/ChatInboxTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user