|
@@ -4404,6 +4404,12 @@ HISTORY_PAGE_MAX = 100
|
|
|
# kept small enough that a model can digest the output without choking.
|
|
# kept small enough that a model can digest the output without choking.
|
|
|
PINNED_PAGE_SIZE = 20
|
|
PINNED_PAGE_SIZE = 20
|
|
|
ATTACHMENT_PAGE_SIZE = 40
|
|
ATTACHMENT_PAGE_SIZE = 40
|
|
|
|
|
+# A self-deleting notice: long enough to read, short enough not to linger.
|
|
|
|
|
+# The floor keeps a TTL from deleting a message before anyone sees it; the
|
|
|
|
|
+# ceiling keeps a "transient" notice from becoming a day-long fixture.
|
|
|
|
|
+EPHEMERAL_TTL_DEFAULT = 60
|
|
|
|
|
+EPHEMERAL_TTL_MIN = 5
|
|
|
|
|
+EPHEMERAL_TTL_MAX = 3600
|
|
|
|
|
|
|
|
|
|
|
|
|
CHATTO_LIST_ROOMS_SCHEMA = {
|
|
CHATTO_LIST_ROOMS_SCHEMA = {
|
|
@@ -4707,6 +4713,52 @@ CHATTO_SEND_ROOM_SCHEMA = {
|
|
|
},
|
|
},
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+CHATTO_SEND_EPHEMERAL_SCHEMA = {
|
|
|
|
|
+ "name": "chatto_send_ephemeral",
|
|
|
|
|
+ "description": (
|
|
|
|
|
+ "Send a text notice to a Chatto room that deletes itself after a TTL. "
|
|
|
|
|
+ "Use it for progress or status updates that should not stay in the "
|
|
|
|
|
+ "timeline. Same room rules as chatto_send_room: a channel the bot is "
|
|
|
|
|
+ "configured to answer in only. Returns the message_ids and the TTL, or "
|
|
|
|
|
+ "an error. Deletion is best-effort: a gateway restart before the TTL "
|
|
|
|
|
+ "expires leaves the notice in the room."
|
|
|
|
|
+ ),
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "room_id": {
|
|
|
|
|
+ "type": "string",
|
|
|
|
|
+ "description": (
|
|
|
|
|
+ "The Chatto room ID of the destination. Exactly one of "
|
|
|
|
|
+ "`room_id` or `room_name` is required."
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ "room_name": {
|
|
|
|
|
+ "type": "string",
|
|
|
|
|
+ "description": (
|
|
|
|
|
+ "The display name of the room (case-insensitive). Exactly one "
|
|
|
|
|
+ "of `room_id` or `room_name` is required. If several rooms "
|
|
|
|
|
+ "share the name, the call returns their IDs instead of guessing."
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ "message": {
|
|
|
|
|
+ "type": "string",
|
|
|
|
|
+ "description": "The notice text (markdown supported).",
|
|
|
|
|
+ },
|
|
|
|
|
+ "ttl_seconds": {
|
|
|
|
|
+ "type": "integer",
|
|
|
|
|
+ "description": (
|
|
|
|
|
+ f"Seconds until the notice is deleted (default "
|
|
|
|
|
+ f"{EPHEMERAL_TTL_DEFAULT}, min {EPHEMERAL_TTL_MIN}, "
|
|
|
|
|
+ f"max {EPHEMERAL_TTL_MAX})."
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["message"],
|
|
|
|
|
+ "additionalProperties": False,
|
|
|
|
|
+ },
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
CHATTO_MARK_THREAD_AS_READ_SCHEMA = {
|
|
CHATTO_MARK_THREAD_AS_READ_SCHEMA = {
|
|
|
"name": "chatto_mark_thread_as_read",
|
|
"name": "chatto_mark_thread_as_read",
|
|
|
"description": (
|
|
"description": (
|
|
@@ -5411,6 +5463,109 @@ async def _send_room(args: dict) -> str:
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+_EPHEMERAL_DELETION_TASKS: set[asyncio.Task[None]] = set()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _send_ephemeral(args: dict) -> str:
|
|
|
|
|
+ """Post a notice that deletes itself. See _handle_chatto_send_ephemeral."""
|
|
|
|
|
+ room_id = args.get("room_id") or ""
|
|
|
|
|
+ room_name = args.get("room_name") or ""
|
|
|
|
|
+ message = args.get("message") or ""
|
|
|
|
|
+ ttl_seconds = _int_arg(
|
|
|
|
|
+ args,
|
|
|
|
|
+ "ttl_seconds",
|
|
|
|
|
+ EPHEMERAL_TTL_DEFAULT,
|
|
|
|
|
+ low=EPHEMERAL_TTL_MIN,
|
|
|
|
|
+ high=EPHEMERAL_TTL_MAX,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if not message:
|
|
|
|
|
+ raise _refuse("message is required")
|
|
|
|
|
+ if not room_id and not room_name:
|
|
|
|
|
+ raise _refuse("Exactly one of room_id or room_name is required")
|
|
|
|
|
+
|
|
|
|
|
+ cfg = _chatto_config_for_tools()
|
|
|
|
|
+ if cfg is None:
|
|
|
|
|
+ raise _refuse("Chatto configuration unavailable")
|
|
|
|
|
+ async with _tool_client(cfg) as client:
|
|
|
|
|
+ resolved = await _resolve_room(client, room_id, room_name)
|
|
|
|
|
+ room = resolved.room
|
|
|
|
|
+
|
|
|
|
|
+ # Same gates as chatto_send_room: this tool posts, so it must not be a
|
|
|
|
|
+ # way into a room the plugin keeps read-only.
|
|
|
|
|
+ if not _room_is_writable(cfg, room):
|
|
|
|
|
+ raise _read_only_room_refusal(room.name or room.id)
|
|
|
|
|
+ if not resolved.is_member:
|
|
|
|
|
+ raise _not_joined_refusal(room)
|
|
|
|
|
+
|
|
|
|
|
+ message_ids, _root = await _post_chunks(client, room.id, message)
|
|
|
|
|
+
|
|
|
|
|
+ _schedule_ephemeral_deletion(cfg, room.id, message_ids, ttl_seconds)
|
|
|
|
|
+ return json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "success": True,
|
|
|
|
|
+ "room_id": room.id,
|
|
|
|
|
+ "message_ids": message_ids,
|
|
|
|
|
+ "chunks_sent": len(message_ids),
|
|
|
|
|
+ "expires_in_seconds": ttl_seconds,
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _schedule_ephemeral_deletion(
|
|
|
|
|
+ cfg: ChattoConfiguration,
|
|
|
|
|
+ room_id: str,
|
|
|
|
|
+ event_ids: list[str],
|
|
|
|
|
+ ttl_seconds: int,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """Delete a delivered notice once its TTL has passed, best-effort.
|
|
|
|
|
+
|
|
|
|
|
+ The task is held in a module-level set because asyncio keeps only a weak
|
|
|
|
|
+ reference: an unreferenced task can be collected before it ever runs.
|
|
|
|
|
+ Failure to delete is logged, never raised — the message was delivered, and
|
|
|
|
|
+ a process restart simply leaves it behind.
|
|
|
|
|
+ """
|
|
|
|
|
+ task = asyncio.create_task(
|
|
|
|
|
+ _delete_after_ttl(cfg, room_id, event_ids, ttl_seconds)
|
|
|
|
|
+ )
|
|
|
|
|
+ _EPHEMERAL_DELETION_TASKS.add(task)
|
|
|
|
|
+ task.add_done_callback(_EPHEMERAL_DELETION_TASKS.discard)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _delete_after_ttl(
|
|
|
|
|
+ cfg: ChattoConfiguration,
|
|
|
|
|
+ room_id: str,
|
|
|
|
|
+ event_ids: list[str],
|
|
|
|
|
+ ttl_seconds: int,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """Sleep out the TTL, then delete each chunk it covered."""
|
|
|
|
|
+ await asyncio.sleep(ttl_seconds)
|
|
|
|
|
+ try:
|
|
|
|
|
+ async with _tool_client(cfg) as client:
|
|
|
|
|
+ for event_id in event_ids:
|
|
|
|
|
+ try:
|
|
|
|
|
+ await client.delete_message(room_id, event_id)
|
|
|
|
|
+ except ChattoError as exc:
|
|
|
|
|
+ logger.debug(
|
|
|
|
|
+ "Chatto tools: ephemeral message %s in %s not deleted: %s",
|
|
|
|
|
+ event_id,
|
|
|
|
|
+ room_id,
|
|
|
|
|
+ exc,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ # No caller to raise to: the notice was delivered and this task exists
|
|
|
|
|
+ # only to make it disappear again. A transport oddity that chattolib
|
|
|
|
|
+ # did not map to ChattoError must not surface as an unretrieved task
|
|
|
|
|
+ # exception, so it is logged like any other cleanup failure.
|
|
|
|
|
+ logger.debug(
|
|
|
|
|
+ "Chatto tools: ephemeral cleanup of %s in %s failed: %s",
|
|
|
|
|
+ event_ids,
|
|
|
|
|
+ room_id,
|
|
|
|
|
+ exc,
|
|
|
|
|
+ exc_info=True,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def _list_rooms(args: dict) -> str:
|
|
async def _list_rooms(args: dict) -> str:
|
|
|
"""List the rooms visible to the bot. See _handle_chatto_list_rooms."""
|
|
"""List the rooms visible to the bot. See _handle_chatto_list_rooms."""
|
|
|
name_filter = (args.get("name") or "").strip().casefold()
|
|
name_filter = (args.get("name") or "").strip().casefold()
|
|
@@ -5883,6 +6038,24 @@ async def _handle_chatto_send_room(args: dict) -> str:
|
|
|
return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
|
|
return json.dumps({"success": False, "error": f"Chatto API error: {exc}"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _handle_chatto_send_ephemeral(args: dict) -> str:
|
|
|
|
|
+ """Tool handler for chatto_send_ephemeral.
|
|
|
|
|
+
|
|
|
|
|
+ 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 _send_ephemeral(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_list_rooms(args: dict) -> str:
|
|
async def _handle_chatto_list_rooms(args: dict) -> str:
|
|
|
"""Tool handler for chatto_list_rooms.
|
|
"""Tool handler for chatto_list_rooms.
|
|
|
|
|
|
|
@@ -6227,6 +6400,11 @@ def _register_bundled_skills(ctx) -> None:
|
|
|
_PLUGIN_TOOLS = (
|
|
_PLUGIN_TOOLS = (
|
|
|
("chatto_send_dm", CHATTO_SEND_DM_SCHEMA, _handle_chatto_send_dm),
|
|
("chatto_send_dm", CHATTO_SEND_DM_SCHEMA, _handle_chatto_send_dm),
|
|
|
("chatto_send_room", CHATTO_SEND_ROOM_SCHEMA, _handle_chatto_send_room),
|
|
("chatto_send_room", CHATTO_SEND_ROOM_SCHEMA, _handle_chatto_send_room),
|
|
|
|
|
+ (
|
|
|
|
|
+ "chatto_send_ephemeral",
|
|
|
|
|
+ CHATTO_SEND_EPHEMERAL_SCHEMA,
|
|
|
|
|
+ _handle_chatto_send_ephemeral,
|
|
|
|
|
+ ),
|
|
|
("chatto_list_rooms", CHATTO_LIST_ROOMS_SCHEMA, _handle_chatto_list_rooms),
|
|
("chatto_list_rooms", CHATTO_LIST_ROOMS_SCHEMA, _handle_chatto_list_rooms),
|
|
|
("chatto_get_message", CHATTO_GET_MESSAGE_SCHEMA, _handle_chatto_get_message),
|
|
("chatto_get_message", CHATTO_GET_MESSAGE_SCHEMA, _handle_chatto_get_message),
|
|
|
("chatto_get_history", CHATTO_GET_HISTORY_SCHEMA, _handle_chatto_get_history),
|
|
("chatto_get_history", CHATTO_GET_HISTORY_SCHEMA, _handle_chatto_get_history),
|