Skip to content

[OMEGA-318 & OMEGA-319] Telegram's multi chat and multi-user issue fixed - #327

Open
blackhammer116 wants to merge 13 commits into
singnet:mainfrom
iCog-Labs-Dev:dev/bug-fix
Open

[OMEGA-318 & OMEGA-319] Telegram's multi chat and multi-user issue fixed#327
blackhammer116 wants to merge 13 commits into
singnet:mainfrom
iCog-Labs-Dev:dev/bug-fix

Conversation

@blackhammer116

@blackhammer116 blackhammer116 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR removes Telegram’s single-chat limitation and adds safe support for using one OmegaClaw instance from an authenticated owner’s DM and one or more authorized groups.

Responses are routed back to the chat that produced the corresponding message. Group access is authorized by the persisted Telegram owner without exposing the reusable authentication secret in the group.

Problem

The Telegram adapter previously stored one global chat_id and one authenticated user_id.

This caused two problems:

  1. Whichever DM or group was bound first became the only usable chat. The owner could not use the bot from both DM and a group.
  2. In a group, only the authenticated owner’s messages were accepted. Messages from other group members were discarded before reaching the agent.

Authentication model

When authentication is enabled:

  1. The owner sends auth <secret> in a private DM.
  2. The owner’s Telegram user_id is persisted.
  3. The owner sends /bind in a group.
  4. OmegaClaw verifies that the command sender is the persisted owner.
  5. The group’s chat_id is persisted as authorized.
  6. All members of the authorized group may interact with the bot.
  7. The owner may continue using the bot in DM.
  8. Other DMs and unauthorized groups remain blocked.

The authentication secret is accepted only in a private chat. It is never required or validated inside a group.

A persisted owner always takes precedence over the reusable secret, preventing another user from replacing the owner after a restart.

Telegram command compatibility

The group authorization command supports:

  • /bind
  • /bind@BotName
  • /authorize_group
  • /authorize_group@BotName

Addressed commands are accepted only when the username suffix matches the current bot’s username, obtained through Telegram’s getMe API.

Commands addressed to another bot are ignored.

Multi-chat routing

Inbound messages are queued with their originating Telegram chat_id.

When the agent processes a message, outbound responses are sent back to that originating chat. This allows an owner DM and authorized groups to operate through the same OmegaClaw process without replies being sent to the wrong destination.

All authorized Telegram chats continue to share the same OmegaClaw agent state and conversation history.

Chat allowlists

Existing TG_CHAT_ID behavior is preserved, and this PR adds:

TG_ALLOWED_CHAT_IDS: ""

Closes #294

How Has This Been Tested?

  • Manual verification and pytest runs

Checklist

  • The code generated by LLM is reviewed by the PR creator
  • Self-review completed
  • Test scenarios above are passed with the version of the code from PR

@TossSky

TossSky commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

@blackhammer116

Tested: d65763a against b96afaa, Telegram channel, live provider, DM and group in one run, clean memory volume per run.

✅ Group access works, and the DM secret leak is closed

The owner bootstrap, /bind, and group access all behave as described. A group member who is not the owner asked a question and got an answer:

(HUMAN-MSG: @<member>: What is 12 x 12?)
COMMAND_RETURN: ((send "@<member>: 12 × 12 = 144")
  • ✅ Before /bind the same group is silent, and the marker never reaches the agent (0 hits, while the post-/bind marker matches the same pattern once)
  • /bind is idempotent, and /bind@<other-bot> is correctly ignored
  • ✅ Owner and group records survive container replacement
  • mock_telegram 24 passed on both images, ./tests/pytest.sh 18 against 12

Worth calling out separately: on b96afaa the secret is accepted straight from a group, so everyone in that room sees it and any of them can claim ownership. Requiring chat_type == "private" in is_owner_bootstrap closes that. The NameError at auth.py:154 on main is fixed here too.

❌ Replies are delivered to whichever chat spoke last

This is the blocker. I asked a question in the owner's DM and sent an unrelated line to the group three seconds later:

iteration 259  (HUMAN-MSG: ... run the shell command uname -r and report exactly what it printed ...)   DM
iteration 260  (HUMAN-MSG: ... nothing needed here)                                                     group
iteration 261  COMMAND_RETURN: ((send "QAX-1787576108: 5.15.0-140-generic")

The answer arrived in the group. The DM got nothing. Shell output requested privately was published to every member of the room. Reproduced 5 times out of 5, at delays of 0, 2, 3, 5, 8 and 10 seconds.

send never passes a target, so send_message falls back to _active_chat_id, which getLastMessage overwrites on every receive. The loop calls receive once per iteration at loop.metta:75, and any command the model runs before answering costs an iteration. The agent has no way to name its addressee, so the reply cannot be routed correctly by construction.

test_reply_uses_the_chat_that_supplied_the_message passes because it alternates receive, send, receive, send. Move one receive and it fails:

telegram._enqueue_message("dm: what is my secret note?", "dm_owner")
telegram.getLastMessage()
telegram._enqueue_message("group: unrelated chatter", "group_public")
telegram.getLastMessage()
telegram.send_message("your secret note is: buy milk")

# AssertionError: assert ['group_public'] == ['dm_owner']

Carrying the originating chat with the message through to the send would fix it.

❌ The agent can no longer speak first

Everything the agent says before someone talks to it is dropped. In one run it executed send "Ready and idle. Memory initialized, no pending tasks." at 12:47:23; the first inbound message arrived at 12:52:22; nothing was delivered. The version banner from loop.metta:69 is gone the same way — b96afaa delivers it, d65763a does not, and the outbound count differs by exactly one message.

send_message returns as soon as _active_chat_id is empty. Since a restart clears it, an agent that wakes up on a heartbeat cannot reach an already authorized chat until that chat writes first.

❌ Telegram is the only channel left without delivery retry

Dropping PendingMessages also removed the retry. irc.py:24, slack.py:37 and mattermost.py:25 still keep theirs. On top of that, the return at telegram.py:75 abandons every remaining chunk, so one failed chunk truncates a long answer with no trace beyond a log line. Multi-chat makes 429 more likely, not less.

❌ A damaged group file silences the bot permanently and eats the messages

Appending garbage to authenticated-group.json gives one warning per poll and nothing else:

WARNING | telegram | Poll error: Failed to read channel authenticated group records

Group messages stop being processed, the DM path keeps working, and the user is told nothing. get_channel_saved_group_id raises, and the poller catches it at telegram.py:299. Because _offset advances at telegram.py:260-266 before the message is examined, those updates are acknowledged and gone. I restored the file and the lost messages never came back.

The file is now read from disk on every inbound message, so this window stays open for the life of the process.

❌ The same question from two chats is answered once

Send identical text from the DM and from the group and only the first is processed. The second never reaches the model: no HUMAN-MSG, no CHARS_SENT. loop.metta:76 compares incoming text against &prevmsg with no notion of chat. Changing one character makes it work, which confirms the cause. Harmless with one chat, wrong once several rooms share an agent.

❌ Operational gaps

  • ❌ A group can be authorized but never revoked. No /unbind, no removal path, the file is append only. Since an authorized group grants shell access to everyone in it, a mistaken /bind can only be undone from the server.
  • ❌ Upgrading breaks a working group silently. The old _chat_id was never persisted, so after deploy the group goes quiet until the owner sends /bind again. Nothing announces this.
  • _initialize_bot_identity is not guarded, unlike _initialize_offset right below it, so a failing getMe takes down channel startup where main survived it.
  • ❌ Telegram changes a group's chat id when it migrates to a supergroup. The stored authorization stops matching, and with no revoke path the stale record simply stays there.
  • docs/reference-channels.md:91-92 still says foreign chats are dropped before the auth gate and that outbound messages are queued through PendingMessages. Both are now false, and /bind is documented nowhere.

Verdict: ❌ FAIL

- serialize inbound Telegram chats to prevent reply misrouting
- retain destination-aware outbound messages for retry
- use TG_CHAT_ID for proactive messages
- add owner-only /unbind support
- validate and cache persisted authorization before polling
- advance update offsets only after successful processing
- tolerate Telegram getMe failures
- document Telegram binding, routing, and retry behavior
@TossSky

TossSky commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

@blackhammer116

Retested on 20ead2b against the previous round d65763a, Telegram channel with a live provider, DM and group in one run, clean memory volume.

✅ Most of the first round is closed

  • ✅ Replies now reach the chat that asked. Three rounds with 0, 3 and 8 second gaps between a DM question and group traffic, every answer landed in its own chat
  • PendingMessages is back, so a failed chunk is retried instead of dropped
  • getMe failure no longer aborts channel startup
  • /unbind works in the group and as /unbind <group_id> from the owner's DM, and re-binding afterwards restores access
  • ✅ Authorization state is read once at startup instead of on every message
  • _offset advances after the update is processed, so a failure no longer acknowledges the message away
  • ✅ The docs now describe /bind, /unbind, serial chat processing and the TG_CHAT_ID role
  • tests/ went from 18 to 31

Group access itself still behaves correctly: silent before /bind, open to every member after it, and the secret is still confined to a private DM.

❌ Any message the model does not answer freezes the whole channel

getLastMessage refuses to hand over the next message until the current one has been answered, and _active_replied is only set in _deliver_outbound after a successful send. When the model produces no send, the flag never flips and the channel stops delivering messages from every chat at once.

The cheapest way to trigger it is to say ok:

11:15:14  driver -> bot:  ok
          (HUMAN-MSG: @omegaclaw_qa_driver_bot: ok)     accepted, no send follows
11:16:32  driver -> bot:  QAS2-NEXT: what is 6+6?
          no reply, ever

QAS2-NEXT never appears in the log (0 hits, control marker 97). The loop kept spinning to 2309 iterations with nothing coming through. I reproduced this four ways: a message that needs no reply, a bare ok, an emoji, and an explicit "store this and send no reply". Waiting does not help — I gave it 14 minutes and 11 minutes in two runs. Only a container restart clears it, and _inbox.clear() in start_telegram throws away everything that queued up meanwhile, so those messages are answered never.

A message repeated from another chat hits the same wall, which is the case test_identical_messages_from_different_chats_are_processed is meant to cover. The dedup that swallows it is not in this file: loop.metta:76 compares the incoming text against &prevmsg, and the queue entry is f"{display_name}: {text}", so the same sender repeating the same words in a second chat produces an identical string. The message passes the gate, gets handed to the agent, the model never sees it as new, and the channel locks. Live check: QAID: reply with the single word PONG answered in the DM at 10:54:29, the same text sent to the group at 10:55:56 got nothing, and everything after it was dead.

The test does not catch this because it calls send_message() after every getLastMessage(), so it only exercises the branch where a reply always exists. The same applies to test_failed_delivery_retains_the_original_chat: its second delivery attempt succeeds. A permanent failure behaves differently — when a bot is kicked from a group, sendMessage returns 403 forever, the head of the outbox never drains, and every other chat stays blocked with it:

telegram._enqueue_message("someone: hi from the dead group", "dead_group")
telegram.getLastMessage()
telegram.send_message("reply into the dead group")     # 403, retried forever

telegram._enqueue_message("owner: are you alive?", "dm_owner")
telegram.getLastMessage()                              # returns "" — DM is blocked too

# AssertionError: assert '' == 'owner: are you alive?'

Releasing the slot on a failure the model cannot recover from, or timing it out, would keep one bad chat from taking the rest down.

❌ The agent still cannot say anything on its own

The version banner is dropped on a cold start and on all three restarts I did, with both chats already authorized and loaded from disk:

10:17:15 | INFO    | telegram | Polling started
10:17:15 | WARNING | telegram | Telegram send skipped: no active or default chat is available

OmegaClaw v0. appears 0 times in the log, while the control string matches 608 times.

I understand TG_CHAT_ID is now documented as the destination for proactive messages, but the effect reaches further than startup. Because getLastMessage clears _active_chat_id once the reply is delivered and the queue is empty, a second send in the same train of thought is discarded as well. That cost a real answer in this run: QAC4 The answer is 42 (7 times 6 = 42). executed as a command and never reached Telegram, with the same warning next to it. Four more went the same way.

On b96afaa the banner survived because the outbox held it until a chat bound. Falling back to the last active chat would restore that without reintroducing cross-chat delivery.

Behaviour change worth a deliberate decision

_initialize_offset() is gone, so after a restart the agent replays whatever accumulated in Telegram's queue while it was down. I stopped the container, sent two group messages, started it again, and both were answered. Telegram keeps updates for 24 hours, so after a longer outage the agent will work through a backlog that old. Not calling it a defect, but it is a change from the previous behaviour and it is not mentioned anywhere.

Verdict: ❌ FAIL

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OMEGA-318 & OMEGA-319] Problems with Telegram bot

4 participants