|
|
@@ -82,6 +82,7 @@ try:
|
|
|
)
|
|
|
from chattolib.exceptions import (
|
|
|
ChattoAuthError,
|
|
|
+ ChattoConnectError,
|
|
|
ChattoError,
|
|
|
)
|
|
|
from chattolib.realtime import (
|
|
|
@@ -93,6 +94,11 @@ try:
|
|
|
from chattolib.types import (
|
|
|
Message,
|
|
|
MessageAttachment,
|
|
|
+ MessageSearchGroupBy,
|
|
|
+ MessageSearchOrder,
|
|
|
+ MessageSearchResult,
|
|
|
+ MessageSearchScope,
|
|
|
+ MessageSearchState,
|
|
|
PresenceStatus,
|
|
|
Room,
|
|
|
RoomKind,
|
|
|
@@ -4633,6 +4639,47 @@ def _int_arg(args: dict, key: str, default: int, *, low: int, high: int) -> int:
|
|
|
return value
|
|
|
|
|
|
|
|
|
+def _timestamp_arg(args: dict, key: str) -> datetime | None:
|
|
|
+ """An ISO-8601 tool argument as a datetime, or a refusal.
|
|
|
+
|
|
|
+ Search bounds are the first date arguments these tools take, so they get
|
|
|
+ the same treatment as numbers: an absent value means "no bound", and a
|
|
|
+ wrong one is refused rather than silently dropped — a caller who mistyped
|
|
|
+ a date is better served by hearing it than by getting an unbounded search.
|
|
|
+ A date without a time is midnight, and a value without an offset is read as
|
|
|
+ UTC, matching how everything else here treats a bare timestamp.
|
|
|
+ """
|
|
|
+ raw = args.get(key)
|
|
|
+ if raw is None or raw == "":
|
|
|
+ return None
|
|
|
+ if isinstance(raw, datetime):
|
|
|
+ return raw if raw.tzinfo is not None else raw.replace(tzinfo=UTC)
|
|
|
+ try:
|
|
|
+ parsed = datetime.fromisoformat(str(raw).strip())
|
|
|
+ except ValueError as exc:
|
|
|
+ raise _refuse(
|
|
|
+ f"{key} must be an ISO-8601 date or timestamp, e.g. '2026-01-31' "
|
|
|
+ "or '2026-01-31T12:00:00Z'"
|
|
|
+ ) from exc
|
|
|
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
|
|
+
|
|
|
+
|
|
|
+def _enum_arg(args: dict, key: str, mapping: dict[str, Any], default: Any) -> Any:
|
|
|
+ """A named tool argument resolved through ``mapping``, or a refusal.
|
|
|
+
|
|
|
+ The choices are spelled out in the schema's enum, so an unknown name means
|
|
|
+ the caller invented one; picking a default instead would answer a
|
|
|
+ different question than the one asked.
|
|
|
+ """
|
|
|
+ raw = args.get(key)
|
|
|
+ if raw is None or raw == "":
|
|
|
+ return default
|
|
|
+ value = mapping.get(str(raw).strip().lower())
|
|
|
+ if value is None:
|
|
|
+ raise _refuse(f"{key} must be one of: {', '.join(sorted(mapping))}")
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
@asynccontextmanager
|
|
|
async def _tool_client(cfg: ChattoConfiguration | None) -> AsyncIterator[ChattoClient]:
|
|
|
"""A short-lived client for one tool call, closed however the call ends.
|
|
|
@@ -4675,6 +4722,47 @@ HISTORY_PAGE_MAX = 100
|
|
|
# kept small enough that a model can digest the output without choking.
|
|
|
PINNED_PAGE_SIZE = 20
|
|
|
ATTACHMENT_PAGE_SIZE = 40
|
|
|
+# Search pages are bounded the same way, and for the same reason: a hit is
|
|
|
+# something to read, not a dump. The server's own ceiling is 100, and a page
|
|
|
+# may come back short while `next_cursor` is set — stale index hits are dropped
|
|
|
+# after the provider matches them, which is not an error.
|
|
|
+SEARCH_PAGE_SIZE = 20
|
|
|
+SEARCH_PAGE_MAX = 100
|
|
|
+
|
|
|
+# The model names a search order the way a person would; the proto spells it
|
|
|
+# out. One table maps the two, so the schema's enum and the lookup cannot
|
|
|
+# disagree about what "newest" means.
|
|
|
+_SEARCH_ORDERS = {
|
|
|
+ "relevance": MessageSearchOrder.RELEVANCE,
|
|
|
+ "newest": MessageSearchOrder.NEWEST,
|
|
|
+ "thread_activity": MessageSearchOrder.THREAD_ACTIVITY,
|
|
|
+ "thread_created": MessageSearchOrder.THREAD_CREATED,
|
|
|
+}
|
|
|
+_SEARCH_SCOPES = {"followed_threads": MessageSearchScope.FOLLOWED_THREADS}
|
|
|
+_SEARCH_GROUPS = {"thread": MessageSearchGroupBy.THREAD}
|
|
|
+
|
|
|
+# Why search cannot answer right now, in terms someone can act on. The server
|
|
|
+# returns FAILED_PRECONDITION for "the operator turned this off" and
|
|
|
+# UNAVAILABLE for "the provider is not ready yet" — the second one resolves
|
|
|
+# itself, the first one never does, and the caller should not have to guess
|
|
|
+# which it hit.
|
|
|
+_SEARCH_STATE_HINTS = {
|
|
|
+ MessageSearchState.DISABLED: (
|
|
|
+ "message search is turned off on this Chatto server; the operator has "
|
|
|
+ "to enable it (`search.enabled = true`) and restart"
|
|
|
+ ),
|
|
|
+ MessageSearchState.STARTING: (
|
|
|
+ "the search provider is starting up; try again shortly"
|
|
|
+ ),
|
|
|
+ MessageSearchState.INDEXING: (
|
|
|
+ "the search provider is still building its index from the message "
|
|
|
+ "history, so recent results may be missing; try again later"
|
|
|
+ ),
|
|
|
+ MessageSearchState.DEGRADED: (
|
|
|
+ "the search provider is degraded and answering only partially"
|
|
|
+ ),
|
|
|
+ MessageSearchState.UNAVAILABLE: ("no search provider is reachable for this server"),
|
|
|
+}
|
|
|
# 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.
|
|
|
@@ -5182,6 +5270,137 @@ CHATTO_GET_HISTORY_SCHEMA = {
|
|
|
}
|
|
|
|
|
|
|
|
|
+CHATTO_SEARCH_MESSAGES_SCHEMA = {
|
|
|
+ "name": "chatto_search_messages",
|
|
|
+ "description": (
|
|
|
+ "Full-text search over Chatto messages the bot can read. Use it to find "
|
|
|
+ "something said earlier when you do not know which room, thread or day: "
|
|
|
+ "`chatto_get_history` reads a window, this finds a needle. Search is a "
|
|
|
+ "server feature the operator has to enable; when it is off or still "
|
|
|
+ "building its index, the answer says so instead of reporting no "
|
|
|
+ 'matches. Query syntax: words are required terms, "quoted text" is an '
|
|
|
+ "exact phrase, `AND` separates terms, and `in:`, `from:`, `before:`, "
|
|
|
+ "`after:`, `has:attachment` are filters. Scope it with `room_id`/"
|
|
|
+ "`room_name`, `author_id` or the time bounds; `group_by: thread` "
|
|
|
+ "answers one row per matching thread instead of per message."
|
|
|
+ ),
|
|
|
+ "parameters": {
|
|
|
+ "type": "object",
|
|
|
+ "properties": {
|
|
|
+ "query": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "Required search expression. Words are required terms "
|
|
|
+ '(all must match), "a quoted phrase" matches exactly, and '
|
|
|
+ "`AND` may join terms. Filters: `in:room-name`, "
|
|
|
+ "`from:user-login`, `before:2026-03-01`, "
|
|
|
+ "`after:2026-01-01`, `has:attachment`. A filter may be used "
|
|
|
+ "without a word or phrase."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "room_id": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "Optional: search only this room (e.g. 'RbmwiHk4vs9a3Ql'). "
|
|
|
+ "Mutually exclusive with `room_name`. Without either, the "
|
|
|
+ "search covers every room the bot is a member of."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "room_name": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "Optional: search only the room with this display name "
|
|
|
+ "(case-insensitive). Mutually exclusive with `room_id`; "
|
|
|
+ "ambiguous names are refused with the candidate IDs."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "author_id": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "Optional: only messages posted by this Chatto user ID. To "
|
|
|
+ "filter by login instead, put `from:<login>` inside "
|
|
|
+ "`query`."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "created_after": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "Optional ISO-8601 lower bound on posting time, exclusive "
|
|
|
+ "(e.g. '2026-01-31' or '2026-01-31T12:00:00Z'). When "
|
|
|
+ "`query` also carries `after:`, the stricter bound wins."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "created_before": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "Optional ISO-8601 upper bound on posting time, exclusive. "
|
|
|
+ "When `query` also carries `before:`, the stricter bound "
|
|
|
+ "wins."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "has_attachments": {
|
|
|
+ "type": "boolean",
|
|
|
+ "description": (
|
|
|
+ "Optional: true returns only messages that currently have "
|
|
|
+ "attachments."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "order": {
|
|
|
+ "type": "string",
|
|
|
+ "enum": [
|
|
|
+ "relevance",
|
|
|
+ "newest",
|
|
|
+ "thread_activity",
|
|
|
+ "thread_created",
|
|
|
+ ],
|
|
|
+ "description": (
|
|
|
+ "Result order (default relevance). `thread_activity` and "
|
|
|
+ "`thread_created` require `group_by: thread`."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "group_by": {
|
|
|
+ "type": "string",
|
|
|
+ "enum": ["thread"],
|
|
|
+ "description": (
|
|
|
+ "Optional: with `thread`, return one row per matching "
|
|
|
+ "thread instead of one per message, with the thread's root, "
|
|
|
+ "reply count and latest reply."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "scope": {
|
|
|
+ "type": "string",
|
|
|
+ "enum": ["followed_threads"],
|
|
|
+ "description": (
|
|
|
+ "Optional: with `followed_threads`, search only threads the "
|
|
|
+ "bot follows. Combine with `group_by: thread` for a thread "
|
|
|
+ "list."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "limit": {
|
|
|
+ "type": "integer",
|
|
|
+ "description": (
|
|
|
+ "Maximum results per page, 1-100 (default 20). The page is "
|
|
|
+ "counted in messages, or in threads when `group_by` is "
|
|
|
+ "`thread`."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ "cursor": {
|
|
|
+ "type": "string",
|
|
|
+ "description": (
|
|
|
+ "Opaque `next_cursor` from a previous call, for the next "
|
|
|
+ "page. Bound to this caller and to every other query field, "
|
|
|
+ "so repeat the same query with it. Results come from a live "
|
|
|
+ "index: a page may repeat or omit a hit that moved while "
|
|
|
+ "paging."
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ "required": ["query"],
|
|
|
+ "additionalProperties": False,
|
|
|
+ },
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
def _chatto_config_for_tools() -> ChattoConfiguration | None:
|
|
|
"""Resolve the Chatto configuration for plugin tool handlers.
|
|
|
|
|
|
@@ -6291,6 +6510,202 @@ async def _get_history(args: dict) -> str:
|
|
|
)
|
|
|
|
|
|
|
|
|
+def _search_result_out(
|
|
|
+ result: MessageSearchResult, author_logins: dict[str, str]
|
|
|
+) -> dict[str, Any]:
|
|
|
+ """One search hit as the tool's JSON shape.
|
|
|
+
|
|
|
+ A hit is a pointer, not a record: ``chatto_get_message`` is what reads one
|
|
|
+ in full, so this carries what decides *which* hit to read — where it is,
|
|
|
+ who said it, when, what it says — and leaves the reaction and attachment
|
|
|
+ detail to that call.
|
|
|
+ """
|
|
|
+ message = result.message
|
|
|
+ out: dict[str, Any] = {"relevance": round(result.relevance_score, 4)}
|
|
|
+ if message is not None:
|
|
|
+ out.update(
|
|
|
+ {
|
|
|
+ "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),
|
|
|
+ "body": message.body,
|
|
|
+ "in_reply_to": message.in_reply_to or None,
|
|
|
+ "thread_root_event_id": message.thread_root_event_id or None,
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ context = result.thread_context
|
|
|
+ if context is not None:
|
|
|
+ # Only THREAD grouping asks for this: the row stands for a whole
|
|
|
+ # conversation, so the caller needs its opening line and how far it got,
|
|
|
+ # not just the message the provider matched.
|
|
|
+ root = context.root_message
|
|
|
+ latest = context.latest_reply
|
|
|
+ thread = context.thread
|
|
|
+ out["thread"] = {
|
|
|
+ "root_event_id": (
|
|
|
+ thread.thread_root_event_id
|
|
|
+ if thread is not None
|
|
|
+ else (root.id if root is not None else None)
|
|
|
+ ),
|
|
|
+ "root_body": root.body if root is not None else None,
|
|
|
+ "room_id": context.room.id if context.room is not None else None,
|
|
|
+ "room_name": context.room.name if context.room is not None else None,
|
|
|
+ "reply_count": thread.reply_count if thread is not None else None,
|
|
|
+ "participant_count": (
|
|
|
+ thread.participant_count if thread is not None else None
|
|
|
+ ),
|
|
|
+ "last_reply_at": (
|
|
|
+ _iso_or_none(thread.last_reply_at) if thread is not None else None
|
|
|
+ ),
|
|
|
+ "latest_reply_event_id": latest.id if latest is not None else None,
|
|
|
+ "latest_reply_body": latest.body if latest is not None else None,
|
|
|
+ }
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+async def _search_unavailable_reason(
|
|
|
+ client: ChattoClient, exc: ChattoConnectError
|
|
|
+) -> str:
|
|
|
+ """Why search cannot answer, in terms the caller can act on.
|
|
|
+
|
|
|
+ The server reports one of two codes for two very different situations:
|
|
|
+ the operator turned search off, or the provider is not ready yet. The first
|
|
|
+ never resolves on its own, the second does — saying which one it is changes
|
|
|
+ what the caller should do next. ``GetStatus`` stays available while search
|
|
|
+ is disabled, so it is the one call that can tell them apart.
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ status = await client.get_search_status()
|
|
|
+ except ChattoError as status_exc:
|
|
|
+ logger.debug("Chatto tools: search status unreadable: %s", status_exc)
|
|
|
+ return f"Message search is unavailable on this Chatto server ({exc.code})"
|
|
|
+
|
|
|
+ hint = _SEARCH_STATE_HINTS.get(status.state)
|
|
|
+ if hint is None:
|
|
|
+ return f"Message search is unavailable on this Chatto server ({exc.code})"
|
|
|
+ reason = f"Message search is unavailable: {hint}"
|
|
|
+ if status.retry_after_seconds:
|
|
|
+ reason += f" (retry in about {status.retry_after_seconds}s)"
|
|
|
+ return reason
|
|
|
+
|
|
|
+
|
|
|
+async def _search_messages(args: dict) -> str:
|
|
|
+ """Search message bodies. See _handle_chatto_search_messages."""
|
|
|
+ query = str(args.get("query") or "").strip()
|
|
|
+ room_id = args.get("room_id") or ""
|
|
|
+ room_name = args.get("room_name") or ""
|
|
|
+ author_id = args.get("author_id") or ""
|
|
|
+ cursor = args.get("cursor") or ""
|
|
|
+ limit = _int_arg(args, "limit", SEARCH_PAGE_SIZE, low=1, high=SEARCH_PAGE_MAX)
|
|
|
+ created_after = _timestamp_arg(args, "created_after")
|
|
|
+ created_before = _timestamp_arg(args, "created_before")
|
|
|
+ order = _enum_arg(args, "order", _SEARCH_ORDERS, MessageSearchOrder.RELEVANCE)
|
|
|
+ group_by = _enum_arg(
|
|
|
+ args, "group_by", _SEARCH_GROUPS, MessageSearchGroupBy.UNSPECIFIED
|
|
|
+ )
|
|
|
+ scope = _enum_arg(args, "scope", _SEARCH_SCOPES, MessageSearchScope.UNSPECIFIED)
|
|
|
+ has_attachments = args.get("has_attachments")
|
|
|
+
|
|
|
+ if not query:
|
|
|
+ raise _refuse("query is required and must not be empty")
|
|
|
+ if room_id and room_name:
|
|
|
+ raise _refuse("room_id and room_name are mutually exclusive")
|
|
|
+ if has_attachments is not None and not isinstance(has_attachments, bool):
|
|
|
+ raise _refuse("has_attachments must be true or false")
|
|
|
+ if order is MessageSearchOrder.THREAD_ACTIVITY and (
|
|
|
+ group_by is not MessageSearchGroupBy.THREAD
|
|
|
+ ):
|
|
|
+ # The server refuses this combination; saying which two arguments
|
|
|
+ # collide is more useful than relaying INVALID_ARGUMENT.
|
|
|
+ raise _refuse("order 'thread_activity' requires group_by 'thread'")
|
|
|
+ if created_after and created_before and created_after >= created_before:
|
|
|
+ raise _refuse("created_after must be earlier than created_before")
|
|
|
+
|
|
|
+ async with _tool_client(_chatto_config_for_tools()) as client:
|
|
|
+ if room_id or room_name:
|
|
|
+ room_id = (await _resolve_room(client, room_id, room_name)).room.id
|
|
|
+
|
|
|
+ try:
|
|
|
+ page = await client.search_messages(
|
|
|
+ query,
|
|
|
+ room_id=room_id or None,
|
|
|
+ author_id=author_id or None,
|
|
|
+ created_after=created_after,
|
|
|
+ created_before=created_before,
|
|
|
+ has_attachments=has_attachments,
|
|
|
+ order=order,
|
|
|
+ page_size=limit,
|
|
|
+ cursor=cursor or None,
|
|
|
+ scope=scope,
|
|
|
+ group_by=group_by,
|
|
|
+ )
|
|
|
+ except ChattoConnectError as exc:
|
|
|
+ if exc.code not in {"failed_precondition", "unavailable"}:
|
|
|
+ raise
|
|
|
+ raise _refuse(await _search_unavailable_reason(client, exc)) from exc
|
|
|
+
|
|
|
+ results = page.results
|
|
|
+
|
|
|
+ # The page carries its own user directory, which answers author names
|
|
|
+ # without a member-list call. A cross-room search can still miss an
|
|
|
+ # author, so the remaining ones are resolved per room — the directory
|
|
|
+ # call is per room, and a hit names the room it came from.
|
|
|
+ author_logins = {
|
|
|
+ user_id: user.login
|
|
|
+ for user_id, user in page.users_by_id.items()
|
|
|
+ if user.login
|
|
|
+ }
|
|
|
+ unresolved_by_room: dict[str, list[str]] = {}
|
|
|
+ for result in results:
|
|
|
+ message = result.message
|
|
|
+ if message is None or not message.actor_id:
|
|
|
+ continue
|
|
|
+ if message.actor_id in author_logins:
|
|
|
+ continue
|
|
|
+ unresolved_by_room.setdefault(message.room_id, []).append(message.actor_id)
|
|
|
+ for hit_room_id, actor_ids in unresolved_by_room.items():
|
|
|
+ if hit_room_id:
|
|
|
+ author_logins.update(
|
|
|
+ await _batch_author_logins(client, hit_room_id, actor_ids)
|
|
|
+ )
|
|
|
+
|
|
|
+ return json.dumps(
|
|
|
+ {
|
|
|
+ "success": True,
|
|
|
+ "query": query,
|
|
|
+ "room_id": room_id or None,
|
|
|
+ "count": len(results),
|
|
|
+ "grouped_by_thread": group_by is MessageSearchGroupBy.THREAD,
|
|
|
+ "thread_total_count": page.thread_total_count,
|
|
|
+ "next_cursor": page.next_cursor or None,
|
|
|
+ "results": [
|
|
|
+ _search_result_out(result, author_logins) for result in results
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+async def _handle_chatto_search_messages(args: dict) -> str:
|
|
|
+ """Tool handler for chatto_search_messages.
|
|
|
+
|
|
|
+ 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 _search_messages(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_send_dm(args: dict) -> str:
|
|
|
"""Tool handler for chatto_send_dm.
|
|
|
|
|
|
@@ -6693,6 +7108,11 @@ _PLUGIN_TOOLS = (
|
|
|
("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),
|
|
|
+ (
|
|
|
+ "chatto_search_messages",
|
|
|
+ CHATTO_SEARCH_MESSAGES_SCHEMA,
|
|
|
+ _handle_chatto_search_messages,
|
|
|
+ ),
|
|
|
(
|
|
|
"chatto_list_room_attachments",
|
|
|
CHATTO_LIST_ROOM_ATTACHMENTS_SCHEMA,
|
|
|
@@ -6807,5 +7227,8 @@ def register(ctx) -> None:
|
|
|
"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. "
|
|
|
+ "chatto_search_messages finds messages by their words when you do not know the room, "
|
|
|
+ "thread or day — it only answers when the server has search enabled, and says so when "
|
|
|
+ "it has not. "
|
|
|
),
|
|
|
)
|