Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
521 changes: 365 additions & 156 deletions channels/auth.py

Large diffs are not rendered by default.

847 changes: 551 additions & 296 deletions channels/telegram.py

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ SL_MAX_FILE_SIZE_MB: 5

# Telegram
# Enabled with commchannel: telegram
# Telegram chat ID. If empty, Omega auto-binds after first valid inbound auth/message.
# Legacy single-chat allowlist. When set, only this chat is accepted.
TG_CHAT_ID: ""
# Optional additional comma-separated chat IDs. These are a hard
# administrator boundary. With authentication enabled, the owner can still
# bootstrap and use their private DM.
TG_ALLOWED_CHAT_IDS: ""
# Telegram polling timeout in seconds.
TG_POLL_TIMEOUT: 20

Expand Down
13 changes: 9 additions & 4 deletions docs/reference-channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,15 @@ Mattermost adapter using a bot token.

Telegram adapter using Bot API long polling.

- `start_telegram(chat_id, poll_timeout)` — starts a poll loop.
- `TG_CHAT_ID` is optional; if empty, the adapter can auto-bind to the first valid inbound chat.
- Outbound messages are chunked to Telegram-safe lengths.
- Uses the same one-time `auth <secret>` ownership gate as the other adapters.
- `start_telegram(chat_id, allowed_chat_ids, poll_timeout)` — validates saved authorization state and starts the poll loop.
- With authentication enabled, the owner authenticates with `auth <secret>` in a private DM. That DM becomes the default destination for startup, heartbeat, and other proactive messages and is restored from persisted owner state after restart.
- The owner uses `/bind` in a group to add its chat ID to the runtime and persisted allowed-group sets. `/unbind` removes the current group; `/unbind <group_id>` performs the same operation from the owner's DM. Targeted forms such as `/bind@BotName` and `/unbind@BotName` are supported.
- `TG_ALLOWED_CHAT_IDS` supplies initial operator-configured chat IDs. Runtime `/bind` additions and `/unbind` removals are persisted in `memory/.channel/authenticated-group.json`; the YAML file itself is never rewritten.
- Each inbound message is delivered to the agent as `[chat_id] [message_id] message`. Dequeueing does not depend on the model producing or successfully delivering a reply, so a no-response turn cannot freeze later inbound messages.
- Outbound replies use the same `[chat_id] [message_id] message` envelope. An empty target falls back to the owner DM, and an empty message ID sends without Telegram reply metadata. Legacy plain outbound text also uses the owner-DM fallback.
- Explicit LLM-generated targets are accepted only for the owner DM or a currently authorized group. Group chat IDs may be negative.
- Outbound messages are split into Telegram-safe chunks and retained with their destination and reply ID for retry after transient delivery failures.
- When `commchannel=telegram`, startup registers the routing instructions from `memory/tg_prompt.txt` through `add-prompt-extension`. Other channels do not receive this prompt section.

## `channels/slack.py`

Expand Down
16 changes: 15 additions & 1 deletion docs/reference-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ This reads a command-line override via `argk` (`name=value` on the MeTTa command
| `IRC_server` | `irc.quakenet.org` | IRC server hostname. |
| `IRC_port` | 6667 | IRC port. |
| `IRC_user` | `omega` | IRC nickname. |
| `TG_CHAT_ID` | *(empty — auto-bind supported)* | Optional fixed Telegram chat ID. Leave empty to auto-bind on first valid inbound auth/message. |
| `TG_CHAT_ID` | *(empty)* | Legacy single-chat bootstrap/fallback, primarily for authentication-disabled deployments. With authentication enabled, the authenticated owner's DM becomes the proactive-message default. |
| `TG_ALLOWED_CHAT_IDS` | *(empty)* | Optional comma-separated initial allowed-chat set. The authenticated owner can add groups with `/bind` and remove them with `/unbind`; those changes are persisted in `memory/.channel/authenticated-group.json`, not written back to YAML. |
| `TG_POLL_TIMEOUT` | 20 | Telegram long-poll timeout in seconds. |
| `SL_CHANNEL_ID` | *(empty — auto-bind supported)* | Optional Slack channel ID where Omega reads/writes messages. Leave empty to auto-bind on first valid inbound auth/message. |
| `SL_POLL_INTERVAL` | 60 | Slack poll interval in seconds (minimum effective value is 60). |
Expand All @@ -54,6 +55,7 @@ This reads a command-line override via `argk` (`name=value` on the MeTTa command
| Environment variable | Meaning |
|---|---|
| `TG_BOT_TOKEN` | Telegram bot token (from BotFather). |
| `OMEGA_AUTH_SECRET` | Enables the one-time owner-authentication handshake when non-empty. The owner sends `auth <secret>` in a private Telegram DM. |
| `MM_BOT_TOKEN` | Bot auth token. |
| `SL_BOT_TOKEN` | Slack bot token (`xoxb-...`). |

Expand All @@ -65,6 +67,18 @@ Any `configure`d parameter can be overridden at startup:
metta run.metta provider=Anthropic LLM=claude-opus-4-6 commchannel=mattermost
```

Configuration values are resolved in this order: command-line `key=value`,
`OMEGACLAW_<KEY>` environment variable, `config/config.yaml`, then the caller's
default. `TG_BOT_TOKEN` and `OMEGA_AUTH_SECRET` are read directly from the
environment and must be placed before the `metta`/`petta` command.

Telegram example:

```bash
TG_BOT_TOKEN=... OMEGA_AUTH_SECRET=... \
metta run.metta commchannel=telegram
```

Slack example:

```bash
Expand Down
19 changes: 19 additions & 0 deletions memory/tg_prompt.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
TELEGRAM ROUTING:

Incoming Telegram messages use this structure:
[chat-id] [reply-id] message

The first field is the destination Telegram chat ID. The second field is the
Telegram message ID to reply to. Group chat IDs may be negative.

When responding to an incoming Telegram message, every send command must begin
with the exact chat ID and reply ID received with that message:
send [chat-id] [reply-id] response
Example:
send [-3341114] [223] Hey!

Copy both IDs exactly. Never invent, alter, exchange, or omit them when replying.
Multiple send commands responding to the same message must all use the same IDs.

An empty target routes to the authenticated owner's direct message. An empty
reply ID sends a normal message without attaching it as a reply.
10 changes: 10 additions & 0 deletions src/loop.metta
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@
" HISTORY: " (getHistory)
" TIME: " (get_time_as_string)))))

(= (addTelegramPromptExtension)
(if (== (commchannel) telegram)
(let $path (joinPath ((memoryDirectory) "tg_prompt.txt"))
(if (exists-file $path)
(add-prompt-extension tg-prompt (read-file $path))
(progn (log WARN "loop" (TELEGRAM_PROMPT_NOT_FOUND: $path))
())))
()))

(= (getPromptExtensions)
(join (newline) (collapse (prompt-extension $_))))

Expand All @@ -66,6 +75,7 @@
(initKnowledge)
(initPlugins)
(initChannels)
(addTelegramPromptExtension)
(commChannelSend (version))
(llmProviderStart (provider)))
(change-state! &loops (- (get-state &loops) 1)))
Expand Down
16 changes: 16 additions & 0 deletions tests/src_skills.metta
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
!(import! &self ../src/helper.py)
!(import! &self ./src/utils)
!(import! &self ./src/skills)

; Define the selected channel and memory path used by the Telegram prompt
; registration test.
(= (commchannel) telegram)
(= (memoryDirectory) (joinPath ((projectRootDirectory) "memory")))

!(import! &self ./src/loop)

!(test (progn
Expand All @@ -24,6 +30,16 @@
(contains-text (getPromptExtensions) "TEST PROMPT EXTENSION"))
False)

!(test (progn
(addTelegramPromptExtension)
(contains-text (getPromptExtensions) "TELEGRAM ROUTING:"))
True)

!(test (progn
(remove-prompt-extension tg-prompt)
(contains-text (getPromptExtensions) "TELEGRAM ROUTING:"))
False)

(= (on-heartbeat $iter)
(let $count (get-state &test_heartbeat_counter)
(change-state! &test_heartbeat_counter (+ $count $iter))))
Expand Down
95 changes: 95 additions & 0 deletions tests/test_auth_standalone.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ def test_saved_owner_blocks_auth_secret_reuse_after_restart(monkeypatch, tmp_pat
assert owner_process.authenticate_channel_user("IRC", "owner") == "allow"


def test_saved_owner_cannot_be_replaced_by_a_reused_secret(monkeypatch, tmp_path):
monkeypatch.setenv("OMEGA_AUTH_SECRET", "one-time-secret")

first_process = load_auth_module(monkeypatch)
monkeypatch.setattr(first_process, "_MEMORY_DIRECTORY", str(tmp_path))
assert first_process.authenticate_channel_user(
"TELEGRAM", "owner", "one-time-secret"
) == "auth_bound"

restarted_process = load_auth_module(monkeypatch)
monkeypatch.setattr(restarted_process, "_MEMORY_DIRECTORY", str(tmp_path))
assert restarted_process.authenticate_channel_user(
"TELEGRAM", "attacker", "one-time-secret"
) == "ignore"
assert restarted_process.get_channel_authenticated_user_id("TELEGRAM") == "owner"


def test_plain_message_does_not_verify_a_token(monkeypatch):
auth = load_auth_module(monkeypatch)
monkeypatch.setattr(
Expand All @@ -95,3 +112,81 @@ def test_plain_message_does_not_verify_a_token(monkeypatch):
monkeypatch.setattr(auth, "get_channel_authenticated_user_id", lambda *args: None)

assert auth.authenticate_channel_user("IRC", "alice") == "ignore"


def test_owner_can_revoke_an_authorized_group(monkeypatch, tmp_path):
auth = load_auth_module(monkeypatch)
monkeypatch.setattr(auth, "_MEMORY_DIRECTORY", str(tmp_path))

monkeypatch.setattr(auth, "get_channel_authenticated_user_id", lambda _channel: "owner")
assert auth.store_channel_authenticated_group_id("TELEGRAM", "group", "owner") is True
assert auth.get_channel_saved_group_id("TELEGRAM", "group") is True

assert auth.revoke_channel_group("TELEGRAM", "group", "attacker") == "ignore"
assert auth.get_channel_saved_group_id("TELEGRAM", "group") is True

assert auth.revoke_channel_group("TELEGRAM", "group", "owner") == "group_unbound"
assert auth.get_channel_saved_group_id("TELEGRAM", "group") is False

assert auth.store_channel_authenticated_group_id("TELEGRAM", "group", "owner") is True
assert auth.get_channel_saved_group_id("TELEGRAM", "group") is True


def test_group_record_requires_authorizing_owner(monkeypatch, tmp_path):
auth = load_auth_module(monkeypatch)
monkeypatch.setattr(auth, "_MEMORY_DIRECTORY", str(tmp_path))

with pytest.raises(ValueError, match="authorized_by_user_id is required"):
auth.store_channel_authenticated_group_id("TELEGRAM", "group", "")


def test_group_records_from_non_owner_are_not_loaded(monkeypatch, tmp_path):
auth = load_auth_module(monkeypatch)
monkeypatch.setattr(auth, "_MEMORY_DIRECTORY", str(tmp_path))

assert auth.store_channel_authenticated_user_id("TELEGRAM", "owner") is True
assert auth.store_channel_authenticated_group_id(
"TELEGRAM", "forged-group", "attacker"
) is True

assert auth.get_channel_saved_group_id("TELEGRAM", "forged-group") is False
assert auth.load_channel_auth_state("TELEGRAM") == ("owner", set())


def test_load_channel_auth_state_validates_and_loads_records(monkeypatch, tmp_path):
auth = load_auth_module(monkeypatch)
monkeypatch.setattr(auth, "_MEMORY_DIRECTORY", str(tmp_path))

assert auth.store_channel_authenticated_user_id("TELEGRAM", "owner") is True
assert auth.store_channel_authenticated_group_id("TELEGRAM", "active", "owner") is True
assert auth.store_channel_authenticated_group_id("TELEGRAM", "revoked", "owner") is True
assert auth.revoke_channel_group("TELEGRAM", "revoked", "owner") == "group_unbound"

owner, groups = auth.load_channel_auth_state("TELEGRAM")

assert owner == "owner"
assert groups == {"active"}


@pytest.mark.parametrize("damaged_record", ["not-json\n", '{"time":'])
def test_load_channel_auth_state_skips_malformed_records(
monkeypatch, tmp_path, damaged_record
):
auth = load_auth_module(monkeypatch)
monkeypatch.setattr(auth, "_MEMORY_DIRECTORY", str(tmp_path))
warnings = []
monkeypatch.setattr(auth.logger, "warning", warnings.append)

assert auth.store_channel_authenticated_user_id("TELEGRAM", "owner") is True
assert auth.store_channel_authenticated_group_id(
"TELEGRAM", "active", "owner"
) is True
path = tmp_path / ".channel" / "authenticated-group.json"
with path.open("a", encoding="utf-8") as target:
target.write(damaged_record)

assert auth.load_channel_auth_state("TELEGRAM") == ("owner", {"active"})
assert any(
"Skipping malformed channel authenticated group record at line 2" in warning
for warning in warnings
)
10 changes: 8 additions & 2 deletions tests/test_channel_auth_gating.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
("module_name", "arguments"),
[
("irc", ("alice", "hello 🌍")),
("telegram", ("chat", "alice", "hello 🌍")),
("telegram", ("chat", "alice", "private", "hello 🌍")),
("slack", ("channel", "alice", "hello 🌍")),
("mattermost", ("alice", "hello 🌍")),
],
Expand All @@ -23,6 +23,9 @@ def test_unbound_plain_message_is_not_used_as_auth_token(monkeypatch, module_nam
auth = types.ModuleType("auth")
auth.is_auth_enabled = lambda: True
auth.get_channel_saved_user_id = lambda *args: False
auth.get_channel_saved_group_id = lambda *args: False
auth.get_channel_authenticated_user_id = lambda *args: None
auth.authorize_channel_group = lambda *args: "ignore"
calls = []

def authenticate_channel_user(*args):
Expand All @@ -46,4 +49,7 @@ def authenticate_channel_user(*args):
spec.loader.exec_module(module)

assert module._is_allowed_message(*arguments) == "ignore"
assert calls == [(module_name.upper(), "alice", None)]
if module_name == "telegram":
assert calls == []
else:
assert calls == [(module_name.upper(), "alice", None)]
Loading
Loading