diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index bba38901876..248c272e564 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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 " diff --git a/tests/tools/test_tlon_tool.py b/tests/tools/test_tlon_tool.py index c8ed235b9b1..083e1f2b9b3 100644 --- a/tests/tools/test_tlon_tool.py +++ b/tests/tools/test_tlon_tool.py @@ -2,6 +2,7 @@ from tools.tlon_tool import ( TlonHttpError, + TlonToolError, TlonGroups, TlonHooks, TlonMessages, @@ -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() diff --git a/tools/tlon_tool.py b/tools/tlon_tool.py index 57e0e973ba2..3c1283bdf11 100644 --- a/tools/tlon_tool.py +++ b/tools/tlon_tool.py @@ -109,8 +109,10 @@ "message_get", "post_react", "post_unreact", + "post_create", "post_edit", "post_delete", + "gallery_post", "dm_accept", "dm_decline", "dm_react", @@ -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."}, @@ -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) @@ -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")) @@ -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}" @@ -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): @@ -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) @@ -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 diff --git a/toolsets.py b/toolsets.py index 04116647b23..501e2b7b427 100644 --- a/toolsets.py +++ b/toolsets.py @@ -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": [] }, diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index acd12872812..b478c4da298 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -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). @@ -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 | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | diff --git a/website/docs/user-guide/messaging/tlon.md b/website/docs/user-guide/messaging/tlon.md new file mode 100644 index 00000000000..b205e6d1dcf --- /dev/null +++ b/website/docs/user-guide/messaging/tlon.md @@ -0,0 +1,354 @@ +--- +sidebar_position: 18 +title: "Tlon" +description: "Set up Hermes Agent as a Tlon ship adapter" +--- + +# Tlon Setup + +Hermes can run as a Tlon ship and talk through Tlon DMs, chat channels, +notebooks, and galleries. The adapter connects to your ship over Eyre, listens +for Tlon events, and gives the agent a native `tlon` tool for group, channel, +role, gallery, contact, and message-history operations. + +Use a dedicated bot ship when possible. The bot ship has real Tlon authority: +it can post, create groups, invite ships, manage channels, and assign roles. + +## What Works + +| Feature | Support | +|---------|---------| +| DMs | Responds to allowed ships | +| Group chats | Responds to mentions, participated threads, and owner messages when enabled | +| Channel discovery | Auto-discovers joined chat, notebook, and gallery channels | +| Blob reading | Downloads readable Tlon blob attachments into the agent context | +| Groups | Create, update, invite, join, leave, delete | +| Roles/admins | Create roles, assign roles, promote/demote admins | +| Galleries | Upload remote media to Tlon storage and post to heap/gallery channels | +| Notebooks | Create diary/notebook posts | +| History | Read DMs, channels, threads, and message context | + +## Step 1: Gather Tlon Credentials + +You need: + +- `TLON_SHIP_URL`: the ship URL, for example `https://bot-palnet.tlon.network` +- `TLON_SHIP_NAME`: the bot ship patp, for example `~bot-palnet` +- `TLON_SHIP_CODE`: the ship login `+code` +- `TLON_OWNER_SHIP`: the human owner ship, for example `~zod` + +:::warning +Keep the ship `+code` secret. Anyone with it can log in as that ship. +::: + +## Step 2: Configure Hermes + +### Option A: Setup Wizard + +Run: + +```bash +hermes gateway setup +``` + +Choose **Tlon**, then enter the ship URL, ship name, `+code`, owner ship, and +allowed ships. The wizard writes the values into your Hermes environment. + +### Option B: Manual `.env` + +Edit `~/.hermes/.env`: + +```bash +TLON_SHIP_URL=https://bot-palnet.tlon.network +TLON_SHIP_NAME=~bot-palnet +TLON_SHIP_CODE=sampel-ticlyt-migfun-falmel + +# Access control +TLON_OWNER_SHIP=~zod +TLON_ALLOWED_USERS=~zod +TLON_DEFAULT_AUTHORIZED_SHIPS=~zod + +# Discover groups/channels the bot ship has joined +TLON_AUTO_DISCOVER=true + +# Names that count as a mention in group channels +TLON_BOT_ALIASES=Hermes,Hermetic + +# Let the owner talk in groups without mentioning the bot +TLON_OWNER_LISTEN_ENABLED=true +``` + +Hermes enables the Tlon platform automatically when `TLON_SHIP_URL`, +`TLON_SHIP_NAME`, and `TLON_SHIP_CODE` are all set. + +## Step 3: Configure Model Credentials + +The Tlon adapter only delivers messages. Hermes still needs a model provider. +If you have not configured one yet, run: + +```bash +hermes setup model +``` + +or set your normal provider environment variables before starting the gateway. + +## Step 4: Start the Gateway + +For local debugging, run in the foreground: + +```bash +hermes gateway run +``` + +For a background service: + +```bash +hermes gateway install +hermes gateway start +``` + +Check status and logs: + +```bash +hermes gateway status +tail -f ~/.hermes/logs/gateway.log +tail -f ~/.hermes/logs/gateway.error.log +``` + +A healthy Tlon startup logs: + +- authenticated ship name +- bot nickname, if available +- auto-discovered channel count +- monitored channel nests +- `Connected and listening` + +## Step 5: Test DMs + +From the owner ship, DM the bot ship: + +```text +hello +``` + +The bot should answer without a mention. If it does not, check: + +- `TLON_ALLOWED_USERS` includes your owner ship +- `TLON_OWNER_SHIP` is set +- the gateway log has a Tlon inbound DM event +- the ship URL and `+code` are current + +The adapter also has a DM history fallback poller. It is enabled by default so +missed Tlon SSE DM events still get picked up: + +```bash +TLON_DM_POLL_ENABLED=true +TLON_DM_POLL_INTERVAL=10 +TLON_DM_POLL_INITIAL_CATCHUP_SECONDS=1800 +``` + +## Step 6: Test Group Channels + +Invite the bot ship to a Tlon group, or ask the bot to create one. + +In group chat channels, Hermes responds when: + +- the message mentions the bot ship, nickname, or one of `TLON_BOT_ALIASES` +- the message is in a thread where Hermes has already participated +- the sender is `TLON_OWNER_SHIP` and `TLON_OWNER_LISTEN_ENABLED=true` +- the owner sends a blob-only message that needs attachment handling + +Examples: + +```text +Hermes: summarize this channel +~bot-palnet what groups can you see? +``` + +Channel IDs are Tlon nests: + +```text +chat/~host/general +diary/~host/notebook +heap/~host/gallery +``` + +If auto-discovery is off, specify channels manually: + +```bash +TLON_AUTO_DISCOVER=false +TLON_CHANNELS=chat/~host/general,heap/~host/gallery +``` + +## Step 7: Create Groups and Make Admins + +For "create a group and make me admin" requests, Hermes should use the +dedicated Tlon action `group_create_with_admins`. That path creates the group, +force-adds the requested ships as seats, assigns the `admin` role, and verifies +that the role is visible in group state. + +Example prompt to the bot: + +```text +Create a group called research and make ~zod an admin. +``` + +Expected behavior: + +1. create the group +2. add/invite the admin ship +3. create the `admin` role if needed +4. mark the role as admin +5. assign the role to the target ship +6. verify the target ship has `["admin"]` + +If an existing group needs repair, ask: + +```text +Promote ~zod to admin in ~bot-palnet/research. +``` + +The adapter should use `group_promote`, not ask the user to join first. + +## Step 8: Post to Galleries + +Tlon galleries are heap channels: + +```text +heap/~host/gallery-name +``` + +For images and files, use a reachable URL. Do not use a local file path as +gallery media because Tlon clients cannot render host-local files. + +The reliable flow is: + +1. start with a public or remote image URL +2. upload it to Tlon storage with the Tlon tool +3. post it to the heap/gallery channel with `gallery_post` + +In natural language: + +```text +Upload these image URLs to Tlon storage and post them to heap/~bot-palnet/cats. +``` + +For link/text gallery posts, the bot can post text or URLs directly to the heap +channel. For image posts, it should use the media URL and an optional caption. + +## Step 9: Set a Home Channel + +The home channel is where cron results, background notifications, and gateway +status messages go. For Tlon, use either a DM ship or a channel nest: + +```bash +TLON_HOME_CHANNEL=~zod +# or +TLON_HOME_CHANNEL=chat/~host/general +``` + +Status messages are routed to the owner DM when possible so shutdown/restart +notices do not leak into shared groups. + +## Access Control + +Recommended personal setup: + +```bash +TLON_OWNER_SHIP=~zod +TLON_ALLOWED_USERS=~zod +TLON_DEFAULT_AUTHORIZED_SHIPS=~zod +TLON_ALLOW_ALL_USERS=false +``` + +Use `TLON_ALLOW_ALL_USERS=true` only for disposable test ships. The agent can +use tools and operate the bot ship, so broad access is risky. + +For group access, keep channels restricted by default and authorize specific +ships or channels through Tlon settings. Owner commands include: + +```text +/pending +/approve +/deny +/block +/unblock ~ship +``` + +## Troubleshooting + +### Gateway starts but the bot does not respond + +Check: + +```bash +hermes gateway status +tail -100 ~/.hermes/logs/gateway.log +tail -100 ~/.hermes/logs/gateway.error.log +``` + +Look for Tlon authentication, channel discovery, and inbound DM/channel events. +If the message appears in Tlon history but not the live event stream, keep +`TLON_DM_POLL_ENABLED=true`. + +### Bot responds in DMs but not groups + +Verify: + +- the bot ship has joined the group +- `TLON_AUTO_DISCOVER=true`, or the channel nest is in `TLON_CHANNELS` +- the message mentions the bot, unless owner-listen is enabled +- `TLON_BOT_ALIASES` includes the displayed bot name you are using + +### Admin assignment does not stick + +Use: + +```text +Create a group called X and make ~zod admin. +``` + +or: + +```text +Promote ~zod to admin in ~host/group. +``` + +The bot should use `group_create_with_admins` for new groups and +`group_promote` for existing groups. If it says you must accept an invite first, +that is the wrong workflow. + +### Gallery posts report success but the gallery is empty + +Use a heap channel (`heap/~host/gallery`) and the `gallery_post` path. Sending a +normal chat message to a heap channel may not create a visible gallery item. + +### `group_info` or scry returns 404 + +Make sure you are passing the group flag, not only a channel nest: + +```text +group: ~host/group-slug +channel: chat/~host/channel-slug +``` + +If you have a Tlon app URL, pass the full URL to `group_info`; the tool can +extract `groupId` and `channelId` from the query string. + +## Maintenance + +After changing `.env`, restart the gateway: + +```bash +hermes gateway restart +``` + +After updating Hermes: + +```bash +hermes update +hermes gateway restart +``` + +If you maintain local adapter changes, commit or stash them before running +`hermes update`, otherwise Git will refuse to overwrite modified files.