|
|
@@ -387,6 +387,13 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
# seen-ring; unlike the roster this is deliberately NOT discarded on
|
|
|
# reconnect, since thread history is immutable.
|
|
|
self._seeded_threads: dict[str, str | None] = {}
|
|
|
+ # Keyed by thread root, which assumes one session per thread. With
|
|
|
+ # thread_sessions_per_user turned on, each participant gets their own
|
|
|
+ # session while this map stays shared, so one participant's turn moves
|
|
|
+ # the cursor past the other's messages and that session is seeded once
|
|
|
+ # and never again. The flag is off by default and this is the price of
|
|
|
+ # switching it on; keying the map by session key would fix it and is
|
|
|
+ # noted next to _thread_context_for.
|
|
|
# session_key -> message ID currently being processed there. Written
|
|
|
# by on_processing_start, cleared by on_processing_complete; an edit
|
|
|
# landing on the recorded ID is a mid-run correction.
|
|
|
@@ -3859,7 +3866,12 @@ def _chatto_config_for_tools() -> ChattoConfiguration | None:
|
|
|
profile_secrets = {}
|
|
|
|
|
|
resolved.overlay_profile_secrets(profile_secrets)
|
|
|
- _resolved_chatto_configs[scope_key] = resolved
|
|
|
+ # Cache only a configuration that can actually send. Caching a tokenless one
|
|
|
+ # would pin this failure for the life of the process: the next call in a
|
|
|
+ # properly scoped context would be served the degraded config and keep
|
|
|
+ # reporting "token not configured" long after the scope came back.
|
|
|
+ if resolved.token.value:
|
|
|
+ _resolved_chatto_configs[scope_key] = resolved
|
|
|
return resolved
|
|
|
|
|
|
|
|
|
@@ -3917,6 +3929,25 @@ def _room_is_writable(
|
|
|
return _room_answers_in(cfg, room_id, room.kind)
|
|
|
|
|
|
|
|
|
+def _not_joined_refusal(room: Room) -> _ToolRefusal:
|
|
|
+ """The refusal for a room the bot can see but has not joined.
|
|
|
+
|
|
|
+ The two 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.
|
|
|
+ """
|
|
|
+ remedy = (
|
|
|
+ f"Invite the bot account in Chatto, or DM it `/join {room.id}`"
|
|
|
+ if room.kind == RoomKind.CHANNEL
|
|
|
+ else "Have someone start a DM with the bot account"
|
|
|
+ )
|
|
|
+ return _refuse(
|
|
|
+ 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."
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
def _read_only_room_refusal(room_label: str) -> _ToolRefusal:
|
|
|
"""The refusal a read-only room earns, naming where to opt it in.
|
|
|
|
|
|
@@ -4026,7 +4057,8 @@ async def _post_chunks(
|
|
|
else "Internal plugin error"
|
|
|
)
|
|
|
raise _refuse(
|
|
|
- f"{cause}: {delivery.error}",
|
|
|
+ f"{cause} on chunk {len(delivery.message_ids) + 1}/"
|
|
|
+ f"{delivery.chunks_total}: {delivery.error}",
|
|
|
# partial marks a half-delivered send; the caller must not
|
|
|
# resend it from the top without knowing about these chunks.
|
|
|
partial=bool(delivery.message_ids),
|
|
|
@@ -4143,7 +4175,7 @@ async def _resolve_room(
|
|
|
)
|
|
|
|
|
|
matches = [
|
|
|
- rws
|
|
|
+ _ResolvedRoom(rws.room, rws.viewer_state.is_member)
|
|
|
for rws in await client.list_rooms()
|
|
|
if rws.room is not None
|
|
|
and (rws.room.name or "").casefold() == room_name.casefold()
|
|
|
@@ -4154,16 +4186,13 @@ async def _resolve_room(
|
|
|
)
|
|
|
if len(matches) > 1:
|
|
|
candidates = ", ".join(
|
|
|
- f"{rws.room.id} ('{rws.room.name}')" # type: ignore[union-attr]
|
|
|
- for rws in matches
|
|
|
+ f"{match.room.id} ('{match.room.name}')" for match in matches
|
|
|
)
|
|
|
raise _refuse(
|
|
|
f"Room name '{room_name}' is ambiguous — {len(matches)} rooms carry it: "
|
|
|
f"{candidates}. Pass the room_id instead."
|
|
|
)
|
|
|
- only = matches[0]
|
|
|
- assert only.room is not None # the filter above guarantees a room
|
|
|
- return _ResolvedRoom(only.room, only.viewer_state.is_member)
|
|
|
+ return matches[0]
|
|
|
|
|
|
|
|
|
async def _send_dm(args: dict) -> str:
|
|
|
@@ -4245,11 +4274,7 @@ async def _send_room(args: dict) -> str:
|
|
|
# posting into one fails server-side with an error that names neither
|
|
|
# the room nor the fix.
|
|
|
if not resolved.is_member:
|
|
|
- raise _refuse(
|
|
|
- f"Room '{room.name or room.id}' ({room.id}) is not one the bot "
|
|
|
- "has joined, so it cannot post there. Have someone invite the bot "
|
|
|
- "natively in Chatto, or send it a direct message, and try again."
|
|
|
- )
|
|
|
+ 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:
|