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

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
@@ -346,20 +354,23 @@ def _target_from_chat_id(chat_id: str) -> Dict[str, str]:
346
354
  return {"chatId": raw}
347
355
 
348
356
 
349
- def _inline_sender_profile(event: Dict[str, Any], message: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
357
+ def _inline_sender_profile(event: Dict[str, Any], message: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
350
358
  raw = event.get("sender")
351
359
  if not isinstance(raw, dict) and isinstance(message, dict):
352
360
  raw = message.get("sender")
353
361
  if not isinstance(raw, dict):
354
362
  return {}
355
- return {
363
+ profile: Dict[str, Any] = {
356
364
  key: str(raw.get(key) or "").strip()
357
365
  for key in ("id", "firstName", "lastName", "username")
358
366
  if str(raw.get(key) or "").strip()
359
367
  }
368
+ if isinstance(raw.get("bot"), bool):
369
+ profile["bot"] = raw["bot"]
370
+ return profile
360
371
 
361
372
 
362
- def _inline_sender_identity(profile: Dict[str, str]) -> tuple[str, str, str]:
373
+ def _inline_sender_identity(profile: Dict[str, Any]) -> tuple[str, str, str]:
363
374
  first_name = str(profile.get("firstName") or "").strip()
364
375
  last_name = str(profile.get("lastName") or "").strip()
365
376
  username = str(profile.get("username") or "").strip().lstrip("@")
@@ -865,6 +876,7 @@ class InlineAdapter(BasePlatformAdapter):
865
876
  self._model_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
866
877
  self._thread_action_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
867
878
  self._chat_info_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
879
+ self._bot_agent_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
868
880
  self._reply_thread_cache: "OrderedDict[str, str]" = OrderedDict()
869
881
  self._reply_thread_parent_reply_ids: "OrderedDict[str, set[str]]" = OrderedDict()
870
882
  self._reply_thread_parent_typing_targets: "OrderedDict[str, str]" = OrderedDict()
@@ -2041,6 +2053,13 @@ class InlineAdapter(BasePlatformAdapter):
2041
2053
  if kind == "message.action.invoke":
2042
2054
  if await self._handle_action(event):
2043
2055
  return
2056
+ ownership = resolve_inline_message_action_ownership(event.get("actionId"))
2057
+ if ownership.owner == "system" and ownership.explicit:
2058
+ await self._answer_action(str(event.get("interactionId") or ""), "Action expired")
2059
+ logger.info("[inline] dropped unhandled system action %s", event.get("actionId") or "")
2060
+ return
2061
+ await self._dispatch_agent_action(event)
2062
+ return
2044
2063
  if kind == "reaction.add":
2045
2064
  await self._dispatch_reaction(event, added=True)
2046
2065
  return
@@ -2099,6 +2118,7 @@ class InlineAdapter(BasePlatformAdapter):
2099
2118
  return False
2100
2119
 
2101
2120
  async def _dispatch_message(self, event: Dict[str, Any], *, edit: bool = False) -> None:
2121
+ agent_action = event.get("_inlineAgentAction") if isinstance(event.get("_inlineAgentAction"), dict) else None
2102
2122
  msg = event.get("message") or {}
2103
2123
  msg_id = str(msg.get("id") or "")
2104
2124
  chat_id = str(event.get("chatId") or msg.get("chatId") or "")
@@ -2134,6 +2154,13 @@ class InlineAdapter(BasePlatformAdapter):
2134
2154
  text = f"{text}\n{media_text}".strip() if text else media_text
2135
2155
  if not text and not media_urls:
2136
2156
  text = "[Inline message with no text]"
2157
+ explicitly_mentions_me = self._message_entity_mentions_me(msg)
2158
+ if event.get("_inlineSenderProvenanceVerified") is False and not explicitly_mentions_me:
2159
+ self._remember_observed_context(chat_id, msg, text)
2160
+ return
2161
+ if sender_profile.get("bot") is True and not explicitly_mentions_me:
2162
+ self._remember_observed_context(chat_id, msg, text)
2163
+ return
2137
2164
 
2138
2165
  chat_type = self._chat_type_from_message(msg)
2139
2166
  thread_id = self._thread_id_from_message(msg)
@@ -2163,7 +2190,7 @@ class InlineAdapter(BasePlatformAdapter):
2163
2190
  )
2164
2191
  if has_command_target and not command_addressed_to_me:
2165
2192
  return
2166
- if await self._handle_thread_command(
2193
+ if not agent_action and await self._handle_thread_command(
2167
2194
  chat_id=chat_id,
2168
2195
  msg_id=msg_id,
2169
2196
  text=text,
@@ -2172,7 +2199,7 @@ class InlineAdapter(BasePlatformAdapter):
2172
2199
  parent_chat_id=parent_chat_id,
2173
2200
  ):
2174
2201
  return
2175
- if await self._handle_follow_command(
2202
+ if not agent_action and await self._handle_follow_command(
2176
2203
  chat_id=chat_id,
2177
2204
  msg_id=msg_id,
2178
2205
  from_id=from_id,
@@ -2227,6 +2254,7 @@ class InlineAdapter(BasePlatformAdapter):
2227
2254
  text = f"message:edited:{text}" if text else "message:edited"
2228
2255
  if (
2229
2256
  not edit
2257
+ and not agent_action
2230
2258
  and not thread_id
2231
2259
  and self._should_create_reply_thread_for_message(
2232
2260
  chat_id=chat_id,
@@ -2244,8 +2272,9 @@ class InlineAdapter(BasePlatformAdapter):
2244
2272
  mentioned_agent_id = self._mentioned_agent_id(msg)
2245
2273
  if mentioned_agent_id:
2246
2274
  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 {}
2275
+ agent = self._activated_agent(msg, mentioned_agent_id)
2276
+ if not agent:
2277
+ agent = await self._resolve_bot_agent(mentioned_agent_id)
2249
2278
  agent_name = str(agent.get("name") or "").strip()
2250
2279
  agent_instructions = str(agent.get("instructions") or "").strip()
2251
2280
  agent_skill = str(agent.get("skillKey") or agent.get("skill_key") or "").strip()
@@ -2253,7 +2282,7 @@ class InlineAdapter(BasePlatformAdapter):
2253
2282
  specialization = agent_instructions or f'You are a specialized agent named "{agent_name}".'
2254
2283
  channel_prompt = self._merge_channel_prompt(channel_prompt, specialization)
2255
2284
  if agent_skill:
2256
- auto_skill = [agent_skill]
2285
+ auto_skill = list(dict.fromkeys([*(auto_skill or []), agent_skill]))
2257
2286
  except Exception as exc:
2258
2287
  logger.warning("[inline] failed to resolve mentioned Agent %s: %s", mentioned_agent_id, exc)
2259
2288
  entity_text = self._inline_entity_text(msg, str(msg.get("message") or ""))
@@ -2314,6 +2343,16 @@ class InlineAdapter(BasePlatformAdapter):
2314
2343
  parent_message_id=parent_message_id,
2315
2344
  entity_text=entity_text,
2316
2345
  )
2346
+ if agent_action:
2347
+ metadata["inline"]["action"] = {
2348
+ "event_kind": "message.action.invoke",
2349
+ "actor_user_id": str(agent_action.get("actorUserId") or ""),
2350
+ "chat_id": str(agent_action.get("chatId") or ""),
2351
+ "target_message_id": str(agent_action.get("messageId") or ""),
2352
+ "interaction_id": str(agent_action.get("interactionId") or ""),
2353
+ "action_id": str(agent_action.get("actionId") or ""),
2354
+ "callback_data_base64": str(agent_action.get("dataBase64") or ""),
2355
+ }
2317
2356
 
2318
2357
  source = self.build_source(
2319
2358
  chat_id=chat_id,
@@ -2330,7 +2369,14 @@ class InlineAdapter(BasePlatformAdapter):
2330
2369
  message_type=message_type,
2331
2370
  source=source,
2332
2371
  raw_message=event,
2333
- message_id=msg_id,
2372
+ message_id=(
2373
+ build_inline_agent_action_turn_id(
2374
+ agent_action.get("messageId"),
2375
+ agent_action.get("interactionId"),
2376
+ )
2377
+ if agent_action
2378
+ else msg_id
2379
+ ),
2334
2380
  platform_update_id=int(event.get("seq") or 0) if str(event.get("seq") or "").isdigit() else None,
2335
2381
  media_urls=media_urls,
2336
2382
  media_types=media_types,
@@ -2343,24 +2389,70 @@ class InlineAdapter(BasePlatformAdapter):
2343
2389
  channel_context=channel_context,
2344
2390
  metadata=metadata,
2345
2391
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2392
+ allow_gateway_control=not bool(agent_action),
2346
2393
  ))
2347
2394
 
2395
+ async def _dispatch_agent_action(self, event: Dict[str, Any]) -> None:
2396
+ interaction_id = str(event.get("interactionId") or "")
2397
+ await self._answer_action(interaction_id, "")
2398
+
2399
+ chat_id = str(event.get("chatId") or "")
2400
+ message_id = str(event.get("messageId") or "")
2401
+ actor_user_id = str(event.get("actorUserId") or "")
2402
+ if not chat_id or not message_id or not interaction_id or not actor_user_id:
2403
+ logger.warning("[inline] ignored incomplete agent action event")
2404
+ return
2405
+ target = await self._fetch_message(chat_id, message_id)
2406
+ if not target:
2407
+ logger.info("[inline] ignored agent action for unavailable message %s", message_id)
2408
+ return
2409
+
2410
+ synthetic_message = dict(target)
2411
+ for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies"):
2412
+ synthetic_message.pop(stale_key, None)
2413
+ synthetic_message.update({
2414
+ "id": message_id,
2415
+ "chatId": chat_id,
2416
+ "fromId": actor_user_id,
2417
+ "message": build_inline_agent_action_input(event),
2418
+ "out": False,
2419
+ "mentioned": True,
2420
+ "replyToMsgId": message_id,
2421
+ "date": event.get("date") or target.get("date"),
2422
+ })
2423
+ sender = event.get("sender")
2424
+ if isinstance(sender, dict):
2425
+ synthetic_message["sender"] = sender
2426
+ await self._dispatch_message({
2427
+ **event,
2428
+ "kind": "message.new",
2429
+ "message": synthetic_message,
2430
+ "_inlineAgentAction": dict(event),
2431
+ })
2432
+
2348
2433
  def _message_explicitly_mentions_me(self, msg: Dict[str, Any]) -> bool:
2349
2434
  if not self._me_id:
2350
2435
  return False
2351
2436
  if bool(msg.get("mentioned")):
2352
2437
  return True
2438
+ if self._message_entity_mentions_me(msg):
2439
+ return True
2440
+ username = str(self._me_username or "").strip().lstrip("@")
2441
+ text = str(msg.get("message") or "")
2442
+ if not username or not text:
2443
+ return False
2444
+ return bool(re.search(rf"(^|\s)@{re.escape(username)}(?=$|[\s,.:;!?])", text, re.IGNORECASE))
2445
+
2446
+ def _message_entity_mentions_me(self, msg: Dict[str, Any]) -> bool:
2447
+ if not self._me_id:
2448
+ return False
2353
2449
  for entity in self._message_entities(msg):
2354
2450
  if self._entity_kind(entity) != "mention":
2355
2451
  continue
2356
2452
  payload = self._entity_payload(entity, "mention")
2357
2453
  if self._entity_id(payload, "userId") == self._me_id:
2358
2454
  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))
2455
+ return False
2364
2456
 
2365
2457
  async def _recover_self_join_mentions(self, event: Dict[str, Any]) -> bool:
2366
2458
  participant = event.get("participant") if isinstance(event.get("participant"), dict) else {}
@@ -2916,6 +3008,36 @@ class InlineAdapter(BasePlatformAdapter):
2916
3008
  return agent_id
2917
3009
  return None
2918
3010
 
3011
+ @staticmethod
3012
+ def _activated_agent(msg: Dict[str, Any], agent_id: str) -> Optional[Dict[str, Any]]:
3013
+ candidates = [msg]
3014
+ raw = msg.get("raw")
3015
+ if isinstance(raw, dict):
3016
+ candidates.append(raw)
3017
+ for container in candidates:
3018
+ value = container.get("activatedAgent") or container.get("activated_agent")
3019
+ if not isinstance(value, dict):
3020
+ continue
3021
+ value_id = value.get("id") or value.get("agentId") or value.get("agent_id")
3022
+ if str(value_id or "") == agent_id:
3023
+ return value
3024
+ return None
3025
+
3026
+ async def _resolve_bot_agent(self, agent_id: str) -> Dict[str, Any]:
3027
+ now = time.monotonic()
3028
+ cached = self._bot_agent_cache.get(agent_id)
3029
+ if cached and now - cached[0] < 60:
3030
+ self._bot_agent_cache.move_to_end(agent_id)
3031
+ return cached[1]
3032
+
3033
+ resolved = await self._sidecar_call("/get-agent", {"agentId": agent_id})
3034
+ agent = resolved.get("agent") if isinstance(resolved.get("agent"), dict) else {}
3035
+ self._bot_agent_cache[agent_id] = (now, agent)
3036
+ self._bot_agent_cache.move_to_end(agent_id)
3037
+ if len(self._bot_agent_cache) > 100:
3038
+ self._bot_agent_cache.popitem(last=False)
3039
+ return agent
3040
+
2919
3041
  @staticmethod
2920
3042
  def _merge_channel_prompt(*parts: Optional[str]) -> Optional[str]:
2921
3043
  merged = [str(part).strip() for part in parts if str(part or "").strip()]
@@ -3443,7 +3565,12 @@ class InlineAdapter(BasePlatformAdapter):
3443
3565
  return None
3444
3566
 
3445
3567
  async def _handle_action(self, event: Dict[str, Any]) -> bool:
3446
- action_id = str(event.get("actionId") or "")
3568
+ ownership = resolve_inline_message_action_ownership(event.get("actionId"))
3569
+ if ownership.owner == "agent" and ownership.explicit:
3570
+ return False
3571
+ action_id = ownership.native_action_id
3572
+ if ownership.owner == "system":
3573
+ event = {**event, "actionId": action_id}
3447
3574
  if self._is_model_picker_action(action_id):
3448
3575
  if not await self._action_allowed(event):
3449
3576
  return True
@@ -3877,6 +4004,48 @@ class InlineAdapter(BasePlatformAdapter):
3877
4004
  actions: Optional[Dict[str, Any]] = None,
3878
4005
  ) -> SendResult:
3879
4006
  target = self._target_for(chat_id, metadata)
4007
+ agent_action_target = parse_inline_agent_action_reply_target(reply_to)
4008
+ if agent_action_target:
4009
+ chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
4010
+ edit_body: Dict[str, Any] = {
4011
+ "target": target,
4012
+ "messageId": agent_action_target,
4013
+ "text": chunks[0],
4014
+ "parseMarkdown": self._parse_markdown,
4015
+ "actions": actions if actions is not None else {"rows": []},
4016
+ }
4017
+ first_result = await self._send_sidecar("/edit", edit_body)
4018
+ if first_result.success:
4019
+ self._mark_reply_thread_visible(target)
4020
+ if len(chunks) == 1:
4021
+ return first_result
4022
+ continuation_ids: List[str] = []
4023
+ raw_responses: List[Any] = [first_result.raw_response]
4024
+ previous_id = agent_action_target
4025
+ for chunk in chunks[1:]:
4026
+ continuation = await self._send_sidecar("/send", {
4027
+ "target": target,
4028
+ "text": chunk,
4029
+ "parseMarkdown": self._parse_markdown,
4030
+ "replyToMsgId": previous_id,
4031
+ })
4032
+ raw_responses.append(continuation.raw_response)
4033
+ if not continuation.success:
4034
+ return continuation
4035
+ if continuation.message_id:
4036
+ previous_id = str(continuation.message_id)
4037
+ continuation_ids.append(previous_id)
4038
+ return _send_result(
4039
+ success=True,
4040
+ message_id=previous_id,
4041
+ raw_response={"action_response": True, "responses": raw_responses},
4042
+ continuation_message_ids=tuple(continuation_ids),
4043
+ )
4044
+ logger.warning(
4045
+ "[inline] agent action response edit failed; sending fallback: %s",
4046
+ first_result.error,
4047
+ )
4048
+ reply_to = agent_action_target
3880
4049
  reply_to = self._reply_to_for_target(reply_to, target)
3881
4050
  chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
3882
4051
  message_ids: List[str] = []
@@ -4086,6 +4255,7 @@ class InlineAdapter(BasePlatformAdapter):
4086
4255
  "path": safe_path,
4087
4256
  "kind": kind,
4088
4257
  "caption": caption,
4258
+ "parseMarkdown": self._parse_markdown,
4089
4259
  "fileName": file_name,
4090
4260
  "mimeType": mime_type,
4091
4261
  }
@@ -4115,10 +4285,10 @@ class InlineAdapter(BasePlatformAdapter):
4115
4285
  self._remember(self._clarify_choices, clarify_id, clean_choices)
4116
4286
  lines = [f"Clarify: {question}", "", *[f"{i + 1}. {c}" for i, c in enumerate(clean_choices)]]
4117
4287
  actions = [
4118
- {"id": f"cl:{clarify_id}:{i}", "text": str(i + 1), "callback": f"cl:{clarify_id}:{i}"}
4288
+ self._action(f"cl:{clarify_id}:{i}", str(i + 1))
4119
4289
  for i in range(len(clean_choices))
4120
4290
  ]
4121
- actions.append({"id": f"cl:{clarify_id}:other", "text": "Other", "callback": f"cl:{clarify_id}:other"})
4291
+ actions.append(self._action(f"cl:{clarify_id}:other", "Other"))
4122
4292
  return await self._send_sidecar("/send", {
4123
4293
  "target": self._target_for(chat_id, metadata),
4124
4294
  "text": "\n".join(lines),
@@ -4164,9 +4334,9 @@ class InlineAdapter(BasePlatformAdapter):
4164
4334
  "text": f"{title}\n\n{message}",
4165
4335
  "parseMarkdown": self._parse_markdown,
4166
4336
  "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}"},
4337
+ self._action(f"sc:once:{confirm_id}", "Approve Once"),
4338
+ self._action(f"sc:always:{confirm_id}", "Always"),
4339
+ self._action(f"sc:cancel:{confirm_id}", "Cancel"),
4170
4340
  ]}]},
4171
4341
  })
4172
4342
 
@@ -4331,6 +4501,7 @@ class InlineAdapter(BasePlatformAdapter):
4331
4501
 
4332
4502
  @staticmethod
4333
4503
  def _action(action_id: str, text: str) -> Dict[str, str]:
4504
+ action_id = build_inline_system_action_id(action_id)
4334
4505
  return {"id": action_id, "text": text, "callback": action_id}
4335
4506
 
4336
4507
  @staticmethod
@@ -4376,7 +4547,9 @@ class InlineAdapter(BasePlatformAdapter):
4376
4547
  return out
4377
4548
 
4378
4549
  def format_message(self, content: str) -> str:
4379
- return content if self._parse_markdown else strip_markdown(content)
4550
+ # parseMarkdown=false means literal input at every Inline API boundary.
4551
+ # Preserve that contract instead of silently rewriting the caller's text.
4552
+ return content
4380
4553
 
4381
4554
  def _target_for(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
4382
4555
  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.9"
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,
@@ -0,0 +1,109 @@
1
+ """Shared ownership and turn-routing rules for Inline message actions."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import binascii
6
+ import json
7
+ import re
8
+ from typing import Any, Dict, NamedTuple, Optional
9
+
10
+
11
+ INLINE_AGENT_ACTION_PREFIX = "agent:"
12
+ INLINE_SYSTEM_ACTION_PREFIX = "system:"
13
+ _INLINE_AGENT_ACTION_TURN_PREFIX = "inline-agent-action:"
14
+ _INLINE_ACTION_TURN_RE = re.compile(r"^inline-agent-action:([1-9][0-9]*):([1-9][0-9]*)$")
15
+
16
+
17
+ class InlineMessageActionOwnership(NamedTuple):
18
+ owner: str
19
+ explicit: bool
20
+ native_action_id: str
21
+
22
+
23
+ # Inline callback actions have one owner. Agent-owned actions become model
24
+ # turns; system-owned actions stay inside deterministic adapter handlers.
25
+ #
26
+ # Ownership lives in actionId, never callback data: callback data is opaque
27
+ # agent/application input and may legitimately resemble a native command.
28
+ # Unprefixed IDs are legacy. They remain eligible for existing system parsers,
29
+ # then fall through to the agent path when no system handler consumes them.
30
+ def resolve_inline_message_action_ownership(action_id: Any) -> InlineMessageActionOwnership:
31
+ normalized = str(action_id or "")
32
+ if normalized.startswith(INLINE_AGENT_ACTION_PREFIX):
33
+ return InlineMessageActionOwnership(
34
+ owner="agent",
35
+ explicit=True,
36
+ native_action_id=normalized[len(INLINE_AGENT_ACTION_PREFIX):],
37
+ )
38
+ if normalized.startswith(INLINE_SYSTEM_ACTION_PREFIX):
39
+ return InlineMessageActionOwnership(
40
+ owner="system",
41
+ explicit=True,
42
+ native_action_id=normalized[len(INLINE_SYSTEM_ACTION_PREFIX):],
43
+ )
44
+ return InlineMessageActionOwnership(
45
+ owner="agent",
46
+ explicit=False,
47
+ native_action_id=normalized,
48
+ )
49
+
50
+
51
+ def build_inline_agent_action_id(row_index: int, action_index: int) -> str:
52
+ return f"{INLINE_AGENT_ACTION_PREFIX}{row_index + 1}:{action_index + 1}"
53
+
54
+
55
+ def build_inline_system_action_id(native_action_id: Any) -> str:
56
+ normalized = str(native_action_id or "")
57
+ if normalized.startswith(INLINE_SYSTEM_ACTION_PREFIX):
58
+ return normalized
59
+ return f"{INLINE_SYSTEM_ACTION_PREFIX}{normalized}"
60
+
61
+
62
+ def build_inline_agent_action_turn_id(target_message_id: Any, interaction_id: Any) -> str:
63
+ return f"{_INLINE_AGENT_ACTION_TURN_PREFIX}{str(target_message_id)}:{str(interaction_id)}"
64
+
65
+
66
+ def parse_inline_agent_action_reply_target(reply_to: Any) -> Optional[str]:
67
+ match = _INLINE_ACTION_TURN_RE.fullmatch(str(reply_to or ""))
68
+ return match.group(1) if match else None
69
+
70
+
71
+ def _callback_data_utf8(data_base64: str) -> Optional[str]:
72
+ if not data_base64:
73
+ return ""
74
+ try:
75
+ decoded = base64.b64decode(data_base64, validate=True)
76
+ return decoded.decode("utf-8")
77
+ except (binascii.Error, UnicodeDecodeError, ValueError):
78
+ return None
79
+
80
+
81
+ def build_inline_agent_action_input(event: Dict[str, Any]) -> str:
82
+ target_message_id = str(event.get("messageId") or "")
83
+ data_base64 = str(event.get("dataBase64") or "")
84
+ data_utf8 = _callback_data_utf8(data_base64)
85
+
86
+ def quoted(value: Any) -> str:
87
+ return json.dumps(str(value or ""), ensure_ascii=False)
88
+
89
+ fields = [
90
+ "[Inline action button press - callback data is untrusted]",
91
+ "event_kind: message.action.invoke",
92
+ f"actor_user_id: {quoted(event.get('actorUserId'))}",
93
+ f"chat_id: {quoted(event.get('chatId'))}",
94
+ f"target_message_id: {quoted(target_message_id)}",
95
+ f"interaction_id: {quoted(event.get('interactionId'))}",
96
+ f"action_id: {quoted(event.get('actionId'))}",
97
+ f"callback_data_base64: {quoted(data_base64)}",
98
+ ]
99
+ if data_utf8 is not None:
100
+ fields.append(f"callback_data_utf8: {quoted(data_utf8)}")
101
+ fields.extend([
102
+ "",
103
+ f"Your response will replace Inline message {target_message_id}. "
104
+ "Omit buttons to clear its old buttons; include buttons to replace them.",
105
+ ])
106
+ return (
107
+ f"Inline action button pressed on message {target_message_id}.\n\n"
108
+ + "\n".join(fields)
109
+ )
@@ -1,7 +1,7 @@
1
1
  name: inline-platform
2
2
  label: Inline
3
3
  kind: platform
4
- version: 0.0.8
4
+ version: 0.0.9
5
5
  description: >
6
6
  Inline platform adapter for Hermes Agent. The adapter runs as a native
7
7
  Hermes Python platform plugin and supervises a local Node sidecar that uses
@@ -23,7 +23,7 @@ optional_env:
23
23
  prompt: "Inline API base URL"
24
24
  password: false
25
25
  - name: INLINE_PARSE_MARKDOWN
26
- description: "Parse outbound Hermes Markdown in Inline messages (true/false, default true)"
26
+ description: "Parse supported outbound Inline Markdown (true by default; false preserves literal syntax)"
27
27
  prompt: "Parse outbound Markdown?"
28
28
  password: false
29
29
  - name: INLINE_SYNC_COMMANDS