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
6 changes: 4 additions & 2 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,10 @@ def _strip_yaml_frontmatter(content: str) -> str:
"You can manage Tlon with the tlon tool: create groups and channels, "
"invite ships, assign admin roles, inspect history, manage contacts, "
"update Tlon/OpenClaw settings, expose posts, manage hooks, upload files, "
"and create notebook posts. Do not tell the user to create groups or "
"channels manually when the tlon tool is available."
"create notebook posts, and post images/media to gallery channels with "
"gallery_post. Use gallery_post for heap/gallery channels instead of "
"ordinary chat sends. Do not tell the user to create groups or channels "
"manually when the tlon tool is available."
),
"email": (
"You are communicating via email. Write clear, well-structured responses "
Expand Down
67 changes: 67 additions & 0 deletions tests/tools/test_tlon_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from tools.tlon_tool import (
TlonHttpError,
TlonToolError,
TlonGroups,
TlonHooks,
TlonMessages,
Expand Down Expand Up @@ -278,6 +279,72 @@ async def test_notebook_post_uses_diary_metadata():
assert post["meta"]["image"] == "https://example.com/cover.png"


@pytest.mark.asyncio
async def test_gallery_post_uploads_media_and_posts_heap_image(monkeypatch):
async def fake_upload(self, args):
assert args["source"] == "https://example.com/cat.jpg"
return {
"url": "https://memex.tlon.network/v1/bot-palnet/cat.jpg",
"file_name": "cat.jpg",
"content_type": "image/jpeg",
"size": 123,
}

monkeypatch.setattr("tools.tlon_tool.TlonMisc.upload_file", fake_upload)
client = FakeTlonClient()
messages = TlonMessages(client)

result = await messages.handle(
"gallery_post",
{
"channel_id": "heap/~bot-palnet/gallery",
"url": "https://example.com/cat.jpg",
"message": "Cat caption",
"title": "Cat",
},
)

assert result["success"] is True
assert result["image"] == "https://memex.tlon.network/v1/bot-palnet/cat.jpg"
poke = client.pokes[0]
assert poke["app"] == "channels"
assert poke["mark"] == "channel-action-1"
assert poke["json"]["channel"]["nest"] == "heap/~bot-palnet/gallery"
post = poke["json"]["channel"]["action"]["post"]["add"]
assert post["kind"] == "/heap"
assert post["meta"] == {
"title": "Cat",
"description": "",
"image": "https://memex.tlon.network/v1/bot-palnet/cat.jpg",
"cover": "",
}
assert post["content"][-1] == {
"block": {
"image": {
"src": "https://memex.tlon.network/v1/bot-palnet/cat.jpg",
"alt": "Cat",
"width": 0,
"height": 0,
}
}
}


@pytest.mark.asyncio
async def test_gallery_post_requires_heap_channel():
client = FakeTlonClient()
messages = TlonMessages(client)

with pytest.raises(TlonToolError, match="heap channel_id"):
await messages.handle(
"gallery_post",
{
"channel_id": "chat/~bot-palnet/general",
"image": "https://example.com/cat.jpg",
},
)


@pytest.mark.asyncio
async def test_hook_add_uses_channels_server_hook_action():
client = FakeTlonClient()
Expand Down
165 changes: 135 additions & 30 deletions tools/tlon_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@
"message_get",
"post_react",
"post_unreact",
"post_create",
"post_edit",
"post_delete",
"gallery_post",
"dm_accept",
"dm_decline",
"dm_react",
Expand Down Expand Up @@ -193,8 +195,10 @@
"post_id": {"type": "string", "description": "Post id, dotted or bare @ud. For DMs, include ~author/id when possible."},
"parent_id": {"type": "string", "description": "Parent post id for reply operations."},
"author_id": {"type": "string", "description": "Author ship for DM/post operations when needed."},
"message": {"type": "string", "description": "Text/markdown for post_edit."},
"source": {"type": "string", "description": "Inline hook source, notebook Story JSON, or upload URL depending on action."},
"message": {"type": "string", "description": "Text/markdown for posts. For gallery_post this is the caption."},
"source": {"type": "string", "description": "Inline hook source, notebook Story JSON, or media URL/local path to upload for gallery_post/upload_file."},
"url": {"type": "string", "description": "Remote media URL for gallery_post or upload_file when source is not used."},
"alt": {"type": "string", "description": "Alt text for gallery_post image blocks."},
"hook_id": {"type": "string", "description": "Hook id for hook_* actions."},
"hook_ids": {"type": "array", "items": {"type": "string"}, "description": "Ordered hook id list for hook_order."},
"schedule": {"type": "string", "description": "Hook cron schedule in @dr format, e.g. ~h1 or ~m30."},
Expand Down Expand Up @@ -422,7 +426,7 @@ async def _tlon_tool_async(args: Dict[str, Any]) -> Dict[str, Any]:
return await groups.handle(action, args)
if action.startswith("channel_") or action == "channels_list":
return await channels.handle(action, args)
if action.startswith("message") or action.startswith("post_") or action.startswith("dm_") or action == "notebook_post":
if action.startswith("message") or action.startswith("post_") or action.startswith("dm_") or action in {"notebook_post", "gallery_post"}:
return await messages.handle(action, args)
if action.startswith("hook_"):
return await hooks.handle(action, args)
Expand Down Expand Up @@ -985,6 +989,15 @@ async def handle(self, action: str, args: Dict[str, Any]) -> Dict[str, Any]:
return await self._react(args, add=True, dm=False)
if action == "post_unreact":
return await self._react(args, add=False, dm=False)
if action == "post_create":
channel_id = _required(args, "channel_id")
if not _is_group_channel(channel_id):
raise TlonToolError("post_create requires a group channel_id; use send_message for DMs")
content = _story_from_args(args)
sent_at = await self._add_channel_post(channel_id, content, meta=_post_meta(args))
return _ok(action, channel_id=channel_id, kind=_kind_for_channel(channel_id), sent_at=sent_at)
if action == "gallery_post":
return await self._gallery_post(args)
if action == "post_edit":
channel_id = _required(args, "channel_id")
post_id = _format_post_id(_required(args, "post_id"))
Expand Down Expand Up @@ -1050,37 +1063,90 @@ async def handle(self, action: str, args: Dict[str, Any]) -> Dict[str, Any]:
if not channel_id.startswith("diary/"):
raise TlonToolError("notebook_post requires a diary channel_id")
title = _required(args, "title")
sent_at = int(time.time() * 1000)
content = _story_from_args(args)
await self.client.poke(
"channels",
os.getenv("TLON_CHANNEL_ACTION_MARK", "channel-action-1"),
{
"channel": {
"nest": channel_id,
"action": {
"post": {
"add": {
"content": content,
"author": self.client.ship_name,
"sent": sent_at,
"kind": "/diary",
"meta": {
"title": title,
"description": str(args.get("description") or ""),
"image": str(args.get("image") or ""),
"cover": str(args.get("cover") or ""),
},
"blob": None,
}
}
},
}
sent_at = await self._add_channel_post(
channel_id,
content,
kind="/diary",
meta={
"title": title,
"description": str(args.get("description") or ""),
"image": str(args.get("image") or ""),
"cover": str(args.get("cover") or ""),
},
)
return _ok(action, channel_id=channel_id, title=title, sent_at=sent_at)
raise TlonToolError(f"Unsupported message action: {action}")

async def _gallery_post(self, args: Dict[str, Any]) -> Dict[str, Any]:
channel_id = _required(args, "channel_id")
if not channel_id.startswith("heap/"):
raise TlonToolError("gallery_post requires a heap channel_id")

media_source = _media_source_from_args(args)
image_url = str(args.get("image") or "").strip()
uploaded: Optional[Dict[str, Any]] = None
content: List[Any]

if media_source and not _looks_like_json(media_source):
upload_args = dict(args)
upload_args["source"] = media_source
uploaded = await TlonMisc(self.client).upload_file(upload_args)
image_url = str(uploaded.get("url") or image_url).strip()

if image_url:
content = _gallery_image_story(
image_url,
caption=str(args.get("message") or ""),
alt=str(args.get("alt") or args.get("title") or "image"),
)
meta = _gallery_meta(args, image_url)
else:
content = _story_from_args(args)
meta = _post_meta(args)

sent_at = await self._add_channel_post(channel_id, content, kind="/heap", meta=meta)
return _ok(
"gallery_post",
channel_id=channel_id,
sent_at=sent_at,
image=image_url,
uploaded=uploaded,
)

async def _add_channel_post(
self,
channel_id: str,
content: List[Any],
*,
kind: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None,
blob: Any = None,
) -> int:
sent_at = int(time.time() * 1000)
await self.client.poke(
"channels",
os.getenv("TLON_CHANNEL_ACTION_MARK", "channel-action-1"),
{
"channel": {
"nest": channel_id,
"action": {
"post": {
"add": {
"content": content,
"author": self.client.ship_name,
"sent": sent_at,
"kind": kind or _kind_for_channel(channel_id),
"meta": meta,
"blob": blob,
}
}
},
}
},
)
return sent_at

async def _posts(self, channel_id: str, *, mode: str = "newest", cursor: Optional[str] = None, count: int = 20, include_replies: bool = True) -> Any:
if _is_group_channel(channel_id):
path = f"/v5/{channel_id}/posts/{mode}"
Expand Down Expand Up @@ -2005,6 +2071,32 @@ def _post_meta(args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
}


def _gallery_meta(args: Dict[str, Any], image_url: str) -> Dict[str, Any]:
return {
"title": str(args.get("title") or ""),
"description": str(args.get("description") or ""),
"image": image_url,
"cover": str(args.get("cover") or ""),
}


def _gallery_image_story(image_url: str, *, caption: str = "", alt: str = "image") -> List[Any]:
story = _text_to_story(caption.strip()) if caption and caption.strip() else []
story.append(
{
"block": {
"image": {
"src": image_url,
"alt": alt or "image",
"width": 0,
"height": 0,
}
}
}
)
return story


def _story_from_args(args: Dict[str, Any]) -> List[Any]:
value = args.get("json")
if isinstance(value, list):
Expand All @@ -2029,6 +2121,19 @@ def _story_from_args(args: Dict[str, Any]) -> List[Any]:
return _text_to_story(str(args.get("message") or ""))


def _looks_like_json(value: str) -> bool:
stripped = value.strip()
return stripped.startswith("[") or stripped.startswith("{")


def _media_source_from_args(args: Dict[str, Any]) -> str:
for key in ("source", "path", "url"):
value = args.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""


def _source_from_args(args: Dict[str, Any], default: str = "") -> str:
for key in ("source", "message", "value"):
value = args.get(key)
Expand Down Expand Up @@ -2160,9 +2265,9 @@ def _format_cite(cite: Any) -> str:


async def _read_upload_input(args: Dict[str, Any]) -> tuple[bytes, str, str]:
source = str(args.get("source") or args.get("path") or "").strip()
source = str(args.get("source") or args.get("path") or args.get("url") or "").strip()
if not source:
raise TlonToolError("upload_file requires source or path")
raise TlonToolError("upload_file requires source, path, or url")
if source.startswith(("http://", "https://")):
import aiohttp

Expand Down
2 changes: 1 addition & 1 deletion toolsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@
},

"tlon": {
"description": "Tlon/Urbit management: groups, channels, invites, roles, contacts, settings, history, activity, expose, hooks, notebook posts, uploads, and raw scry/poke/thread",
"description": "Tlon/Urbit management: groups, channels, invites, roles, contacts, settings, history, activity, expose, hooks, notebook posts, gallery posts, uploads, and raw scry/poke/thread",
"tools": ["tlon"],
"includes": []
},
Expand Down
5 changes: 3 additions & 2 deletions website/docs/user-guide/messaging/index.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
sidebar_position: 1
title: "Messaging Gateway"
description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Yuanbao, Microsoft Teams, LINE, Webhooks, or any OpenAI-compatible frontend via the API server — architecture and setup overview"
description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, Tlon, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Yuanbao, Microsoft Teams, LINE, Webhooks, or any OpenAI-compatible frontend via the API server — architecture and setup overview"
---

# Messaging Gateway

Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, Tlon, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.

For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes).

Expand All @@ -25,6 +25,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies
| Home Assistant | — | — | — | — | — | — | — |
| Mattermost | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
| Matrix | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Tlon | — | ✅ | ✅ | ✅ | ✅ | — | — |
| DingTalk | — | ✅ | ✅ | — | ✅ | — | ✅ |
| Feishu/Lark | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| WeCom | ✅ | ✅ | ✅ | — | — | ✅ | ✅ |
Expand Down
Loading
Loading