@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.
- package/README.md +13 -7
- package/dist/install.js +92 -42
- package/package.json +5 -4
- package/plugin/inline/adapter.py +203 -22
- 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 +1390 -34
- 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
|
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
2248
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
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
|
-
|
|
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")
|
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.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
|
-
|
|
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,
|