Skip to content
Draft
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
20 changes: 19 additions & 1 deletion gateway/platforms/tlon.py
Original file line number Diff line number Diff line change
Expand Up @@ -2299,6 +2299,11 @@ async def _dispatch_pending_message(self, approval: PendingApproval) -> None:
user_id=approval.requesting_ship,
user_name=approval.requesting_ship,
thread_id=str(raw.get("thread_id")) if raw.get("thread_id") else None,
parent_chat_id=(
str(raw.get("parent_chat_id"))
if raw.get("parent_chat_id")
else None
),
)
event_obj = MessageEvent(
text=str(raw.get("text") or ""),
Expand Down Expand Up @@ -2684,6 +2689,15 @@ async def _handle_channel_event(self, event: Any) -> None:
return

# Check user authorization
group_id = self._channel_to_group.get(nest)
if not group_id and self._sse:
try:
await self._discover_channels()
group_id = self._channel_to_group.get(nest)
except Exception as exc:
logger.debug("[tlon] Group lookup refresh failed for %s: %s", nest, exc)
group_name = self._group_names.get(group_id or "")

if not self._is_channel_allowed(sender, nest):
logger.info("[tlon] Unauthorized user %s in %s", sender, nest)
if self.owner_ship:
Expand All @@ -2697,6 +2711,7 @@ async def _handle_channel_event(self, event: Any) -> None:
"chat_id": nest,
"chat_name": (_parse_channel_nest(nest) or {}).get("name", nest),
"chat_type": "group",
"parent_chat_id": group_id,
"text": self._strip_bot_mention(text) if mentioned else text,
"message_id": str(effective_id),
"reply_to_message_id": str(parent_id) if parent_id else None,
Expand All @@ -2716,13 +2731,16 @@ async def _handle_channel_event(self, event: Any) -> None:

# Build message event
parsed = _parse_channel_nest(nest)
channel_name = parsed["name"] if parsed else nest
chat_name = f"{group_name} / {channel_name}" if group_name else channel_name
source = self.build_source(
chat_id=nest,
chat_name=parsed["name"] if parsed else nest,
chat_name=chat_name,
chat_type="group",
user_id=sender,
user_name=sender,
thread_id=str(parent_id) if parent_id else None,
parent_chat_id=group_id,
)

event_obj = MessageEvent(
Expand Down
187 changes: 150 additions & 37 deletions gateway/run.py

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,19 @@ def build_session_context_prompt(
"Use target='yuanbao:direct:<account_id>' for DM "
"and target='yuanbao:group:<group_code>' for group chat."
)
elif context.source.platform == Platform.TLON:
src = context.source
if src.chat_type == "group":
lines.append("")
lines.append("**Tlon IDs:**")
if src.parent_chat_id:
lines.append(f" - Group: `{src.parent_chat_id}` (use as `group_id` for the `tlon` tool)")
lines.append(f" - Channel: `{src.chat_id}` (use as `channel_id` for messages/channels)")
if src.thread_id:
lines.append(f" - Thread/root post: `{src.thread_id}`")
lines.append(
"When the user refers to this group or channel, use these exact IDs."
)

# Connected platforms
platforms_list = ["local (files on this machine)"]
Expand Down
62 changes: 62 additions & 0 deletions tests/gateway/test_restart_drain.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source


def make_tlon_status_runner(owner_ship="~malmur-halmex"):
runner, adapter = make_restart_runner()
adapter.owner_ship = owner_ship
runner.config.platforms = {
gateway_run.Platform.TLON: gateway_run.PlatformConfig(enabled=True),
}
runner.adapters = {gateway_run.Platform.TLON: adapter}
return runner, adapter


@pytest.mark.asyncio
async def test_restart_command_while_busy_requests_drain_without_interrupt(monkeypatch):
# Ensure INVOCATION_ID is NOT set — systemd sets this in service mode,
Expand Down Expand Up @@ -318,3 +328,55 @@ async def test_shutdown_notification_uses_persisted_origin_for_colon_ids():

assert adapter.send.await_count == 1
assert adapter.send.await_args.args[0] == "!room123:example.org"


@pytest.mark.asyncio
async def test_tlon_shutdown_notification_routes_group_session_to_owner_dm():
"""Tlon lifecycle status must go to the owner DM, never the group channel."""
runner, adapter = make_tlon_status_runner()
source = make_restart_source(
chat_id="chat/~ramlud-bintun/v1fsl36d",
chat_type="group",
)
source.platform = gateway_run.Platform.TLON
session_key = build_session_key(source)
runner._running_agents[session_key] = MagicMock()
runner.session_store._entries = {
session_key: SessionEntry(
session_key=session_key,
session_id="sess-tlon",
created_at=datetime.now(),
updated_at=datetime.now(),
origin=source,
platform=source.platform,
chat_type=source.chat_type,
)
}

await runner._notify_active_sessions_of_shutdown()

assert len(adapter.sent_calls) == 1
chat_id, content, metadata = adapter.sent_calls[0]
assert chat_id == "~malmur-halmex"
assert "Gateway shutting down" in content
assert metadata is None


@pytest.mark.asyncio
async def test_tlon_shutdown_notification_skips_without_owner_ship(monkeypatch):
monkeypatch.delenv("TLON_OWNER_SHIP", raising=False)
runner, adapter = make_tlon_status_runner(owner_ship="")
source = make_restart_source(
chat_id="chat/~ramlud-bintun/v1fsl36d",
chat_type="group",
)
source.platform = gateway_run.Platform.TLON
session_key = build_session_key(source)
runner._running_agents[session_key] = MagicMock()
runner.session_store._entries = {
session_key: MagicMock(origin=source),
}

await runner._notify_active_sessions_of_shutdown()

assert adapter.sent_calls == []
52 changes: 52 additions & 0 deletions tests/gateway/test_restart_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@
)


def make_tlon_status_runner(owner_ship="~malmur-halmex"):
runner, adapter = make_restart_runner()
adapter.owner_ship = owner_ship
runner.config.platforms = {
Platform.TLON: gateway_run.PlatformConfig(enabled=True),
}
runner.adapters = {Platform.TLON: adapter}
return runner, adapter


# ── restart marker helpers ───────────────────────────────────────────────


Expand Down Expand Up @@ -388,6 +398,48 @@ async def test_send_restart_notification_with_thread(tmp_path, monkeypatch):
assert not notify_path.exists()


@pytest.mark.asyncio
async def test_tlon_restart_notification_routes_group_to_owner_dm(tmp_path, monkeypatch):
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)

notify_path = tmp_path / ".restart_notify.json"
notify_path.write_text(json.dumps({
"platform": "tlon",
"chat_id": "chat/~ramlud-bintun/v1fsl36d",
"thread_id": "170.141",
}))

runner, adapter = make_tlon_status_runner()

delivered_target = await runner._send_restart_notification()

assert delivered_target == ("tlon", "~malmur-halmex", None)
chat_id, content, metadata = adapter.sent_calls[0]
assert chat_id == "~malmur-halmex"
assert "restarted" in content.lower()
assert metadata is None
assert not notify_path.exists()


@pytest.mark.asyncio
async def test_tlon_home_startup_notification_routes_home_group_to_owner_dm():
runner, adapter = make_tlon_status_runner()
runner.config.platforms[Platform.TLON].home_channel = HomeChannel(
platform=Platform.TLON,
chat_id="chat/~ramlud-bintun/v1fsl36d",
name="Hermes Group",
thread_id="170.141",
)

delivered = await runner._send_home_channel_startup_notifications()

assert delivered == {("tlon", "~malmur-halmex", None)}
chat_id, content, metadata = adapter.sent_calls[0]
assert chat_id == "~malmur-halmex"
assert "Gateway online" in content
assert metadata is None


@pytest.mark.asyncio
async def test_send_restart_notification_noop_when_no_file(tmp_path, monkeypatch):
"""Nothing happens if there's no pending restart notification."""
Expand Down
23 changes: 23 additions & 0 deletions tests/gateway/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,29 @@ def test_bluebubbles_prompt_mentions_short_conversational_i_message_format(self)
assert "short and conversational" in prompt
assert "blank line" in prompt

def test_tlon_group_prompt_includes_group_and_channel_ids(self):
config = GatewayConfig(
platforms={
Platform.TLON: PlatformConfig(enabled=True),
},
)
source = SessionSource(
platform=Platform.TLON,
chat_id="chat/~ramlud-bintun/v1fsl36d",
chat_name="Hermes Group / general",
chat_type="group",
user_id="~malmur-halmex",
user_name="~malmur-halmex",
parent_chat_id="~ramlud-bintun/v1l3qcoq",
)
ctx = build_session_context(source, config)
prompt = build_session_context_prompt(ctx)

assert "Tlon IDs" in prompt
assert "Group: `~ramlud-bintun/v1l3qcoq`" in prompt
assert "Channel: `chat/~ramlud-bintun/v1fsl36d`" in prompt
assert "use these exact IDs" in prompt

def test_discord_prompt(self):
config = GatewayConfig(
platforms={
Expand Down
50 changes: 50 additions & 0 deletions tests/gateway/test_tlon_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,8 +416,11 @@ async def test_foreigns_event_accepts_owner_invite_even_without_auto_accept(monk
@pytest.mark.asyncio
async def test_channel_event_routes_top_level_mentions(monkeypatch):
monkeypatch.setenv("TLON_SHIP_NAME", "~bot-palnet")
monkeypatch.setenv("TLON_ALLOW_ALL_USERS", "true")
adapter = TlonAdapter(PlatformConfig())
adapter.monitored_channels = {"chat/~host/test"}
adapter._channel_to_group["chat/~host/test"] = "~host/group"
adapter._group_names["~host/group"] = "Test Group"
adapter.handle_message = AsyncMock()

await adapter._handle_channel_event({
Expand Down Expand Up @@ -447,6 +450,8 @@ async def test_channel_event_routes_top_level_mentions(monkeypatch):
assert event.message_id == "170141184507864167403996323545639550976"
assert event.reply_to_message_id is None
assert event.source.chat_id == "chat/~host/test"
assert event.source.parent_chat_id == "~host/group"
assert event.source.chat_name == "Test Group / test"
assert event.source.user_id == "~zod"
assert isinstance(event.timestamp, datetime)

Expand Down Expand Up @@ -661,6 +666,7 @@ async def test_channel_event_ignores_owner_when_owner_listen_disabled_for_channe
@pytest.mark.asyncio
async def test_channel_event_routes_thread_reply_to_parent(monkeypatch):
monkeypatch.setenv("TLON_SHIP_NAME", "~bot-palnet")
monkeypatch.setenv("TLON_ALLOW_ALL_USERS", "true")
adapter = TlonAdapter(PlatformConfig())
adapter.monitored_channels = {"chat/~host/test"}
adapter.handle_message = AsyncMock()
Expand Down Expand Up @@ -741,6 +747,50 @@ async def test_channel_event_routes_openclaw_thread_reply_essay(monkeypatch):
assert event.source.thread_id == "parent-post"


@pytest.mark.asyncio
async def test_channel_event_refreshes_group_mapping_for_context(monkeypatch):
monkeypatch.setenv("TLON_SHIP_NAME", "~bot-palnet")
monkeypatch.setenv("TLON_ALLOW_ALL_USERS", "true")
monkeypatch.setenv("TLON_AUTO_DISCOVER", "true")
adapter = TlonAdapter(PlatformConfig())
adapter.monitored_channels = {"chat/~host/test"}
adapter._sse = AsyncMock()
adapter._sse.scry.return_value = {
"groups": {
"~host/group": {
"meta": {"title": "Test Group"},
"channels": {"chat/~host/test": {}},
}
}
}
adapter.handle_message = AsyncMock()

await adapter._handle_channel_event({
"nest": "chat/~host/test",
"response": {
"post": {
"id": "post-id",
"r-post": {
"set": {
"seal": {"id": "post-id"},
"essay": {
"author": "~zod",
"sent": 1_700_000_000_000,
"content": [
{"inline": [{"ship": "~bot-palnet"}, " hello"]}
],
},
}
},
}
},
})

event = adapter.handle_message.await_args.args[0]
assert event.source.parent_chat_id == "~host/group"
assert event.source.chat_name == "Test Group / test"


@pytest.mark.asyncio
async def test_channel_event_routes_blob_only_owner_message(monkeypatch):
monkeypatch.setenv("TLON_SHIP_NAME", "~bot-palnet")
Expand Down
30 changes: 30 additions & 0 deletions tests/gateway/test_update_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,36 @@ async def test_sends_notification_with_thread_metadata(self, tmp_path):

assert mock_adapter.send.call_args.kwargs["metadata"] == {"thread_id": "777"}

@pytest.mark.asyncio
async def test_tlon_update_notification_routes_group_to_owner_dm(self, tmp_path):
"""Tlon update status goes to owner DM instead of the source group."""
runner = _make_runner()
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()

pending = {
"platform": "tlon",
"chat_id": "chat/~ramlud-bintun/v1fsl36d",
"thread_id": "170.141",
"user_id": "~malmur-halmex",
}
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
(hermes_home / ".update_output.txt").write_text("done")
(hermes_home / ".update_exit_code").write_text("0")

mock_adapter = AsyncMock()
mock_adapter.owner_ship = "~malmur-halmex"
mock_adapter.send = AsyncMock()
runner.adapters = {Platform.TLON: mock_adapter}

with patch("gateway.run._hermes_home", hermes_home):
await runner._send_update_notification()

mock_adapter.send.assert_called_once()
call_args = mock_adapter.send.call_args
assert call_args.args[0] == "~malmur-halmex"
assert call_args.kwargs["metadata"] is None

@pytest.mark.asyncio
async def test_strips_ansi_codes(self, tmp_path):
"""ANSI escape codes are removed from output."""
Expand Down
Loading
Loading