agent-ecosystem/docs/research/messenger-connectors-uncertainty-pass-32.md
2026-04-29 10:55:07 +03:00

796 lines
23 KiB
Markdown

# Messenger Connectors - Uncertainty Pass 32
Date: 2026-04-29
Scope: agent reply capture, outbound Telegram delivery, message visibility policy, duplicate prevention, and provider delivery ambiguity
## Executive Delta
The next lowest-confidence boundary is the final leg:
```text
agent/team message
-> local app feed
-> outbound eligibility decision
-> Telegram sendMessage
-> provider message id
-> future reply-to route
```
This is where two severe bugs can happen:
```text
1. Privacy leak:
internal thoughts, tool summaries, teammate protocol XML, retry prompts, or slash output
get sent to Telegram as if they were user-facing replies.
2. Duplicate provider send:
Telegram receives a sendMessage request, but our process times out before seeing the result.
Automatic retry can send the same user-visible reply twice.
```
The fix is a dedicated outbound projection layer:
```text
MessengerOutboundProjectionGate
decides if a local message is eligible for external provider delivery
MessengerProviderDeliveryLedger
records provider send intent, in-flight state, success, ambiguity, and terminal failure
ProviderMessageLink
records Telegram message id after success so reply-to routing works later
```
Do not use the renderer feed or `sentMessages.json` as the outbound provider queue. They are useful inputs, but not the delivery protocol.
## Source Facts Rechecked
Telegram official facts checked on 2026-04-29:
- Bot API methods return a JSON object with `ok`; successful calls put the method result in `result`.
- `sendMessage` sends text and returns the sent `Message` on success.
- `sendMessage` supports `message_thread_id` for forum/private-chat topics.
- `sendMessage` supports `reply_parameters` for replying to a specific message.
- When using webhook inline responses to call Bot API methods, Telegram says it is not possible to know whether the method succeeded or to get its result.
- `ResponseParameters.retry_after` tells how many seconds to wait after flood control.
- Telegram FAQ recommends avoiding more than one message per second in a single chat; otherwise 429 errors can happen.
- Telegram FAQ says bots should not rely on webhook inline response if they need to know the result of the method.
- Bot API docs and FAQ do not expose a client-supplied idempotency key for `sendMessage`.
Sources:
- https://core.telegram.org/bots/api#making-requests
- https://core.telegram.org/bots/api#making-requests-when-getting-updates
- https://core.telegram.org/bots/api#sendmessage
- https://core.telegram.org/bots/api#replyparameters
- https://core.telegram.org/bots/api#responseparameters
- https://core.telegram.org/bots/faq
Local code facts:
- `TeamSentMessagesStore` persists `sentMessages.json`, but it caps history at 200 messages and is optimized as a local UI/persistence store, not a provider delivery ledger.
- `TeamSentMessagesStore` preserves message fields such as `from`, `to`, `source`, `leadSessionId`, `conversationId`, and `replyToConversationId`.
- `TeamDataService.extractLeadSessionTextsFromJsonl` creates lead-session text rows with `source: 'lead_session'` and usually no `to`.
- `leadSessionMessageExtractor` creates slash command result rows with `source: 'lead_session'` and `messageKind: 'slash_command_result'`.
- `TeamProvisioningService` captures native `SendMessage` tool calls. `recipient === 'user'` is persisted to `sentMessages.json`; other recipients are persisted to inbox.
- `relayLeadInboxMessages` captures plain lead output for inbox relay, strips agent-only blocks, then persists a `lead_process` message to user.
- `stripAgentBlocks` removes `info_for_agent`, legacy agent blocks, and OpenCode runtime delivery blocks.
- `inboxNoise` detects internal JSON noise and teammate-message XML protocol artifacts.
- `RuntimeDeliveryService` already has strong local idempotency ideas: journal begin, payload hash conflict detection, destination verification, committed state, failed retryable state, and reconciler.
- Existing runtime delivery works for local destinations because it can verify local files/stores. Telegram provider sends are different because success may be unknowable after network timeout.
Implication:
```text
The current app has good ingredients,
but messenger outbound needs a separate provider delivery ledger
with stricter "external visibility" rules than the UI feed.
```
## 1. Outbound Eligibility Is A Security Boundary
The local feed contains multiple categories:
```text
user_sent
lead_process
lead_session
runtime_delivery
inbox
system_notification
cross_team
cross_team_sent
slash_command_result
tool summaries
command output
internal protocol blocks
noise JSON
```
Only a small subset should be allowed to leave the app through Telegram.
Minimal provider-send eligibility:
```text
message.to == "user"
message.from is a known active team member or lead
message.source is user-visible by policy
message.text remains non-empty after sanitization
message is linked to a provider route or an explicit publish action
message has not already been sent to that provider route
route is active
topic is active
outbound policy allows this member/source/kind
```
Hard excludes:
```text
message.from == "user"
message.from == "system"
message.to != "user"
messageKind == "slash_command" unless explicitly mirrored as a user command echo
messageKind == "slash_command_result" unless explicitly requested
isInboxNoiseMessage(text)
isThoughtProtocolNoise(text)
stripAgentBlocks(text) is empty
only teammate-message XML blocks
tool-only rows with no human answer
debug diagnostics
runtime retry prompt text
permission_request JSON
```
The important rule:
```text
If a message is visible in the local app, that does not automatically mean it is safe to send to Telegram.
```
## 2. What Counts As A User-Facing Agent Reply
For the Telegram topic product, user-facing means:
```text
Lead or teammate intentionally answered the external user.
```
Good candidates:
- `SendMessage(to="user")` captured from lead or teammate runtime.
- Runtime delivery envelope whose destination is `user_sent_messages`.
- A visible reply proof with `relayOfMessageId` linked to a messenger inbound turn.
- A manual user action in our UI like "send this to Telegram".
Risky candidates:
- Lead session thoughts without `to`.
- Plain assistant text captured from stdout during a relay batch.
- Slash command output.
- Task/comment notifications.
- Cross-team internal coordination.
- Teammate-to-teammate messages.
Recommended MVP:
```text
Auto-send to Telegram only messages that have an explicit destination to external user.
Do not auto-send generic lead thoughts.
```
This means:
```text
lead_process with to=user -> eligible if linked to route
runtime delivery to user -> eligible if linked to route
lead_session without to -> not eligible
slash_command_result -> not eligible by default
cross_team_sent -> not eligible unless to=user and explicit external link exists
```
## 3. User Wants Teammate Messages Too
The user's desired behavior:
```text
Messages from other teammates to the user should appear in Telegram too,
signed by each teammate.
```
This is real and understandable. The safe model:
```text
If any team member sends a message to "user" in a route-linked conversation,
send it into the team topic with a member prefix.
```
Example Telegram rendering:
```text
[Frontend] Alice
I found the failing test. The auth callback returns before token refresh completes.
```
```text
[Frontend] Lead
Alice is checking the failing test. I will update you when she has a patch.
```
Do not send teammate-internal chatter:
```text
Alice -> Lead: "Can you clarify the expected API?"
Lead -> Bob: "Please review Alice's patch"
Bob -> Alice: "Approved"
```
unless the destination is explicitly `user`.
Therefore the outbound projection should key off destination, not role:
```text
to=user + route link + eligible source -> send to Telegram
to=lead/teammate/cross-team -> do not send
```
## 4. Route Link Requirement
Do not send every `to=user` message to Telegram. The user may have multiple channels:
```text
local UI only
Telegram official bot
Telegram own bot
future WhatsApp
future Discord
```
Outbound needs an explicit route link:
```ts
type MessengerOutboundContext =
| {
kind: 'reply_to_provider_turn';
routeId: string;
inboundProviderMessageKey: string;
internalInboundMessageId: string;
}
| {
kind: 'manual_publish';
routeId: string;
requestedBy: 'user';
localMessageId: string;
};
```
For auto-send MVP, require `reply_to_provider_turn`.
Manual publish can come later. Without route link, local app replies remain local app replies.
## 5. Provider Delivery Is Not Local Delivery
Existing `RuntimeDeliveryService` can retry local destinations because it can verify them:
```text
write deterministic local message id
verify file/store contains destination message id
mark committed
```
Telegram is different:
```text
POST sendMessage
network timeout before response
unknown whether Telegram created the message
no Bot API client idempotency key
cannot verify by deterministic local id
```
Therefore provider delivery states need an ambiguity state:
```ts
type MessengerProviderDeliveryStatus =
| 'pending'
| 'send_in_flight'
| 'sent'
| 'send_ambiguous'
| 'rate_limited'
| 'failed_retryable_before_send'
| 'failed_terminal'
| 'cancelled';
```
Critical rule:
```text
Never automatically retry send_in_flight after a transport timeout
unless the provider adapter can prove the previous attempt did not reach Telegram.
```
Most HTTP timeout cases cannot prove that.
## 6. Provider Delivery Ledger
Suggested ledger:
```ts
type MessengerProviderDeliveryRecord = {
idempotencyKey: string;
provider: 'telegram';
botScope: 'official' | 'own_bot';
routeId: string;
providerChatIdHash: string;
providerMessageThreadId: number | null;
internalMessageId: string;
internalPayloadHash: string;
visibilityDecisionId: string;
status: MessengerProviderDeliveryStatus;
providerMessageId: number | null;
replyToProviderMessageId: number | null;
attempts: number;
nextAttemptAt: string | null;
ambiguousSince: string | null;
lastErrorCode: string | null;
lastErrorMessageRedacted: string | null;
createdAt: string;
updatedAt: string;
sentAt: string | null;
};
```
Idempotency key should be deterministic:
```text
sha256(provider + routeId + internalMessageId + normalizedTextHash + deliveryKind)
```
Payload hash prevents accidental reuse:
```text
same idempotencyKey + different payloadHash -> conflict, terminal
```
When `sent`:
```text
create ProviderMessageLink:
providerMessageId -> internalMessageId
```
When `send_ambiguous`:
```text
do not create ProviderMessageLink
show warning in connector status
allow manual "send again anyway" or "mark as sent" if future support flow exists
```
## 7. Send State Machine
Safe provider send state machine:
```text
pending
-> send_in_flight
-> sent
pending
-> failed_retryable_before_send
-> pending
send_in_flight
-> rate_limited
-> pending at retry_after
send_in_flight
-> send_ambiguous
send_in_flight
-> failed_terminal
```
Retryable before-send examples:
- route temporarily locked;
- local rate limiter says wait;
- backend/desktop connection unavailable before calling Telegram;
- provider adapter rejected validation before network send.
Ambiguous examples:
- request body was handed to HTTP client and connection timed out;
- process crashed after starting `sendMessage`;
- backend sent inline webhook response with method payload and needs the provider result;
- connection reset after partial response;
- app received malformed response after Telegram may have accepted request.
Terminal examples:
- blocked by user;
- chat not found;
- topic missing and repair is required;
- message text empty after sanitization;
- payload too long and split policy disabled;
- route disabled;
- payload hash conflict.
## 8. Do Not Use Inline Webhook Response For Outbound Replies
Telegram allows calling a Bot API method by returning it in the webhook response. This is tempting for fast replies.
Do not use it for messenger outbound replies.
Reason:
```text
Telegram says we cannot know if the inline method succeeded or get its result.
Without the returned Message, we cannot store providerMessageId.
Without providerMessageId, reply-to teammate routing becomes weaker.
```
Use normal Bot API calls for outbound messages:
```text
POST /bot<TOKEN>/sendMessage
await result
persist provider message id
then mark sent
```
Inline webhook response is acceptable only for non-critical throwaway notices where no future reply routing is needed.
## 9. Rate Limiting
Telegram FAQ warns to avoid more than one message per second in a single chat.
For one-topic-per-team inside one private chat, the chat-level limiter matters more than topic-level limiter:
```text
same Telegram private chat
many team topics
many team replies
one chat-level provider limit
```
Add provider route limiter:
```text
global bot limiter
per chat limiter
per route/topic limiter
```
MVP values:
```text
per chat: 1 message per second steady
per route/topic: 1 message per second steady
burst: small queue, for example 3 messages
queue overflow: collapse or mark delayed
```
Avoid splitting a single long answer into many Telegram messages unless necessary. If splitting is needed because text exceeds Telegram limit, send chunks under one ledger group and be careful:
```text
part 1 sent
part 2 ambiguous
part 3 pending
```
Multi-part provider delivery needs a group ledger, so MVP should keep replies concise and reject/trim with clear policy before adding splitting.
## 10. Text Sanitization And Formatting
Outbound text pipeline:
```text
raw local message
strip agent-only blocks
strip teammate protocol blocks if present
reject JSON noise
normalize whitespace
prefix with team/member context
enforce max length
send plain text or Telegram entities
```
Avoid parse modes in MVP:
```text
send plain text
do not use MarkdownV2 until escaping is proven
```
Reason:
- model output can contain arbitrary punctuation;
- MarkdownV2 escaping is brittle;
- malformed formatting can fail provider send;
- provider failure after partial route logic increases ambiguity.
Use explicit Telegram entities later if rich formatting is necessary.
## 11. Reply-To Mapping
When sending a provider reply, use `reply_parameters` if we are replying to a known inbound provider message:
```text
reply_to_provider_message_id = inbound Telegram message id
message_thread_id = team topic id
```
But do not depend only on Telegram reply UI.
Also store:
```text
ProviderMessageLink(providerMessageId -> internalMessageId)
```
Then future user replies can route:
```text
reply_to_message.message_id
-> ProviderMessageLink
-> internal from member
-> route to that teammate
```
If provider send succeeds but link persistence fails:
```text
send was externally visible
do not retry send
mark provider link missing
schedule repair if possible
```
This should become `sent_link_missing`, or `sent` with diagnostics. It is not a send failure.
## 12. Local Store Is Not Enough
`sentMessages.json` is capped at 200 rows. This is fine for a UI feed but not for provider reply-to history.
Provider message links need their own retention policy:
```text
keep links for active route history window
minimum 90 days or until route deletion by user
prune only with route-level retention
never prune solely because local sentMessages hit 200 rows
```
If links are pruned:
- future replies to old Telegram messages route to lead;
- UI should show "old reply target not available";
- do not guess teammate from display prefix.
## 13. Deletion And Edits
MVP can ignore edits and deletions mostly, but not silently:
Inbound Telegram edited messages:
- do not mutate already delivered internal turns in MVP;
- create an edit event or ignore with diagnostics;
- if edited before desktop acceptance, process latest only if ingress design supports it.
Outbound local message edits:
- do not edit Telegram messages in MVP;
- send corrections as new messages only on explicit action.
Telegram delete:
- if provider message deleted, later reply-to links may break;
- keep link but mark stale when detected by send/reply errors.
This avoids complicated bidirectional sync in v1.
## 14. Failure Matrix
Critical cases:
- Local lead thought appears with no `to`.
- Do not send.
- Lead uses `SendMessage(to="user")` answering a Telegram-origin message.
- Eligible, send to that route.
- Teammate uses `SendMessage(to="user")` answering a Telegram-origin message.
- Eligible, send to same team topic with teammate prefix.
- Teammate sends to lead.
- Not eligible.
- Message contains only `<info_for_agent>`.
- Strip to empty, not eligible.
- Message contains teammate XML blocks.
- Strip/block by protocol-noise policy.
- Slash command output row appears.
- Not eligible by default.
- Provider route disabled after local reply was generated.
- Mark terminal or cancelled, do not send.
- Topic route repair-required.
- Do not fallback to general chat.
- Telegram returns 429 with retry_after.
- Mark rate_limited, schedule retry after given time.
- HTTP timeout after request sent.
- Mark send_ambiguous, do not auto-retry.
- HTTP timeout before request body leaves process.
- If adapter can prove no send, mark failed_retryable_before_send.
- Telegram returns success but local link write fails.
- Do not retry provider send, repair link.
- Duplicate local message event.
- Ledger idempotency key returns existing provider status.
- Same idempotency key with different text.
- Payload conflict, terminal.
- App restarts with `send_in_flight`.
- Convert to send_ambiguous unless adapter has proof.
- Provider message link pruned.
- Future reply falls back to lead with stale target metadata.
## 15. Top 3 Options
### Option 1 - Strict outbound projection gate + provider delivery ledger
🎯 9 🛡️ 9 🧠 7
Approx changed LOC: 2500-5500.
What it means:
- build `MessengerOutboundProjectionGate`;
- build `MessengerProviderDeliveryLedger`;
- auto-send only explicit `to=user` replies linked to a provider route;
- use Telegram normal API calls, not inline webhook response, for routable replies;
- mark network unknowns as `send_ambiguous`, not retryable;
- store `ProviderMessageLink` after success.
Why this is best:
- prevents internal-message leakage;
- avoids unsafe Telegram duplicates;
- supports teammate messages to user;
- gives reply-to routing a durable provider message id;
- matches the feature architecture standard.
Risk:
- more code;
- some ambiguous sends need user-visible diagnostics;
- initial behavior may feel conservative.
### Option 2 - Reuse `sentMessages.json` as outbound queue with simple dedupe
🎯 5 🛡️ 5 🧠 4
Approx changed LOC: 800-1800.
What it means:
- watch `sentMessages.json`;
- send any new `to=user` message to Telegram;
- store last sent internal message ids.
Why it is tempting:
- quick demo;
- current system already writes user-directed lead messages there;
- easy to observe from renderer.
Why it is risky:
- `sentMessages.json` is capped at 200;
- it is not route-specific;
- not all `to=user` messages should go to Telegram;
- provider send ambiguity is not represented;
- reply-to provider ids need another store anyway.
### Option 3 - Send all visible feed messages with broad filters
🎯 3 🛡️ 3 🧠 3
Approx changed LOC: 500-1400.
What it means:
- use `MessagesPanel`/feed projection;
- filter obvious noise;
- push visible items to Telegram.
Why it is bad:
- visibility in app is not external eligibility;
- feed contains lead thoughts, slash results, diagnostics, and UI-specific projections;
- dedupe is feed-oriented, not provider-send oriented;
- provider reply-to routing remains fragile.
This should not be used beyond a throwaway prototype.
## 16. Decision Update
Recommended model:
```text
Inbound Telegram turn creates route-linked internal message.
Agent/team responses become local messages as today.
MessengerOutboundProjectionGate observes durable local messages.
Only explicit user-directed, route-linked replies become provider send intents.
MessengerProviderDeliveryLedger handles Telegram send state.
ProviderMessageLink stores successful Telegram message ids.
Future reply-to routing uses ProviderMessageLink.
```
Minimal eligibility formula:
```text
eligible =
route.active
&& message.to == "user"
&& message.from is active member
&& origin/reply context links message to provider route
&& message not already delivered to provider
&& sanitized text non-empty
&& message kind/source allowed by policy
```
Important product behavior:
```text
Teammate messages to user are sent to Telegram.
Teammate messages to lead or other teammates are not sent.
Lead thoughts without explicit to=user are not sent.
```
## 17. Tests To Write First
Domain tests:
- `to=user` lead reply linked to provider route is eligible.
- `to=user` teammate reply linked to provider route is eligible.
- `to=user` local-only reply without route link is not eligible.
- `to=lead` teammate message is not eligible.
- lead session thought without `to` is not eligible.
- slash command result is not eligible by default.
- agent-only block strips to empty and is not eligible.
- JSON noise is not eligible.
- provider route disabled blocks eligibility.
- same internal message maps to same provider idempotency key.
- same idempotency key with changed payload is conflict.
Provider ledger tests:
- pending -> send_in_flight -> sent creates provider link.
- pre-send validation failure is retryable.
- 429 response stores retry_after and schedules retry.
- HTTP timeout after request started becomes send_ambiguous.
- restart with send_in_flight becomes send_ambiguous.
- duplicate local event returns existing sent/ambiguous state.
- success with provider link write failure does not retry provider send.
Adapter tests:
- Telegram send uses `message_thread_id`.
- Telegram send uses `reply_parameters` when inbound provider message id is known.
- Telegram send does not use webhook inline response for routable replies.
- long text is rejected or handled by explicit split policy.
- parse mode is omitted in MVP.
Renderer tests:
- connector status shows ambiguous provider sends.
- ambiguous send has manual resolution affordance.
- user can see why a local reply was not sent to Telegram.
- teammate prefix renders in Telegram projection preview.
## 18. Remaining Low-Confidence Areas
Still worth deeper research next:
- exact local event source for teammate `SendMessage(to="user")` across all supported runtimes, not just OpenCode;
- whether legacy Claude lead-session plain text should ever auto-send to Telegram or always require explicit SendMessage;
- how to migrate old `sentMessages.json` rows into provider delivery state without accidental sends;
- how to model manual "send again anyway" for `send_ambiguous` without hiding duplicate risk;
- whether `sendMessageDraft` can safely show typing/progress in a topic without confusing delivery state;
- exact Telegram error taxonomy for deleted private topic, blocked bot, and migrated chats in Bot API responses;
- retention policy for `ProviderMessageLink` under privacy delete/export requirements.