@inline-chat/hermes-agent-adapter 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +315 -0
- package/dist/install.d.ts +4 -0
- package/dist/install.js +7957 -0
- package/package.json +81 -0
- package/plugin/inline/__init__.py +4 -0
- package/plugin/inline/adapter.py +2996 -0
- package/plugin/inline/cli.py +101 -0
- package/plugin/inline/plugin.yaml +132 -0
- package/plugin/inline/sidecar/index.mjs +24941 -0
- package/plugin/inline/tools.py +663 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""Model-callable Inline tool surface for Hermes Agent."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any, Dict, Iterable, Optional
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from tools.registry import tool_error, tool_result
|
|
13
|
+
except Exception: # pragma: no cover - used by lightweight package tests
|
|
14
|
+
def tool_error(message: Any, **extra: Any) -> str:
|
|
15
|
+
data = {"error": str(message)}
|
|
16
|
+
data.update(extra)
|
|
17
|
+
return json.dumps(data, ensure_ascii=False)
|
|
18
|
+
|
|
19
|
+
def tool_result(data: Any = None, **kwargs: Any) -> str:
|
|
20
|
+
return json.dumps(data if data is not None else kwargs, ensure_ascii=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_DEFAULT_SIDECAR_PORT = 8794
|
|
24
|
+
_DEFAULT_SIDECAR_BIND = "127.0.0.1"
|
|
25
|
+
_MAX_HISTORY_LIMIT = 100
|
|
26
|
+
_DEFAULT_HISTORY_LIMIT = 20
|
|
27
|
+
_MAX_TEXT_CHARS = 4000
|
|
28
|
+
_MAX_RESULT_TEXT_CHARS = 1600
|
|
29
|
+
_MAX_MESSAGE_ENTITIES = 12
|
|
30
|
+
_ENTITY_TEXT_LIMIT = 120
|
|
31
|
+
_ENTITY_TYPE_NAMES = {
|
|
32
|
+
1: "mention",
|
|
33
|
+
2: "url",
|
|
34
|
+
3: "text_link",
|
|
35
|
+
4: "email",
|
|
36
|
+
5: "bold",
|
|
37
|
+
6: "italic",
|
|
38
|
+
7: "username_mention",
|
|
39
|
+
8: "code",
|
|
40
|
+
9: "pre",
|
|
41
|
+
10: "phone_number",
|
|
42
|
+
11: "thread",
|
|
43
|
+
12: "thread_title",
|
|
44
|
+
13: "bot_command",
|
|
45
|
+
14: "group_mention",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_sidecar: Dict[str, Any] = {}
|
|
49
|
+
|
|
50
|
+
_ACTION_MANIFEST = [
|
|
51
|
+
("get_chat", "(chat_id?)", "Get metadata for a chat or reply thread."),
|
|
52
|
+
("get_messages", "(chat_id?, message_ids)", "Fetch specific Inline messages by ID."),
|
|
53
|
+
("get_history", "(chat_id?, limit?, anchor_id?)", "Fetch recent or anchored Inline history."),
|
|
54
|
+
("send_message", "(chat_id?|user_id?, text, reply_to_msg_id?)", "Send a Markdown message."),
|
|
55
|
+
("edit_message", "(chat_id?|user_id?, message_id, text)", "Edit a bot-owned message."),
|
|
56
|
+
("delete_message", "(chat_id?|user_id?, message_id)", "Delete a bot-owned message."),
|
|
57
|
+
("create_thread", "(parent_chat_id?, parent_message_id?, title?)", "Create an Inline reply thread."),
|
|
58
|
+
("set_typing", "(chat_id?, state)", "Start or stop the typing indicator."),
|
|
59
|
+
("set_presence", "(chat_id?|user_id?, kind, comment?)", "Set the bot presence state."),
|
|
60
|
+
]
|
|
61
|
+
_ACTIONS = [name for name, _, _ in _ACTION_MANIFEST]
|
|
62
|
+
_PRESENCE_KINDS = ["idle", "happy", "waving", "jumping", "failed", "waiting", "running", "review"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def configure_sidecar(*, bind: str, port: int, token: str) -> None:
|
|
66
|
+
"""Store live adapter sidecar details for model tools in this process."""
|
|
67
|
+
if not token:
|
|
68
|
+
return
|
|
69
|
+
_sidecar.update({"bind": bind, "port": int(port), "token": token})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_inline_tool_requirements() -> bool:
|
|
73
|
+
if _sidecar.get("token") or os.getenv("INLINE_SIDECAR_TOKEN"):
|
|
74
|
+
return True
|
|
75
|
+
return bool(os.getenv("INLINE_TOKEN") or os.getenv("INLINE_BOT_TOKEN"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def tool_context_prompt(
|
|
79
|
+
*,
|
|
80
|
+
chat_id: str,
|
|
81
|
+
message_id: str,
|
|
82
|
+
sender_user_id: Optional[str] = None,
|
|
83
|
+
thread_id: Optional[str] = None,
|
|
84
|
+
parent_chat_id: Optional[str] = None,
|
|
85
|
+
parent_message_id: Optional[str] = None,
|
|
86
|
+
) -> Optional[str]:
|
|
87
|
+
if not check_inline_tool_requirements():
|
|
88
|
+
return None
|
|
89
|
+
lines = [
|
|
90
|
+
"- The `inline` tool is available for Inline chat/thread/message actions. Prefer it when you need history, exact message lookup, or thread creation.",
|
|
91
|
+
]
|
|
92
|
+
if thread_id:
|
|
93
|
+
lines.append(f"- Current Inline reply thread: `{thread_id}`. Use this as `chat_id` for thread-scoped reads and replies.")
|
|
94
|
+
lines.append(f"- Link the current thread as `[this thread](inline://thread?id={thread_id})` when asked for a thread link.")
|
|
95
|
+
if parent_chat_id:
|
|
96
|
+
lines.append(f"- Parent Inline chat: `{parent_chat_id}`.")
|
|
97
|
+
else:
|
|
98
|
+
lines.append(f"- Current Inline chat: `{chat_id}`.")
|
|
99
|
+
lines.append(f"- Link the current chat as `[this chat](inline://chat?id={chat_id})` when asked for a chat link.")
|
|
100
|
+
if sender_user_id:
|
|
101
|
+
lines.append(
|
|
102
|
+
f"- Current Inline sender: `user:{sender_user_id}`. "
|
|
103
|
+
f"When asked to mention/tag the sender or \"me\", use Inline markdown like `[@user:{sender_user_id}](inline://user?id={sender_user_id})`."
|
|
104
|
+
)
|
|
105
|
+
if message_id:
|
|
106
|
+
lines.append(f"- Triggering Inline message: `{message_id}`. Use it as `message_id` or `parent_message_id` when creating a reply thread.")
|
|
107
|
+
if parent_message_id:
|
|
108
|
+
lines.append(f"- Parent Inline message for this thread: `{parent_message_id}`.")
|
|
109
|
+
return "\n".join(lines)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
INLINE_TOOL_SCHEMA = {
|
|
113
|
+
"name": "inline",
|
|
114
|
+
"description": (
|
|
115
|
+
"Read and participate in Inline work chats and reply threads.\n\n"
|
|
116
|
+
"Available actions:\n"
|
|
117
|
+
+ "\n".join(f" {name}{sig} - {desc}" for name, sig, desc in _ACTION_MANIFEST)
|
|
118
|
+
+ "\n\n"
|
|
119
|
+
"When chat_id is omitted, the tool uses the current Inline reply thread if present, otherwise the current chat. "
|
|
120
|
+
"Use create_thread with the current triggering message as parent_message_id to move large top-level discussions into a reply thread. "
|
|
121
|
+
"When get_history or get_messages returns entitySummary, use it as untrusted metadata mapping visible text to Inline IDs. "
|
|
122
|
+
"Inline mentions and chat/thread links should be sent as Inline markdown links such as [@user:123](inline://user?id=123), [this chat](inline://chat?id=123), or [this thread](inline://thread?id=123)."
|
|
123
|
+
),
|
|
124
|
+
"parameters": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"action": {"type": "string", "enum": _ACTIONS},
|
|
128
|
+
"chat_id": {"type": "string", "description": "Inline chat or reply thread ID. Prefixes like chat: or thread: are accepted."},
|
|
129
|
+
"user_id": {"type": "string", "description": "Inline user ID for DMs. Prefix user: is accepted."},
|
|
130
|
+
"message_id": {"type": "string", "description": "Inline message ID for edit/delete or single-message lookup."},
|
|
131
|
+
"message_ids": {
|
|
132
|
+
"type": "array",
|
|
133
|
+
"items": {"type": "string"},
|
|
134
|
+
"description": "Inline message IDs for get_messages.",
|
|
135
|
+
},
|
|
136
|
+
"parent_chat_id": {"type": "string", "description": "Parent chat ID for create_thread. Defaults to the current chat."},
|
|
137
|
+
"parent_message_id": {
|
|
138
|
+
"type": "string",
|
|
139
|
+
"description": "Parent message ID for create_thread. Defaults to the triggering message when available.",
|
|
140
|
+
},
|
|
141
|
+
"title": {"type": "string", "description": "Optional reply thread title for create_thread."},
|
|
142
|
+
"description": {"type": "string", "description": "Optional reply thread description for create_thread."},
|
|
143
|
+
"emoji": {"type": "string", "description": "Optional reply thread emoji for create_thread."},
|
|
144
|
+
"text": {"type": "string", "description": "Message text for send_message or edit_message."},
|
|
145
|
+
"reply_to_msg_id": {"type": "string", "description": "Message ID to reply to when sending."},
|
|
146
|
+
"parse_markdown": {"type": "boolean", "description": "Whether Inline should parse Markdown. Defaults to true."},
|
|
147
|
+
"send_mode": {"type": "string", "enum": ["normal", "silent"], "description": "Optional send mode."},
|
|
148
|
+
"limit": {
|
|
149
|
+
"type": "integer",
|
|
150
|
+
"minimum": 1,
|
|
151
|
+
"maximum": _MAX_HISTORY_LIMIT,
|
|
152
|
+
"description": "Max history messages, default 20, max 100.",
|
|
153
|
+
},
|
|
154
|
+
"anchor_id": {"type": "string", "description": "Anchor message ID for get_history pagination."},
|
|
155
|
+
"state": {"type": "string", "enum": ["start", "stop"], "description": "Typing indicator state."},
|
|
156
|
+
"kind": {"type": "string", "enum": _PRESENCE_KINDS, "description": "Bot presence kind."},
|
|
157
|
+
"comment": {"type": "string", "description": "Optional bot presence comment."},
|
|
158
|
+
},
|
|
159
|
+
"required": ["action"],
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def register(ctx: Any) -> None:
|
|
165
|
+
ctx.register_tool(
|
|
166
|
+
name="inline",
|
|
167
|
+
toolset="inline",
|
|
168
|
+
schema=INLINE_TOOL_SCHEMA,
|
|
169
|
+
handler=_handle_inline_tool,
|
|
170
|
+
check_fn=check_inline_tool_requirements,
|
|
171
|
+
description="Read and participate in Inline chats, messages, and reply threads.",
|
|
172
|
+
emoji="I",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _handle_inline_tool(args: Dict[str, Any], **_: Any) -> str:
|
|
177
|
+
if not isinstance(args, dict):
|
|
178
|
+
return tool_error("inline: expected JSON object arguments")
|
|
179
|
+
|
|
180
|
+
action = _str(args.get("action"))
|
|
181
|
+
if action not in _ACTIONS:
|
|
182
|
+
return tool_error(f"inline: unknown action {action or '(empty)'}", allowed_actions=_ACTIONS)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
path, body = _request_for_action(action, args)
|
|
186
|
+
response = _sidecar_call(path, body)
|
|
187
|
+
result = _compact_result(action, response.get("result") or {})
|
|
188
|
+
return tool_result({"success": True, "action": action, "result": result})
|
|
189
|
+
except InlineToolError as exc:
|
|
190
|
+
return tool_error(str(exc), action=action, error_kind=exc.error_kind)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
|
|
194
|
+
if action == "get_chat":
|
|
195
|
+
return "/chat", {"target": _target(args)}
|
|
196
|
+
|
|
197
|
+
if action == "get_messages":
|
|
198
|
+
ids = _message_ids(args)
|
|
199
|
+
if not ids:
|
|
200
|
+
raise InlineToolError("get_messages requires message_ids or message_id", "bad_format")
|
|
201
|
+
return "/messages", {"target": _target(args), "messageIds": ids}
|
|
202
|
+
|
|
203
|
+
if action == "get_history":
|
|
204
|
+
body = {
|
|
205
|
+
"target": _target(args),
|
|
206
|
+
"limit": _limit(args.get("limit")),
|
|
207
|
+
}
|
|
208
|
+
anchor_id = _inline_id(args.get("anchor_id"))
|
|
209
|
+
if anchor_id:
|
|
210
|
+
body["anchorId"] = anchor_id
|
|
211
|
+
return "/history", body
|
|
212
|
+
|
|
213
|
+
if action == "send_message":
|
|
214
|
+
text = _required_str(args, "text", max_chars=_MAX_TEXT_CHARS)
|
|
215
|
+
body = {
|
|
216
|
+
"target": _target(args),
|
|
217
|
+
"text": text,
|
|
218
|
+
"parseMarkdown": _bool(args.get("parse_markdown"), True),
|
|
219
|
+
}
|
|
220
|
+
reply_to = _inline_id(args.get("reply_to_msg_id"))
|
|
221
|
+
if reply_to:
|
|
222
|
+
body["replyToMsgId"] = reply_to
|
|
223
|
+
if _str(args.get("send_mode")) == "silent":
|
|
224
|
+
body["sendMode"] = "silent"
|
|
225
|
+
return "/send", body
|
|
226
|
+
|
|
227
|
+
if action == "edit_message":
|
|
228
|
+
return "/edit", {
|
|
229
|
+
"target": _target(args),
|
|
230
|
+
"messageId": _required_id(args, "message_id"),
|
|
231
|
+
"text": _required_str(args, "text", max_chars=_MAX_TEXT_CHARS),
|
|
232
|
+
"parseMarkdown": _bool(args.get("parse_markdown"), True),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if action == "delete_message":
|
|
236
|
+
return "/delete", {
|
|
237
|
+
"target": _target(args),
|
|
238
|
+
"messageId": _required_id(args, "message_id"),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if action == "create_thread":
|
|
242
|
+
parent_chat_id = _inline_id(args.get("parent_chat_id")) or _session_chat_id(prefer_thread=False)
|
|
243
|
+
if not parent_chat_id:
|
|
244
|
+
raise InlineToolError("create_thread requires parent_chat_id or current Inline chat context", "bad_format")
|
|
245
|
+
body = {"parentChatId": parent_chat_id}
|
|
246
|
+
parent_message_id = _inline_id(args.get("parent_message_id")) or _session_message_id()
|
|
247
|
+
if parent_message_id:
|
|
248
|
+
body["parentMessageId"] = parent_message_id
|
|
249
|
+
for key in ("title", "description", "emoji"):
|
|
250
|
+
value = _str(args.get(key))
|
|
251
|
+
if value:
|
|
252
|
+
body[key] = value
|
|
253
|
+
return "/create-subthread", body
|
|
254
|
+
|
|
255
|
+
if action == "set_typing":
|
|
256
|
+
return "/typing", {
|
|
257
|
+
"target": _target(args),
|
|
258
|
+
"state": _str(args.get("state")) or "start",
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if action == "set_presence":
|
|
262
|
+
kind = _str(args.get("kind"))
|
|
263
|
+
if kind not in _PRESENCE_KINDS:
|
|
264
|
+
raise InlineToolError(f"set_presence requires kind: {', '.join(_PRESENCE_KINDS)}", "bad_format")
|
|
265
|
+
body = {"target": _target(args), "kind": kind}
|
|
266
|
+
comment = _str(args.get("comment"))
|
|
267
|
+
if comment:
|
|
268
|
+
body["comment"] = comment
|
|
269
|
+
return "/presence", body
|
|
270
|
+
|
|
271
|
+
raise InlineToolError(f"inline: unimplemented action {action}", "bad_format")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _sidecar_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
275
|
+
base_url, token = _sidecar_config()
|
|
276
|
+
request = urllib.request.Request(
|
|
277
|
+
f"{base_url}{path}",
|
|
278
|
+
data=json.dumps({k: v for k, v in body.items() if v is not None}).encode("utf-8"),
|
|
279
|
+
headers={
|
|
280
|
+
"content-type": "application/json; charset=utf-8",
|
|
281
|
+
"x-hermes-sidecar-token": token,
|
|
282
|
+
},
|
|
283
|
+
method="POST",
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
287
|
+
raw = response.read().decode("utf-8")
|
|
288
|
+
status = response.status
|
|
289
|
+
except urllib.error.HTTPError as exc:
|
|
290
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
291
|
+
status = exc.code
|
|
292
|
+
except urllib.error.URLError as exc:
|
|
293
|
+
raise InlineToolError(f"Inline sidecar is not reachable: {exc}", "transient") from exc
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
data = json.loads(raw or "{}")
|
|
297
|
+
except json.JSONDecodeError as exc:
|
|
298
|
+
raise InlineToolError(f"Inline sidecar returned invalid JSON: {exc}", "unknown") from exc
|
|
299
|
+
|
|
300
|
+
if status != 200 or not data.get("ok"):
|
|
301
|
+
error = str(data.get("error") or f"Inline sidecar returned HTTP {status}")
|
|
302
|
+
error_kind = str(data.get("errorKind") or "unknown")
|
|
303
|
+
raise InlineToolError(error, error_kind)
|
|
304
|
+
return data
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _sidecar_config() -> tuple[str, str]:
|
|
308
|
+
token = _str(_sidecar.get("token")) or _str(os.getenv("INLINE_SIDECAR_TOKEN"))
|
|
309
|
+
if not token:
|
|
310
|
+
raise InlineToolError("Inline sidecar token is not configured; start the Inline gateway adapter first", "forbidden")
|
|
311
|
+
bind = _normalize_bind(_sidecar.get("bind") or os.getenv("INLINE_SIDECAR_BIND"))
|
|
312
|
+
port = _normalize_port(_sidecar.get("port") or os.getenv("INLINE_SIDECAR_PORT"))
|
|
313
|
+
host = f"[{bind}]" if ":" in bind and not bind.startswith("[") else bind
|
|
314
|
+
return f"http://{host}:{port}", token
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _target(args: Dict[str, Any]) -> Dict[str, str]:
|
|
318
|
+
chat_id = _inline_id(args.get("chat_id"))
|
|
319
|
+
user_id = _inline_id(args.get("user_id"))
|
|
320
|
+
if chat_id and user_id:
|
|
321
|
+
raise InlineToolError("target cannot include both chat_id and user_id", "bad_format")
|
|
322
|
+
if chat_id:
|
|
323
|
+
return {"chatId": chat_id}
|
|
324
|
+
if user_id:
|
|
325
|
+
return {"userId": user_id}
|
|
326
|
+
current = _session_chat_id(prefer_thread=True)
|
|
327
|
+
if current:
|
|
328
|
+
return {"chatId": current}
|
|
329
|
+
raise InlineToolError("target requires chat_id, user_id, or current Inline chat context", "bad_format")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _session_chat_id(*, prefer_thread: bool) -> str:
|
|
333
|
+
if _session_env("HERMES_SESSION_PLATFORM", "") not in {"", "inline"}:
|
|
334
|
+
return ""
|
|
335
|
+
if prefer_thread:
|
|
336
|
+
thread_id = _inline_id(_session_env("HERMES_SESSION_THREAD_ID", ""))
|
|
337
|
+
if thread_id:
|
|
338
|
+
return thread_id
|
|
339
|
+
return _inline_id(_session_env("HERMES_SESSION_CHAT_ID", ""))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _session_message_id() -> str:
|
|
343
|
+
if _session_env("HERMES_SESSION_PLATFORM", "") not in {"", "inline"}:
|
|
344
|
+
return ""
|
|
345
|
+
return _inline_id(_session_env("HERMES_SESSION_MESSAGE_ID", ""))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _session_env(name: str, default: str = "") -> str:
|
|
349
|
+
try:
|
|
350
|
+
from gateway.session_context import get_session_env
|
|
351
|
+
return str(get_session_env(name, default) or "")
|
|
352
|
+
except Exception:
|
|
353
|
+
return str(os.getenv(name, default) or "")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _message_ids(args: Dict[str, Any]) -> list[str]:
|
|
357
|
+
raw = args.get("message_ids")
|
|
358
|
+
values: Iterable[Any]
|
|
359
|
+
if isinstance(raw, list):
|
|
360
|
+
values = raw
|
|
361
|
+
elif _str(raw):
|
|
362
|
+
values = _str(raw).split(",")
|
|
363
|
+
else:
|
|
364
|
+
values = []
|
|
365
|
+
ids = [_inline_id(value) for value in values]
|
|
366
|
+
single = _inline_id(args.get("message_id"))
|
|
367
|
+
if single:
|
|
368
|
+
ids.append(single)
|
|
369
|
+
return [item for item in ids if item]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _compact_result(action: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
373
|
+
if action == "get_chat":
|
|
374
|
+
return _compact_chat(result)
|
|
375
|
+
if action in {"get_messages", "get_history"}:
|
|
376
|
+
messages = result.get("messages") if isinstance(result, dict) else []
|
|
377
|
+
if not isinstance(messages, list):
|
|
378
|
+
messages = []
|
|
379
|
+
return {
|
|
380
|
+
"count": len(messages),
|
|
381
|
+
"messages": [_compact_message(msg) for msg in messages],
|
|
382
|
+
}
|
|
383
|
+
if action == "create_thread":
|
|
384
|
+
return {
|
|
385
|
+
"chatId": _str(result.get("chatId")),
|
|
386
|
+
"chat": _compact_chat(result.get("chat") if isinstance(result.get("chat"), dict) else {}),
|
|
387
|
+
}
|
|
388
|
+
return result
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _compact_chat(chat: Dict[str, Any]) -> Dict[str, Any]:
|
|
392
|
+
if not isinstance(chat, dict):
|
|
393
|
+
return {}
|
|
394
|
+
out: Dict[str, Any] = {}
|
|
395
|
+
for source, dest in [
|
|
396
|
+
("chatId", "chatId"),
|
|
397
|
+
("id", "chatId"),
|
|
398
|
+
("title", "title"),
|
|
399
|
+
("spaceId", "spaceId"),
|
|
400
|
+
("parentChatId", "parentChatId"),
|
|
401
|
+
("parentMessageId", "parentMessageId"),
|
|
402
|
+
("isPublic", "isPublic"),
|
|
403
|
+
("untitled", "untitled"),
|
|
404
|
+
("number", "number"),
|
|
405
|
+
]:
|
|
406
|
+
if source in chat and chat[source] is not None and dest not in out:
|
|
407
|
+
out[dest] = chat[source]
|
|
408
|
+
return out
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _compact_message(message: Any) -> Dict[str, Any]:
|
|
412
|
+
if not isinstance(message, dict):
|
|
413
|
+
return {"raw": _truncate(str(message), 200)}
|
|
414
|
+
out: Dict[str, Any] = {}
|
|
415
|
+
for key in ["id", "chatId", "fromId", "date", "out", "replyToMsgId", "mentioned", "rev"]:
|
|
416
|
+
if key in message and message[key] is not None:
|
|
417
|
+
out[key] = message[key]
|
|
418
|
+
text = message.get("message")
|
|
419
|
+
if text is None:
|
|
420
|
+
text = message.get("text")
|
|
421
|
+
if text is not None:
|
|
422
|
+
out["text"] = _truncate(str(text), _MAX_RESULT_TEXT_CHARS)
|
|
423
|
+
for key in ["media", "attachments", "reactions", "replies", "actions"]:
|
|
424
|
+
if key in message and message[key] is not None:
|
|
425
|
+
out[key] = _summarize_value(message[key])
|
|
426
|
+
entity_summary, entity_count = _entity_summary_text(message, str(text or ""))
|
|
427
|
+
if entity_summary:
|
|
428
|
+
out["entitySummary"] = entity_summary
|
|
429
|
+
if entity_count > 0:
|
|
430
|
+
out["entityCount"] = entity_count
|
|
431
|
+
if entity_count > _MAX_MESSAGE_ENTITIES:
|
|
432
|
+
out["entitiesMore"] = entity_count - _MAX_MESSAGE_ENTITIES
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _message_entities(message: Dict[str, Any]) -> list[Dict[str, Any]]:
|
|
437
|
+
candidates: list[Any] = [message.get("entities")]
|
|
438
|
+
raw = message.get("raw")
|
|
439
|
+
if isinstance(raw, dict):
|
|
440
|
+
candidates.append(raw.get("entities"))
|
|
441
|
+
for container in candidates:
|
|
442
|
+
if isinstance(container, dict):
|
|
443
|
+
entities = container.get("entities")
|
|
444
|
+
else:
|
|
445
|
+
entities = container
|
|
446
|
+
if isinstance(entities, list):
|
|
447
|
+
return [entity for entity in entities if isinstance(entity, dict)]
|
|
448
|
+
return []
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _entity_summary_text(message: Dict[str, Any], text: str) -> tuple[str, int]:
|
|
452
|
+
entities = _message_entities(message)
|
|
453
|
+
parts: list[str] = []
|
|
454
|
+
for entity in entities[:_MAX_MESSAGE_ENTITIES]:
|
|
455
|
+
summary = _format_entity_summary(entity, text)
|
|
456
|
+
if summary:
|
|
457
|
+
parts.append(summary)
|
|
458
|
+
if len(entities) > _MAX_MESSAGE_ENTITIES:
|
|
459
|
+
parts.append(f"+{len(entities) - _MAX_MESSAGE_ENTITIES} more")
|
|
460
|
+
return " | ".join(parts), len(entities)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _format_entity_summary(entity: Dict[str, Any], text: str) -> Optional[str]:
|
|
464
|
+
kind = _entity_kind(entity)
|
|
465
|
+
label = _entity_slice(text, entity)
|
|
466
|
+
quoted = f' "{label}"' if label else ""
|
|
467
|
+
|
|
468
|
+
if kind == "mention":
|
|
469
|
+
user_id = _entity_id(_entity_payload(entity, "mention"), "userId")
|
|
470
|
+
return f"mention{quoted} -> user:{user_id}" if user_id else f"mention{quoted}"
|
|
471
|
+
if kind == "group_mention":
|
|
472
|
+
group_id = _entity_id(_entity_payload(entity, "groupMention", "group_mention"), "groupId")
|
|
473
|
+
return f"group mention{quoted} -> group:{group_id}" if group_id else f"group mention{quoted}"
|
|
474
|
+
if kind == "text_link":
|
|
475
|
+
url = _compact_text(_entity_payload(entity, "textUrl", "text_url").get("url"), 240)
|
|
476
|
+
return f"text link{quoted} -> {url}" if url else f"text link{quoted}"
|
|
477
|
+
if kind == "thread":
|
|
478
|
+
chat_id = _entity_id(_entity_payload(entity, "thread"), "chatId")
|
|
479
|
+
return f"thread link{quoted} -> thread:{chat_id}" if chat_id else f"thread link{quoted}"
|
|
480
|
+
if kind == "thread_title":
|
|
481
|
+
payload = _entity_payload(entity, "threadTitle", "thread_title")
|
|
482
|
+
space_id = _entity_id(payload, "spaceId")
|
|
483
|
+
title = _compact_text(payload.get("title"), _ENTITY_TEXT_LIMIT)
|
|
484
|
+
if space_id and title:
|
|
485
|
+
return f"thread title link{quoted} -> space:{space_id} title:\"{title}\""
|
|
486
|
+
return f"thread title link{quoted} -> space:{space_id}" if space_id else f"thread title link{quoted}"
|
|
487
|
+
if kind == "pre":
|
|
488
|
+
language = _compact_text(_entity_payload(entity, "pre").get("language"), 80)
|
|
489
|
+
return f"preformatted block{quoted} (language: {language})" if language else f"preformatted block{quoted}"
|
|
490
|
+
if kind == "username_mention":
|
|
491
|
+
return f"username mention{quoted}"
|
|
492
|
+
if kind == "phone_number":
|
|
493
|
+
return f"phone number{quoted}"
|
|
494
|
+
if kind == "bot_command":
|
|
495
|
+
return f"bot command{quoted}"
|
|
496
|
+
if kind == "unknown" and not label:
|
|
497
|
+
return None
|
|
498
|
+
return f"{kind.replace('_', ' ')}{quoted}"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _entity_kind(entity: Dict[str, Any]) -> str:
|
|
502
|
+
payload = entity.get("entity")
|
|
503
|
+
oneof = str(payload.get("oneofKind") or "") if isinstance(payload, dict) else ""
|
|
504
|
+
if oneof == "textUrl":
|
|
505
|
+
return "text_link"
|
|
506
|
+
if oneof == "threadTitle":
|
|
507
|
+
return "thread_title"
|
|
508
|
+
if oneof == "groupMention":
|
|
509
|
+
return "group_mention"
|
|
510
|
+
if oneof:
|
|
511
|
+
return re.sub(r"[^a-z0-9_]", "_", oneof.strip().lower())
|
|
512
|
+
|
|
513
|
+
type_id = _to_int(entity.get("type"))
|
|
514
|
+
if type_id is not None:
|
|
515
|
+
return _ENTITY_TYPE_NAMES.get(type_id, "unknown")
|
|
516
|
+
text = str(entity.get("type") or "").strip().lower()
|
|
517
|
+
text = re.sub(r"^type_", "", text).replace("-", "_")
|
|
518
|
+
if text in {"text_url", "texturl"}:
|
|
519
|
+
return "text_link"
|
|
520
|
+
if text in {"threadtitle", "thread_title"}:
|
|
521
|
+
return "thread_title"
|
|
522
|
+
if text in {"groupmention", "group_mention"}:
|
|
523
|
+
return "group_mention"
|
|
524
|
+
return text or "unknown"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _entity_payload(entity: Dict[str, Any], *keys: str) -> Dict[str, Any]:
|
|
528
|
+
payload = entity.get("entity")
|
|
529
|
+
if not isinstance(payload, dict):
|
|
530
|
+
return {}
|
|
531
|
+
for key in keys:
|
|
532
|
+
value = payload.get(key)
|
|
533
|
+
if isinstance(value, dict):
|
|
534
|
+
return value
|
|
535
|
+
return {}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _entity_id(payload: Dict[str, Any], key: str) -> Optional[str]:
|
|
539
|
+
value = payload.get(key)
|
|
540
|
+
if value is None:
|
|
541
|
+
return None
|
|
542
|
+
text = str(value).strip()
|
|
543
|
+
return text or None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _entity_slice(text: str, entity: Dict[str, Any]) -> str:
|
|
547
|
+
offset = _to_int(entity.get("offset"))
|
|
548
|
+
length = _to_int(entity.get("length"))
|
|
549
|
+
if offset is None or length is None or offset < 0 or length <= 0:
|
|
550
|
+
return ""
|
|
551
|
+
return _compact_text(text[offset: offset + length], _ENTITY_TEXT_LIMIT)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _summarize_value(value: Any) -> Any:
|
|
555
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
556
|
+
return value
|
|
557
|
+
if isinstance(value, str):
|
|
558
|
+
return _truncate(value, 240)
|
|
559
|
+
if isinstance(value, list):
|
|
560
|
+
return [_summarize_value(item) for item in value[:8]]
|
|
561
|
+
if isinstance(value, dict):
|
|
562
|
+
out: Dict[str, Any] = {}
|
|
563
|
+
for key, item in list(value.items())[:12]:
|
|
564
|
+
if str(key).lower() == "raw":
|
|
565
|
+
continue
|
|
566
|
+
out[str(key)] = _summarize_value(item)
|
|
567
|
+
return out
|
|
568
|
+
return _truncate(str(value), 240)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _compact_text(value: Any, limit: int) -> str:
|
|
572
|
+
return _truncate(str(value or "").replace("\n", " ").strip(), limit)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _to_int(value: Any) -> Optional[int]:
|
|
576
|
+
try:
|
|
577
|
+
return int(str(value))
|
|
578
|
+
except (TypeError, ValueError):
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _required_str(args: Dict[str, Any], key: str, *, max_chars: int) -> str:
|
|
583
|
+
value = _str(args.get(key))
|
|
584
|
+
if not value:
|
|
585
|
+
raise InlineToolError(f"{key} is required", "bad_format")
|
|
586
|
+
return _truncate(value, max_chars)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _required_id(args: Dict[str, Any], key: str) -> str:
|
|
590
|
+
value = _inline_id(args.get(key))
|
|
591
|
+
if not value:
|
|
592
|
+
raise InlineToolError(f"{key} is required", "bad_format")
|
|
593
|
+
return value
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _inline_id(value: Any) -> str:
|
|
597
|
+
text = _str(value)
|
|
598
|
+
if not text:
|
|
599
|
+
return ""
|
|
600
|
+
if ":" in text:
|
|
601
|
+
prefix, rest = text.split(":", 1)
|
|
602
|
+
if prefix.lower() in {"chat", "thread", "user", "message", "msg"}:
|
|
603
|
+
text = rest.strip()
|
|
604
|
+
return text
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _limit(value: Any) -> int:
|
|
608
|
+
try:
|
|
609
|
+
limit = int(value)
|
|
610
|
+
except (TypeError, ValueError):
|
|
611
|
+
return _DEFAULT_HISTORY_LIMIT
|
|
612
|
+
return min(max(limit, 1), _MAX_HISTORY_LIMIT)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _bool(value: Any, default: bool) -> bool:
|
|
616
|
+
if isinstance(value, bool):
|
|
617
|
+
return value
|
|
618
|
+
text = _str(value).lower()
|
|
619
|
+
if text in {"1", "true", "yes", "on"}:
|
|
620
|
+
return True
|
|
621
|
+
if text in {"0", "false", "no", "off"}:
|
|
622
|
+
return False
|
|
623
|
+
return default
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _normalize_bind(value: Any) -> str:
|
|
627
|
+
text = _str(value) or _DEFAULT_SIDECAR_BIND
|
|
628
|
+
if text == "[::1]":
|
|
629
|
+
return "::1"
|
|
630
|
+
if text in {"127.0.0.1", "localhost", "::1"}:
|
|
631
|
+
return text
|
|
632
|
+
raise InlineToolError(f"INLINE_SIDECAR_BIND must be loopback, got {text}", "bad_format")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _normalize_port(value: Any) -> int:
|
|
636
|
+
text = _str(value)
|
|
637
|
+
if not text:
|
|
638
|
+
return _DEFAULT_SIDECAR_PORT
|
|
639
|
+
try:
|
|
640
|
+
port = int(text)
|
|
641
|
+
except ValueError as exc:
|
|
642
|
+
raise InlineToolError("INLINE_SIDECAR_PORT must be an integer from 1 to 65535", "bad_format") from exc
|
|
643
|
+
if port < 1 or port > 65535:
|
|
644
|
+
raise InlineToolError("INLINE_SIDECAR_PORT must be an integer from 1 to 65535", "bad_format")
|
|
645
|
+
return port
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _str(value: Any) -> str:
|
|
649
|
+
if value is None:
|
|
650
|
+
return ""
|
|
651
|
+
return str(value).strip()
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _truncate(text: str, limit: int) -> str:
|
|
655
|
+
if limit <= 0 or len(text) <= limit:
|
|
656
|
+
return text
|
|
657
|
+
return text[: max(0, limit - 3)] + "..."
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class InlineToolError(RuntimeError):
|
|
661
|
+
def __init__(self, message: str, error_kind: str = "unknown"):
|
|
662
|
+
self.error_kind = error_kind
|
|
663
|
+
super().__init__(message)
|