@inline-chat/hermes-agent-adapter 0.0.8-alpha.0 → 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.
- package/README.md +12 -6
- package/dist/install.js +88 -42
- package/package.json +3 -2
- package/plugin/inline/adapter.py +194 -21
- package/plugin/inline/cli.py +88 -27
- package/plugin/inline/message_actions.py +109 -0
- package/plugin/inline/plugin.yaml +2 -2
- package/plugin/inline/sidecar/index.mjs +1495 -54
- package/plugin/inline/tools.py +162 -12
package/plugin/inline/adapter.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
2248
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
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
|
-
|
|
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")
|
package/plugin/inline/cli.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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(
|
|
498
|
-
except (json.JSONDecodeError, TypeError):
|
|
499
|
-
return {"ok": False, "error": "Inline credential probe returned unreadable output."}
|
|
500
|
-
|
|
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
|
|
504
|
-
username = str(
|
|
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.
|
|
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
|
|
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
|