@inline-chat/hermes-agent-adapter 0.0.8 → 0.0.10

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.
@@ -48,6 +48,14 @@ from gateway.platforms.base import (
48
48
  )
49
49
  from gateway.platforms.helpers import strip_markdown
50
50
 
51
+ from .message_actions import (
52
+ build_inline_agent_action_input,
53
+ build_inline_agent_action_turn_id,
54
+ build_inline_system_action_id,
55
+ parse_inline_agent_action_reply_target,
56
+ resolve_inline_message_action_ownership,
57
+ )
58
+
51
59
  logger = logging.getLogger(__name__)
52
60
 
53
61
  _DEFAULT_SIDECAR_PORT = 8794
@@ -58,6 +66,7 @@ _DEDUP_MAX_SIZE = 5000
58
66
  _DEDUP_WINDOW_SECONDS = 48 * 3600
59
67
  _CHAT_INFO_CACHE_SECONDS = 10 * 60
60
68
  _CHAT_INFO_CACHE_MAX_SIZE = 512
69
+ _BOT_SETTINGS_CHAT_INFO_TIMEOUT_SECONDS = 2.0
61
70
  _BOT_SETTINGS_MODEL_CATALOG_CACHE_SECONDS = 60
62
71
  _DEFAULT_CONTEXT_BACKFILL = "selective"
63
72
  _CONTEXT_BACKFILL_MODES = {"off", "selective", "always"}
@@ -346,20 +355,23 @@ def _target_from_chat_id(chat_id: str) -> Dict[str, str]:
346
355
  return {"chatId": raw}
347
356
 
348
357
 
349
- def _inline_sender_profile(event: Dict[str, Any], message: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
358
+ def _inline_sender_profile(event: Dict[str, Any], message: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
350
359
  raw = event.get("sender")
351
360
  if not isinstance(raw, dict) and isinstance(message, dict):
352
361
  raw = message.get("sender")
353
362
  if not isinstance(raw, dict):
354
363
  return {}
355
- return {
364
+ profile: Dict[str, Any] = {
356
365
  key: str(raw.get(key) or "").strip()
357
366
  for key in ("id", "firstName", "lastName", "username")
358
367
  if str(raw.get(key) or "").strip()
359
368
  }
369
+ if isinstance(raw.get("bot"), bool):
370
+ profile["bot"] = raw["bot"]
371
+ return profile
360
372
 
361
373
 
362
- def _inline_sender_identity(profile: Dict[str, str]) -> tuple[str, str, str]:
374
+ def _inline_sender_identity(profile: Dict[str, Any]) -> tuple[str, str, str]:
363
375
  first_name = str(profile.get("firstName") or "").strip()
364
376
  last_name = str(profile.get("lastName") or "").strip()
365
377
  username = str(profile.get("username") or "").strip().lstrip("@")
@@ -865,6 +877,7 @@ class InlineAdapter(BasePlatformAdapter):
865
877
  self._model_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
866
878
  self._thread_action_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
867
879
  self._chat_info_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
880
+ self._bot_agent_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
868
881
  self._reply_thread_cache: "OrderedDict[str, str]" = OrderedDict()
869
882
  self._reply_thread_parent_reply_ids: "OrderedDict[str, set[str]]" = OrderedDict()
870
883
  self._reply_thread_parent_typing_targets: "OrderedDict[str, str]" = OrderedDict()
@@ -1001,7 +1014,14 @@ class InlineAdapter(BasePlatformAdapter):
1001
1014
  if not chat_id or not actor_id:
1002
1015
  return {"access": "guideOnly", "scope_id": chat_id or "unknown", "reply_threads": "auto"}
1003
1016
 
1004
- info = await self._get_chat_info(chat_id)
1017
+ try:
1018
+ info = await asyncio.wait_for(
1019
+ self._get_chat_info(chat_id),
1020
+ timeout=_BOT_SETTINGS_CHAT_INFO_TIMEOUT_SECONDS,
1021
+ )
1022
+ except asyncio.TimeoutError:
1023
+ logger.warning("[inline] agent settings chat metadata timed out")
1024
+ info = {}
1005
1025
  if not info:
1006
1026
  return {
1007
1027
  "access": "guideOnly",
@@ -2041,6 +2061,13 @@ class InlineAdapter(BasePlatformAdapter):
2041
2061
  if kind == "message.action.invoke":
2042
2062
  if await self._handle_action(event):
2043
2063
  return
2064
+ ownership = resolve_inline_message_action_ownership(event.get("actionId"))
2065
+ if ownership.owner == "system" and ownership.explicit:
2066
+ await self._answer_action(str(event.get("interactionId") or ""), "Action expired")
2067
+ logger.info("[inline] dropped unhandled system action %s", event.get("actionId") or "")
2068
+ return
2069
+ await self._dispatch_agent_action(event)
2070
+ return
2044
2071
  if kind == "reaction.add":
2045
2072
  await self._dispatch_reaction(event, added=True)
2046
2073
  return
@@ -2099,6 +2126,7 @@ class InlineAdapter(BasePlatformAdapter):
2099
2126
  return False
2100
2127
 
2101
2128
  async def _dispatch_message(self, event: Dict[str, Any], *, edit: bool = False) -> None:
2129
+ agent_action = event.get("_inlineAgentAction") if isinstance(event.get("_inlineAgentAction"), dict) else None
2102
2130
  msg = event.get("message") or {}
2103
2131
  msg_id = str(msg.get("id") or "")
2104
2132
  chat_id = str(event.get("chatId") or msg.get("chatId") or "")
@@ -2134,6 +2162,13 @@ class InlineAdapter(BasePlatformAdapter):
2134
2162
  text = f"{text}\n{media_text}".strip() if text else media_text
2135
2163
  if not text and not media_urls:
2136
2164
  text = "[Inline message with no text]"
2165
+ explicitly_mentions_me = self._message_entity_mentions_me(msg)
2166
+ if event.get("_inlineSenderProvenanceVerified") is False and not explicitly_mentions_me:
2167
+ self._remember_observed_context(chat_id, msg, text)
2168
+ return
2169
+ if sender_profile.get("bot") is True and not explicitly_mentions_me:
2170
+ self._remember_observed_context(chat_id, msg, text)
2171
+ return
2137
2172
 
2138
2173
  chat_type = self._chat_type_from_message(msg)
2139
2174
  thread_id = self._thread_id_from_message(msg)
@@ -2163,7 +2198,7 @@ class InlineAdapter(BasePlatformAdapter):
2163
2198
  )
2164
2199
  if has_command_target and not command_addressed_to_me:
2165
2200
  return
2166
- if await self._handle_thread_command(
2201
+ if not agent_action and await self._handle_thread_command(
2167
2202
  chat_id=chat_id,
2168
2203
  msg_id=msg_id,
2169
2204
  text=text,
@@ -2172,7 +2207,7 @@ class InlineAdapter(BasePlatformAdapter):
2172
2207
  parent_chat_id=parent_chat_id,
2173
2208
  ):
2174
2209
  return
2175
- if await self._handle_follow_command(
2210
+ if not agent_action and await self._handle_follow_command(
2176
2211
  chat_id=chat_id,
2177
2212
  msg_id=msg_id,
2178
2213
  from_id=from_id,
@@ -2227,6 +2262,7 @@ class InlineAdapter(BasePlatformAdapter):
2227
2262
  text = f"message:edited:{text}" if text else "message:edited"
2228
2263
  if (
2229
2264
  not edit
2265
+ and not agent_action
2230
2266
  and not thread_id
2231
2267
  and self._should_create_reply_thread_for_message(
2232
2268
  chat_id=chat_id,
@@ -2244,8 +2280,9 @@ class InlineAdapter(BasePlatformAdapter):
2244
2280
  mentioned_agent_id = self._mentioned_agent_id(msg)
2245
2281
  if mentioned_agent_id:
2246
2282
  try:
2247
- resolved_agent = await self._sidecar_call("/get-agent", {"agentId": mentioned_agent_id})
2248
- agent = resolved_agent.get("agent") if isinstance(resolved_agent.get("agent"), dict) else {}
2283
+ agent = self._activated_agent(msg, mentioned_agent_id)
2284
+ if not agent:
2285
+ agent = await self._resolve_bot_agent(mentioned_agent_id)
2249
2286
  agent_name = str(agent.get("name") or "").strip()
2250
2287
  agent_instructions = str(agent.get("instructions") or "").strip()
2251
2288
  agent_skill = str(agent.get("skillKey") or agent.get("skill_key") or "").strip()
@@ -2253,7 +2290,7 @@ class InlineAdapter(BasePlatformAdapter):
2253
2290
  specialization = agent_instructions or f'You are a specialized agent named "{agent_name}".'
2254
2291
  channel_prompt = self._merge_channel_prompt(channel_prompt, specialization)
2255
2292
  if agent_skill:
2256
- auto_skill = [agent_skill]
2293
+ auto_skill = list(dict.fromkeys([*(auto_skill or []), agent_skill]))
2257
2294
  except Exception as exc:
2258
2295
  logger.warning("[inline] failed to resolve mentioned Agent %s: %s", mentioned_agent_id, exc)
2259
2296
  entity_text = self._inline_entity_text(msg, str(msg.get("message") or ""))
@@ -2314,6 +2351,16 @@ class InlineAdapter(BasePlatformAdapter):
2314
2351
  parent_message_id=parent_message_id,
2315
2352
  entity_text=entity_text,
2316
2353
  )
2354
+ if agent_action:
2355
+ metadata["inline"]["action"] = {
2356
+ "event_kind": "message.action.invoke",
2357
+ "actor_user_id": str(agent_action.get("actorUserId") or ""),
2358
+ "chat_id": str(agent_action.get("chatId") or ""),
2359
+ "target_message_id": str(agent_action.get("messageId") or ""),
2360
+ "interaction_id": str(agent_action.get("interactionId") or ""),
2361
+ "action_id": str(agent_action.get("actionId") or ""),
2362
+ "callback_data_base64": str(agent_action.get("dataBase64") or ""),
2363
+ }
2317
2364
 
2318
2365
  source = self.build_source(
2319
2366
  chat_id=chat_id,
@@ -2330,7 +2377,14 @@ class InlineAdapter(BasePlatformAdapter):
2330
2377
  message_type=message_type,
2331
2378
  source=source,
2332
2379
  raw_message=event,
2333
- message_id=msg_id,
2380
+ message_id=(
2381
+ build_inline_agent_action_turn_id(
2382
+ agent_action.get("messageId"),
2383
+ agent_action.get("interactionId"),
2384
+ )
2385
+ if agent_action
2386
+ else msg_id
2387
+ ),
2334
2388
  platform_update_id=int(event.get("seq") or 0) if str(event.get("seq") or "").isdigit() else None,
2335
2389
  media_urls=media_urls,
2336
2390
  media_types=media_types,
@@ -2343,24 +2397,70 @@ class InlineAdapter(BasePlatformAdapter):
2343
2397
  channel_context=channel_context,
2344
2398
  metadata=metadata,
2345
2399
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2400
+ allow_gateway_control=not bool(agent_action),
2346
2401
  ))
2347
2402
 
2403
+ async def _dispatch_agent_action(self, event: Dict[str, Any]) -> None:
2404
+ interaction_id = str(event.get("interactionId") or "")
2405
+ await self._answer_action(interaction_id, "")
2406
+
2407
+ chat_id = str(event.get("chatId") or "")
2408
+ message_id = str(event.get("messageId") or "")
2409
+ actor_user_id = str(event.get("actorUserId") or "")
2410
+ if not chat_id or not message_id or not interaction_id or not actor_user_id:
2411
+ logger.warning("[inline] ignored incomplete agent action event")
2412
+ return
2413
+ target = await self._fetch_message(chat_id, message_id)
2414
+ if not target:
2415
+ logger.info("[inline] ignored agent action for unavailable message %s", message_id)
2416
+ return
2417
+
2418
+ synthetic_message = dict(target)
2419
+ for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies"):
2420
+ synthetic_message.pop(stale_key, None)
2421
+ synthetic_message.update({
2422
+ "id": message_id,
2423
+ "chatId": chat_id,
2424
+ "fromId": actor_user_id,
2425
+ "message": build_inline_agent_action_input(event),
2426
+ "out": False,
2427
+ "mentioned": True,
2428
+ "replyToMsgId": message_id,
2429
+ "date": event.get("date") or target.get("date"),
2430
+ })
2431
+ sender = event.get("sender")
2432
+ if isinstance(sender, dict):
2433
+ synthetic_message["sender"] = sender
2434
+ await self._dispatch_message({
2435
+ **event,
2436
+ "kind": "message.new",
2437
+ "message": synthetic_message,
2438
+ "_inlineAgentAction": dict(event),
2439
+ })
2440
+
2348
2441
  def _message_explicitly_mentions_me(self, msg: Dict[str, Any]) -> bool:
2349
2442
  if not self._me_id:
2350
2443
  return False
2351
2444
  if bool(msg.get("mentioned")):
2352
2445
  return True
2446
+ if self._message_entity_mentions_me(msg):
2447
+ return True
2448
+ username = str(self._me_username or "").strip().lstrip("@")
2449
+ text = str(msg.get("message") or "")
2450
+ if not username or not text:
2451
+ return False
2452
+ return bool(re.search(rf"(^|\s)@{re.escape(username)}(?=$|[\s,.:;!?])", text, re.IGNORECASE))
2453
+
2454
+ def _message_entity_mentions_me(self, msg: Dict[str, Any]) -> bool:
2455
+ if not self._me_id:
2456
+ return False
2353
2457
  for entity in self._message_entities(msg):
2354
2458
  if self._entity_kind(entity) != "mention":
2355
2459
  continue
2356
2460
  payload = self._entity_payload(entity, "mention")
2357
2461
  if self._entity_id(payload, "userId") == self._me_id:
2358
2462
  return True
2359
- username = str(self._me_username or "").strip().lstrip("@")
2360
- text = str(msg.get("message") or "")
2361
- if not username or not text:
2362
- return False
2363
- return bool(re.search(rf"(^|\s)@{re.escape(username)}(?=$|[\s,.:;!?])", text, re.IGNORECASE))
2463
+ return False
2364
2464
 
2365
2465
  async def _recover_self_join_mentions(self, event: Dict[str, Any]) -> bool:
2366
2466
  participant = event.get("participant") if isinstance(event.get("participant"), dict) else {}
@@ -2916,6 +3016,36 @@ class InlineAdapter(BasePlatformAdapter):
2916
3016
  return agent_id
2917
3017
  return None
2918
3018
 
3019
+ @staticmethod
3020
+ def _activated_agent(msg: Dict[str, Any], agent_id: str) -> Optional[Dict[str, Any]]:
3021
+ candidates = [msg]
3022
+ raw = msg.get("raw")
3023
+ if isinstance(raw, dict):
3024
+ candidates.append(raw)
3025
+ for container in candidates:
3026
+ value = container.get("activatedAgent") or container.get("activated_agent")
3027
+ if not isinstance(value, dict):
3028
+ continue
3029
+ value_id = value.get("id") or value.get("agentId") or value.get("agent_id")
3030
+ if str(value_id or "") == agent_id:
3031
+ return value
3032
+ return None
3033
+
3034
+ async def _resolve_bot_agent(self, agent_id: str) -> Dict[str, Any]:
3035
+ now = time.monotonic()
3036
+ cached = self._bot_agent_cache.get(agent_id)
3037
+ if cached and now - cached[0] < 60:
3038
+ self._bot_agent_cache.move_to_end(agent_id)
3039
+ return cached[1]
3040
+
3041
+ resolved = await self._sidecar_call("/get-agent", {"agentId": agent_id})
3042
+ agent = resolved.get("agent") if isinstance(resolved.get("agent"), dict) else {}
3043
+ self._bot_agent_cache[agent_id] = (now, agent)
3044
+ self._bot_agent_cache.move_to_end(agent_id)
3045
+ if len(self._bot_agent_cache) > 100:
3046
+ self._bot_agent_cache.popitem(last=False)
3047
+ return agent
3048
+
2919
3049
  @staticmethod
2920
3050
  def _merge_channel_prompt(*parts: Optional[str]) -> Optional[str]:
2921
3051
  merged = [str(part).strip() for part in parts if str(part or "").strip()]
@@ -3443,7 +3573,12 @@ class InlineAdapter(BasePlatformAdapter):
3443
3573
  return None
3444
3574
 
3445
3575
  async def _handle_action(self, event: Dict[str, Any]) -> bool:
3446
- action_id = str(event.get("actionId") or "")
3576
+ ownership = resolve_inline_message_action_ownership(event.get("actionId"))
3577
+ if ownership.owner == "agent" and ownership.explicit:
3578
+ return False
3579
+ action_id = ownership.native_action_id
3580
+ if ownership.owner == "system":
3581
+ event = {**event, "actionId": action_id}
3447
3582
  if self._is_model_picker_action(action_id):
3448
3583
  if not await self._action_allowed(event):
3449
3584
  return True
@@ -3877,6 +4012,48 @@ class InlineAdapter(BasePlatformAdapter):
3877
4012
  actions: Optional[Dict[str, Any]] = None,
3878
4013
  ) -> SendResult:
3879
4014
  target = self._target_for(chat_id, metadata)
4015
+ agent_action_target = parse_inline_agent_action_reply_target(reply_to)
4016
+ if agent_action_target:
4017
+ chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
4018
+ edit_body: Dict[str, Any] = {
4019
+ "target": target,
4020
+ "messageId": agent_action_target,
4021
+ "text": chunks[0],
4022
+ "parseMarkdown": self._parse_markdown,
4023
+ "actions": actions if actions is not None else {"rows": []},
4024
+ }
4025
+ first_result = await self._send_sidecar("/edit", edit_body)
4026
+ if first_result.success:
4027
+ self._mark_reply_thread_visible(target)
4028
+ if len(chunks) == 1:
4029
+ return first_result
4030
+ continuation_ids: List[str] = []
4031
+ raw_responses: List[Any] = [first_result.raw_response]
4032
+ previous_id = agent_action_target
4033
+ for chunk in chunks[1:]:
4034
+ continuation = await self._send_sidecar("/send", {
4035
+ "target": target,
4036
+ "text": chunk,
4037
+ "parseMarkdown": self._parse_markdown,
4038
+ "replyToMsgId": previous_id,
4039
+ })
4040
+ raw_responses.append(continuation.raw_response)
4041
+ if not continuation.success:
4042
+ return continuation
4043
+ if continuation.message_id:
4044
+ previous_id = str(continuation.message_id)
4045
+ continuation_ids.append(previous_id)
4046
+ return _send_result(
4047
+ success=True,
4048
+ message_id=previous_id,
4049
+ raw_response={"action_response": True, "responses": raw_responses},
4050
+ continuation_message_ids=tuple(continuation_ids),
4051
+ )
4052
+ logger.warning(
4053
+ "[inline] agent action response edit failed; sending fallback: %s",
4054
+ first_result.error,
4055
+ )
4056
+ reply_to = agent_action_target
3880
4057
  reply_to = self._reply_to_for_target(reply_to, target)
3881
4058
  chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
3882
4059
  message_ids: List[str] = []
@@ -4086,6 +4263,7 @@ class InlineAdapter(BasePlatformAdapter):
4086
4263
  "path": safe_path,
4087
4264
  "kind": kind,
4088
4265
  "caption": caption,
4266
+ "parseMarkdown": self._parse_markdown,
4089
4267
  "fileName": file_name,
4090
4268
  "mimeType": mime_type,
4091
4269
  }
@@ -4115,10 +4293,10 @@ class InlineAdapter(BasePlatformAdapter):
4115
4293
  self._remember(self._clarify_choices, clarify_id, clean_choices)
4116
4294
  lines = [f"Clarify: {question}", "", *[f"{i + 1}. {c}" for i, c in enumerate(clean_choices)]]
4117
4295
  actions = [
4118
- {"id": f"cl:{clarify_id}:{i}", "text": str(i + 1), "callback": f"cl:{clarify_id}:{i}"}
4296
+ self._action(f"cl:{clarify_id}:{i}", str(i + 1))
4119
4297
  for i in range(len(clean_choices))
4120
4298
  ]
4121
- actions.append({"id": f"cl:{clarify_id}:other", "text": "Other", "callback": f"cl:{clarify_id}:other"})
4299
+ actions.append(self._action(f"cl:{clarify_id}:other", "Other"))
4122
4300
  return await self._send_sidecar("/send", {
4123
4301
  "target": self._target_for(chat_id, metadata),
4124
4302
  "text": "\n".join(lines),
@@ -4164,9 +4342,9 @@ class InlineAdapter(BasePlatformAdapter):
4164
4342
  "text": f"{title}\n\n{message}",
4165
4343
  "parseMarkdown": self._parse_markdown,
4166
4344
  "actions": {"rows": [{"actions": [
4167
- {"id": f"sc:once:{confirm_id}", "text": "Approve Once", "callback": f"sc:once:{confirm_id}"},
4168
- {"id": f"sc:always:{confirm_id}", "text": "Always", "callback": f"sc:always:{confirm_id}"},
4169
- {"id": f"sc:cancel:{confirm_id}", "text": "Cancel", "callback": f"sc:cancel:{confirm_id}"},
4345
+ self._action(f"sc:once:{confirm_id}", "Approve Once"),
4346
+ self._action(f"sc:always:{confirm_id}", "Always"),
4347
+ self._action(f"sc:cancel:{confirm_id}", "Cancel"),
4170
4348
  ]}]},
4171
4349
  })
4172
4350
 
@@ -4331,6 +4509,7 @@ class InlineAdapter(BasePlatformAdapter):
4331
4509
 
4332
4510
  @staticmethod
4333
4511
  def _action(action_id: str, text: str) -> Dict[str, str]:
4512
+ action_id = build_inline_system_action_id(action_id)
4334
4513
  return {"id": action_id, "text": text, "callback": action_id}
4335
4514
 
4336
4515
  @staticmethod
@@ -4376,7 +4555,9 @@ class InlineAdapter(BasePlatformAdapter):
4376
4555
  return out
4377
4556
 
4378
4557
  def format_message(self, content: str) -> str:
4379
- return content if self._parse_markdown else strip_markdown(content)
4558
+ # parseMarkdown=false means literal input at every Inline API boundary.
4559
+ # Preserve that contract instead of silently rewriting the caller's text.
4560
+ return content
4380
4561
 
4381
4562
  def _target_for(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
4382
4563
  thread_id = (metadata or {}).get("thread_id")
@@ -13,6 +13,9 @@ import re
13
13
  import shutil
14
14
  import subprocess
15
15
  import sys
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
16
19
 
17
20
  from pathlib import Path
18
21
 
@@ -21,7 +24,19 @@ _MIN_NODE_MAJOR = 20
21
24
  _BOT_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]+bot$", re.IGNORECASE)
22
25
  _CLI_INSTALL_URL = "https://inline.chat/cli/install.sh"
23
26
  _MAX_TOKEN_BYTES = 16 * 1024
27
+ _MAX_PROBE_RESPONSE_BYTES = 64 * 1024
24
28
  _MACHINE_SETUP_PROTOCOL_VERSION = 1
29
+ _PROBE_USER_AGENT = "inline-hermes-agent-adapter/0.0.10"
30
+ _ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
31
+
32
+
33
+ class _RejectRedirects(urllib.request.HTTPRedirectHandler):
34
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
35
+ return None
36
+
37
+
38
+ def _open_bot_api_probe(request: urllib.request.Request):
39
+ return urllib.request.build_opener(_RejectRedirects()).open(request, timeout=30)
25
40
 
26
41
 
27
42
  def gateway_setup() -> None:
@@ -411,13 +426,21 @@ def _machine_setup(args) -> int:
411
426
  def _status(args) -> int:
412
427
  from hermes_cli import gateway as hermes_gateway
413
428
 
429
+ raw_config = _read_inline_config(hermes_gateway)
414
430
  token = (
415
431
  hermes_gateway.get_env_value("INLINE_TOKEN")
416
432
  or hermes_gateway.get_env_value("INLINE_BOT_TOKEN")
433
+ or _resolve_config_value(hermes_gateway, raw_config.get("token"))
417
434
  )
418
435
  configured = bool(token)
419
436
  probe_requested = bool(getattr(args, "probe", False))
420
- probe = _probe_inline_token(token) if configured and probe_requested else None
437
+ base_url = (
438
+ hermes_gateway.get_env_value("INLINE_BASE_URL")
439
+ or os.getenv("INLINE_BASE_URL")
440
+ or _resolve_config_value(hermes_gateway, raw_config.get("base_url"))
441
+ or "https://api.inline.chat"
442
+ )
443
+ probe = _probe_inline_token(token, base_url) if configured and probe_requested else None
421
444
  node = _node_status()
422
445
  sidecar = _sidecar_status(node)
423
446
  sidecar_bundled = bool(
@@ -471,37 +494,75 @@ def _plugin_version() -> str:
471
494
  return "unknown"
472
495
 
473
496
 
474
- def _probe_inline_token(token: str) -> dict:
475
- inline_bin = _find_inline_cli()
476
- if not inline_bin:
477
- return {"ok": False, "error": "Inline CLI was not found for the credential probe."}
478
- env = os.environ.copy()
479
- for name in ("INLINE_TOKEN", "INLINE_BOT_TOKEN", "INLINE_OWNER_TOKEN", "INLINE_ACCESS_TOKEN"):
480
- env.pop(name, None)
481
- env["INLINE_TOKEN"] = token
497
+ def _read_inline_config(hermes_gateway) -> dict:
482
498
  try:
483
- result = subprocess.run(
484
- [inline_bin, "--json", "--compact", "auth", "me"],
485
- stdout=subprocess.PIPE,
486
- stderr=subprocess.PIPE,
487
- text=True,
488
- timeout=30,
489
- check=False,
490
- env=env,
499
+ config = hermes_gateway.read_raw_config()
500
+ except (AttributeError, OSError, TypeError, ValueError):
501
+ return {}
502
+ if not isinstance(config, dict):
503
+ return {}
504
+ platforms = config.get("platforms")
505
+ platform_inline = platforms.get("inline") if isinstance(platforms, dict) else None
506
+ top_level_inline = config.get("inline")
507
+ merged = {}
508
+ if isinstance(top_level_inline, dict):
509
+ merged.update(top_level_inline)
510
+ if isinstance(platform_inline, dict):
511
+ merged.update(platform_inline)
512
+ return merged
513
+
514
+
515
+ def _resolve_config_value(hermes_gateway, raw) -> str:
516
+ value = str(raw or "").strip()
517
+ match = _ENV_REFERENCE_RE.fullmatch(value)
518
+ if match:
519
+ name = match.group(1)
520
+ return str(hermes_gateway.get_env_value(name) or os.getenv(name) or "").strip()
521
+ return value
522
+
523
+
524
+ def _probe_inline_token(token: str, base_url: str = "https://api.inline.chat") -> dict:
525
+ try:
526
+ base_url = str(base_url or "https://api.inline.chat").rstrip("/")
527
+ parsed = urllib.parse.urlsplit(base_url)
528
+ if parsed.scheme not in ("http", "https") or not parsed.netloc or parsed.query or parsed.fragment:
529
+ raise ValueError("invalid Inline base URL")
530
+ request = urllib.request.Request(
531
+ f"{base_url}/v1/getMe",
532
+ headers={
533
+ "Accept": "application/json",
534
+ "Authorization": f"Bearer {token}",
535
+ "User-Agent": _PROBE_USER_AGENT,
536
+ },
537
+ method="GET",
491
538
  )
492
- except (OSError, subprocess.TimeoutExpired):
493
- return {"ok": False, "error": "Inline credential probe could not run."}
494
- if result.returncode != 0:
495
- return {"ok": False, "error": "Inline rejected the configured credential."}
539
+ with _open_bot_api_probe(request) as response:
540
+ raw = response.read(_MAX_PROBE_RESPONSE_BYTES + 1)
541
+ except urllib.error.HTTPError as exc:
542
+ if exc.code in (401, 403):
543
+ return {"ok": False, "errorKind": "invalid_credential", "error": "Inline rejected the configured credential."}
544
+ return {"ok": False, "errorKind": "unavailable", "error": "Inline API credential probe failed."}
545
+ except ValueError:
546
+ return {"ok": False, "errorKind": "invalid_config", "error": "Inline API base URL is invalid."}
547
+ except (OSError, TimeoutError, urllib.error.URLError):
548
+ return {"ok": False, "errorKind": "unavailable", "error": "Inline API credential probe could not run."}
549
+ if len(raw) > _MAX_PROBE_RESPONSE_BYTES:
550
+ return {"ok": False, "errorKind": "invalid_response", "error": "Inline API credential probe returned too much data."}
496
551
  try:
497
- payload = json.loads(result.stdout)
498
- except (json.JSONDecodeError, TypeError):
499
- return {"ok": False, "error": "Inline credential probe returned unreadable output."}
500
- raw_id = payload.get("id") if isinstance(payload, dict) else None
552
+ payload = json.loads(raw)
553
+ except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
554
+ return {"ok": False, "errorKind": "invalid_response", "error": "Inline API credential probe returned unreadable output."}
555
+ if not isinstance(payload, dict) or payload.get("ok") is not True:
556
+ return {"ok": False, "errorKind": "invalid_response", "error": "Inline API credential probe returned an unsuccessful response."}
557
+ result = payload.get("result")
558
+ user = result.get("user") if isinstance(result, dict) else None
559
+ if not isinstance(user, dict):
560
+ return {"ok": False, "errorKind": "invalid_response", "error": "Inline API credential probe returned no user identity."}
561
+ raw_id = user.get("id") if isinstance(user, dict) else None
501
562
  bot_user_id = str(raw_id).strip() if raw_id is not None else ""
502
563
  if not bot_user_id.isdigit() or int(bot_user_id) <= 0:
503
- return {"ok": False, "error": "Inline credential probe returned no bot identity."}
504
- username = str(payload.get("username") or "").strip().lstrip("@")
564
+ return {"ok": False, "errorKind": "invalid_response", "error": "Inline API credential probe returned no user identity."}
565
+ username = str(user.get("username") or "").strip().lstrip("@")
505
566
  return {
506
567
  "ok": True,
507
568
  "botUserId": bot_user_id,