|
@@ -94,6 +94,7 @@ try:
|
|
|
RoomKind,
|
|
RoomKind,
|
|
|
RoomWithViewerState,
|
|
RoomWithViewerState,
|
|
|
User,
|
|
User,
|
|
|
|
|
+ format_datetime,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
except ImportError as e:
|
|
except ImportError as e:
|
|
@@ -263,6 +264,24 @@ def _normalise_outbound_text(content: str) -> str:
|
|
|
return normalised
|
|
return normalised
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _oldest_first(messages: list[Message]) -> list[Message]:
|
|
|
|
|
+ """Sort messages by the timestamp they carry, oldest first.
|
|
|
|
|
+
|
|
|
|
|
+ The server's ordering is not a contract anything here builds on — it is a
|
|
|
|
|
+ page, and a page's order depends on which end it was fetched from — so
|
|
|
|
|
+ every timeline is read the way a conversation is spoken. A message with no
|
|
|
|
|
+ timestamp sorts first: it is the one whose position we know least about,
|
|
|
|
|
+ and putting it at the end would claim an order the data does not support.
|
|
|
|
|
+
|
|
|
|
|
+ Shared by the thread seeding and the read tools so the block an agent reads
|
|
|
|
|
+ in a session and the block it gets from ``chatto_get_history`` are ordered
|
|
|
|
|
+ by one rule.
|
|
|
|
|
+ """
|
|
|
|
|
+ return sorted(
|
|
|
|
|
+ messages, key=lambda m: m.created_at or datetime.min.replace(tzinfo=UTC)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
# --------------------------------------------------------------------------- #
|
|
# --------------------------------------------------------------------------- #
|
|
|
# Adapter
|
|
# Adapter
|
|
|
# --------------------------------------------------------------------------- #
|
|
# --------------------------------------------------------------------------- #
|
|
@@ -1492,12 +1511,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
)
|
|
)
|
|
|
+ messages
|
|
+ messages
|
|
|
)
|
|
)
|
|
|
- # The server's ordering is not a contract we build on — sort by the
|
|
|
|
|
- # timestamp each message carries, so the block reads like the thread.
|
|
|
|
|
- return sorted(
|
|
|
|
|
- messages,
|
|
|
|
|
- key=lambda m: m.created_at or datetime.min.replace(tzinfo=UTC),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ return _oldest_first(messages)
|
|
|
|
|
|
|
|
async def _thread_root_message(
|
|
async def _thread_root_message(
|
|
|
self, client: ChattoClient, *, room_id: str, thread_root_event_id: str
|
|
self, client: ChattoClient, *, room_id: str, thread_root_event_id: str
|
|
@@ -3847,6 +3861,31 @@ def _refuse(error: str, **extra: Any) -> _ToolRefusal:
|
|
|
return _ToolRefusal(json.dumps({"success": False, "error": error, **extra}))
|
|
return _ToolRefusal(json.dumps({"success": False, "error": error, **extra}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _int_arg(args: dict, key: str, default: int, *, low: int, high: int) -> int:
|
|
|
|
|
+ """An integer tool argument, validated — a refusal rather than a guess.
|
|
|
|
|
+
|
|
|
|
|
+ The value comes from a model, so "twenty" and a float are both reachable,
|
|
|
|
|
+ and an unvalidated ``int()`` would raise out of the handler instead of
|
|
|
|
|
+ answering. An absent argument takes the default; a *wrong* one is refused,
|
|
|
|
|
+ which also keeps a falsy ``0`` from silently becoming the default and
|
|
|
|
|
+ quietly widening the answer.
|
|
|
|
|
+ """
|
|
|
|
|
+ raw = args.get(key)
|
|
|
|
|
+ if raw is None or raw == "":
|
|
|
|
|
+ return default
|
|
|
|
|
+ not_a_number = f"{key} must be a whole number between {low} and {high}"
|
|
|
|
|
+ try:
|
|
|
|
|
+ value = int(raw)
|
|
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
|
|
+ raise _refuse(not_a_number) from exc
|
|
|
|
|
+ if isinstance(raw, float) and not raw.is_integer():
|
|
|
|
|
+ # int() would truncate 3.5 to 3, answering a question nobody asked.
|
|
|
|
|
+ raise _refuse(not_a_number)
|
|
|
|
|
+ if value < low or value > high:
|
|
|
|
|
+ raise _refuse(f"{key} must be between {low} and {high}")
|
|
|
|
|
+ return value
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@asynccontextmanager
|
|
@asynccontextmanager
|
|
|
async def _tool_client(cfg: ChattoConfiguration | None) -> AsyncIterator[ChattoClient]:
|
|
async def _tool_client(cfg: ChattoConfiguration | None) -> AsyncIterator[ChattoClient]:
|
|
|
"""A short-lived client for one tool call, closed however the call ends.
|
|
"""A short-lived client for one tool call, closed however the call ends.
|
|
@@ -3876,6 +3915,16 @@ async def _tool_client(cfg: ChattoConfiguration | None) -> AsyncIterator[ChattoC
|
|
|
logger.debug("Chatto tools: client close failed: %s", exc)
|
|
logger.debug("Chatto tools: client close failed: %s", exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# Read-tool bounds, named so the schemas and the validation cannot disagree.
|
|
|
|
|
+# A chain is walked hop by hop, so its ceiling is a cost bound: each hop is a
|
|
|
|
|
+# round trip, and a caller asking for the whole conversation has
|
|
|
|
|
+# chatto_get_history to page it. The page size is bounded for the opposite
|
|
|
|
|
+# reason — one read must stay inside a model context.
|
|
|
|
|
+FOLLOW_REPLY_CHAIN_MAX = 10
|
|
|
|
|
+HISTORY_PAGE_SIZE = 20
|
|
|
|
|
+HISTORY_PAGE_MAX = 100
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
CHATTO_LIST_ROOMS_SCHEMA = {
|
|
CHATTO_LIST_ROOMS_SCHEMA = {
|
|
|
"name": "chatto_list_rooms",
|
|
"name": "chatto_list_rooms",
|
|
|
"description": (
|
|
"description": (
|
|
@@ -4002,8 +4051,10 @@ CHATTO_GET_MESSAGE_SCHEMA = {
|
|
|
"and deleted status. Use it to resolve a `reply_to=`/`in_reply_to` "
|
|
"and deleted status. Use it to resolve a `reply_to=`/`in_reply_to` "
|
|
|
"reference or any other event ID mentioned in a message. "
|
|
"reference or any other event ID mentioned in a message. "
|
|
|
"`follow_reply_chain` (0-10) additionally walks the in_reply_to chain "
|
|
"`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)."
|
|
|
|
|
|
|
+ "backwards and returns the ancestor messages oldest-first; it applies to "
|
|
|
|
|
+ "a single `event_id` and is refused with `event_ids`. `event_ids` "
|
|
|
|
|
+ "fetches several messages in one call, answered in the order asked for "
|
|
|
|
|
+ "(then `event_id` is refused)."
|
|
|
),
|
|
),
|
|
|
"parameters": {
|
|
"parameters": {
|
|
|
"type": "object",
|
|
"type": "object",
|
|
@@ -4043,7 +4094,7 @@ CHATTO_GET_MESSAGE_SCHEMA = {
|
|
|
"type": "integer",
|
|
"type": "integer",
|
|
|
"description": (
|
|
"description": (
|
|
|
"How many in_reply_to ancestors to resolve, 0-10 "
|
|
"How many in_reply_to ancestors to resolve, 0-10 "
|
|
|
- "(default 0 = do not follow)."
|
|
|
|
|
|
|
+ "(default 0 = do not follow). Single `event_id` only."
|
|
|
),
|
|
),
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
@@ -4494,6 +4545,35 @@ async def _resolve_room(
|
|
|
return matches[0]
|
|
return matches[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _resolve_thread_root(
|
|
|
|
|
+ client: ChattoClient, room: Room, event_id: str
|
|
|
|
|
+) -> Message:
|
|
|
|
|
+ """The thread root a tool was pointed at, or a refusal saying why not.
|
|
|
|
|
+
|
|
|
|
|
+ Both tools that take a ``thread_root_event_id`` — posting into one and
|
|
|
|
|
+ reading one — resolve it here, because two copies of "is this a root?" is
|
|
|
|
|
+ one too many: they drift, and the drift shows up as two tools disagreeing
|
|
|
|
|
+ about the same event ID.
|
|
|
|
|
+
|
|
|
|
|
+ Two things have to hold. The message has to exist in this room (a root from
|
|
|
|
|
+ another room would attach a thread to the wrong conversation), and it has
|
|
|
|
|
+ to BE a root (a reply's ID names no thread, so following it lands nowhere).
|
|
|
|
|
+ """
|
|
|
|
|
+ root = await client.get_message(room.id, event_id)
|
|
|
|
|
+ if root is None:
|
|
|
|
|
+ raise _refuse(
|
|
|
|
|
+ f"No message {event_id} in room {room.id}. It has to be an event ID "
|
|
|
|
|
+ f"from this room — chatto_get_history lists them."
|
|
|
|
|
+ )
|
|
|
|
|
+ if root.thread_root_event_id:
|
|
|
|
|
+ raise _refuse(
|
|
|
|
|
+ f"Message {event_id} is a reply inside thread "
|
|
|
|
|
+ f"{root.thread_root_event_id}, not a thread root. Pass that "
|
|
|
|
|
+ "thread's root ID instead."
|
|
|
|
|
+ )
|
|
|
|
|
+ return root
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def _send_dm(args: dict) -> str:
|
|
async def _send_dm(args: dict) -> str:
|
|
|
"""Resolve the recipient, find the DM room, post. See _handle_chatto_send_dm."""
|
|
"""Resolve the recipient, find the DM room, post. See _handle_chatto_send_dm."""
|
|
|
user_login = args.get("user_login") or ""
|
|
user_login = args.get("user_login") or ""
|
|
@@ -4575,22 +4655,12 @@ async def _send_room(args: dict) -> str:
|
|
|
if not resolved.is_member:
|
|
if not resolved.is_member:
|
|
|
raise _not_joined_refusal(room)
|
|
raise _not_joined_refusal(room)
|
|
|
|
|
|
|
|
- # A thread root has to belong to the room we post into, or the message
|
|
|
|
|
- # attaches to a thread somewhere else entirely. And it has to BE a root:
|
|
|
|
|
- # passing a reply's ID would attach to a thread nobody can follow.
|
|
|
|
|
|
|
+ # 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
|
|
|
|
|
+ # none that can be followed. One shared check, so this tool and
|
|
|
|
|
+ # chatto_get_history cannot answer differently about the same ID.
|
|
|
if thread_root_event_id:
|
|
if thread_root_event_id:
|
|
|
- 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}. "
|
|
|
|
|
- "Send without thread_root_event_id to post to 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, or send without thread_root_event_id."
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await _resolve_thread_root(client, room, thread_root_event_id)
|
|
|
|
|
|
|
|
message_ids, root_event_id = await _post_chunks(
|
|
message_ids, root_event_id = await _post_chunks(
|
|
|
client,
|
|
client,
|
|
@@ -4669,10 +4739,15 @@ async def _list_rooms(args: dict) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
def _iso_or_none(moment: datetime | None) -> str | None:
|
|
def _iso_or_none(moment: datetime | None) -> str | None:
|
|
|
- """A Message timestamp as ISO-8601, or None when the server sent none."""
|
|
|
|
|
|
|
+ """A Message timestamp as ISO-8601, or None when the server sent none.
|
|
|
|
|
+
|
|
|
|
|
+ chattolib's own formatter does the work: it pins a naive timestamp to UTC
|
|
|
|
|
+ instead of letting it print without an offset, so two timestamps from
|
|
|
|
|
+ different sources compare the way a reader expects.
|
|
|
|
|
+ """
|
|
|
if moment is None:
|
|
if moment is None:
|
|
|
return None
|
|
return None
|
|
|
- return moment.isoformat()
|
|
|
|
|
|
|
+ return format_datetime(moment)
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _batch_author_logins(
|
|
async def _batch_author_logins(
|
|
@@ -4717,6 +4792,33 @@ def _attachment_out(attachment: MessageAttachment) -> dict[str, Any]:
|
|
|
out["asset_url"] = attachment.asset_url.url
|
|
out["asset_url"] = attachment.asset_url.url
|
|
|
if attachment.thumbnail_asset_url is not None:
|
|
if attachment.thumbnail_asset_url is not None:
|
|
|
out["thumbnail_asset_url"] = attachment.thumbnail_asset_url.url
|
|
out["thumbnail_asset_url"] = attachment.thumbnail_asset_url.url
|
|
|
|
|
+ if attachment.video_processing is not None:
|
|
|
|
|
+ # A video carries its own dimensions, duration and transcoded
|
|
|
|
|
+ # variants, and its own URL only once the server has produced one —
|
|
|
|
|
+ # without this block the tool reports a video as an attachment with no
|
|
|
|
|
+ # size, no length and nothing to fetch.
|
|
|
|
|
+ video = attachment.video_processing
|
|
|
|
|
+ out["video"] = {
|
|
|
|
|
+ "duration_ms": video.duration_ms,
|
|
|
|
|
+ "status": video.status.name,
|
|
|
|
|
+ "thumbnail_url": (
|
|
|
|
|
+ video.thumbnail_asset_url.url
|
|
|
|
|
+ if video.thumbnail_asset_url is not None
|
|
|
|
|
+ else None
|
|
|
|
|
+ ),
|
|
|
|
|
+ "variants": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "quality": variant.quality,
|
|
|
|
|
+ "width": variant.width,
|
|
|
|
|
+ "height": variant.height,
|
|
|
|
|
+ "size": variant.size,
|
|
|
|
|
+ "url": (
|
|
|
|
|
+ variant.asset_url.url if variant.asset_url is not None else None
|
|
|
|
|
+ ),
|
|
|
|
|
+ }
|
|
|
|
|
+ for variant in video.variants
|
|
|
|
|
+ ],
|
|
|
|
|
+ }
|
|
|
return out
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
@@ -4768,7 +4870,9 @@ async def _get_message(args: dict) -> str:
|
|
|
room_name = args.get("room_name") or ""
|
|
room_name = args.get("room_name") or ""
|
|
|
event_id = args.get("event_id") or ""
|
|
event_id = args.get("event_id") or ""
|
|
|
event_ids = [e for e in (args.get("event_ids") or []) if e]
|
|
event_ids = [e for e in (args.get("event_ids") or []) if e]
|
|
|
- follow_reply_chain = int(args.get("follow_reply_chain") or 0)
|
|
|
|
|
|
|
+ follow_reply_chain = _int_arg(
|
|
|
|
|
+ args, "follow_reply_chain", 0, low=0, high=FOLLOW_REPLY_CHAIN_MAX
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
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")
|
|
@@ -4776,42 +4880,49 @@ async def _get_message(args: dict) -> str:
|
|
|
raise _refuse("Exactly one of event_id or event_ids is required")
|
|
raise _refuse("Exactly one of event_id or event_ids is required")
|
|
|
if event_id and event_ids:
|
|
if event_id and event_ids:
|
|
|
raise _refuse("event_id and event_ids are mutually exclusive")
|
|
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")
|
|
|
|
|
|
|
+ if event_ids and follow_reply_chain:
|
|
|
|
|
+ # A chain belongs to one message. With several requested there is no
|
|
|
|
|
+ # single answer, and picking one would hand back a conversation the
|
|
|
|
|
+ # caller did not ask for.
|
|
|
|
|
+ raise _refuse(
|
|
|
|
|
+ "follow_reply_chain applies to a single event_id — read one message "
|
|
|
|
|
+ "per call to follow its chain"
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
- 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.
|
|
|
|
|
|
|
+ if event_id:
|
|
|
|
|
+ # One message is one lookup: the single-message RPC is the
|
|
|
|
|
+ # authoritative one, so there is no batch to fall back from.
|
|
|
single = await client.get_message(room.id, event_id)
|
|
single = await client.get_message(room.id, event_id)
|
|
|
- if single is not None:
|
|
|
|
|
- found[single.id] = single
|
|
|
|
|
- missing = []
|
|
|
|
|
|
|
+ found = {single.id: single} if single is not None else {}
|
|
|
|
|
+ missing = [] if single is not None else [event_id]
|
|
|
|
|
+ else:
|
|
|
|
|
+ fetched = await client.batch_get_messages(room.id, event_ids)
|
|
|
|
|
+ by_id = {m.id: m for m in fetched if m.id}
|
|
|
|
|
+ # Reported in the order they were asked for, not the order the
|
|
|
|
|
+ # server happened to answer in.
|
|
|
|
|
+ found = {eid: by_id[eid] for eid in event_ids if eid in by_id}
|
|
|
|
|
+ missing = [eid for eid in event_ids if eid not in found]
|
|
|
|
|
+
|
|
|
if not found:
|
|
if not found:
|
|
|
raise _refuse(
|
|
raise _refuse(
|
|
|
f"No message {', '.join(missing)} found in room {room.id}. "
|
|
f"No message {', '.join(missing)} found in room {room.id}. "
|
|
|
"Check the event ID and the room."
|
|
"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.
|
|
|
|
|
|
|
+ # The reply chain walks in_reply_to backwards, oldest ancestor first,
|
|
|
|
|
+ # one hop per call — a chain is a path, so it cannot be batched. A hop
|
|
|
|
|
+ # the server cannot resolve ends the chain: the ancestors above it are
|
|
|
|
|
+ # unreachable, and stopping beats reporting a hole as the top.
|
|
|
chain: list[Message] = []
|
|
chain: list[Message] = []
|
|
|
if follow_reply_chain > 0:
|
|
if follow_reply_chain > 0:
|
|
|
frontier_id = next(iter(found.values())).in_reply_to
|
|
frontier_id = next(iter(found.values())).in_reply_to
|
|
|
hops = follow_reply_chain
|
|
hops = follow_reply_chain
|
|
|
while frontier_id and hops > 0:
|
|
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
|
|
|
|
|
|
|
+ ancestor = await client.get_message(room.id, frontier_id)
|
|
|
if ancestor is None:
|
|
if ancestor is None:
|
|
|
break
|
|
break
|
|
|
chain.append(ancestor)
|
|
chain.append(ancestor)
|
|
@@ -4839,33 +4950,18 @@ async def _get_history(args: dict) -> str:
|
|
|
room_id = args.get("room_id") or ""
|
|
room_id = args.get("room_id") or ""
|
|
|
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(args.get("limit") or 20)
|
|
|
|
|
|
|
+ limit = _int_arg(args, "limit", HISTORY_PAGE_SIZE, low=1, high=HISTORY_PAGE_MAX)
|
|
|
|
|
|
|
|
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 limit < 1 or limit > 100:
|
|
|
|
|
- raise _refuse("limit must be between 1 and 100")
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
+ root: Message | None = None
|
|
|
if thread_root_event_id:
|
|
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."
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ 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
|
|
|
)
|
|
)
|
|
@@ -4873,9 +4969,14 @@ async def _get_history(args: dict) -> str:
|
|
|
page = await client.get_room_events(room.id, limit=limit)
|
|
page = await client.get_room_events(room.id, limit=limit)
|
|
|
|
|
|
|
|
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]
|
|
|
- # 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))
|
|
|
|
|
|
|
+ if root is not None and all(m.id != root.id for m in messages):
|
|
|
|
|
+ # Whether the server lists the root back as the thread's first
|
|
|
|
|
+ # event is not something the client signature promises, and a
|
|
|
|
|
+ # thread read without its root is a conversation whose opening
|
|
|
|
|
+ # line is missing. The root is already in hand, so putting it back
|
|
|
|
|
+ # costs nothing.
|
|
|
|
|
+ messages.insert(0, root)
|
|
|
|
|
+ messages = _oldest_first(messages)
|
|
|
|
|
|
|
|
# The timeline page carries its own user directory (``users_by_id``),
|
|
# The timeline page carries its own user directory (``users_by_id``),
|
|
|
# which answers author names without a member-list call. Fall back to
|
|
# which answers author names without a member-list call. Fall back to
|
|
@@ -5107,5 +5208,7 @@ def register(ctx) -> None:
|
|
|
"chatto_send_room and chatto_send_dm post to a channel or a person's DM on your "
|
|
"chatto_send_room and chatto_send_dm post to a channel or a person's DM on your "
|
|
|
"behalf. Only send into a channel that the bot answers in, and reach a person by DM "
|
|
"behalf. Only send into a channel that the bot answers in, and reach a person by DM "
|
|
|
"only once they have written to the bot themselves. "
|
|
"only once they have written to the bot themselves. "
|
|
|
|
|
+ "chatto_get_history and chatto_get_message read instead of posting: use them to "
|
|
|
|
|
+ "look up what was said in a room or thread, and to resolve an event ID you were given. "
|
|
|
),
|
|
),
|
|
|
)
|
|
)
|