@inline-chat/hermes-agent-adapter 0.0.20 → 0.0.21
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/README.md +27 -7
- package/package.json +1 -1
- package/plugin/inline/adapter.py +349 -98
- package/plugin/inline/cli.py +7 -5
- package/plugin/inline/plugin.yaml +1 -1
- package/plugin/inline/sidecar/index.mjs +49 -45
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Supported:
|
|
|
25
25
|
- `INLINE_TOKEN`, `INLINE_BOT_TOKEN`, `platforms.inline.token`, and `inline.token` auth paths, including simple `${ENV_NAME}` config references.
|
|
26
26
|
- Supervised loopback Node sidecar using the Inline realtime SDK.
|
|
27
27
|
- Realtime inbound messages, catch-up, replies to bot messages, and action callbacks.
|
|
28
|
+
- Inbound SDK receipts remain pending until Python handles the event. Temporary routing-metadata and authorization failures retry before deduplication or effects, preserving per-chat order while other chats can progress. Lost acknowledgement responses retry only the acknowledgement. Agent-button preflight retries are bounded, then show a retry prompt so an unavailable button cannot indefinitely block the shared action cursor. Shutdown leaves unresolved receipts available for catch-up; this is an at-least-once handoff, not a durable exactly-once guarantee for model turns or external effects.
|
|
28
29
|
- Outbound text, Markdown parsing, opt-in edit-message streaming, long-message splitting, edits, deletes, typing, and presence.
|
|
29
30
|
- Inline reply-thread routing, explicit-request auto mode, `/threads` controls, explicit `/follow` and `/unfollow` dialog relevance controls, parent chat metadata, parent/thread prompt fallback, and thread-specific skill bindings.
|
|
30
31
|
- Native Hermes `inline` tool for current-chat/thread reads, bounded history and search, exact message lookup, button-message sends, editing/deleting bot-owned messages, reactions, pin/unpin/list pins, reply-thread creation, top-level thread/chat creation outside the current conversation, and avatar presence/status.
|
|
@@ -33,7 +34,7 @@ Supported:
|
|
|
33
34
|
- DM and group policies, user allowlists, group sender allowlists, mention requirements, strict mention mode, allowed chats, and free-response chats.
|
|
34
35
|
- Native Inline `/` command-menu sync for Hermes slash commands, including `/threads`, `/follow`, `/unfollow`, `/inline_sync`, `/inline_version`, and `/update`; typed slash commands continue to work even if menu sync is disabled or rejected.
|
|
35
36
|
- Inline-native buttons for clarify prompts, command approvals, slash confirmations, and model selection.
|
|
36
|
-
- Agent-created `send_message`/`edit_message` button rows with opaque callback data. A callback is acknowledged
|
|
37
|
+
- Agent-created `send_message`/`edit_message` button rows with opaque callback data. A callback is acknowledged after its target and access checks succeed, then enters Hermes as a normal turn naming the source message and exact action fields. The normal response edits that source message and clears omitted buttons; the agent can instead call `edit_message` with replacement buttons and finish with `NO_REPLY` so the explicit edit remains authoritative.
|
|
37
38
|
- Outbound local photo, video, voice, and document uploads with configurable size caps.
|
|
38
39
|
- Inbound photo, video, voice, and document summaries, with URL-backed media cached locally for Hermes when available.
|
|
39
40
|
- Reactions on bot messages, plus opt-in lifecycle/system events as synthetic Hermes messages.
|
|
@@ -155,7 +156,7 @@ uv run ./hermes plugins list --plain --no-bundled
|
|
|
155
156
|
Expected local output includes:
|
|
156
157
|
|
|
157
158
|
```text
|
|
158
|
-
enabled user 0.0.
|
|
159
|
+
enabled user 0.0.21 inline-platform
|
|
159
160
|
```
|
|
160
161
|
|
|
161
162
|
## Update Or Reinstall
|
|
@@ -334,6 +335,22 @@ Access control follows Hermes' native platform model:
|
|
|
334
335
|
|
|
335
336
|
Policy is evaluated in three ordered stages: **access**, then **wake**, then **delivery**. Access checks the chat and sender policy and is a hard gate; a mention, reply, callback, or command never grants access to a blocked chat or actor. Wake decides whether an allowed group turn invokes Hermes: free-response chats wake normally, while mention-gated chats require an explicit mention unless a configured reply-to-bot or followed-thread exception applies. Delivery keeps existing child-thread conversations in place; top-level `auto` creates a child only for explicit thread intent, `on` always creates one, and `off` stays flat.
|
|
336
337
|
|
|
338
|
+
`open` controls intake; it does not grant permission to use the bot. After
|
|
339
|
+
Inline's sender and group/thread restrictions pass, the adapter asks Hermes'
|
|
340
|
+
registered authorization callback before executing local commands, creating
|
|
341
|
+
reply threads, downloading media, collecting context, or exposing runtime
|
|
342
|
+
settings. This uses the same pairing and profile-aware authorization as native
|
|
343
|
+
Hermes adapters. An unapproved sender reaches Hermes' pairing or rejection
|
|
344
|
+
flow with a minimal text event and no preceding adapter mutations or media work.
|
|
345
|
+
Pairing approval does not override an explicit Inline allowlist, disabled
|
|
346
|
+
policy, or excluded group. DMs remain exempt from `allowed_chats`.
|
|
347
|
+
Child-thread authorization preserves both the child and parent chat IDs so
|
|
348
|
+
Hermes can select the correct profile. If required group metadata is unavailable,
|
|
349
|
+
the adapter denies the operation instead of guessing a profile. Controls that
|
|
350
|
+
change the parent's reply-thread mode also require access to that parent;
|
|
351
|
+
thread-local model and following settings remain available to an authorized
|
|
352
|
+
child-thread user.
|
|
353
|
+
|
|
337
354
|
Equivalent Hermes YAML can use `allow_from`, `allowed_users`,
|
|
338
355
|
`group_allow_from`, `dm_policy`, `group_policy`, `require_mention`,
|
|
339
356
|
`strict_mention`, `allowed_chats`, `free_response_chats`, `reply_threads`,
|
|
@@ -361,11 +378,14 @@ platforms:
|
|
|
361
378
|
skills: ["support-triage", "incident-report"]
|
|
362
379
|
```
|
|
363
380
|
|
|
364
|
-
Inline-native button callbacks,
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
381
|
+
Inline-native button callbacks, including approvals, clarify choices,
|
|
382
|
+
model pickers, and thread controls, use the same local restrictions and Hermes
|
|
383
|
+
authorization callback. Pairing-approved users can use them without a second
|
|
384
|
+
Inline allowlist. A denial, error, or unknown result from a registered callback
|
|
385
|
+
blocks the action. Only standalone adapters without a registered callback use
|
|
386
|
+
explicit Inline/global allowlists or allow-all settings as a fallback; `open`
|
|
387
|
+
alone never authorizes controls. Settings requests from unauthorized users show
|
|
388
|
+
an access guide without runtime or model information.
|
|
369
389
|
Adapter-owned controls use `system:` action IDs and stay in these deterministic
|
|
370
390
|
handlers. Agent-authored callbacks use `agent:` IDs and follow the ordinary
|
|
371
391
|
message intake policy because they are conversational input, not approval or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inline-chat/hermes-agent-adapter",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"description": "Hermes Agent platform adapter for Inline, with a native Python plugin and bundled Inline realtime sidecar.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/plugin/inline/adapter.py
CHANGED
|
@@ -70,7 +70,10 @@ _DEDUP_MAX_SIZE = 5000
|
|
|
70
70
|
_DEDUP_WINDOW_SECONDS = 48 * 3600
|
|
71
71
|
_CHAT_INFO_CACHE_SECONDS = 10 * 60
|
|
72
72
|
_CHAT_INFO_CACHE_MAX_SIZE = 512
|
|
73
|
-
|
|
73
|
+
_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS = 2.0
|
|
74
|
+
_INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS = 30.0
|
|
75
|
+
_INBOUND_RETRY_INITIAL_SECONDS = 1.0
|
|
76
|
+
_MAX_INBOUND_DELIVERIES = 16
|
|
74
77
|
# Provider discovery may invoke host CLIs. Keep repeated panel opens and
|
|
75
78
|
# unrelated mutations off that slow path while always injecting the live
|
|
76
79
|
# current/default model into the document below.
|
|
@@ -191,6 +194,10 @@ _INLINE_DISPLAY_DEFAULTS = {
|
|
|
191
194
|
}
|
|
192
195
|
|
|
193
196
|
|
|
197
|
+
class InlineInboundDeferred(RuntimeError):
|
|
198
|
+
"""Pre-effect dependency unavailable; keep the SDK delivery unacknowledged."""
|
|
199
|
+
|
|
200
|
+
|
|
194
201
|
class InlineSidecarError(RuntimeError):
|
|
195
202
|
def __init__(self, path: str, status_code: int, message: str, error_kind: str = "unknown", raw: Optional[Any] = None):
|
|
196
203
|
self.path = path
|
|
@@ -973,6 +980,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
973
980
|
self._last_catalog_sync: Optional[Dict[str, Any]] = None
|
|
974
981
|
self._inbound_task: Optional[asyncio.Task] = None
|
|
975
982
|
self._bot_settings_tasks: set[asyncio.Task] = set()
|
|
983
|
+
self._inbound_deliveries: Dict[str, asyncio.Task] = {}
|
|
976
984
|
self._bot_settings_locks: Dict[str, asyncio.Lock] = {}
|
|
977
985
|
self._bot_settings_lock_users: Dict[str, int] = {}
|
|
978
986
|
self._bot_settings_model_catalog_cache: Optional[tuple[float, List[Dict[str, Any]]]] = None
|
|
@@ -1147,7 +1155,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1147
1155
|
try:
|
|
1148
1156
|
info = await asyncio.wait_for(
|
|
1149
1157
|
self._get_chat_info(chat_id),
|
|
1150
|
-
timeout=
|
|
1158
|
+
timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
|
|
1151
1159
|
)
|
|
1152
1160
|
except asyncio.TimeoutError:
|
|
1153
1161
|
logger.warning("[inline] agent settings chat metadata timed out")
|
|
@@ -1163,13 +1171,15 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1163
1171
|
scope_chat_id = parent_chat_id or chat_id
|
|
1164
1172
|
is_reply_thread = bool(parent_chat_id)
|
|
1165
1173
|
chat_type = self._bot_settings_chat_type(info)
|
|
1166
|
-
can_inspect = self.
|
|
1167
|
-
chat_id,
|
|
1168
|
-
chat_id if is_reply_thread else None,
|
|
1169
|
-
parent_chat_id,
|
|
1174
|
+
can_inspect = self._actor_authorization(
|
|
1175
|
+
chat_type, actor_id, chat_id,
|
|
1176
|
+
thread_id=chat_id if is_reply_thread else None,
|
|
1177
|
+
parent_chat_id=parent_chat_id,
|
|
1178
|
+
is_bot=_inline_sender_profile(event).get("bot") is True,
|
|
1170
1179
|
)
|
|
1171
|
-
if not
|
|
1172
|
-
return {"access": "guideOnly", "scope_id": scope_chat_id, "reply_threads": "auto"
|
|
1180
|
+
if can_inspect is not True:
|
|
1181
|
+
return {"access": "guideOnly", "scope_id": scope_chat_id, "reply_threads": "auto",
|
|
1182
|
+
**({"unavailable_reason": "authorization"} if can_inspect is None else {})}
|
|
1173
1183
|
|
|
1174
1184
|
source = self.build_source(
|
|
1175
1185
|
chat_id=chat_id,
|
|
@@ -1180,7 +1190,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1180
1190
|
parent_chat_id=parent_chat_id,
|
|
1181
1191
|
)
|
|
1182
1192
|
runner = self._bot_settings_runner()
|
|
1183
|
-
can_modify =
|
|
1193
|
+
can_modify = can_inspect
|
|
1194
|
+
can_set_reply_threads = not is_reply_thread or await self._chat_actor_authorized(scope_chat_id, actor_id)
|
|
1184
1195
|
context: Dict[str, Any] = {
|
|
1185
1196
|
"access": "full" if can_modify else "readOnly",
|
|
1186
1197
|
"scope_id": scope_chat_id,
|
|
@@ -1189,7 +1200,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1189
1200
|
"chat_type": chat_type,
|
|
1190
1201
|
"is_reply_thread": is_reply_thread,
|
|
1191
1202
|
"following": None if chat_type == "dm" else self._chat_follow_mode_following(info),
|
|
1192
|
-
"reply_threads": self._reply_thread_mode_for_chat(scope_chat_id),
|
|
1203
|
+
"reply_threads": self._reply_thread_mode_for_chat(scope_chat_id) if can_set_reply_threads else None,
|
|
1204
|
+
"can_set_reply_threads": can_set_reply_threads,
|
|
1193
1205
|
"can_set_default_model": can_modify,
|
|
1194
1206
|
"source": source,
|
|
1195
1207
|
"runner": runner,
|
|
@@ -1323,7 +1335,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1323
1335
|
if context.get("access") == "guideOnly":
|
|
1324
1336
|
unavailable_text = (
|
|
1325
1337
|
"Hermes could not verify this chat. Try again when the bot is reachable."
|
|
1326
|
-
if context.get("unavailable_reason")
|
|
1338
|
+
if context.get("unavailable_reason") in {"chat_metadata", "authorization"}
|
|
1327
1339
|
else "This chat is not allowed by Hermes' access policy."
|
|
1328
1340
|
)
|
|
1329
1341
|
return {
|
|
@@ -1386,15 +1398,26 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1386
1398
|
"id": "following", "label": "Following", "description": "Wake on eligible activity.", **common,
|
|
1387
1399
|
"control": {"oneofKind": "toggle", "toggle": {"value": bool(context.get("following"))}},
|
|
1388
1400
|
}]})
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
"
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1401
|
+
if context.get("can_set_reply_threads"):
|
|
1402
|
+
sections.append({"id": "replies", "items": [{
|
|
1403
|
+
"id": "reply-threads", "label": "Reply in threads", **common,
|
|
1404
|
+
"control": {"oneofKind": "select", "select": {
|
|
1405
|
+
"value": context.get("reply_threads") or "auto",
|
|
1406
|
+
"options": [
|
|
1407
|
+
{"value": "auto", "label": "Auto", "description": "Agent decides.", "disabled": False},
|
|
1408
|
+
{"value": "on", "label": "On", "description": "Always use threads.", "disabled": False},
|
|
1409
|
+
{"value": "off", "label": "Off", "description": "Stay in chat.", "disabled": False},
|
|
1410
|
+
],
|
|
1411
|
+
}},
|
|
1412
|
+
}]})
|
|
1413
|
+
else:
|
|
1414
|
+
sections.append({"id": "replies", "items": [{
|
|
1415
|
+
"id": "reply-threads-access", "label": "Reply in threads", "disabled": False,
|
|
1416
|
+
"control": {"oneofKind": "info", "info": {
|
|
1417
|
+
"text": ("Parent chat access is temporarily unavailable. Try again."
|
|
1418
|
+
if context.get("can_set_reply_threads") is None
|
|
1419
|
+
else "Changing reply mode requires access to the parent chat."),
|
|
1420
|
+
"tone": _INLINE_BOT_SETTINGS_TONE_WARNING,
|
|
1398
1421
|
}},
|
|
1399
1422
|
}]})
|
|
1400
1423
|
if disabled:
|
|
@@ -1492,6 +1515,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1492
1515
|
self._invalidate_chat_info(context["chat_id"])
|
|
1493
1516
|
return
|
|
1494
1517
|
if item_id == "reply-threads" and value_body.get("oneofKind") == "stringValue":
|
|
1518
|
+
if not context.get("can_set_reply_threads"):
|
|
1519
|
+
raise ValueError("parent chat thread settings are not authorized")
|
|
1495
1520
|
mode = str(value_body.get("stringValue") or "")
|
|
1496
1521
|
if mode not in {"auto", "on", "off"}:
|
|
1497
1522
|
raise ValueError("invalid reply mode")
|
|
@@ -1768,6 +1793,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1768
1793
|
*,
|
|
1769
1794
|
chat_id: str,
|
|
1770
1795
|
msg_id: str,
|
|
1796
|
+
from_id: str,
|
|
1771
1797
|
text: str,
|
|
1772
1798
|
chat_type: str,
|
|
1773
1799
|
thread_id: Optional[str],
|
|
@@ -1779,6 +1805,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1779
1805
|
|
|
1780
1806
|
metadata = {"thread_id": thread_id} if thread_id else None
|
|
1781
1807
|
target_chat_id = parent_chat_id or chat_id
|
|
1808
|
+
if target_chat_id != chat_id and not await self._chat_actor_authorized(target_chat_id, from_id):
|
|
1809
|
+
await self.send(chat_id, "Parent chat access could not be confirmed. Check access and try again.", reply_to=msg_id, metadata=metadata)
|
|
1810
|
+
return True
|
|
1782
1811
|
if action == "on":
|
|
1783
1812
|
self._set_reply_threads_for_chat(target_chat_id, "on")
|
|
1784
1813
|
elif action == "off":
|
|
@@ -1935,6 +1964,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1935
1964
|
except Exception:
|
|
1936
1965
|
pass
|
|
1937
1966
|
self._inbound_task = None
|
|
1967
|
+
deliveries = list(self._inbound_deliveries.values())
|
|
1968
|
+
for task in deliveries:
|
|
1969
|
+
task.cancel()
|
|
1970
|
+
if deliveries:
|
|
1971
|
+
await asyncio.gather(*deliveries, return_exceptions=True)
|
|
1972
|
+
self._inbound_deliveries.clear()
|
|
1938
1973
|
for task in list(self._bot_settings_tasks):
|
|
1939
1974
|
task.cancel()
|
|
1940
1975
|
if self._bot_settings_tasks:
|
|
@@ -2247,6 +2282,78 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2247
2282
|
event = json.loads(line)
|
|
2248
2283
|
except json.JSONDecodeError:
|
|
2249
2284
|
return
|
|
2285
|
+
delivery_id = event.get("_inlineDeliveryId")
|
|
2286
|
+
if not isinstance(delivery_id, str) or not delivery_id:
|
|
2287
|
+
await self._dispatch_inbound(event)
|
|
2288
|
+
return
|
|
2289
|
+
# The SDK bounds outstanding receipts and orders each chat. Retain only
|
|
2290
|
+
# their active coroutines; reconnect replay must not run one twice.
|
|
2291
|
+
if delivery_id in self._inbound_deliveries:
|
|
2292
|
+
return
|
|
2293
|
+
# An ACK may reach the sidecar while its response is lost, releasing an
|
|
2294
|
+
# SDK slot before this coroutine finishes. Bound those extra ACK waiters
|
|
2295
|
+
# too, using stream backpressure rather than another event queue.
|
|
2296
|
+
while len(self._inbound_deliveries) >= _MAX_INBOUND_DELIVERIES:
|
|
2297
|
+
await asyncio.wait(tuple(self._inbound_deliveries.values()), return_when=asyncio.FIRST_COMPLETED)
|
|
2298
|
+
task = asyncio.create_task(self._consume_inbound_delivery(delivery_id, event))
|
|
2299
|
+
self._inbound_deliveries[delivery_id] = task
|
|
2300
|
+
|
|
2301
|
+
def finished(completed: asyncio.Task) -> None:
|
|
2302
|
+
self._inbound_deliveries.pop(delivery_id, None)
|
|
2303
|
+
if not completed.cancelled():
|
|
2304
|
+
try:
|
|
2305
|
+
completed.result()
|
|
2306
|
+
except Exception as exc:
|
|
2307
|
+
self._report_error("inbound.delivery", exc)
|
|
2308
|
+
task.add_done_callback(finished)
|
|
2309
|
+
|
|
2310
|
+
async def _consume_inbound_delivery(self, delivery_id: str, event: Dict[str, Any]) -> None:
|
|
2311
|
+
try:
|
|
2312
|
+
await self._handle_inbound_delivery(delivery_id, event)
|
|
2313
|
+
except Exception as exc:
|
|
2314
|
+
# Do not ACK an unexpected failure or silently pin its chat forever.
|
|
2315
|
+
# Native notification shields runner teardown/reconnect from the
|
|
2316
|
+
# cancellation of this task during adapter.disconnect().
|
|
2317
|
+
self._report_error("inbound.delivery", exc)
|
|
2318
|
+
self._set_fatal_error("INBOUND_FAILED", str(exc), retryable=True)
|
|
2319
|
+
await self._notify_fatal_error()
|
|
2320
|
+
|
|
2321
|
+
async def _handle_inbound_delivery(self, delivery_id: str, event: Dict[str, Any]) -> None:
|
|
2322
|
+
delay = _INBOUND_RETRY_INITIAL_SECONDS
|
|
2323
|
+
attempts = 0
|
|
2324
|
+
while True:
|
|
2325
|
+
attempts += 1
|
|
2326
|
+
try:
|
|
2327
|
+
await self._dispatch_inbound(event)
|
|
2328
|
+
break
|
|
2329
|
+
except InlineSidecarError as exc:
|
|
2330
|
+
if exc.error_kind not in {"forbidden", "not_found"}:
|
|
2331
|
+
raise
|
|
2332
|
+
logger.info("[inline] inbound target is no longer accessible; retiring delivery")
|
|
2333
|
+
break
|
|
2334
|
+
except InlineInboundDeferred as exc:
|
|
2335
|
+
# Action events share the SDK's user-cursor barrier. A persistent
|
|
2336
|
+
# outage must not hold every chat behind a stale button press.
|
|
2337
|
+
if event.get("kind") == "message.action.invoke" and attempts >= 2:
|
|
2338
|
+
await self._answer_action(str(event.get("interactionId") or ""),
|
|
2339
|
+
"Temporarily unavailable. Please try again.")
|
|
2340
|
+
break
|
|
2341
|
+
logger.warning("[inline] inbound preflight unavailable; retrying in %.1fs: %s", delay, exc)
|
|
2342
|
+
await asyncio.sleep(delay)
|
|
2343
|
+
delay = min(delay * 2, 30.0)
|
|
2344
|
+
# A lost ACK response must retry only the ACK, never local commands or
|
|
2345
|
+
# model delivery. The endpoint is idempotent, including after reconnect.
|
|
2346
|
+
delay = _INBOUND_RETRY_INITIAL_SECONDS
|
|
2347
|
+
while True:
|
|
2348
|
+
try:
|
|
2349
|
+
await self._sidecar_call("/inbound/ack", {"deliveryId": delivery_id})
|
|
2350
|
+
return
|
|
2351
|
+
except Exception:
|
|
2352
|
+
logger.warning("[inline] inbound acknowledgement unavailable; retrying in %.1fs", delay)
|
|
2353
|
+
await asyncio.sleep(delay)
|
|
2354
|
+
delay = min(delay * 2, 30.0)
|
|
2355
|
+
|
|
2356
|
+
async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
|
|
2250
2357
|
self._me_id = str(event.get("meId") or self._me_id or "") or None
|
|
2251
2358
|
self._me_username = _normalize_inline_username(event.get("meUsername") or self._me_username)
|
|
2252
2359
|
# Chat snapshots include mutable dialog and routing fields such as
|
|
@@ -2257,10 +2364,16 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2257
2364
|
self._invalidate_chat_info(event.get("chatId"))
|
|
2258
2365
|
kind = event.get("kind")
|
|
2259
2366
|
if kind == "bot.chatSettings.request":
|
|
2260
|
-
|
|
2367
|
+
if event.get("_inlineDeliveryId"):
|
|
2368
|
+
await self._handle_bot_settings_request(event)
|
|
2369
|
+
else:
|
|
2370
|
+
self._schedule_bot_settings_task(self._handle_bot_settings_request(event))
|
|
2261
2371
|
return
|
|
2262
2372
|
if kind == "bot.chatSettings.item.invoke":
|
|
2263
|
-
|
|
2373
|
+
if event.get("_inlineDeliveryId"):
|
|
2374
|
+
await self._handle_bot_settings_item(event)
|
|
2375
|
+
else:
|
|
2376
|
+
self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
|
|
2264
2377
|
return
|
|
2265
2378
|
if kind == "message.action.invoke":
|
|
2266
2379
|
if await self._handle_action(event):
|
|
@@ -2349,10 +2462,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2349
2462
|
dedup_key = f"new:{chat_id}:msg:{msg_id}:date:{message_date}"
|
|
2350
2463
|
else:
|
|
2351
2464
|
dedup_key = f"new:{chat_id}:msg:{msg_id}"
|
|
2352
|
-
if self._is_duplicate(dedup_key):
|
|
2353
|
-
return
|
|
2354
|
-
if not edit and self._is_duplicate_message_instance(chat_id, msg, event):
|
|
2355
|
-
return
|
|
2356
2465
|
from_id = str(msg.get("fromId") or "")
|
|
2357
2466
|
if msg.get("out") or (self._me_id and from_id == self._me_id):
|
|
2358
2467
|
return
|
|
@@ -2361,18 +2470,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2361
2470
|
|
|
2362
2471
|
raw_message_text = str(msg.get("message") or "")
|
|
2363
2472
|
text = raw_message_text.strip()
|
|
2364
|
-
media_text, media_urls, media_types, message_type = await self._normalize_media(msg)
|
|
2365
|
-
if media_text:
|
|
2366
|
-
text = f"{text}\n{media_text}".strip() if text else media_text
|
|
2367
|
-
if not text and not media_urls:
|
|
2368
|
-
text = "[Inline message with no text]"
|
|
2369
2473
|
explicitly_mentions_me = self._message_entity_mentions_me(msg)
|
|
2370
|
-
if event.get("_inlineSenderProvenanceVerified") is False and not explicitly_mentions_me:
|
|
2371
|
-
self._remember_observed_context(chat_id, msg, text)
|
|
2372
|
-
return
|
|
2373
|
-
if sender_profile.get("bot") is True and not explicitly_mentions_me:
|
|
2374
|
-
self._remember_observed_context(chat_id, msg, text)
|
|
2375
|
-
return
|
|
2376
2474
|
|
|
2377
2475
|
chat_type = self._chat_type_from_message(msg)
|
|
2378
2476
|
thread_id = self._thread_id_from_message(msg)
|
|
@@ -2381,7 +2479,20 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2381
2479
|
chat_name = chat_id
|
|
2382
2480
|
chat_info: Dict[str, Any] = {}
|
|
2383
2481
|
if chat_type == "group":
|
|
2384
|
-
|
|
2482
|
+
try:
|
|
2483
|
+
chat_info = await asyncio.wait_for(
|
|
2484
|
+
self._get_chat_info(chat_id, required=True),
|
|
2485
|
+
timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS if agent_action else _INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS,
|
|
2486
|
+
)
|
|
2487
|
+
except asyncio.TimeoutError as exc:
|
|
2488
|
+
raise InlineInboundDeferred("chat metadata timed out") from exc
|
|
2489
|
+
except InlineSidecarError as exc:
|
|
2490
|
+
if exc.error_kind in {"forbidden", "not_found"}:
|
|
2491
|
+
logger.info("[inline] chat is no longer accessible; retiring inbound delivery")
|
|
2492
|
+
return
|
|
2493
|
+
raise
|
|
2494
|
+
if not chat_info:
|
|
2495
|
+
raise InlineInboundDeferred("chat metadata unavailable")
|
|
2385
2496
|
chat_name = self._chat_title_from_info(chat_info) or chat_id
|
|
2386
2497
|
info_parent_chat_id = self._chat_info_id(chat_info, "parentChatId")
|
|
2387
2498
|
info_parent_message_id = self._chat_info_id(chat_info, "parentMessageId")
|
|
@@ -2402,9 +2513,52 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2402
2513
|
)
|
|
2403
2514
|
if has_command_target and not command_addressed_to_me:
|
|
2404
2515
|
return
|
|
2516
|
+
actor_authorized = self._actor_authorization(
|
|
2517
|
+
chat_type, from_id, chat_id, thread_id=thread_id,
|
|
2518
|
+
parent_chat_id=parent_chat_id, is_bot=sender_profile.get("bot") is True,
|
|
2519
|
+
)
|
|
2520
|
+
if actor_authorized is None:
|
|
2521
|
+
raise InlineInboundDeferred("host authorization unavailable")
|
|
2522
|
+
if self._is_duplicate(dedup_key):
|
|
2523
|
+
return
|
|
2524
|
+
if not edit and self._is_duplicate_message_instance(chat_id, msg, event):
|
|
2525
|
+
return
|
|
2526
|
+
context_only = not explicitly_mentions_me and (
|
|
2527
|
+
event.get("_inlineSenderProvenanceVerified") is False or sender_profile.get("bot") is True
|
|
2528
|
+
)
|
|
2529
|
+
if not actor_authorized:
|
|
2530
|
+
if context_only:
|
|
2531
|
+
return
|
|
2532
|
+
# Let Hermes pair/ignore/decline the sender, without first downloading
|
|
2533
|
+
# media, collecting context, changing settings, or creating a thread.
|
|
2534
|
+
source = self.build_source(
|
|
2535
|
+
chat_id=chat_id, chat_name=chat_name, chat_type=chat_type,
|
|
2536
|
+
user_id=from_id, user_name=sender_name or None,
|
|
2537
|
+
thread_id=thread_id, parent_chat_id=parent_chat_id, message_id=msg_id,
|
|
2538
|
+
is_bot=sender_profile.get("bot") is True,
|
|
2539
|
+
)
|
|
2540
|
+
await self.handle_message(MessageEvent(
|
|
2541
|
+
text=text or "[Inline message with no text]", message_type=MessageType.TEXT,
|
|
2542
|
+
source=source, raw_message=event,
|
|
2543
|
+
message_id=build_inline_agent_action_turn_id(
|
|
2544
|
+
agent_action.get("messageId"), agent_action.get("interactionId"),
|
|
2545
|
+
) if agent_action else msg_id,
|
|
2546
|
+
timestamp=self._timestamp(event.get("date") or msg.get("date")),
|
|
2547
|
+
allow_gateway_control=not bool(agent_action),
|
|
2548
|
+
))
|
|
2549
|
+
return
|
|
2550
|
+
media_text, media_urls, media_types, message_type = await self._normalize_media(msg)
|
|
2551
|
+
if media_text:
|
|
2552
|
+
text = f"{text}\n{media_text}".strip() if text else media_text
|
|
2553
|
+
if not text and not media_urls:
|
|
2554
|
+
text = "[Inline message with no text]"
|
|
2555
|
+
if context_only:
|
|
2556
|
+
self._remember_observed_context(chat_id, msg, text)
|
|
2557
|
+
return
|
|
2405
2558
|
if not agent_action and await self._handle_thread_command(
|
|
2406
2559
|
chat_id=chat_id,
|
|
2407
2560
|
msg_id=msg_id,
|
|
2561
|
+
from_id=from_id,
|
|
2408
2562
|
text=text,
|
|
2409
2563
|
chat_type=chat_type,
|
|
2410
2564
|
thread_id=thread_id,
|
|
@@ -2584,6 +2738,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2584
2738
|
chat_type=chat_type,
|
|
2585
2739
|
user_id=from_id,
|
|
2586
2740
|
user_name=sender_name or None,
|
|
2741
|
+
is_bot=sender_profile.get("bot") is True,
|
|
2587
2742
|
thread_id=thread_id,
|
|
2588
2743
|
parent_chat_id=parent_chat_id,
|
|
2589
2744
|
message_id=msg_id,
|
|
@@ -2618,7 +2773,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2618
2773
|
|
|
2619
2774
|
async def _dispatch_agent_action(self, event: Dict[str, Any]) -> None:
|
|
2620
2775
|
interaction_id = str(event.get("interactionId") or "")
|
|
2621
|
-
await self._answer_action(interaction_id, "")
|
|
2622
2776
|
|
|
2623
2777
|
chat_id = str(event.get("chatId") or "")
|
|
2624
2778
|
message_id = str(event.get("messageId") or "")
|
|
@@ -2626,13 +2780,19 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2626
2780
|
if not chat_id or not message_id or not interaction_id or not actor_user_id:
|
|
2627
2781
|
logger.warning("[inline] ignored incomplete agent action event")
|
|
2628
2782
|
return
|
|
2629
|
-
|
|
2783
|
+
try:
|
|
2784
|
+
target = await asyncio.wait_for(
|
|
2785
|
+
self._fetch_message(chat_id, message_id, required=True),
|
|
2786
|
+
timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
|
|
2787
|
+
)
|
|
2788
|
+
except asyncio.TimeoutError as exc:
|
|
2789
|
+
raise InlineInboundDeferred("action target lookup timed out") from exc
|
|
2630
2790
|
if not target:
|
|
2631
|
-
|
|
2791
|
+
await self._answer_action(interaction_id, "Action expired or no longer accessible.")
|
|
2632
2792
|
return
|
|
2633
2793
|
|
|
2634
2794
|
synthetic_message = dict(target)
|
|
2635
|
-
for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies"):
|
|
2795
|
+
for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies", "sender"):
|
|
2636
2796
|
synthetic_message.pop(stale_key, None)
|
|
2637
2797
|
synthetic_message.update({
|
|
2638
2798
|
"id": message_id,
|
|
@@ -2653,6 +2813,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2653
2813
|
"message": synthetic_message,
|
|
2654
2814
|
"_inlineAgentAction": dict(event),
|
|
2655
2815
|
})
|
|
2816
|
+
await self._answer_action(interaction_id, "")
|
|
2656
2817
|
|
|
2657
2818
|
def _message_explicitly_mentions_me(self, msg: Dict[str, Any]) -> bool:
|
|
2658
2819
|
if not self._me_id:
|
|
@@ -2758,13 +2919,19 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2758
2919
|
if self._me_id and user_id == self._me_id:
|
|
2759
2920
|
return
|
|
2760
2921
|
key = f"{event.get('kind')}:{chat_id}:{message_id}:{user_id}:{emoji}:{event.get('seq') or ''}"
|
|
2761
|
-
if self._is_duplicate(key):
|
|
2762
|
-
return
|
|
2763
2922
|
|
|
2764
2923
|
target = await self._fetch_message(chat_id, message_id)
|
|
2765
2924
|
chat_type = self._chat_type_from_message(target or {"peerId": {"type": {"oneofKind": "chat"}}})
|
|
2766
2925
|
if not self._allowed(chat_type, user_id):
|
|
2767
2926
|
return
|
|
2927
|
+
scope = await self._message_scope(chat_id, target or {}, required=True)
|
|
2928
|
+
if scope is None:
|
|
2929
|
+
return
|
|
2930
|
+
chat_type, thread_id, parent_chat_id = scope
|
|
2931
|
+
if chat_type == "group" and not self._chat_allowed(chat_id, thread_id, parent_chat_id):
|
|
2932
|
+
return
|
|
2933
|
+
if self._is_duplicate(key):
|
|
2934
|
+
return
|
|
2768
2935
|
target_text = str((target or {}).get("message") or "") or None
|
|
2769
2936
|
target_author = str((target or {}).get("fromId") or "") or None
|
|
2770
2937
|
target_is_own = bool(self._me_id and target_author == self._me_id)
|
|
@@ -2777,6 +2944,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2777
2944
|
chat_type=chat_type,
|
|
2778
2945
|
user_id=user_id,
|
|
2779
2946
|
user_name=_inline_sender_identity(_inline_sender_profile(event))[0] or None,
|
|
2947
|
+
thread_id=thread_id,
|
|
2948
|
+
parent_chat_id=parent_chat_id,
|
|
2780
2949
|
message_id=key,
|
|
2781
2950
|
)
|
|
2782
2951
|
await self.handle_message(MessageEvent(
|
|
@@ -2813,7 +2982,13 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2813
2982
|
text = "message:history_cleared"
|
|
2814
2983
|
if not chat_id or not text:
|
|
2815
2984
|
return
|
|
2816
|
-
if self._group_policy == "disabled":
|
|
2985
|
+
if self._group_policy == "disabled" or (user_id and not self._allowed("group", user_id)):
|
|
2986
|
+
return
|
|
2987
|
+
scope = await self._message_scope(chat_id, {}, required=True)
|
|
2988
|
+
if scope is None:
|
|
2989
|
+
return
|
|
2990
|
+
_, thread_id, parent_chat_id = scope
|
|
2991
|
+
if not self._chat_allowed(chat_id, thread_id, parent_chat_id):
|
|
2817
2992
|
return
|
|
2818
2993
|
key = f"{kind}:{chat_id}:{user_id}:{event.get('seq') or ''}:{text}"
|
|
2819
2994
|
if self._is_duplicate(key):
|
|
@@ -2824,8 +2999,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2824
2999
|
chat_type="group",
|
|
2825
3000
|
user_id=user_id or None,
|
|
2826
3001
|
user_name=_inline_sender_identity(_inline_sender_profile(event))[0] or None,
|
|
3002
|
+
thread_id=thread_id,
|
|
3003
|
+
parent_chat_id=parent_chat_id,
|
|
2827
3004
|
message_id=key,
|
|
2828
3005
|
)
|
|
3006
|
+
# Delete/history events may have no actor. Preserve the host's own
|
|
3007
|
+
# authorization of these events rather than inventing a sender grant.
|
|
2829
3008
|
await self.handle_message(MessageEvent(
|
|
2830
3009
|
text=text,
|
|
2831
3010
|
message_type=MessageType.TEXT,
|
|
@@ -3665,7 +3844,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3665
3844
|
return None
|
|
3666
3845
|
return str(reply_to)
|
|
3667
3846
|
|
|
3668
|
-
async def _get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
3847
|
+
async def _get_chat_info(self, chat_id: str, *, required: bool = False) -> Dict[str, Any]:
|
|
3669
3848
|
target = _target_from_chat_id(chat_id)
|
|
3670
3849
|
normalized = str(target.get("chatId") or "").strip()
|
|
3671
3850
|
if not normalized:
|
|
@@ -3679,7 +3858,16 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3679
3858
|
data = await self._sidecar_call("/chat", {"target": {"chatId": normalized}})
|
|
3680
3859
|
result = data.get("result") if isinstance(data, dict) else None
|
|
3681
3860
|
info = result if isinstance(result, dict) else {}
|
|
3682
|
-
except Exception:
|
|
3861
|
+
except Exception as exc:
|
|
3862
|
+
if required:
|
|
3863
|
+
if isinstance(exc, InlineSidecarError) and exc.error_kind in {"forbidden", "not_found"}:
|
|
3864
|
+
raise
|
|
3865
|
+
raise InlineInboundDeferred("chat metadata request failed") from exc
|
|
3866
|
+
return {}
|
|
3867
|
+
resolved_id = self._chat_info_id(info, "id")
|
|
3868
|
+
if not info or (resolved_id is not None and resolved_id != normalized):
|
|
3869
|
+
if required:
|
|
3870
|
+
raise InlineInboundDeferred("chat metadata unavailable or mismatched")
|
|
3683
3871
|
return {}
|
|
3684
3872
|
self._chat_info_cache[normalized] = (now, info)
|
|
3685
3873
|
self._chat_info_cache.move_to_end(normalized)
|
|
@@ -3779,12 +3967,14 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3779
3967
|
return self._id_allowed(self._group_allow_from, from_id)
|
|
3780
3968
|
return True
|
|
3781
3969
|
|
|
3782
|
-
async def _fetch_message(self, chat_id: str, message_id: str) -> Optional[Dict[str, Any]]:
|
|
3970
|
+
async def _fetch_message(self, chat_id: str, message_id: str, *, required: bool = False) -> Optional[Dict[str, Any]]:
|
|
3783
3971
|
try:
|
|
3784
3972
|
data = await self._sidecar_call("/messages", {"target": _target_from_chat_id(chat_id), "messageIds": [message_id]})
|
|
3785
3973
|
messages = (data.get("result") or {}).get("messages") or []
|
|
3786
3974
|
return messages[0] if messages else None
|
|
3787
|
-
except Exception:
|
|
3975
|
+
except Exception as exc:
|
|
3976
|
+
if required and not (isinstance(exc, InlineSidecarError) and exc.error_kind in {"forbidden", "not_found"}):
|
|
3977
|
+
raise InlineInboundDeferred("action target temporarily unavailable") from exc
|
|
3788
3978
|
return None
|
|
3789
3979
|
|
|
3790
3980
|
async def _handle_action(self, event: Dict[str, Any]) -> bool:
|
|
@@ -3825,43 +4015,117 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3825
4015
|
async def _action_allowed(self, event: Dict[str, Any]) -> bool:
|
|
3826
4016
|
actor_id = str(event.get("actorUserId") or "").strip()
|
|
3827
4017
|
interaction_id = str(event.get("interactionId") or "")
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
await self.
|
|
3832
|
-
|
|
4018
|
+
chat_id = str(event.get("chatId") or "")
|
|
4019
|
+
message_id = str(event.get("messageId") or "")
|
|
4020
|
+
msg = await self._fetch_message(chat_id, message_id) if chat_id and message_id else None
|
|
4021
|
+
scope = await self._message_scope(chat_id, msg) if msg else None
|
|
4022
|
+
verdict = None
|
|
4023
|
+
if scope is not None:
|
|
4024
|
+
chat_type, thread_id, parent_chat_id = scope
|
|
4025
|
+
verdict = self._actor_authorization(
|
|
4026
|
+
chat_type, actor_id, chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id,
|
|
4027
|
+
is_bot=_inline_sender_profile(event).get("bot") is True,
|
|
4028
|
+
)
|
|
4029
|
+
if verdict is True:
|
|
4030
|
+
return True
|
|
4031
|
+
await self._answer_action(interaction_id, "Access check temporarily unavailable. Try again." if verdict is None else "Not authorized")
|
|
4032
|
+
logger.info("[inline] blocked action actor=%s chat=%s action=%s", actor_id or "unknown", chat_id or "unknown", event.get("actionId") or "")
|
|
3833
4033
|
return False
|
|
3834
4034
|
|
|
3835
|
-
def
|
|
3836
|
-
|
|
4035
|
+
async def _message_scope(
|
|
4036
|
+
self, chat_id: str, msg: Dict[str, Any], *, required: bool = False,
|
|
4037
|
+
) -> Optional[tuple[str, Optional[str], Optional[str]]]:
|
|
4038
|
+
"""Resolve the same parent/thread identity used by normal message intake."""
|
|
4039
|
+
chat_type = self._chat_type_from_message(msg)
|
|
4040
|
+
thread_id = self._thread_id_from_message(msg)
|
|
4041
|
+
parent_chat_id = self._parent_chat_id_from_message(msg)
|
|
4042
|
+
if chat_type == "group" and not thread_id:
|
|
4043
|
+
if not parent_chat_id:
|
|
4044
|
+
try:
|
|
4045
|
+
info = await asyncio.wait_for(
|
|
4046
|
+
self._get_chat_info(chat_id, required=required),
|
|
4047
|
+
timeout=_INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS if required else _CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
|
|
4048
|
+
)
|
|
4049
|
+
except asyncio.TimeoutError as exc:
|
|
4050
|
+
if required:
|
|
4051
|
+
raise InlineInboundDeferred("chat metadata timed out") from exc
|
|
4052
|
+
return None
|
|
4053
|
+
if not info:
|
|
4054
|
+
if required:
|
|
4055
|
+
raise InlineInboundDeferred("chat metadata unavailable")
|
|
4056
|
+
return None
|
|
4057
|
+
parent_chat_id = self._chat_info_id(info, "parentChatId")
|
|
4058
|
+
if parent_chat_id:
|
|
4059
|
+
thread_id = chat_id
|
|
4060
|
+
return chat_type, thread_id, parent_chat_id
|
|
4061
|
+
|
|
4062
|
+
async def _chat_actor_authorized(self, chat_id: str, actor_id: str) -> Optional[bool]:
|
|
4063
|
+
"""Check the actual target when a child control changes parent-wide state."""
|
|
4064
|
+
try:
|
|
4065
|
+
info = await asyncio.wait_for(
|
|
4066
|
+
self._get_chat_info(chat_id), timeout=_CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS,
|
|
4067
|
+
)
|
|
4068
|
+
except asyncio.TimeoutError:
|
|
4069
|
+
return None
|
|
4070
|
+
if not info:
|
|
4071
|
+
return None
|
|
4072
|
+
parent_chat_id = self._chat_info_id(info, "parentChatId")
|
|
4073
|
+
return self._actor_authorization(
|
|
4074
|
+
self._bot_settings_chat_type(info), actor_id, chat_id,
|
|
4075
|
+
thread_id=chat_id if parent_chat_id else None, parent_chat_id=parent_chat_id,
|
|
4076
|
+
)
|
|
4077
|
+
|
|
4078
|
+
def _actor_authorized(
|
|
4079
|
+
self, chat_type: Optional[str], actor_id: str, chat_id: Optional[str] = None, *,
|
|
4080
|
+
thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None, is_bot: bool = False,
|
|
4081
|
+
) -> bool:
|
|
4082
|
+
return self._actor_authorization(
|
|
4083
|
+
chat_type, actor_id, chat_id, thread_id=thread_id,
|
|
4084
|
+
parent_chat_id=parent_chat_id, is_bot=is_bot,
|
|
4085
|
+
) is True
|
|
4086
|
+
|
|
4087
|
+
def _actor_authorization(
|
|
4088
|
+
self, chat_type: Optional[str], actor_id: str, chat_id: Optional[str] = None, *,
|
|
4089
|
+
thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None, is_bot: bool = False,
|
|
4090
|
+
) -> Optional[bool]:
|
|
4091
|
+
# The host may trust an adapter's allowlist policy on the assumption that
|
|
4092
|
+
# intake already checked it. Always enforce our restrictions before asking.
|
|
4093
|
+
if not actor_id or chat_type not in {"dm", "group"} or not self._allowed(chat_type, actor_id):
|
|
3837
4094
|
return False
|
|
4095
|
+
if chat_type == "group" and not self._chat_allowed(chat_id or "", thread_id, parent_chat_id):
|
|
4096
|
+
return False
|
|
4097
|
+
if getattr(self, "_authorization_check", None) is not None:
|
|
4098
|
+
# Hermes owns pairing, profile routing, and runtime authorization.
|
|
4099
|
+
# Unknown/error is unavailable, never a reason to bypass a wired checker.
|
|
4100
|
+
runner = getattr(self, "gateway_runner", None)
|
|
4101
|
+
if parent_chat_id and runner is not None:
|
|
4102
|
+
# The three-argument native callback drops parent_chat_id. Inline
|
|
4103
|
+
# child chats need it for parent-routed profiles, so ask the same
|
|
4104
|
+
# host predicate with the complete source, retaining transport identity.
|
|
4105
|
+
try:
|
|
4106
|
+
source = self.build_source(
|
|
4107
|
+
chat_id=chat_id or "", chat_type=chat_type, user_id=actor_id,
|
|
4108
|
+
thread_id=thread_id, parent_chat_id=parent_chat_id, is_bot=is_bot,
|
|
4109
|
+
)
|
|
4110
|
+
if getattr(source, "profile_route_rejected", False):
|
|
4111
|
+
return False
|
|
4112
|
+
verdict = runner._is_user_authorized_for_source(source)
|
|
4113
|
+
return verdict if isinstance(verdict, bool) else None
|
|
4114
|
+
except Exception:
|
|
4115
|
+
logger.warning("[inline] thread authorization unavailable", exc_info=True)
|
|
4116
|
+
return None
|
|
4117
|
+
return self._is_sender_authorized(
|
|
4118
|
+
actor_id, chat_type, chat_id, is_bot=is_bot, thread_id=thread_id,
|
|
4119
|
+
)
|
|
4120
|
+
# Standalone adapters have no runner. Only explicit grants are usable;
|
|
4121
|
+
# an open intake policy alone must not authorize credentialed side effects.
|
|
3838
4122
|
if self._allow_all or _truthy(os.getenv("GATEWAY_ALLOW_ALL_USERS"), False):
|
|
3839
4123
|
return True
|
|
3840
4124
|
if self._id_allowed(self._parse_id_set(os.getenv("GATEWAY_ALLOWED_USERS")), actor_id):
|
|
3841
4125
|
return True
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
if self._dm_policy == "allowlist" or self._allow_from:
|
|
3846
|
-
return self._id_allowed(self._allow_from, actor_id)
|
|
3847
|
-
return False
|
|
3848
|
-
if self._group_policy == "disabled":
|
|
3849
|
-
return False
|
|
3850
|
-
if self._id_allowed(self._group_allow_from, actor_id):
|
|
3851
|
-
return True
|
|
3852
|
-
if self._id_allowed(self._allow_from, actor_id):
|
|
3853
|
-
return True
|
|
3854
|
-
return False
|
|
3855
|
-
|
|
3856
|
-
async def _action_chat_type(self, event: Dict[str, Any]) -> Optional[str]:
|
|
3857
|
-
chat_id = str(event.get("chatId") or "")
|
|
3858
|
-
message_id = str(event.get("messageId") or "")
|
|
3859
|
-
if not chat_id or not message_id:
|
|
3860
|
-
return None
|
|
3861
|
-
msg = await self._fetch_message(chat_id, message_id)
|
|
3862
|
-
if not msg:
|
|
3863
|
-
return None
|
|
3864
|
-
return self._chat_type_from_message(msg)
|
|
4126
|
+
return self._id_allowed(self._allow_from, actor_id) or (
|
|
4127
|
+
chat_type == "group" and self._id_allowed(self._group_allow_from, actor_id)
|
|
4128
|
+
)
|
|
3865
4129
|
|
|
3866
4130
|
async def _handle_clarify_action(self, event: Dict[str, Any]) -> bool:
|
|
3867
4131
|
action_id = str(event.get("actionId") or "")
|
|
@@ -3990,7 +4254,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3990
4254
|
if not target_chat_id:
|
|
3991
4255
|
await self._answer_action(interaction_id, "Thread controls expired")
|
|
3992
4256
|
return True
|
|
3993
|
-
if not await self.
|
|
4257
|
+
if not await self._action_allowed(event):
|
|
4258
|
+
return True
|
|
4259
|
+
if target_chat_id != chat_id and not await self._chat_actor_authorized(
|
|
4260
|
+
target_chat_id, str(event.get("actorUserId") or ""),
|
|
4261
|
+
):
|
|
4262
|
+
await self._answer_action(interaction_id, "Parent chat access could not be confirmed. Check access and try again.")
|
|
3994
4263
|
return True
|
|
3995
4264
|
try:
|
|
3996
4265
|
if choice == "reset":
|
|
@@ -4012,24 +4281,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4012
4281
|
await self._answer_action(interaction_id, "Thread setting failed")
|
|
4013
4282
|
return True
|
|
4014
4283
|
|
|
4015
|
-
async def _thread_action_allowed(self, event: Dict[str, Any], state: Dict[str, Any]) -> bool:
|
|
4016
|
-
actor_id = str(event.get("actorUserId") or "").strip()
|
|
4017
|
-
interaction_id = str(event.get("interactionId") or "")
|
|
4018
|
-
chat_type = await self._action_chat_type(event)
|
|
4019
|
-
if self._actor_authorized(chat_type, actor_id):
|
|
4020
|
-
return True
|
|
4021
|
-
display_chat_id = self._chat_key(state.get("display_chat_id"))
|
|
4022
|
-
target_chat_id = self._chat_key(state.get("target_chat_id"))
|
|
4023
|
-
if chat_type and actor_id and self._allowed(chat_type, actor_id):
|
|
4024
|
-
if chat_type == "dm":
|
|
4025
|
-
return True
|
|
4026
|
-
thread_id = display_chat_id if target_chat_id and display_chat_id != target_chat_id else None
|
|
4027
|
-
if self._chat_allowed(display_chat_id or str(event.get("chatId") or ""), thread_id, target_chat_id):
|
|
4028
|
-
return True
|
|
4029
|
-
await self._answer_action(interaction_id, "Not authorized")
|
|
4030
|
-
logger.info("[inline] blocked thread action actor=%s chat_type=%s action=%s", actor_id or "unknown", chat_type or "unknown", event.get("actionId") or "")
|
|
4031
|
-
return False
|
|
4032
|
-
|
|
4033
4284
|
@staticmethod
|
|
4034
4285
|
def _is_model_picker_action(action_id: str) -> bool:
|
|
4035
4286
|
return action_id.startswith(("mp:", "mpg:", "mm:", "mc:", "mg:", "mb:", "mx:"))
|
package/plugin/inline/cli.py
CHANGED
|
@@ -27,7 +27,7 @@ _CLI_INSTALL_URL = "https://inline.chat/cli/install.sh"
|
|
|
27
27
|
_MAX_TOKEN_BYTES = 16 * 1024
|
|
28
28
|
_MAX_PROBE_RESPONSE_BYTES = 64 * 1024
|
|
29
29
|
_MACHINE_SETUP_PROTOCOL_VERSION = 1
|
|
30
|
-
_PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.
|
|
30
|
+
_PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.21"
|
|
31
31
|
_ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
32
32
|
|
|
33
33
|
|
|
@@ -514,15 +514,17 @@ def _compatibility_status() -> dict:
|
|
|
514
514
|
return {"ok": False, "reason": "plugin_load_failed"}
|
|
515
515
|
if "inline" not in loaded.tools_registered:
|
|
516
516
|
return {"ok": False, "reason": "tool_not_registered"}
|
|
517
|
-
#
|
|
518
|
-
#
|
|
517
|
+
# The moved-import scanner is optional: hosts may predate it or retain
|
|
518
|
+
# only updater stubs after removing the compat layer. Actual loading and
|
|
519
|
+
# this installation's tool registration above remain mandatory.
|
|
519
520
|
try:
|
|
520
|
-
|
|
521
|
+
import hermes_cli.plugin_compat as plugin_compat
|
|
521
522
|
except ModuleNotFoundError as error:
|
|
522
523
|
if error.name != "hermes_cli.plugin_compat":
|
|
523
524
|
raise
|
|
524
525
|
else:
|
|
525
|
-
|
|
526
|
+
plugin_hits = getattr(plugin_compat, "plugin_hits", None)
|
|
527
|
+
if plugin_hits is not None and plugin_hits(loaded.manifest):
|
|
526
528
|
return {"ok": False, "reason": "deprecated_imports"}
|
|
527
529
|
return {"ok": True, "reason": "loaded", "pluginPath": str(plugin_dir)}
|
|
528
530
|
except Exception:
|
|
@@ -5599,10 +5599,13 @@ var require_websocket_server = __commonJS(function(exports, module) {
|
|
|
5599
5599
|
import http from "node:http";
|
|
5600
5600
|
|
|
5601
5601
|
// src/sidecar/inbound-stream.ts
|
|
5602
|
+
import { randomUUID } from "node:crypto";
|
|
5603
|
+
|
|
5602
5604
|
class InboundStream {
|
|
5603
5605
|
consumer = null;
|
|
5604
5606
|
stopped = false;
|
|
5605
5607
|
changed = new Set;
|
|
5608
|
+
pending = new Map;
|
|
5606
5609
|
attach(consumer) {
|
|
5607
5610
|
if (this.stopped) {
|
|
5608
5611
|
consumer.end();
|
|
@@ -5621,6 +5624,13 @@ class InboundStream {
|
|
|
5621
5624
|
this.wake();
|
|
5622
5625
|
previous?.end();
|
|
5623
5626
|
}
|
|
5627
|
+
acknowledge(deliveryId) {
|
|
5628
|
+
const receipt = this.pending.get(deliveryId);
|
|
5629
|
+
if (!receipt)
|
|
5630
|
+
return;
|
|
5631
|
+
receipt.acknowledged = true;
|
|
5632
|
+
this.wake();
|
|
5633
|
+
}
|
|
5624
5634
|
close() {
|
|
5625
5635
|
this.stopped = true;
|
|
5626
5636
|
const previous = this.consumer;
|
|
@@ -5629,54 +5639,41 @@ class InboundStream {
|
|
|
5629
5639
|
previous?.end();
|
|
5630
5640
|
}
|
|
5631
5641
|
async deliver(event) {
|
|
5632
|
-
|
|
5642
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
5643
|
+
throw new Error("Inbound delivery requires an event object");
|
|
5644
|
+
}
|
|
5645
|
+
const deliveryId = randomUUID();
|
|
5646
|
+
const line = JSON.stringify({ ...event, _inlineDeliveryId: deliveryId }) + `
|
|
5633
5647
|
`;
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
owner.once("close", onChange);
|
|
5656
|
-
owner.once("error", onChange);
|
|
5657
|
-
this.changed.add(onChange);
|
|
5658
|
-
});
|
|
5659
|
-
try {
|
|
5660
|
-
if (owner.destroyed || owner.writableEnded) {
|
|
5661
|
-
if (this.consumer === owner)
|
|
5662
|
-
this.consumer = null;
|
|
5648
|
+
const receipt = { acknowledged: false };
|
|
5649
|
+
this.pending.set(deliveryId, receipt);
|
|
5650
|
+
let writtenTo = null;
|
|
5651
|
+
try {
|
|
5652
|
+
while (!this.stopped) {
|
|
5653
|
+
if (receipt.acknowledged)
|
|
5654
|
+
return;
|
|
5655
|
+
const owner = this.consumer;
|
|
5656
|
+
if (owner && owner !== writtenTo) {
|
|
5657
|
+
try {
|
|
5658
|
+
if (owner.destroyed || owner.writableEnded) {
|
|
5659
|
+
if (this.consumer === owner)
|
|
5660
|
+
this.consumer = null;
|
|
5661
|
+
continue;
|
|
5662
|
+
}
|
|
5663
|
+
writtenTo = owner;
|
|
5664
|
+
owner.write(line);
|
|
5665
|
+
} catch {
|
|
5666
|
+
if (this.consumer === owner)
|
|
5667
|
+
this.consumer = null;
|
|
5668
|
+
}
|
|
5663
5669
|
continue;
|
|
5664
5670
|
}
|
|
5665
|
-
|
|
5666
|
-
if (this.consumer !== owner || this.stopped)
|
|
5667
|
-
continue;
|
|
5668
|
-
if (accepted)
|
|
5669
|
-
return;
|
|
5670
|
-
if (await drained && this.consumer === owner && !this.stopped)
|
|
5671
|
-
return;
|
|
5672
|
-
} catch {
|
|
5673
|
-
if (this.consumer === owner)
|
|
5674
|
-
this.consumer = null;
|
|
5675
|
-
} finally {
|
|
5676
|
-
cleanup();
|
|
5671
|
+
await new Promise((resolve) => this.changed.add(resolve));
|
|
5677
5672
|
}
|
|
5673
|
+
throw new Error("Inbound stream closed before delivery completed");
|
|
5674
|
+
} finally {
|
|
5675
|
+
this.pending.delete(deliveryId);
|
|
5678
5676
|
}
|
|
5679
|
-
throw new Error("Inbound stream closed before delivery completed");
|
|
5680
5677
|
}
|
|
5681
5678
|
wake() {
|
|
5682
5679
|
for (const resolve of this.changed)
|
|
@@ -46757,7 +46754,7 @@ function readUsers(result, kind) {
|
|
|
46757
46754
|
}
|
|
46758
46755
|
|
|
46759
46756
|
// src/sidecar/telemetry.ts
|
|
46760
|
-
import { randomUUID } from "node:crypto";
|
|
46757
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
46761
46758
|
import { readFile as readFile2 } from "node:fs/promises";
|
|
46762
46759
|
var TELEMETRY_TIMEOUT_MS = 2000;
|
|
46763
46760
|
var TELEMETRY_DEDUP_MS = 5 * 60000;
|
|
@@ -46818,7 +46815,7 @@ function buildHermesTelemetryEvent(operation, error, options = {}) {
|
|
|
46818
46815
|
const exception = error instanceof Error ? error : new Error(String(error ?? "Unknown error"));
|
|
46819
46816
|
const frames = parseStack(exception, env);
|
|
46820
46817
|
return {
|
|
46821
|
-
event_id:
|
|
46818
|
+
event_id: randomUUID2().replaceAll("-", ""),
|
|
46822
46819
|
timestamp: new Date().toISOString(),
|
|
46823
46820
|
platform: "javascript",
|
|
46824
46821
|
level: "error",
|
|
@@ -47090,6 +47087,13 @@ async function handleRequest(req, res) {
|
|
|
47090
47087
|
writeJson(res, 405, { ok: false, error: "method not allowed", errorKind: "bad_format" });
|
|
47091
47088
|
return;
|
|
47092
47089
|
}
|
|
47090
|
+
if (url.pathname === "/inbound/ack") {
|
|
47091
|
+
const body2 = asRecord(await readJsonBody(req));
|
|
47092
|
+
const deliveryId = readRequiredString(body2, "deliveryId");
|
|
47093
|
+
inboundStream.acknowledge(deliveryId);
|
|
47094
|
+
writeJson(res, 200, { ok: true, result: {} });
|
|
47095
|
+
return;
|
|
47096
|
+
}
|
|
47093
47097
|
if (!connected && url.pathname !== "/shutdown") {
|
|
47094
47098
|
writeJson(res, 503, {
|
|
47095
47099
|
ok: false,
|