DEV Community

Cover image for Correlating Telegram AI-writer replies without confusing correlation for correctness
MatIntellect
MatIntellect

Posted on AI-assisted

Correlating Telegram AI-writer replies without confusing correlation for correctness

A Telegram-based writing workflow has an awkward boundary: the content engine sends a text file to a separate AI writer, then receives one or more messages back. The listener needs to know when those messages belong to a particular request and when it can stop waiting.

AI content factory illustration featuring Mat

People working at a computer. Photo: Pavel Danilyuk, Pexels

Video editing workspace. Photo: Ron Lach, Pexels

The implementation in llm.py uses request IDs, a terminator, buffering and a silence fallback. It also serializes model calls with an asyncio.Lock. These choices solve a narrow coordination problem. They do not validate the returned prose.

The request envelope

Every request begins with a JSON line containing three fields:

{"request_id":"…","stage":"…","schema_version":1}
Enter fullscreen mode Exit fullscreen mode

The engine generates a short request ID and asks the writer to put [END:<request_id>] on the last line. The stage identifies which writing step is running; the schema version labels the envelope format. The code sends the prompt as a .txt file to @matpibot through the owner’s Telegram client.

On the receiving side, a NewMessage listener buffers messages from the configured bot account. It checks each buffer for the current request ID. If the joined text contains the exact end marker, the marker is removed and the waiting future resolves immediately.

This is correlation, not authentication or content validation. The ID lets the engine connect a reply to a pending task under the expected chat/user setup. It says nothing about whether research was adequate, whether the response is complete, or whether its statements are true.

Why buffer fragments?

A reply can arrive as several Telegram messages, including fragments that split text mid-line. The listener stores messages with timestamps and whether each includes the request ID. _matpibot_join retains ID-bearing messages and nearby id-less messages in the same burst, then concatenates fragments without inserting a newline inside a burst. Separate bursts are joined with a newline.

There is a subtle detail in the silence path: an ID-bearing message without the terminator schedules a timer. Every later matching message cancels and resets that timer. If the writer keeps sending fragments, the listener waits for the stream to go quiet rather than resolving after a fixed interval from the first message.

When the timer fires, it resolves the future with the buffered text even though the terminator never arrived. In this implementation, SILENCE_SECONDS is 15 seconds. That fallback is useful when the writer forgets the marker, but it is still a heuristic: silence is not proof that the writer intended to finish.

One request at a time

call_via_matpibot creates an asyncio.Lock and enters it before registering the pending future, sending the file and waiting for the answer. The lock is released only when the function exits. Consequently, calls through this path are single-flight: a second call waits for the first send-and-wait operation to finish.

That design avoids overlapping pending conversations through this doorway, making the response-matching story simpler. It trades throughput for simpler coordination. A slow writer blocks later requests even if their prompts are unrelated.

The normal timeout defaults to 600 seconds. On timeout, the future is shielded rather than cancelled and the code waits for a further 60 seconds for a late reply. If that grace period expires, the call returns an empty LLMReply carrying matpibot_timeout. A missing client, send exception and timeout are represented as distinct error states. There is no fallback to another model call path.

State machine sketch

idle
  └─ acquire lock; register future and buffer
       └─ send request file
            ├─ matching joined END marker → return answer
            ├─ matching ID, no END → reset 15s silence timer
            │    └─ silence expires → return buffered answer (fallback)
            └─ 600s timeout → wait up to 60s more for late answer
                 └─ still nothing → return timeout error
  └─ remove pending state, cancel timer, delete temporary request file
Enter fullscreen mode Exit fullscreen mode

Review the protocol separately from editorial quality

The factory’s README describes a broader pipeline: source cards in an “Ideas” topic, a human selects the channel and platforms, the AI writer researches and drafts, then a preview arrives in a “Posts” topic. A person must press “Publish”; otherwise nothing is published. Rewrites, media replacement and cancellation are separate preview actions.

That human review remains necessary even when the transport protocol works perfectly. An ID can be correct while a fact is wrong. A terminator can arrive while the answer is weak. A silence fallback can return buffered text that deserves another review.

For the surrounding architecture, see my AI content factory walkthrough. If you are adapting the handoff, exercise the branches in a safe test setup: correct marker, omitted marker followed by silence, fragmented marker, late answer and timeout. Treat those as tests to run, not claims that this article has executed them. Then test the human path independently: a draft should require an explicit publish action, and cancellation should stay a cancellation.

This article was created with the help of AI.

Top comments (0)