|
|
@@ -3983,6 +3983,115 @@ CHATTO_SEND_ROOM_SCHEMA = {
|
|
|
}
|
|
|
|
|
|
|
|
|
+CHATTO_GET_MESSAGE_SCHEMA = {
|
|
|
+ "name": "chatto_get_message",
|
|
|
+ "description": (
|
|
|
+ "Read one or more Chatto messages by event ID, with all their data: "
|
|
|
+ "body, author (user ID + login), timestamps, reply/thread references "
|
|
|
+ "(in_reply_to, thread_root_event_id), attachments, reactions, pinned "
|
|
|
+ "and deleted status. Use it to resolve a `reply_to=`/`in_reply_to` "
|
|
|
+ "reference or any other event ID mentioned in a message. "
|
|
|
+ "`follow_reply_chain` (0-10) additionally walks the in_reply_to chain "
|
|
|
+ "backwards and returns the ancestor messages oldest-first. `event_ids` "
|
|
|
+ "fetches several messages in one call (then `event_id` is refused)."
|
|
|
+ ),
|
|
|
+ "parameters": {
|
|
|
+ "type": "object",
|
|
|
+ "properties": {
|
|
|
+ "room_id": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "The Chatto room ID the message lives in "
|
|
|
+ "(e.g. 'RbmwiHk4vs9a3Ql'). Exactly one of `room_id` or "
|
|
|
+ "`room_name` is required."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "room_name": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "The room's display name (case-insensitive). Exactly one of "
|
|
|
+ "`room_id` or `room_name` is required; ambiguous names are "
|
|
|
+ "refused with the candidate IDs."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "event_id": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "The event ID of the message to read (e.g. 'EM0D7tFdvd14EXS'). "
|
|
|
+ "Exactly one of `event_id` or `event_ids` is required."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "event_ids": {
|
|
|
+ "type": "array",
|
|
|
+ "items": {"type": "string"},
|
|
|
+ "description": (
|
|
|
+ "Several event IDs to read in one call. Mutually exclusive "
|
|
|
+ "with `event_id`."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "follow_reply_chain": {
|
|
|
+ "type": "integer",
|
|
|
+ "description": (
|
|
|
+ "How many in_reply_to ancestors to resolve, 0-10 "
|
|
|
+ "(default 0 = do not follow)."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "required": [],
|
|
|
+ "additionalProperties": False,
|
|
|
+ },
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+CHATTO_GET_HISTORY_SCHEMA = {
|
|
|
+ "name": "chatto_get_history",
|
|
|
+ "description": (
|
|
|
+ "Read the most recent messages of a Chatto room, or of one thread in "
|
|
|
+ "it, oldest-first. Use it for context: what was said before a message "
|
|
|
+ "the bot is about to answer or reference. With `thread_root_event_id` "
|
|
|
+ "the thread's events are read instead of the room timeline; the root "
|
|
|
+ "has to belong to the room and BE a root, like in chatto_send_room. "
|
|
|
+ "`limit` (1-100, default 20) bounds the page; `has_older` says whether "
|
|
|
+ "older history exists."
|
|
|
+ ),
|
|
|
+ "parameters": {
|
|
|
+ "type": "object",
|
|
|
+ "properties": {
|
|
|
+ "room_id": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "The Chatto room ID to read (e.g. 'RbmwiHk4vs9a3Ql'). "
|
|
|
+ "Exactly one of `room_id` or `room_name` is required."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "room_name": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "The room's display name (case-insensitive). Exactly one of "
|
|
|
+ "`room_id` or `room_name` is required; ambiguous names are "
|
|
|
+ "refused with the candidate IDs."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "thread_root_event_id": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "Optional: read this thread's events instead of the room "
|
|
|
+ "timeline. The ID must be a thread ROOT in this room."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "limit": {
|
|
|
+ "type": "integer",
|
|
|
+ "description": (
|
|
|
+ "Maximum number of messages to return, 1-100 (default 20)."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "required": [],
|
|
|
+ "additionalProperties": False,
|
|
|
+ },
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
def _chatto_config_for_tools() -> ChattoConfiguration | None:
|
|
|
"""Resolve the Chatto configuration for plugin tool handlers.
|
|
|
|
|
|
@@ -4549,6 +4658,246 @@ async def _list_rooms(args: dict) -> str:
|
|
|
)
|
|
|
|
|
|
|
|
|
+def _iso_or_none(moment: datetime | None) -> str | None:
|
|
|
+ """A Message timestamp as ISO-8601, or None when the server sent none."""
|
|
|
+ if moment is None:
|
|
|
+ return None
|
|
|
+ return moment.isoformat()
|
|
|
+
|
|
|
+
|
|
|
+async def _batch_author_logins(
|
|
|
+ client: ChattoClient, room_id: str, actor_ids: list[str]
|
|
|
+) -> dict[str, str]:
|
|
|
+ """Map actor IDs to logins, using the room directory the bot may read.
|
|
|
+
|
|
|
+ ``batch_get_room_members`` needs no admin rights and answers member
|
|
|
+ profiles for the caller's own rooms, so it is the one sanctioned way to
|
|
|
+ turn an ``actor_id`` into a name here. Members who left (or were never
|
|
|
+ members) come back missing — their IDs stay as their only label, which
|
|
|
+ is still unambiguous for the caller. A directory that refuses the call
|
|
|
+ costs the names, not the messages: the logins are enrichment, and an
|
|
|
+ unreadable roster must not make a readable history unreadable.
|
|
|
+ """
|
|
|
+ unique_ids = list({actor_id for actor_id in actor_ids if actor_id})
|
|
|
+ if not unique_ids:
|
|
|
+ return {}
|
|
|
+ try:
|
|
|
+ members = await client.batch_get_room_members(room_id, unique_ids)
|
|
|
+ except ChattoError as exc:
|
|
|
+ logger.debug("Chatto tools: members of room %s unreadable: %s", room_id, exc)
|
|
|
+ return {}
|
|
|
+ logins: dict[str, str] = {}
|
|
|
+ for member in members:
|
|
|
+ if member.user is not None and member.user.id:
|
|
|
+ logins[member.user.id] = member.user.login
|
|
|
+ return logins
|
|
|
+
|
|
|
+
|
|
|
+def _attachment_out(attachment: MessageAttachment) -> dict[str, Any]:
|
|
|
+ """One attachment as the tool's JSON shape."""
|
|
|
+ out: dict[str, Any] = {
|
|
|
+ "id": attachment.id,
|
|
|
+ "filename": attachment.filename,
|
|
|
+ "content_type": attachment.content_type,
|
|
|
+ "width": attachment.width,
|
|
|
+ "height": attachment.height,
|
|
|
+ "description": attachment.description,
|
|
|
+ }
|
|
|
+ if attachment.asset_url is not None:
|
|
|
+ out["asset_url"] = attachment.asset_url.url
|
|
|
+ if attachment.thumbnail_asset_url is not None:
|
|
|
+ out["thumbnail_asset_url"] = attachment.thumbnail_asset_url.url
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def _message_out(message: Message, author_logins: dict[str, str]) -> dict[str, Any]:
|
|
|
+ """A Message as the tool's JSON shape — every field the caller can act on."""
|
|
|
+ return {
|
|
|
+ "event_id": message.id,
|
|
|
+ "room_id": message.room_id,
|
|
|
+ "author_user_id": message.actor_id,
|
|
|
+ "author_login": author_logins.get(message.actor_id),
|
|
|
+ "created_at": _iso_or_none(message.created_at),
|
|
|
+ "updated_at": _iso_or_none(message.updated_at),
|
|
|
+ "deleted_at": _iso_or_none(message.deleted_at),
|
|
|
+ "body": message.body,
|
|
|
+ "in_reply_to": message.in_reply_to or None,
|
|
|
+ "thread_root_event_id": message.thread_root_event_id or None,
|
|
|
+ "pinned": message.pinned,
|
|
|
+ "attachments": [
|
|
|
+ _attachment_out(attachment) for attachment in message.attachments
|
|
|
+ ],
|
|
|
+ "reactions": [
|
|
|
+ {
|
|
|
+ "emoji": reaction.emoji,
|
|
|
+ "count": reaction.count,
|
|
|
+ "has_reacted": reaction.has_reacted,
|
|
|
+ }
|
|
|
+ for reaction in message.reactions
|
|
|
+ ],
|
|
|
+ "link_preview": (
|
|
|
+ {"url": message.link_preview.url, "title": message.link_preview.title}
|
|
|
+ if message.link_preview is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ "thread_summary": (
|
|
|
+ {
|
|
|
+ "thread_root_event_id": message.thread.thread_root_event_id,
|
|
|
+ "reply_count": message.thread.reply_count,
|
|
|
+ "participant_count": message.thread.participant_count,
|
|
|
+ }
|
|
|
+ if message.thread is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+async def _get_message(args: dict) -> str:
|
|
|
+ """Resolve the room, fetch the message(s). See _handle_chatto_get_message."""
|
|
|
+ room_id = args.get("room_id") or ""
|
|
|
+ room_name = args.get("room_name") or ""
|
|
|
+ event_id = args.get("event_id") or ""
|
|
|
+ event_ids = [e for e in (args.get("event_ids") or []) if e]
|
|
|
+ follow_reply_chain = int(args.get("follow_reply_chain") or 0)
|
|
|
+
|
|
|
+ if not room_id and not room_name:
|
|
|
+ raise _refuse("Exactly one of room_id or room_name is required")
|
|
|
+ if not event_id and not event_ids:
|
|
|
+ raise _refuse("Exactly one of event_id or event_ids is required")
|
|
|
+ if event_id and event_ids:
|
|
|
+ raise _refuse("event_id and event_ids are mutually exclusive")
|
|
|
+ if follow_reply_chain < 0 or follow_reply_chain > 10:
|
|
|
+ raise _refuse("follow_reply_chain must be between 0 and 10")
|
|
|
+
|
|
|
+ async with _tool_client(_chatto_config_for_tools()) as client:
|
|
|
+ resolved = await _resolve_room(client, room_id, room_name)
|
|
|
+ room = resolved.room
|
|
|
+
|
|
|
+ target_ids = [event_id] if event_id else event_ids
|
|
|
+ messages = await client.batch_get_messages(room.id, target_ids)
|
|
|
+ found: dict[str, Message] = {m.id: m for m in messages if m.id}
|
|
|
+ missing = [eid for eid in target_ids if eid not in found]
|
|
|
+ if missing and not event_ids:
|
|
|
+ # A single miss has no batch to fall back on — get_message is the
|
|
|
+ # authoritative lookup, so a None there is a miss here too.
|
|
|
+ single = await client.get_message(room.id, event_id)
|
|
|
+ if single is not None:
|
|
|
+ found[single.id] = single
|
|
|
+ missing = []
|
|
|
+ if not found:
|
|
|
+ raise _refuse(
|
|
|
+ f"No message {', '.join(missing)} found in room {room.id}. "
|
|
|
+ "Check the event ID and the room."
|
|
|
+ )
|
|
|
+
|
|
|
+ # The reply chain walks in_reply_to backwards, oldest ancestor first.
|
|
|
+ # Batching keeps it at one RPC for the common short chain.
|
|
|
+ chain: list[Message] = []
|
|
|
+ if follow_reply_chain > 0:
|
|
|
+ frontier_id = next(iter(found.values())).in_reply_to
|
|
|
+ hops = follow_reply_chain
|
|
|
+ while frontier_id and hops > 0:
|
|
|
+ ancestors = await client.batch_get_messages(room.id, [frontier_id])
|
|
|
+ ancestor = next((m for m in ancestors if m.id == frontier_id), None)
|
|
|
+ if ancestor is None:
|
|
|
+ single = await client.get_message(room.id, frontier_id)
|
|
|
+ ancestor = single if single is not None else ancestor
|
|
|
+ if ancestor is None:
|
|
|
+ break
|
|
|
+ chain.append(ancestor)
|
|
|
+ frontier_id = ancestor.in_reply_to
|
|
|
+ hops -= 1
|
|
|
+ chain.reverse()
|
|
|
+
|
|
|
+ author_ids = [m.actor_id for m in found.values()] + [m.actor_id for m in chain]
|
|
|
+ author_logins = await _batch_author_logins(client, room.id, author_ids)
|
|
|
+
|
|
|
+ return json.dumps(
|
|
|
+ {
|
|
|
+ "success": True,
|
|
|
+ "room_id": room.id,
|
|
|
+ "room_name": room.name,
|
|
|
+ "messages": [_message_out(m, author_logins) for m in found.values()],
|
|
|
+ "missing_event_ids": missing,
|
|
|
+ "reply_chain": [_message_out(m, author_logins) for m in chain],
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+async def _get_history(args: dict) -> str:
|
|
|
+ """Resolve the room, read the timeline. See _handle_chatto_get_history."""
|
|
|
+ room_id = args.get("room_id") or ""
|
|
|
+ room_name = args.get("room_name") or ""
|
|
|
+ thread_root_event_id = args.get("thread_root_event_id") or ""
|
|
|
+ limit = int(args.get("limit") or 20)
|
|
|
+
|
|
|
+ if not room_id and not room_name:
|
|
|
+ raise _refuse("Exactly one of room_id or room_name is required")
|
|
|
+ if limit < 1 or limit > 100:
|
|
|
+ raise _refuse("limit must be between 1 and 100")
|
|
|
+
|
|
|
+ async with _tool_client(_chatto_config_for_tools()) as client:
|
|
|
+ resolved = await _resolve_room(client, room_id, room_name)
|
|
|
+ room = resolved.room
|
|
|
+
|
|
|
+ if thread_root_event_id:
|
|
|
+ # A thread root has to belong to this room, and it has to BE a
|
|
|
+ # root — the same two checks chatto_send_room makes before
|
|
|
+ # posting, so the tools cannot disagree about what a root is.
|
|
|
+ root = await client.get_message(room.id, thread_root_event_id)
|
|
|
+ if root is None:
|
|
|
+ raise _refuse(
|
|
|
+ f"No message {thread_root_event_id} in room {room.id}. "
|
|
|
+ "Drop thread_root_event_id to read the room itself."
|
|
|
+ )
|
|
|
+ if root.thread_root_event_id:
|
|
|
+ raise _refuse(
|
|
|
+ f"Message {thread_root_event_id} is a reply inside thread "
|
|
|
+ f"{root.thread_root_event_id}, not a thread root. Pass that "
|
|
|
+ "thread's root ID instead."
|
|
|
+ )
|
|
|
+ page = await client.get_thread_events(
|
|
|
+ room.id, thread_root_event_id, limit=limit
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ page = await client.get_room_events(room.id, limit=limit)
|
|
|
+
|
|
|
+ messages = [ev.message for ev in page.events if ev.message is not None]
|
|
|
+ # The server's ordering is not a contract we build on — sort by the
|
|
|
+ # timestamp each message carries, so the block reads oldest-first.
|
|
|
+ messages.sort(key=lambda m: m.created_at or datetime.min.replace(tzinfo=UTC))
|
|
|
+
|
|
|
+ # The timeline page carries its own user directory (``users_by_id``),
|
|
|
+ # which answers author names without a member-list call. Fall back to
|
|
|
+ # the room directory for actors the page did not include.
|
|
|
+ author_logins = {
|
|
|
+ user_id: user.login
|
|
|
+ for user_id, user in page.users_by_id.items()
|
|
|
+ if user.login
|
|
|
+ }
|
|
|
+ unresolved = [
|
|
|
+ m.actor_id
|
|
|
+ for m in messages
|
|
|
+ if m.actor_id and m.actor_id not in author_logins
|
|
|
+ ]
|
|
|
+ if unresolved:
|
|
|
+ author_logins.update(
|
|
|
+ await _batch_author_logins(client, room.id, unresolved)
|
|
|
+ )
|
|
|
+
|
|
|
+ return json.dumps(
|
|
|
+ {
|
|
|
+ "success": True,
|
|
|
+ "room_id": room.id,
|
|
|
+ "room_name": room.name,
|
|
|
+ "thread_root_event_id": thread_root_event_id or None,
|
|
|
+ "count": len(messages),
|
|
|
+ "has_older": page.has_older,
|
|
|
+ "messages": [_message_out(m, author_logins) for m in messages],
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
async def _handle_chatto_send_dm(args: dict) -> str:
|
|
|
"""Tool handler for chatto_send_dm.
|
|
|
|
|
|
@@ -4602,6 +4951,42 @@ async def _handle_chatto_list_rooms(args: dict) -> str:
|
|
|
return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
|
|
|
|
|
|
|
|
|
+async def _handle_chatto_get_message(args: dict) -> str:
|
|
|
+ """Tool handler for chatto_get_message.
|
|
|
+
|
|
|
+ Returns a JSON string: Hermes' tool contract accepts no other type, and a
|
|
|
+ dict (or an unflagged coroutine) fails at dispatch with "unsupported result
|
|
|
+ type". Every early exit arrives here as a _ToolRefusal and leaves as that
|
|
|
+ refusal's own JSON, so no path escapes as an unhandled error.
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ return await _get_message(args)
|
|
|
+ except _ToolRefusal as refusal:
|
|
|
+ return refusal.result_json
|
|
|
+ except ChattoAuthError as exc:
|
|
|
+ return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
|
|
|
+ except ChattoError as exc:
|
|
|
+ return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
|
|
|
+
|
|
|
+
|
|
|
+async def _handle_chatto_get_history(args: dict) -> str:
|
|
|
+ """Tool handler for chatto_get_history.
|
|
|
+
|
|
|
+ Returns a JSON string: Hermes' tool contract accepts no other type, and a
|
|
|
+ dict (or an unflagged coroutine) fails at dispatch with "unsupported result
|
|
|
+ type". Every early exit arrives here as a _ToolRefusal and leaves as that
|
|
|
+ refusal's own JSON, so no path escapes as an unhandled error.
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ return await _get_history(args)
|
|
|
+ except _ToolRefusal as refusal:
|
|
|
+ return refusal.result_json
|
|
|
+ except ChattoAuthError as exc:
|
|
|
+ return json.dumps({"success": False, "error": f"Authentication error: {exc}"})
|
|
|
+ except ChattoError as exc:
|
|
|
+ return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
|
|
|
+
|
|
|
+
|
|
|
def _register_bundled_skills(ctx) -> None:
|
|
|
"""Register the skills shipped in ``skills/``, each with its own description.
|
|
|
|
|
|
@@ -4638,6 +5023,8 @@ _PLUGIN_TOOLS = (
|
|
|
("chatto_send_dm", CHATTO_SEND_DM_SCHEMA, _handle_chatto_send_dm),
|
|
|
("chatto_send_room", CHATTO_SEND_ROOM_SCHEMA, _handle_chatto_send_room),
|
|
|
("chatto_list_rooms", CHATTO_LIST_ROOMS_SCHEMA, _handle_chatto_list_rooms),
|
|
|
+ ("chatto_get_message", CHATTO_GET_MESSAGE_SCHEMA, _handle_chatto_get_message),
|
|
|
+ ("chatto_get_history", CHATTO_GET_HISTORY_SCHEMA, _handle_chatto_get_history),
|
|
|
)
|
|
|
|
|
|
|