fix(telegram): cut the voice transcript echo to 1024 characters

This commit is contained in:
EugeneTes
2026-10-02 09:51:57 +00:00
parent 3a57b81d88
commit 39e7849039
4 changed files with 19 additions and 6 deletions

View File

@@ -30,7 +30,7 @@ contract to reach "this session's chat".
| Image documents | JPEG/PNG/WebP/GIF sent "as file" are saved like any document **and** attached to the prompt as `ImageContent` when ≤ `InlineImageMaxBytes` (3.75 MB, so base64 stays under the 5 MB image cap providers enforce) | | Image documents | JPEG/PNG/WebP/GIF sent "as file" are saved like any document **and** attached to the prompt as `ImageContent` when ≤ `InlineImageMaxBytes` (3.75 MB, so base64 stays under the 5 MB image cap providers enforce) |
| Speech to text | A **host service** (`Speech/ISpeechToText`, `OpenAiSpeechToText`), not an abstraction: it normalises input before a prompt exists, and the core only ever sees text | | Speech to text | A **host service** (`Speech/ISpeechToText`, `OpenAiSpeechToText`), not an abstraction: it normalises input before a prompt exists, and the core only ever sees text |
| STT model | `gpt-4o-transcribe` by default, configurable. Off when no API key is configured; voice then gets a clear "not configured" reply | | STT model | `gpt-4o-transcribe` by default, configurable. Off when no API key is configured; voice then gets a clear "not configured" reply |
| Transcript echo | `🎤 <transcript>` goes to the chat before the run starts, cut to Telegram's 4096-character limit | | Transcript echo | `🎤 <transcript>` goes to the chat before the run starts, cut to 1024 characters |
| Ordering | Inbound preparation (download, save, transcribe) runs as content steps inside `InboundComposer.ComposeAsync`, under the message's `ChatInbox` lease (message-context spec). Polling never blocks, and parts merge in Telegram order whatever order they finish in. Notices, the `🎤` echo and outbound files go through the chat renderer's queue (`RunInOrderAsync`) so they stay in order with rendered text | | Ordering | Inbound preparation (download, save, transcribe) runs as content steps inside `InboundComposer.ComposeAsync`, under the message's `ChatInbox` lease (message-context spec). Polling never blocks, and parts merge in Telegram order whatever order they finish in. Notices, the `🎤` echo and outbound files go through the chat renderer's queue (`RunInOrderAsync`) so they stay in order with rendered text |
| Commands / answers | A message with an attachment is never a command and never answers a pending `InputAsync` question. Its caption is prompt text. This extends today's photo rule | | Commands / answers | A message with an attachment is never a command and never answers a pending `InputAsync` question. Its caption is prompt text. This extends today's photo rule |
@@ -252,7 +252,7 @@ Router changes (`UpdateRouter.OnMessageAsync`), on top of the message-context sp
That includes a cancellation the message's own token didn't cause, such as the `HttpClient` timeout; only a That includes a cancellation the message's own token didn't cause, such as the `HttpClient` timeout; only a
shutdown (the caller's token) ends composing silently. The same rule holds for downloads, and `UpdateRouter` shutdown (the caller's token) ends composing silently. The same rule holds for downloads, and `UpdateRouter`
treats any other `OperationCanceledException` from `ComposeAsync` as a failure (`I could not read that message.`). treats any other `OperationCanceledException` from `ComposeAsync` as a failure (`I could not read that message.`).
5. Echo `🎤 <transcript>` as plain text, cut to 4096 characters with `…`. 5. Echo `🎤 <transcript>` as plain text, cut to 1024 characters with `…` (the prompt keeps all of it).
6. Prompt text: the caption (if any), then `[Voice message] <transcript>`. For `Audio`, the file is also saved to 6. Prompt text: the caption (if any), then `[Voice message] <transcript>`. For `Audio`, the file is also saved to
the inbox like a document and gets its own `[File received: …]` line: it's something the user might want the inbox like a document and gets its own `[File received: …]` line: it's something the user might want
processed, not just heard. processed, not just heard.

View File

@@ -132,7 +132,7 @@ question in that chat (for example from an extension) replaces the pending promp
GIF) up to 3.75 MB (`Telegram:InlineImageMaxBytes`) is also shown to the model; a larger one, or one whose contents GIF) up to 3.75 MB (`Telegram:InlineImageMaxBytes`) is also shown to the model; a larger one, or one whose contents
aren't really one of those formats, reaches it as a path only. aren't really one of those formats, reaches it as a path only.
- **Voice messages and audio files** are transcribed with OpenAI (`gpt-4o-transcribe` by default). The bot echoes - **Voice messages and audio files** are transcribed with OpenAI (`gpt-4o-transcribe` by default). The bot echoes
`🎤 <transcript>` and the agent gets `[Voice message] <transcript>` (an audio file also gets its `[File received: …]` `🎤 <transcript>` (cut to 1024 characters) and the agent gets `[Voice message] <transcript>` (an audio file also gets its `[File received: …]`
line). Without an API key the bot replies with how to set one up (`OPENAI_API_KEY`). line). Without an API key the bot replies with how to set one up (`OPENAI_API_KEY`).
- Telegram lets bots download at most 20 MB. A bigger file is refused with a note, and nothing reaches the agent. - Telegram lets bots download at most 20 MB. A bigger file is refused with a note, and nothing reaches the agent.
- A message with an attachment is never a command and never answers a pending question: its caption is prompt text. - A message with an attachment is never a command and never answers a pending question: its caption is prompt text.

View File

@@ -20,6 +20,9 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
/// <summary>Sent for a voice note or audio file when no speech-to-text service is configured.</summary> /// <summary>Sent for a voice note or audio file when no speech-to-text service is configured.</summary>
public const string SpeechNotConfiguredNotice = "Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host."; public const string SpeechNotConfiguredNotice = "Voice messages need speech-to-text: set OPENAI_API_KEY (or Telegram:Speech:ApiKey) on the bot host.";
/// <summary>The longest <c>🎤</c> echo, in UTF-16 code units; a long transcript is cut so it doesn't flood the chat.</summary>
public const int MaxEchoLength = 1024;
/// <summary>Sent when the transcript is empty.</summary> /// <summary>Sent when the transcript is empty.</summary>
public const string NoWordsNotice = "I couldn't make out any words in that voice message."; public const string NoWordsNotice = "I couldn't make out any words in that voice message.";
@@ -170,10 +173,20 @@ public sealed class InboundMedia(TelegramSender sender, TelegramOptions options,
await chat.Renderer.SayAsync(NoWordsNotice).ConfigureAwait(false); await chat.Renderer.SayAsync(NoWordsNotice).ConfigureAwait(false);
return; return;
} }
await chat.Renderer.SayAsync("🎤 " + transcript).ConfigureAwait(false); // plain text, cut to 4096 by the sender await chat.Renderer.SayAsync(Echo(transcript)).ConfigureAwait(false); // plain text
draft.Body.Add($"[Voice message] {transcript}"); draft.Body.Add($"[Voice message] {transcript}");
} }
/// <summary>The <c>🎤 …</c> echo, cut to <see cref="MaxEchoLength"/> characters with <c>…</c> (the model gets all of it).</summary>
internal static string Echo(string transcript)
{
var echo = "🎤 " + transcript;
if (echo.Length <= MaxEchoLength) return echo;
var cut = MaxEchoLength - 1;
if (char.IsHighSurrogate(echo[cut - 1])) cut--; // don't split an emoji
return echo[..cut] + "…";
}
/// <summary> /// <summary>
/// The name an audio file is sent to speech to text under: OpenAI picks the format from the extension, so it's an /// The name an audio file is sent to speech to text under: OpenAI picks the format from the extension, so it's an
/// ASCII <c>audio.&lt;ext&gt;</c> from the original extension when OpenAI knows it, else from the MIME type. /// ASCII <c>audio.&lt;ext&gt;</c> from the original extension when OpenAI knows it, else from the MIME type.

View File

@@ -31,7 +31,7 @@ public sealed class VoiceChatTests
} }
[Fact] [Fact]
public async Task A_long_transcript_echo_is_cut_to_4096_characters() public async Task A_long_transcript_echo_is_cut_to_1024_characters()
{ {
var stt = new FakeSpeechToText(new string('a', 5000)); var stt = new FakeSpeechToText(new string('a', 5000));
await using var host = new ChatHost(speech: stt); await using var host = new ChatHost(speech: stt);
@@ -42,7 +42,7 @@ public sealed class VoiceChatTests
await host.SettleAsync(); await host.SettleAsync();
var echo = host.Bot.Sent.Single(s => s.Text.StartsWith("🎤", StringComparison.Ordinal)).Text; var echo = host.Bot.Sent.Single(s => s.Text.StartsWith("🎤", StringComparison.Ordinal)).Text;
Assert.Equal(4096, echo.Length); Assert.Equal(1024, echo.Length);
Assert.EndsWith("…", echo, StringComparison.Ordinal); Assert.EndsWith("…", echo, StringComparison.Ordinal);
Assert.Contains(new string('a', 5000), Prompt(host), StringComparison.Ordinal); // the model gets all of it Assert.Contains(new string('a', 5000), Prompt(host), StringComparison.Ordinal); // the model gets all of it
} }