|
@@ -4113,7 +4113,8 @@ CHATTO_GET_HISTORY_SCHEMA = {
|
|
|
"the thread's events are read instead of the room timeline; the root "
|
|
"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. "
|
|
"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 "
|
|
"`limit` (1-100, default 20) bounds the page; `has_older` says whether "
|
|
|
- "older history exists."
|
|
|
|
|
|
|
+ "older history exists. To read further back, pass the previous "
|
|
|
|
|
+ "result's `start_cursor` as `before` — one call reads one page."
|
|
|
),
|
|
),
|
|
|
"parameters": {
|
|
"parameters": {
|
|
|
"type": "object",
|
|
"type": "object",
|
|
@@ -4146,6 +4147,21 @@ CHATTO_GET_HISTORY_SCHEMA = {
|
|
|
"Maximum number of messages to return, 1-100 (default 20)."
|
|
"Maximum number of messages to return, 1-100 (default 20)."
|
|
|
),
|
|
),
|
|
|
},
|
|
},
|
|
|
|
|
+ "before": {
|
|
|
|
|
+ "type": "string",
|
|
|
|
|
+ "description": (
|
|
|
|
|
+ "Optional: page backwards. Pass the `start_cursor` of the "
|
|
|
|
|
+ "previous result — cursors are opaque positions, not event "
|
|
|
|
|
+ "IDs. Mutually exclusive with `after`."
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ "after": {
|
|
|
|
|
+ "type": "string",
|
|
|
|
|
+ "description": (
|
|
|
|
|
+ "Optional: page forwards. Pass the `end_cursor` of the "
|
|
|
|
|
+ "previous result. Mutually exclusive with `before`."
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
},
|
|
},
|
|
|
"required": [],
|
|
"required": [],
|
|
|
"additionalProperties": False,
|
|
"additionalProperties": False,
|
|
@@ -4273,13 +4289,15 @@ def _room_is_writable(cfg: ChattoConfiguration | None, room: Room) -> bool:
|
|
|
return _room_answers_in(cfg, room.id, room.kind)
|
|
return _room_answers_in(cfg, room.id, room.kind)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _not_joined_refusal(room: Room) -> _ToolRefusal:
|
|
|
|
|
|
|
+def _not_joined_refusal(room: Room, action: str) -> _ToolRefusal:
|
|
|
"""The refusal for a room the bot can see but has not joined.
|
|
"""The refusal for a room the bot can see but has not joined.
|
|
|
|
|
|
|
|
- The room kinds need different advice, and giving a channel the DM remedy
|
|
|
|
|
- sends the user down a path that cannot work: a direct message to the bot is
|
|
|
|
|
- not how you get it into a channel. For a channel the paths are an invite in
|
|
|
|
|
- Chatto, or the ``/join`` the bot answers over DM.
|
|
|
|
|
|
|
+ ``action`` names what the caller wanted to do there — "post there", "read
|
|
|
|
|
+ its history there" — because the remedy is identical for both while the
|
|
|
|
|
+ symptom is not, and a read that reports a sending problem sends the reader
|
|
|
|
|
+ looking in the wrong place. The room kinds differ in the remedy: giving a
|
|
|
|
|
+ channel the DM remedy sends the user down a path that cannot work, since a
|
|
|
|
|
+ direct message to the bot is not how you get it into a channel.
|
|
|
|
|
|
|
|
The test is on ``DM`` rather than on ``CHANNEL`` on purpose: a server that
|
|
The test is on ``DM`` rather than on ``CHANNEL`` on purpose: a server that
|
|
|
never sets ``kind`` reports ``UNSPECIFIED``, and this plugin counts an unknown
|
|
never sets ``kind`` reports ``UNSPECIFIED``, and this plugin counts an unknown
|
|
@@ -4294,7 +4312,7 @@ def _not_joined_refusal(room: Room) -> _ToolRefusal:
|
|
|
)
|
|
)
|
|
|
return _refuse(
|
|
return _refuse(
|
|
|
f"Room '{room.name or room.id}' ({room.id}) is not one the bot has "
|
|
f"Room '{room.name or room.id}' ({room.id}) is not one the bot has "
|
|
|
- f"joined, so it cannot post there. {remedy}, then try again."
|
|
|
|
|
|
|
+ f"joined, so it cannot {action}. {remedy}, then try again."
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@@ -4653,7 +4671,7 @@ async def _send_room(args: dict) -> str:
|
|
|
# posting into one fails server-side with an error that names neither
|
|
# posting into one fails server-side with an error that names neither
|
|
|
# the room nor the fix.
|
|
# the room nor the fix.
|
|
|
if not resolved.is_member:
|
|
if not resolved.is_member:
|
|
|
- raise _not_joined_refusal(room)
|
|
|
|
|
|
|
+ raise _not_joined_refusal(room, "post there")
|
|
|
|
|
|
|
|
# A thread root has to belong to this room and has to BE a root, or
|
|
# A thread root has to belong to this room and has to BE a root, or
|
|
|
# the message attaches to a thread somewhere else entirely — or to
|
|
# the message attaches to a thread somewhere else entirely — or to
|
|
@@ -4893,6 +4911,13 @@ async def _get_message(args: dict) -> str:
|
|
|
resolved = await _resolve_room(client, room_id, room_name)
|
|
resolved = await _resolve_room(client, room_id, room_name)
|
|
|
room = resolved.room
|
|
room = resolved.room
|
|
|
|
|
|
|
|
|
|
+ # Every read RPC the plugin uses documents room membership as a
|
|
|
|
|
+ # precondition (GetRoomEvents, GetMessage, BatchGetMessages), so an
|
|
|
|
|
+ # unjoined room is unreadable whatever the mention lists say. Saying so
|
|
|
|
|
+ # here beats a PERMISSION_DENIED that names neither the room nor the fix.
|
|
|
|
|
+ if not resolved.is_member:
|
|
|
|
|
+ raise _not_joined_refusal(room, "read it there")
|
|
|
|
|
+
|
|
|
if event_id:
|
|
if event_id:
|
|
|
# One message is one lookup: the single-message RPC is the
|
|
# One message is one lookup: the single-message RPC is the
|
|
|
# authoritative one, so there is no batch to fall back from.
|
|
# authoritative one, so there is no batch to fall back from.
|
|
@@ -4951,22 +4976,42 @@ async def _get_history(args: dict) -> str:
|
|
|
room_name = args.get("room_name") or ""
|
|
room_name = args.get("room_name") or ""
|
|
|
thread_root_event_id = args.get("thread_root_event_id") or ""
|
|
thread_root_event_id = args.get("thread_root_event_id") or ""
|
|
|
limit = _int_arg(args, "limit", HISTORY_PAGE_SIZE, low=1, high=HISTORY_PAGE_MAX)
|
|
limit = _int_arg(args, "limit", HISTORY_PAGE_SIZE, low=1, high=HISTORY_PAGE_MAX)
|
|
|
|
|
+ before = args.get("before") or ""
|
|
|
|
|
+ after = args.get("after") or ""
|
|
|
|
|
|
|
|
if not room_id and not room_name:
|
|
if not room_id and not room_name:
|
|
|
raise _refuse("Exactly one of room_id or room_name is required")
|
|
raise _refuse("Exactly one of room_id or room_name is required")
|
|
|
|
|
+ if before and after:
|
|
|
|
|
+ # The client sends `before` OR `after` and drops the second without a
|
|
|
|
|
+ # word, so both would answer a question nobody asked.
|
|
|
|
|
+ raise _refuse(
|
|
|
|
|
+ "before and after are mutually exclusive — pick the direction you "
|
|
|
|
|
+ "want to page in"
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
async with _tool_client(_chatto_config_for_tools()) as client:
|
|
async with _tool_client(_chatto_config_for_tools()) as client:
|
|
|
resolved = await _resolve_room(client, room_id, room_name)
|
|
resolved = await _resolve_room(client, room_id, room_name)
|
|
|
room = resolved.room
|
|
room = resolved.room
|
|
|
|
|
|
|
|
|
|
+ # A read of an unjoined room fails server-side whatever the mention
|
|
|
|
|
+ # lists say; saying so here names the room and the way in.
|
|
|
|
|
+ if not resolved.is_member:
|
|
|
|
|
+ raise _not_joined_refusal(room, "read its history there")
|
|
|
|
|
+
|
|
|
root: Message | None = None
|
|
root: Message | None = None
|
|
|
if thread_root_event_id:
|
|
if thread_root_event_id:
|
|
|
root = await _resolve_thread_root(client, room, thread_root_event_id)
|
|
root = await _resolve_thread_root(client, room, thread_root_event_id)
|
|
|
page = await client.get_thread_events(
|
|
page = await client.get_thread_events(
|
|
|
- room.id, thread_root_event_id, limit=limit
|
|
|
|
|
|
|
+ room.id,
|
|
|
|
|
+ thread_root_event_id,
|
|
|
|
|
+ limit=limit,
|
|
|
|
|
+ before=before or None,
|
|
|
|
|
+ after=after or None,
|
|
|
)
|
|
)
|
|
|
else:
|
|
else:
|
|
|
- page = await client.get_room_events(room.id, limit=limit)
|
|
|
|
|
|
|
+ page = await client.get_room_events(
|
|
|
|
|
+ room.id, limit=limit, before=before or None, after=after or None
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
messages = [ev.message for ev in page.events if ev.message is not None]
|
|
messages = [ev.message for ev in page.events if ev.message is not None]
|
|
|
if root is not None and all(m.id != root.id for m in messages):
|
|
if root is not None and all(m.id != root.id for m in messages):
|
|
@@ -5004,6 +5049,13 @@ async def _get_history(args: dict) -> str:
|
|
|
"thread_root_event_id": thread_root_event_id or None,
|
|
"thread_root_event_id": thread_root_event_id or None,
|
|
|
"count": len(messages),
|
|
"count": len(messages),
|
|
|
"has_older": page.has_older,
|
|
"has_older": page.has_older,
|
|
|
|
|
+ "has_newer": page.has_newer,
|
|
|
|
|
+ # Paging is the caller's next call, so this answer has to carry
|
|
|
|
|
+ # the positions that next call takes as its arguments —
|
|
|
|
|
+ # otherwise the model would have to guess which message to
|
|
|
|
|
+ # anchor on.
|
|
|
|
|
+ "start_cursor": page.start_cursor,
|
|
|
|
|
+ "end_cursor": page.end_cursor,
|
|
|
"messages": [_message_out(m, author_logins) for m in messages],
|
|
"messages": [_message_out(m, author_logins) for m in messages],
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|