@inline-chat/hermes-agent-adapter 0.0.5 → 0.0.6
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 +36 -12
- package/dist/install.js +108 -7
- package/package.json +4 -4
- package/plugin/inline/adapter.py +657 -41
- package/plugin/inline/plugin.yaml +2 -2
- package/plugin/inline/sidecar/index.mjs +9457 -3279
- package/plugin/inline/tools.py +58 -5
package/plugin/inline/adapter.py
CHANGED
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import base64
|
|
11
|
+
import hashlib
|
|
11
12
|
import json
|
|
12
13
|
import logging
|
|
13
14
|
import math
|
|
@@ -15,6 +16,7 @@ import mimetypes
|
|
|
15
16
|
import os
|
|
16
17
|
import re
|
|
17
18
|
import secrets
|
|
19
|
+
import shlex
|
|
18
20
|
import shutil
|
|
19
21
|
import signal
|
|
20
22
|
import socket
|
|
@@ -56,6 +58,7 @@ _DEDUP_MAX_SIZE = 5000
|
|
|
56
58
|
_DEDUP_WINDOW_SECONDS = 48 * 3600
|
|
57
59
|
_CHAT_INFO_CACHE_SECONDS = 10 * 60
|
|
58
60
|
_CHAT_INFO_CACHE_MAX_SIZE = 512
|
|
61
|
+
_BOT_SETTINGS_MODEL_CATALOG_CACHE_SECONDS = 60
|
|
59
62
|
_DEFAULT_CONTEXT_BACKFILL = "selective"
|
|
60
63
|
_CONTEXT_BACKFILL_MODES = {"off", "selective", "always"}
|
|
61
64
|
_DEFAULT_THREAD_CONTEXT_LIMIT = 30
|
|
@@ -115,6 +118,13 @@ _INLINE_REPLY_THREAD_INTENT_RE = re.compile(
|
|
|
115
118
|
re.IGNORECASE,
|
|
116
119
|
)
|
|
117
120
|
_INLINE_SETTINGS_VERSION = 1
|
|
121
|
+
_INLINE_BOT_SETTINGS_VERSION = 1
|
|
122
|
+
_INLINE_BOT_SETTINGS_UNAVAILABLE = 1
|
|
123
|
+
_INLINE_BOT_SETTINGS_INVALID_VALUE = 2
|
|
124
|
+
_INLINE_BOT_SETTINGS_STALE = 3
|
|
125
|
+
_INLINE_BOT_SETTINGS_FAILED = 4
|
|
126
|
+
_INLINE_BOT_SETTINGS_TONE_NEUTRAL = 1
|
|
127
|
+
_INLINE_BOT_SETTINGS_TONE_WARNING = 3
|
|
118
128
|
_INLINE_ENTITY_LIMIT = 12
|
|
119
129
|
_INLINE_ENTITY_TEXT_LIMIT = 120
|
|
120
130
|
|
|
@@ -424,6 +434,26 @@ def _normalize_inline_command_name(raw: str) -> str:
|
|
|
424
434
|
return name.strip("_")
|
|
425
435
|
|
|
426
436
|
|
|
437
|
+
def _normalize_inline_username(raw: Any) -> Optional[str]:
|
|
438
|
+
username = str(raw or "").strip().lstrip("@").lower()
|
|
439
|
+
return username or None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _resolve_inline_targeted_command(text: str, bot_username: Optional[str]) -> tuple[str, bool, bool]:
|
|
443
|
+
match = re.match(
|
|
444
|
+
r"^/([a-z0-9_][a-z0-9_-]*)@([a-z0-9_]+)(\s.*)?$",
|
|
445
|
+
str(text or ""),
|
|
446
|
+
re.IGNORECASE | re.DOTALL,
|
|
447
|
+
)
|
|
448
|
+
if not match:
|
|
449
|
+
return text, False, False
|
|
450
|
+
target_username = _normalize_inline_username(match.group(2))
|
|
451
|
+
own_username = _normalize_inline_username(bot_username)
|
|
452
|
+
if not own_username or target_username != own_username:
|
|
453
|
+
return text, True, False
|
|
454
|
+
return f"/{match.group(1)}{match.group(3) or ''}", True, True
|
|
455
|
+
|
|
456
|
+
|
|
427
457
|
def _normalize_inline_plugin_command_text(text: str) -> str:
|
|
428
458
|
match = re.match(r"^/([a-z0-9_]+)(@[A-Za-z0-9_]+)?(\s.*)?$", str(text or ""), re.IGNORECASE | re.DOTALL)
|
|
429
459
|
if not match or "_" not in match.group(1):
|
|
@@ -816,9 +846,14 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
816
846
|
self._sidecar_supervisor_task: Optional[asyncio.Task] = None
|
|
817
847
|
self._command_sync_task: Optional[asyncio.Task] = None
|
|
818
848
|
self._inbound_task: Optional[asyncio.Task] = None
|
|
849
|
+
self._bot_settings_tasks: set[asyncio.Task] = set()
|
|
850
|
+
self._bot_settings_locks: Dict[str, asyncio.Lock] = {}
|
|
851
|
+
self._bot_settings_lock_users: Dict[str, int] = {}
|
|
852
|
+
self._bot_settings_model_catalog_cache: Optional[tuple[float, List[Dict[str, Any]]]] = None
|
|
819
853
|
self._inbound_running = False
|
|
820
854
|
self._http_client: Optional[httpx.AsyncClient] = None
|
|
821
855
|
self._me_id: Optional[str] = None
|
|
856
|
+
self._me_username: Optional[str] = None
|
|
822
857
|
self._seen_messages: Dict[str, float] = {}
|
|
823
858
|
self._clarify_choices: "OrderedDict[str, List[str]]" = OrderedDict()
|
|
824
859
|
self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
@@ -931,6 +966,479 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
931
966
|
self._reply_thread_overrides[key] = _reply_thread_mode(value, "auto")
|
|
932
967
|
self._save_reply_thread_overrides()
|
|
933
968
|
|
|
969
|
+
@staticmethod
|
|
970
|
+
def _bot_settings_chat_type(info: Dict[str, Any]) -> str:
|
|
971
|
+
peer = info.get("peer")
|
|
972
|
+
if peer is None and isinstance(info.get("chat"), dict):
|
|
973
|
+
peer = info["chat"].get("peerId")
|
|
974
|
+
if isinstance(peer, dict):
|
|
975
|
+
peer_type = peer.get("peer") or peer.get("type") or {}
|
|
976
|
+
if isinstance(peer_type, dict) and peer_type.get("oneofKind") == "user":
|
|
977
|
+
return "dm"
|
|
978
|
+
return "group"
|
|
979
|
+
|
|
980
|
+
def _bot_settings_runner(self) -> Any:
|
|
981
|
+
runner = getattr(self, "gateway_runner", None)
|
|
982
|
+
if runner is not None:
|
|
983
|
+
return runner
|
|
984
|
+
try:
|
|
985
|
+
from gateway.run import _gateway_runner_ref
|
|
986
|
+
return _gateway_runner_ref()
|
|
987
|
+
except Exception:
|
|
988
|
+
return None
|
|
989
|
+
|
|
990
|
+
async def _bot_settings_context(
|
|
991
|
+
self,
|
|
992
|
+
event: Dict[str, Any],
|
|
993
|
+
*,
|
|
994
|
+
model_catalog: Optional[Dict[str, Any]] = None,
|
|
995
|
+
) -> Dict[str, Any]:
|
|
996
|
+
chat_id = self._chat_key(event.get("chatId"))
|
|
997
|
+
actor_id = str(event.get("actorUserId") or "").strip()
|
|
998
|
+
if not chat_id or not actor_id:
|
|
999
|
+
return {"access": "guideOnly", "scope_id": chat_id or "unknown", "reply_threads": "auto"}
|
|
1000
|
+
|
|
1001
|
+
info = await self._get_chat_info(chat_id)
|
|
1002
|
+
if not info:
|
|
1003
|
+
return {
|
|
1004
|
+
"access": "guideOnly",
|
|
1005
|
+
"scope_id": chat_id,
|
|
1006
|
+
"reply_threads": "auto",
|
|
1007
|
+
"unavailable_reason": "chat_metadata",
|
|
1008
|
+
}
|
|
1009
|
+
parent_chat_id = self._chat_info_id(info, "parentChatId")
|
|
1010
|
+
scope_chat_id = parent_chat_id or chat_id
|
|
1011
|
+
is_reply_thread = bool(parent_chat_id)
|
|
1012
|
+
chat_type = self._bot_settings_chat_type(info)
|
|
1013
|
+
can_inspect = self._allowed(chat_type, actor_id) and self._chat_allowed(
|
|
1014
|
+
chat_id,
|
|
1015
|
+
chat_id if is_reply_thread else None,
|
|
1016
|
+
parent_chat_id,
|
|
1017
|
+
)
|
|
1018
|
+
if not can_inspect:
|
|
1019
|
+
return {"access": "guideOnly", "scope_id": scope_chat_id, "reply_threads": "auto"}
|
|
1020
|
+
|
|
1021
|
+
source = self.build_source(
|
|
1022
|
+
chat_id=chat_id,
|
|
1023
|
+
chat_name=self._chat_title_from_info(info) or chat_id,
|
|
1024
|
+
chat_type=chat_type,
|
|
1025
|
+
user_id=actor_id,
|
|
1026
|
+
thread_id=chat_id if is_reply_thread else None,
|
|
1027
|
+
parent_chat_id=parent_chat_id,
|
|
1028
|
+
)
|
|
1029
|
+
runner = self._bot_settings_runner()
|
|
1030
|
+
can_modify = self._actor_authorized(chat_type, actor_id)
|
|
1031
|
+
context: Dict[str, Any] = {
|
|
1032
|
+
"access": "full" if can_modify else "readOnly",
|
|
1033
|
+
"scope_id": scope_chat_id,
|
|
1034
|
+
"chat_id": chat_id,
|
|
1035
|
+
"actor_id": actor_id,
|
|
1036
|
+
"chat_type": chat_type,
|
|
1037
|
+
"is_reply_thread": is_reply_thread,
|
|
1038
|
+
"following": self._chat_follow_mode_following(info),
|
|
1039
|
+
"reply_threads": self._reply_thread_mode_for_chat(scope_chat_id),
|
|
1040
|
+
"can_set_default_model": can_modify,
|
|
1041
|
+
"source": source,
|
|
1042
|
+
"runner": runner,
|
|
1043
|
+
"model_options": [],
|
|
1044
|
+
"model_map": {},
|
|
1045
|
+
"reasoning_options": [],
|
|
1046
|
+
}
|
|
1047
|
+
if runner is None:
|
|
1048
|
+
return context
|
|
1049
|
+
|
|
1050
|
+
try:
|
|
1051
|
+
from gateway.run import _load_gateway_config
|
|
1052
|
+
from hermes_cli.model_switch import list_picker_providers
|
|
1053
|
+
|
|
1054
|
+
cfg = _load_gateway_config() or {}
|
|
1055
|
+
model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
|
|
1056
|
+
if not isinstance(model_cfg, dict):
|
|
1057
|
+
model_cfg = {"default": str(model_cfg or "")}
|
|
1058
|
+
default_model = str(model_cfg.get("default") or model_cfg.get("model") or "").strip()
|
|
1059
|
+
default_provider = str(model_cfg.get("provider") or "openrouter").strip()
|
|
1060
|
+
session_key = runner._session_key_for_source(source)
|
|
1061
|
+
override = (getattr(runner, "_session_model_overrides", {}) or {}).get(session_key) or {}
|
|
1062
|
+
current_model = str(override.get("model") or default_model).strip()
|
|
1063
|
+
current_provider = str(override.get("provider") or default_provider).strip()
|
|
1064
|
+
providers = None
|
|
1065
|
+
if model_catalog is None:
|
|
1066
|
+
cached_catalog = self._bot_settings_model_catalog_cache
|
|
1067
|
+
if cached_catalog and time.monotonic() - cached_catalog[0] < _BOT_SETTINGS_MODEL_CATALOG_CACHE_SECONDS:
|
|
1068
|
+
providers = cached_catalog[1]
|
|
1069
|
+
logger.info("[inline] AGENT_SETTINGS_TRACE phase=model_catalog cache=hit")
|
|
1070
|
+
else:
|
|
1071
|
+
catalog_started = time.monotonic()
|
|
1072
|
+
providers = await asyncio.wait_for(
|
|
1073
|
+
asyncio.to_thread(
|
|
1074
|
+
list_picker_providers,
|
|
1075
|
+
current_provider=current_provider,
|
|
1076
|
+
current_base_url=str(override.get("base_url") or model_cfg.get("base_url") or ""),
|
|
1077
|
+
current_model=current_model,
|
|
1078
|
+
user_providers=cfg.get("providers") if isinstance(cfg, dict) else None,
|
|
1079
|
+
custom_providers=cfg.get("custom_providers") if isinstance(cfg, dict) else None,
|
|
1080
|
+
max_models=100,
|
|
1081
|
+
include_moa=True,
|
|
1082
|
+
excluded_providers=(cfg.get("model_catalog") or {}).get("excluded_providers")
|
|
1083
|
+
if isinstance(cfg.get("model_catalog"), dict) else None,
|
|
1084
|
+
),
|
|
1085
|
+
timeout=5.0,
|
|
1086
|
+
)
|
|
1087
|
+
self._bot_settings_model_catalog_cache = (time.monotonic(), providers)
|
|
1088
|
+
logger.info(
|
|
1089
|
+
"[inline] AGENT_SETTINGS_TRACE phase=model_catalog cache=miss elapsed_ms=%d",
|
|
1090
|
+
int((time.monotonic() - catalog_started) * 1000),
|
|
1091
|
+
)
|
|
1092
|
+
model_options: List[Dict[str, str]] = []
|
|
1093
|
+
model_map: Dict[str, tuple[str, str]] = {}
|
|
1094
|
+
|
|
1095
|
+
def add_model(provider: str, model: str) -> Optional[str]:
|
|
1096
|
+
provider = str(provider or "").strip()
|
|
1097
|
+
model = str(model or "").strip()
|
|
1098
|
+
if not provider or not model:
|
|
1099
|
+
return None
|
|
1100
|
+
value = f"{provider}\t{model}"
|
|
1101
|
+
if value not in model_map and len(model_options) < 100:
|
|
1102
|
+
label = model if model.startswith(f"{provider}/") else f"{provider}/{model}"
|
|
1103
|
+
model_map[value] = (provider, model)
|
|
1104
|
+
model_options.append({"value": value, "label": label})
|
|
1105
|
+
return value
|
|
1106
|
+
|
|
1107
|
+
current_value = add_model(current_provider, current_model)
|
|
1108
|
+
if providers is not None:
|
|
1109
|
+
for provider in providers:
|
|
1110
|
+
provider_slug = str(provider.get("slug") or "").strip()
|
|
1111
|
+
if not provider_slug:
|
|
1112
|
+
continue
|
|
1113
|
+
for model in provider.get("models") or []:
|
|
1114
|
+
add_model(provider_slug, str(model).strip())
|
|
1115
|
+
else:
|
|
1116
|
+
previous_map = model_catalog.get("model_map") if isinstance(model_catalog, dict) else None
|
|
1117
|
+
previous_options = model_catalog.get("model_options") if isinstance(model_catalog, dict) else None
|
|
1118
|
+
if isinstance(previous_map, dict) and isinstance(previous_options, list):
|
|
1119
|
+
for option in previous_options:
|
|
1120
|
+
if not isinstance(option, dict):
|
|
1121
|
+
continue
|
|
1122
|
+
selected = previous_map.get(option.get("value"))
|
|
1123
|
+
if isinstance(selected, (list, tuple)) and len(selected) == 2:
|
|
1124
|
+
add_model(str(selected[0]), str(selected[1]))
|
|
1125
|
+
default_value = add_model(default_provider, default_model)
|
|
1126
|
+
context.update({"session_key": session_key})
|
|
1127
|
+
if current_value:
|
|
1128
|
+
context.update({
|
|
1129
|
+
"current_model": current_value,
|
|
1130
|
+
"default_model": default_value,
|
|
1131
|
+
"model_options": model_options,
|
|
1132
|
+
"model_map": model_map,
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
reasoning = runner._resolve_session_reasoning_config(
|
|
1136
|
+
source=source,
|
|
1137
|
+
session_key=session_key,
|
|
1138
|
+
model=current_model,
|
|
1139
|
+
)
|
|
1140
|
+
if reasoning is None:
|
|
1141
|
+
current_reasoning = "medium"
|
|
1142
|
+
elif reasoning.get("enabled") is False:
|
|
1143
|
+
current_reasoning = "none"
|
|
1144
|
+
else:
|
|
1145
|
+
current_reasoning = str(reasoning.get("effort") or "medium")
|
|
1146
|
+
from hermes_constants import VALID_REASONING_EFFORTS
|
|
1147
|
+
reasoning_values = ["none", *[str(value) for value in VALID_REASONING_EFFORTS]]
|
|
1148
|
+
if current_reasoning not in reasoning_values:
|
|
1149
|
+
reasoning_values.insert(0, current_reasoning)
|
|
1150
|
+
context.update({
|
|
1151
|
+
"reasoning": current_reasoning,
|
|
1152
|
+
"reasoning_options": [
|
|
1153
|
+
{"value": value, "label": value.capitalize()} for value in dict.fromkeys(reasoning_values)
|
|
1154
|
+
],
|
|
1155
|
+
})
|
|
1156
|
+
except Exception as exc:
|
|
1157
|
+
logger.debug("[inline] Hermes runtime settings unavailable: %s", exc)
|
|
1158
|
+
return context
|
|
1159
|
+
|
|
1160
|
+
@staticmethod
|
|
1161
|
+
def _bot_settings_revision(context: Dict[str, Any]) -> str:
|
|
1162
|
+
visible = {
|
|
1163
|
+
key: value for key, value in context.items()
|
|
1164
|
+
if key not in {"runner", "source", "model_map"}
|
|
1165
|
+
}
|
|
1166
|
+
encoded = json.dumps(visible, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
|
1167
|
+
return f"hermes-v1-{hashlib.sha256(encoded).hexdigest()[:12]}"
|
|
1168
|
+
|
|
1169
|
+
def _bot_settings_document(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
1170
|
+
if context.get("access") == "guideOnly":
|
|
1171
|
+
unavailable_text = (
|
|
1172
|
+
"Hermes could not verify this chat. Try again when the bot is reachable."
|
|
1173
|
+
if context.get("unavailable_reason") == "chat_metadata"
|
|
1174
|
+
else "This chat is not allowed by Hermes' access policy."
|
|
1175
|
+
)
|
|
1176
|
+
return {
|
|
1177
|
+
"version": _INLINE_BOT_SETTINGS_VERSION,
|
|
1178
|
+
"revision": self._bot_settings_revision(context),
|
|
1179
|
+
"sections": [{
|
|
1180
|
+
"id": "access",
|
|
1181
|
+
"items": [{
|
|
1182
|
+
"id": "access-guide",
|
|
1183
|
+
"label": "Hermes unavailable",
|
|
1184
|
+
"disabled": False,
|
|
1185
|
+
"control": {"oneofKind": "info", "info": {
|
|
1186
|
+
"text": unavailable_text,
|
|
1187
|
+
"tone": _INLINE_BOT_SETTINGS_TONE_WARNING,
|
|
1188
|
+
}},
|
|
1189
|
+
}],
|
|
1190
|
+
}],
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
disabled = context.get("access") != "full"
|
|
1194
|
+
disabled_reason = "Only an authorized Hermes controller can change this."
|
|
1195
|
+
common = {"disabled": disabled, **({"disabledReason": disabled_reason} if disabled else {})}
|
|
1196
|
+
runtime_items: List[Dict[str, Any]] = []
|
|
1197
|
+
model_options = context.get("model_options") or []
|
|
1198
|
+
current_model = context.get("current_model")
|
|
1199
|
+
if current_model and model_options:
|
|
1200
|
+
runtime_items.append({
|
|
1201
|
+
"id": "model", "label": "Model", "description": "This session.", **common,
|
|
1202
|
+
"control": {"oneofKind": "select", "select": {
|
|
1203
|
+
"value": current_model,
|
|
1204
|
+
"options": [{**option, "disabled": False} for option in model_options],
|
|
1205
|
+
}},
|
|
1206
|
+
})
|
|
1207
|
+
if context.get("can_set_default_model") and current_model != context.get("default_model"):
|
|
1208
|
+
runtime_items.append({
|
|
1209
|
+
"id": "model-default", "label": "Use as default", "description": "For new sessions.", **common,
|
|
1210
|
+
"control": {"oneofKind": "button", "button": {}},
|
|
1211
|
+
})
|
|
1212
|
+
reasoning_options = context.get("reasoning_options") or []
|
|
1213
|
+
if context.get("reasoning") and reasoning_options:
|
|
1214
|
+
runtime_items.append({
|
|
1215
|
+
"id": "reasoning", "label": "Reasoning", **common,
|
|
1216
|
+
"control": {"oneofKind": "select", "select": {
|
|
1217
|
+
"value": context["reasoning"],
|
|
1218
|
+
"options": [{**option, "disabled": False} for option in reasoning_options],
|
|
1219
|
+
}},
|
|
1220
|
+
})
|
|
1221
|
+
if not runtime_items:
|
|
1222
|
+
runtime_items.append({
|
|
1223
|
+
"id": "runtime-unavailable", "label": "Runtime", "disabled": False,
|
|
1224
|
+
"control": {"oneofKind": "info", "info": {
|
|
1225
|
+
"text": "Runtime options are not available for this session.",
|
|
1226
|
+
"tone": _INLINE_BOT_SETTINGS_TONE_NEUTRAL,
|
|
1227
|
+
}},
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
sections: List[Dict[str, Any]] = [
|
|
1231
|
+
{"id": "runtime", "items": runtime_items},
|
|
1232
|
+
{"id": "attention", "items": [{
|
|
1233
|
+
"id": "following", "label": "Following", "description": "Wake on eligible activity.", **common,
|
|
1234
|
+
"control": {"oneofKind": "toggle", "toggle": {"value": bool(context.get("following"))}},
|
|
1235
|
+
}]},
|
|
1236
|
+
{"id": "replies", "items": [{
|
|
1237
|
+
"id": "reply-threads", "label": "Reply in threads", **common,
|
|
1238
|
+
"control": {"oneofKind": "select", "select": {
|
|
1239
|
+
"value": context.get("reply_threads") or "auto",
|
|
1240
|
+
"options": [
|
|
1241
|
+
{"value": "auto", "label": "Auto", "description": "Agent decides.", "disabled": False},
|
|
1242
|
+
{"value": "on", "label": "On", "description": "Always use threads.", "disabled": False},
|
|
1243
|
+
{"value": "off", "label": "Off", "description": "Stay in chat.", "disabled": False},
|
|
1244
|
+
],
|
|
1245
|
+
}},
|
|
1246
|
+
}]},
|
|
1247
|
+
]
|
|
1248
|
+
if disabled:
|
|
1249
|
+
sections.append({"id": "access", "items": [{
|
|
1250
|
+
"id": "read-only", "label": "Read-only", "disabled": False,
|
|
1251
|
+
"control": {"oneofKind": "info", "info": {
|
|
1252
|
+
"text": "Access and reply mode inherit from the parent chat. Model, reasoning, and following stay with this thread."
|
|
1253
|
+
if context.get("is_reply_thread") else "A Hermes owner controls who can make changes.",
|
|
1254
|
+
"tone": _INLINE_BOT_SETTINGS_TONE_WARNING,
|
|
1255
|
+
}},
|
|
1256
|
+
}]})
|
|
1257
|
+
return {
|
|
1258
|
+
"version": _INLINE_BOT_SETTINGS_VERSION,
|
|
1259
|
+
"revision": self._bot_settings_revision(context),
|
|
1260
|
+
"sections": sections,
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
@staticmethod
|
|
1264
|
+
def _bot_settings_problem(code: int, message: str, document: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
1265
|
+
problem = {"code": code, "message": message}
|
|
1266
|
+
if document is not None:
|
|
1267
|
+
problem["currentDocument"] = document
|
|
1268
|
+
return {"result": {"oneofKind": "problem", "problem": problem}}
|
|
1269
|
+
|
|
1270
|
+
@staticmethod
|
|
1271
|
+
def _bot_settings_result_kind(response: Dict[str, Any]) -> str:
|
|
1272
|
+
result = response.get("result") if isinstance(response, dict) else None
|
|
1273
|
+
if not isinstance(result, dict):
|
|
1274
|
+
return "invalid"
|
|
1275
|
+
kind = str(result.get("oneofKind") or "invalid")
|
|
1276
|
+
if kind == "problem" and isinstance(result.get("problem"), dict):
|
|
1277
|
+
return f"problem:{result['problem'].get('code', 'unknown')}"
|
|
1278
|
+
return kind
|
|
1279
|
+
|
|
1280
|
+
async def _answer_bot_settings(self, request_id: str, response: Dict[str, Any]) -> bool:
|
|
1281
|
+
try:
|
|
1282
|
+
await self._sidecar_call("/answer-bot-settings", {
|
|
1283
|
+
"requestId": request_id,
|
|
1284
|
+
"response": response,
|
|
1285
|
+
})
|
|
1286
|
+
return True
|
|
1287
|
+
except asyncio.CancelledError:
|
|
1288
|
+
raise
|
|
1289
|
+
except Exception as exc:
|
|
1290
|
+
logger.warning("[inline] agent settings answer expired: %s", exc)
|
|
1291
|
+
return False
|
|
1292
|
+
|
|
1293
|
+
async def _handle_bot_settings_request(self, event: Dict[str, Any]) -> None:
|
|
1294
|
+
request_id = str(event.get("requestId") or "").strip()
|
|
1295
|
+
if not request_id:
|
|
1296
|
+
return
|
|
1297
|
+
started_at = time.monotonic()
|
|
1298
|
+
logger.info(
|
|
1299
|
+
"[inline] AGENT_SETTINGS_TRACE request=%s phase=request_start chat=%s",
|
|
1300
|
+
request_id,
|
|
1301
|
+
self._chat_key(event.get("chatId")),
|
|
1302
|
+
)
|
|
1303
|
+
try:
|
|
1304
|
+
if int(event.get("version") or 0) != _INLINE_BOT_SETTINGS_VERSION:
|
|
1305
|
+
response = self._bot_settings_problem(
|
|
1306
|
+
_INLINE_BOT_SETTINGS_UNAVAILABLE,
|
|
1307
|
+
"This Hermes version supports agent settings V1 only.",
|
|
1308
|
+
)
|
|
1309
|
+
else:
|
|
1310
|
+
context = await self._bot_settings_context(event)
|
|
1311
|
+
response = {"result": {"oneofKind": "document", "document": self._bot_settings_document(context)}}
|
|
1312
|
+
except Exception as exc:
|
|
1313
|
+
logger.warning("[inline] failed to load Hermes agent settings: %s", exc)
|
|
1314
|
+
response = self._bot_settings_problem(_INLINE_BOT_SETTINGS_FAILED, "Hermes could not load settings.")
|
|
1315
|
+
answered = await self._answer_bot_settings(request_id, response)
|
|
1316
|
+
logger.info(
|
|
1317
|
+
"[inline] AGENT_SETTINGS_TRACE request=%s phase=request_answer result=%s answered=%d elapsed_ms=%d",
|
|
1318
|
+
request_id,
|
|
1319
|
+
self._bot_settings_result_kind(response),
|
|
1320
|
+
1 if answered else 0,
|
|
1321
|
+
int((time.monotonic() - started_at) * 1000),
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
async def _apply_bot_setting(self, event: Dict[str, Any], context: Dict[str, Any]) -> None:
|
|
1325
|
+
item_id = str(event.get("itemId") or "")
|
|
1326
|
+
value = event.get("value") if isinstance(event.get("value"), dict) else {}
|
|
1327
|
+
value_body = value.get("value") if isinstance(value.get("value"), dict) else {}
|
|
1328
|
+
runner = context.get("runner")
|
|
1329
|
+
source = context.get("source")
|
|
1330
|
+
if item_id == "following" and value_body.get("oneofKind") == "boolValue":
|
|
1331
|
+
enabled = bool(value_body.get("boolValue"))
|
|
1332
|
+
target = (
|
|
1333
|
+
{"userId": context["actor_id"]}
|
|
1334
|
+
if context.get("chat_type") == "dm"
|
|
1335
|
+
else {"chatId": context["chat_id"]}
|
|
1336
|
+
)
|
|
1337
|
+
await self._sidecar_call("/follow-mode", {
|
|
1338
|
+
"target": target,
|
|
1339
|
+
"mode": "following" if enabled else "unfollowed",
|
|
1340
|
+
})
|
|
1341
|
+
self._invalidate_chat_info(context["chat_id"])
|
|
1342
|
+
return
|
|
1343
|
+
if item_id == "reply-threads" and value_body.get("oneofKind") == "stringValue":
|
|
1344
|
+
mode = str(value_body.get("stringValue") or "")
|
|
1345
|
+
if mode not in {"auto", "on", "off"}:
|
|
1346
|
+
raise ValueError("invalid reply mode")
|
|
1347
|
+
self._set_reply_threads_for_chat(context["scope_id"], mode)
|
|
1348
|
+
return
|
|
1349
|
+
if runner is None or source is None:
|
|
1350
|
+
raise ValueError("runtime unavailable")
|
|
1351
|
+
if item_id in {"model", "model-default"}:
|
|
1352
|
+
if item_id == "model-default" and not context.get("can_set_default_model"):
|
|
1353
|
+
raise ValueError("default model unavailable")
|
|
1354
|
+
selection = context.get("current_model") if item_id == "model-default" else value_body.get("stringValue")
|
|
1355
|
+
selected = (context.get("model_map") or {}).get(selection)
|
|
1356
|
+
if not selected:
|
|
1357
|
+
raise ValueError("model unavailable")
|
|
1358
|
+
provider, model = selected
|
|
1359
|
+
command = (
|
|
1360
|
+
f"/model {shlex.quote(model)} --provider {shlex.quote(provider)} "
|
|
1361
|
+
f"{'--global' if item_id == 'model-default' else '--session'}"
|
|
1362
|
+
)
|
|
1363
|
+
result = await runner._handle_model_command(MessageEvent(
|
|
1364
|
+
text=command,
|
|
1365
|
+
message_type=MessageType.TEXT,
|
|
1366
|
+
source=source,
|
|
1367
|
+
raw_message=event,
|
|
1368
|
+
))
|
|
1369
|
+
if result and re.search(r"(?:error|confirm|failed|could not)", str(result), re.IGNORECASE):
|
|
1370
|
+
raise RuntimeError(str(result))
|
|
1371
|
+
return
|
|
1372
|
+
if item_id == "reasoning" and value_body.get("oneofKind") == "stringValue":
|
|
1373
|
+
selected = str(value_body.get("stringValue") or "")
|
|
1374
|
+
if selected not in {option.get("value") for option in context.get("reasoning_options") or []}:
|
|
1375
|
+
raise ValueError("reasoning unavailable")
|
|
1376
|
+
runner._apply_reasoning_selection(context["session_key"], "inline", selected)
|
|
1377
|
+
return
|
|
1378
|
+
raise ValueError("setting unavailable")
|
|
1379
|
+
|
|
1380
|
+
async def _handle_bot_settings_item(self, event: Dict[str, Any]) -> None:
|
|
1381
|
+
lock_key = self._chat_key(event.get("chatId")) or "unknown"
|
|
1382
|
+
lock = self._bot_settings_locks.setdefault(lock_key, asyncio.Lock())
|
|
1383
|
+
self._bot_settings_lock_users[lock_key] = self._bot_settings_lock_users.get(lock_key, 0) + 1
|
|
1384
|
+
try:
|
|
1385
|
+
async with lock:
|
|
1386
|
+
await self._handle_bot_settings_item_serialized(event)
|
|
1387
|
+
finally:
|
|
1388
|
+
remaining = self._bot_settings_lock_users.get(lock_key, 1) - 1
|
|
1389
|
+
if remaining <= 0:
|
|
1390
|
+
self._bot_settings_lock_users.pop(lock_key, None)
|
|
1391
|
+
if self._bot_settings_locks.get(lock_key) is lock:
|
|
1392
|
+
self._bot_settings_locks.pop(lock_key, None)
|
|
1393
|
+
else:
|
|
1394
|
+
self._bot_settings_lock_users[lock_key] = remaining
|
|
1395
|
+
|
|
1396
|
+
async def _handle_bot_settings_item_serialized(self, event: Dict[str, Any]) -> None:
|
|
1397
|
+
request_id = str(event.get("requestId") or "").strip()
|
|
1398
|
+
if not request_id:
|
|
1399
|
+
return
|
|
1400
|
+
started_at = time.monotonic()
|
|
1401
|
+
item_id = str(event.get("itemId") or "")
|
|
1402
|
+
logger.info(
|
|
1403
|
+
"[inline] AGENT_SETTINGS_TRACE request=%s phase=mutation_start chat=%s item=%s",
|
|
1404
|
+
request_id,
|
|
1405
|
+
self._chat_key(event.get("chatId")),
|
|
1406
|
+
item_id,
|
|
1407
|
+
)
|
|
1408
|
+
try:
|
|
1409
|
+
context = await self._bot_settings_context(event)
|
|
1410
|
+
document = self._bot_settings_document(context)
|
|
1411
|
+
if str(event.get("documentRevision") or "") != document["revision"]:
|
|
1412
|
+
response = self._bot_settings_problem(
|
|
1413
|
+
_INLINE_BOT_SETTINGS_STALE,
|
|
1414
|
+
"Settings changed. Try again.",
|
|
1415
|
+
document,
|
|
1416
|
+
)
|
|
1417
|
+
elif context.get("access") != "full":
|
|
1418
|
+
response = self._bot_settings_problem(
|
|
1419
|
+
_INLINE_BOT_SETTINGS_FAILED,
|
|
1420
|
+
"You do not have access to change this.",
|
|
1421
|
+
document,
|
|
1422
|
+
)
|
|
1423
|
+
else:
|
|
1424
|
+
await self._apply_bot_setting(event, context)
|
|
1425
|
+
next_context = await self._bot_settings_context(event, model_catalog=context)
|
|
1426
|
+
response = {"result": {"oneofKind": "document", "document": self._bot_settings_document(next_context)}}
|
|
1427
|
+
except ValueError as exc:
|
|
1428
|
+
response = self._bot_settings_problem(_INLINE_BOT_SETTINGS_INVALID_VALUE, str(exc).capitalize())
|
|
1429
|
+
except Exception as exc:
|
|
1430
|
+
logger.warning("[inline] failed to update Hermes agent settings: %s", exc)
|
|
1431
|
+
response = self._bot_settings_problem(_INLINE_BOT_SETTINGS_FAILED, "Hermes could not update this setting.")
|
|
1432
|
+
answered = await self._answer_bot_settings(request_id, response)
|
|
1433
|
+
logger.info(
|
|
1434
|
+
"[inline] AGENT_SETTINGS_TRACE request=%s phase=mutation_answer item=%s result=%s answered=%d elapsed_ms=%d",
|
|
1435
|
+
request_id,
|
|
1436
|
+
item_id,
|
|
1437
|
+
self._bot_settings_result_kind(response),
|
|
1438
|
+
1 if answered else 0,
|
|
1439
|
+
int((time.monotonic() - started_at) * 1000),
|
|
1440
|
+
)
|
|
1441
|
+
|
|
934
1442
|
@staticmethod
|
|
935
1443
|
def _has_reply_thread_intent(text: str) -> bool:
|
|
936
1444
|
normalized = re.sub(r"\s+", " ", str(text or "").strip())
|
|
@@ -1176,15 +1684,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1176
1684
|
|
|
1177
1685
|
self._chat_info_cache.pop(self._chat_key(chat_id), None)
|
|
1178
1686
|
if command == "follow":
|
|
1179
|
-
body =
|
|
1180
|
-
"Now explicitly following this Inline chat or thread. "
|
|
1181
|
-
"Eligible activity can wake Hermes without an @mention."
|
|
1182
|
-
)
|
|
1687
|
+
body = "Following this chat—eligible messages can wake Hermes without an @mention."
|
|
1183
1688
|
else:
|
|
1184
|
-
body =
|
|
1185
|
-
"Explicitly unfollowed this Inline chat or thread. Automatic follow heuristics "
|
|
1186
|
-
"will not turn following back on; mentions and replies can still wake Hermes."
|
|
1187
|
-
)
|
|
1689
|
+
body = "Unfollowed this chat—mentions and replies can still wake Hermes."
|
|
1188
1690
|
await self.send(chat_id, body, reply_to=msg_id, metadata=metadata)
|
|
1189
1691
|
return True
|
|
1190
1692
|
|
|
@@ -1241,6 +1743,11 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1241
1743
|
except Exception:
|
|
1242
1744
|
pass
|
|
1243
1745
|
self._inbound_task = None
|
|
1746
|
+
for task in list(self._bot_settings_tasks):
|
|
1747
|
+
task.cancel()
|
|
1748
|
+
if self._bot_settings_tasks:
|
|
1749
|
+
await asyncio.gather(*self._bot_settings_tasks, return_exceptions=True)
|
|
1750
|
+
self._bot_settings_tasks.clear()
|
|
1244
1751
|
await self._stop_sidecar()
|
|
1245
1752
|
if self._http_client is not None:
|
|
1246
1753
|
await self._http_client.aclose()
|
|
@@ -1287,6 +1794,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1287
1794
|
result = data.get("result") or {}
|
|
1288
1795
|
if result.get("connected"):
|
|
1289
1796
|
self._me_id = str(result.get("meId") or "") or None
|
|
1797
|
+
self._me_username = _normalize_inline_username(result.get("meUsername"))
|
|
1290
1798
|
return
|
|
1291
1799
|
if result.get("connectError"):
|
|
1292
1800
|
last_error = RuntimeError(str(result.get("connectError")))
|
|
@@ -1465,6 +1973,21 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1465
1973
|
def _is_bot_commands_too_much(error: Exception) -> bool:
|
|
1466
1974
|
return bool(re.search(r"\bBOT_COMMANDS_TOO_MUCH\b", str(error), re.IGNORECASE))
|
|
1467
1975
|
|
|
1976
|
+
def _schedule_bot_settings_task(self, operation: Any) -> None:
|
|
1977
|
+
task = asyncio.get_event_loop().create_task(operation)
|
|
1978
|
+
self._bot_settings_tasks.add(task)
|
|
1979
|
+
|
|
1980
|
+
def finished(completed: asyncio.Task) -> None:
|
|
1981
|
+
self._bot_settings_tasks.discard(completed)
|
|
1982
|
+
if completed.cancelled():
|
|
1983
|
+
return
|
|
1984
|
+
try:
|
|
1985
|
+
completed.result()
|
|
1986
|
+
except Exception as exc:
|
|
1987
|
+
logger.warning("[inline] agent settings task failed: %s", exc)
|
|
1988
|
+
|
|
1989
|
+
task.add_done_callback(finished)
|
|
1990
|
+
|
|
1468
1991
|
async def _inbound_loop(self) -> None:
|
|
1469
1992
|
if self._http_client is None:
|
|
1470
1993
|
return
|
|
@@ -1497,6 +2020,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1497
2020
|
event = json.loads(line)
|
|
1498
2021
|
except json.JSONDecodeError:
|
|
1499
2022
|
return
|
|
2023
|
+
self._me_id = str(event.get("meId") or self._me_id or "") or None
|
|
2024
|
+
self._me_username = _normalize_inline_username(event.get("meUsername") or self._me_username)
|
|
1500
2025
|
# Chat snapshots include mutable dialog and routing fields such as
|
|
1501
2026
|
# followMode and lastMsgId. Invalidating here intentionally makes the
|
|
1502
2027
|
# next group-message dispatch perform an extra GET_CHAT; correctness
|
|
@@ -1504,6 +2029,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1504
2029
|
# owns a coherent client-state cache.
|
|
1505
2030
|
self._invalidate_chat_info(event.get("chatId"))
|
|
1506
2031
|
kind = event.get("kind")
|
|
2032
|
+
if kind == "bot.chatSettings.request":
|
|
2033
|
+
self._schedule_bot_settings_task(self._handle_bot_settings_request(event))
|
|
2034
|
+
return
|
|
2035
|
+
if kind == "bot.chatSettings.item.invoke":
|
|
2036
|
+
self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
|
|
2037
|
+
return
|
|
1507
2038
|
if kind == "message.action.invoke":
|
|
1508
2039
|
if await self._handle_action(event):
|
|
1509
2040
|
return
|
|
@@ -1543,7 +2074,19 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1543
2074
|
chat_id = str(event.get("chatId") or msg.get("chatId") or "")
|
|
1544
2075
|
if not msg_id or not chat_id:
|
|
1545
2076
|
return
|
|
1546
|
-
|
|
2077
|
+
if edit:
|
|
2078
|
+
dedup_key = f"edit:{chat_id}:{msg_id}:{msg.get('rev') or event.get('seq') or ''}"
|
|
2079
|
+
else:
|
|
2080
|
+
event_seq = str(event.get("seq") or "").strip()
|
|
2081
|
+
message_date = str(msg.get("date") or event.get("date") or "").strip()
|
|
2082
|
+
# Update sequence identifies the delivery even when an older server
|
|
2083
|
+
# reuses a deleted message ID. Date keeps sequence-less events safer.
|
|
2084
|
+
if event_seq:
|
|
2085
|
+
dedup_key = f"new:{chat_id}:seq:{event_seq}"
|
|
2086
|
+
elif message_date:
|
|
2087
|
+
dedup_key = f"new:{chat_id}:msg:{msg_id}:date:{message_date}"
|
|
2088
|
+
else:
|
|
2089
|
+
dedup_key = f"new:{chat_id}:msg:{msg_id}"
|
|
1547
2090
|
if self._is_duplicate(dedup_key):
|
|
1548
2091
|
return
|
|
1549
2092
|
from_id = str(msg.get("fromId") or "")
|
|
@@ -1552,7 +2095,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1552
2095
|
sender_profile = _inline_sender_profile(event, msg)
|
|
1553
2096
|
sender_name, sender_first_name, sender_username = _inline_sender_identity(sender_profile)
|
|
1554
2097
|
|
|
1555
|
-
|
|
2098
|
+
raw_message_text = str(msg.get("message") or "")
|
|
2099
|
+
text = raw_message_text.strip()
|
|
1556
2100
|
media_text, media_urls, media_types, message_type = await self._normalize_media(msg)
|
|
1557
2101
|
if media_text:
|
|
1558
2102
|
text = f"{text}\n{media_text}".strip() if text else media_text
|
|
@@ -1581,6 +2125,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1581
2125
|
return
|
|
1582
2126
|
if chat_type == "group" and not self._chat_allowed(chat_id, thread_id, parent_chat_id):
|
|
1583
2127
|
return
|
|
2128
|
+
text, has_command_target, command_addressed_to_me = _resolve_inline_targeted_command(
|
|
2129
|
+
text,
|
|
2130
|
+
self._me_username,
|
|
2131
|
+
)
|
|
2132
|
+
if has_command_target and not command_addressed_to_me:
|
|
2133
|
+
return
|
|
1584
2134
|
if await self._handle_thread_command(
|
|
1585
2135
|
chat_id=chat_id,
|
|
1586
2136
|
msg_id=msg_id,
|
|
@@ -1618,7 +2168,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1618
2168
|
and not self._free_response_chat(chat_id, thread_id, parent_chat_id)
|
|
1619
2169
|
)
|
|
1620
2170
|
if mention_gate_active:
|
|
1621
|
-
mentioned = bool(msg.get("mentioned")) or self._matches_mention(text)
|
|
2171
|
+
mentioned = command_addressed_to_me or bool(msg.get("mentioned")) or self._matches_mention(text)
|
|
1622
2172
|
reply_wakes_thread = reply_to_is_own and not self._strict_mention
|
|
1623
2173
|
# Freshness only controls whether the server auto-follows a thread.
|
|
1624
2174
|
# Once this dialog is FOLLOWING, it remains relevant until unfollowed.
|
|
@@ -1629,6 +2179,16 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1629
2179
|
if not mentioned and not reply_wakes_thread and not follow_mode_wakes_thread:
|
|
1630
2180
|
self._remember_observed_context(chat_id, msg, text)
|
|
1631
2181
|
return
|
|
2182
|
+
# A leading explicit address to another person overwhelmingly means
|
|
2183
|
+
# the turn is for them. It overrides only inferred follow/reply
|
|
2184
|
+
# attention; an explicit address to this bot still wins.
|
|
2185
|
+
if (
|
|
2186
|
+
not mentioned
|
|
2187
|
+
and (reply_wakes_thread or follow_mode_wakes_thread)
|
|
2188
|
+
and self._starts_with_other_user_mention(msg, raw_message_text)
|
|
2189
|
+
):
|
|
2190
|
+
self._remember_observed_context(chat_id, msg, text)
|
|
2191
|
+
return
|
|
1632
2192
|
if mentioned:
|
|
1633
2193
|
text = self._clean_mention(text)
|
|
1634
2194
|
if edit:
|
|
@@ -2133,6 +2693,29 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2133
2693
|
text = str(value).strip()
|
|
2134
2694
|
return text or None
|
|
2135
2695
|
|
|
2696
|
+
def _starts_with_other_user_mention(self, msg: Dict[str, Any], message_text: str) -> bool:
|
|
2697
|
+
if not self._me_id:
|
|
2698
|
+
return False
|
|
2699
|
+
candidates: List[tuple[int, int, Dict[str, Any]]] = []
|
|
2700
|
+
for index, entity in enumerate(self._message_entities(msg)):
|
|
2701
|
+
offset = _to_int(entity.get("offset"))
|
|
2702
|
+
if offset is not None and offset >= 0:
|
|
2703
|
+
candidates.append((offset, index, entity))
|
|
2704
|
+
if not candidates:
|
|
2705
|
+
return False
|
|
2706
|
+
offset, _, first_entity = min(candidates, key=lambda item: (item[0], item[1]))
|
|
2707
|
+
if self._entity_kind(first_entity) != "mention":
|
|
2708
|
+
return False
|
|
2709
|
+
payload = self._entity_payload(first_entity, "mention")
|
|
2710
|
+
mentioned_user_id = self._entity_id(payload, "userId")
|
|
2711
|
+
if not mentioned_user_id or mentioned_user_id == self._me_id:
|
|
2712
|
+
return False
|
|
2713
|
+
try:
|
|
2714
|
+
utf16_prefix = message_text.encode("utf-16-le")[: offset * 2].decode("utf-16-le")
|
|
2715
|
+
except UnicodeDecodeError:
|
|
2716
|
+
return False
|
|
2717
|
+
return not utf16_prefix.strip()
|
|
2718
|
+
|
|
2136
2719
|
def _format_entity_summary(self, entity: Dict[str, Any], message_text: str) -> Optional[str]:
|
|
2137
2720
|
kind = self._entity_kind(entity)
|
|
2138
2721
|
text = self._entity_slice(message_text, entity)
|
|
@@ -2715,8 +3298,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2715
3298
|
|
|
2716
3299
|
async def _handle_action(self, event: Dict[str, Any]) -> bool:
|
|
2717
3300
|
action_id = str(event.get("actionId") or "")
|
|
2718
|
-
chat_id = str(event.get("chatId") or "")
|
|
2719
|
-
interaction_id = str(event.get("interactionId") or "")
|
|
2720
3301
|
if self._is_model_picker_action(action_id):
|
|
2721
3302
|
if not await self._action_allowed(event):
|
|
2722
3303
|
return True
|
|
@@ -2724,15 +3305,15 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2724
3305
|
if action_id.startswith("cl:"):
|
|
2725
3306
|
if not await self._action_allowed(event):
|
|
2726
3307
|
return True
|
|
2727
|
-
return await self._handle_clarify_action(
|
|
3308
|
+
return await self._handle_clarify_action(event)
|
|
2728
3309
|
if action_id.startswith("appr:"):
|
|
2729
3310
|
if not await self._action_allowed(event):
|
|
2730
3311
|
return True
|
|
2731
|
-
return await self._handle_approval_action(
|
|
3312
|
+
return await self._handle_approval_action(event)
|
|
2732
3313
|
if action_id.startswith("sc:"):
|
|
2733
3314
|
if not await self._action_allowed(event):
|
|
2734
3315
|
return True
|
|
2735
|
-
return await self._handle_slash_action(
|
|
3316
|
+
return await self._handle_slash_action(event)
|
|
2736
3317
|
if action_id.startswith(_INLINE_THREADS_ACTION_PREFIX):
|
|
2737
3318
|
return await self._handle_thread_action(event)
|
|
2738
3319
|
return False
|
|
@@ -2778,14 +3359,16 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2778
3359
|
return None
|
|
2779
3360
|
return self._chat_type_from_message(msg)
|
|
2780
3361
|
|
|
2781
|
-
async def _handle_clarify_action(self,
|
|
3362
|
+
async def _handle_clarify_action(self, event: Dict[str, Any]) -> bool:
|
|
3363
|
+
action_id = str(event.get("actionId") or "")
|
|
2782
3364
|
parts = action_id.split(":", 2)
|
|
2783
3365
|
if len(parts) != 3:
|
|
2784
3366
|
return False
|
|
2785
3367
|
_, clarify_id, choice = parts
|
|
2786
3368
|
session_key = self._clarify_sessions.get(clarify_id)
|
|
2787
3369
|
if not session_key:
|
|
2788
|
-
|
|
3370
|
+
await self._finish_action(event, "Prompt expired", "Clarification expired.")
|
|
3371
|
+
return True
|
|
2789
3372
|
if choice == "other":
|
|
2790
3373
|
try:
|
|
2791
3374
|
from tools.clarify_gateway import mark_awaiting_text
|
|
@@ -2793,10 +3376,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2793
3376
|
if not marked:
|
|
2794
3377
|
self._clarify_sessions.pop(clarify_id, None)
|
|
2795
3378
|
self._clarify_choices.pop(clarify_id, None)
|
|
2796
|
-
await self.
|
|
3379
|
+
await self._finish_action(event, "Prompt expired", "Clarification expired.")
|
|
2797
3380
|
return True
|
|
2798
|
-
await self.
|
|
2799
|
-
await self.send(chat_id, "Type your answer:")
|
|
3381
|
+
await self._finish_action(event, "Type your answer", "Type your answer:")
|
|
2800
3382
|
return True
|
|
2801
3383
|
except Exception:
|
|
2802
3384
|
logger.exception("[inline] clarify other failed")
|
|
@@ -2810,59 +3392,76 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2810
3392
|
if resolved:
|
|
2811
3393
|
self._clarify_sessions.pop(clarify_id, None)
|
|
2812
3394
|
self._clarify_choices.pop(clarify_id, None)
|
|
2813
|
-
await self.
|
|
3395
|
+
await self._finish_action(event, "Answer recorded", "Answer recorded.")
|
|
2814
3396
|
else:
|
|
2815
3397
|
self._clarify_sessions.pop(clarify_id, None)
|
|
2816
3398
|
self._clarify_choices.pop(clarify_id, None)
|
|
2817
|
-
await self.
|
|
3399
|
+
await self._finish_action(event, "Prompt expired", "Clarification expired.")
|
|
2818
3400
|
return True
|
|
2819
3401
|
except Exception:
|
|
2820
3402
|
logger.exception("[inline] clarify action failed")
|
|
2821
3403
|
return True
|
|
2822
3404
|
|
|
2823
|
-
async def _handle_approval_action(self,
|
|
3405
|
+
async def _handle_approval_action(self, event: Dict[str, Any]) -> bool:
|
|
3406
|
+
action_id = str(event.get("actionId") or "")
|
|
2824
3407
|
parts = action_id.split(":", 2)
|
|
2825
3408
|
if len(parts) != 3:
|
|
2826
3409
|
return False
|
|
2827
3410
|
_, approval_id, choice = parts
|
|
3411
|
+
choice = "once" if choice == "approve" else choice
|
|
2828
3412
|
session_key = self._approval_sessions.get(approval_id)
|
|
2829
|
-
if
|
|
3413
|
+
if choice not in {"once", "session", "always", "deny"}:
|
|
2830
3414
|
return False
|
|
3415
|
+
if not session_key:
|
|
3416
|
+
await self._finish_action(event, "Approval expired", "Approval expired; the command was not run.")
|
|
3417
|
+
return True
|
|
2831
3418
|
try:
|
|
2832
3419
|
from tools.approval import resolve_gateway_approval
|
|
2833
3420
|
count = resolve_gateway_approval(session_key, choice)
|
|
2834
3421
|
self._approval_sessions.pop(approval_id, None)
|
|
2835
3422
|
if not count:
|
|
2836
|
-
await self.
|
|
3423
|
+
await self._finish_action(event, "Approval expired", "Approval expired; the command was not run.")
|
|
2837
3424
|
return True
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
3425
|
+
labels = {
|
|
3426
|
+
"once": ("Approved once", "Approved once."),
|
|
3427
|
+
"session": ("Approved for session", "Approved for this session."),
|
|
3428
|
+
"always": ("Always allowed", "Always allowed."),
|
|
3429
|
+
"deny": ("Denied", "Denied."),
|
|
3430
|
+
}
|
|
3431
|
+
toast, text = labels[choice]
|
|
3432
|
+
await self._finish_action(event, toast, text)
|
|
2841
3433
|
return True
|
|
2842
3434
|
except Exception:
|
|
2843
3435
|
logger.exception("[inline] approval action failed")
|
|
2844
3436
|
return True
|
|
2845
3437
|
|
|
2846
|
-
async def _handle_slash_action(self,
|
|
3438
|
+
async def _handle_slash_action(self, event: Dict[str, Any]) -> bool:
|
|
3439
|
+
action_id = str(event.get("actionId") or "")
|
|
2847
3440
|
parts = action_id.split(":", 2)
|
|
2848
3441
|
if len(parts) != 3:
|
|
2849
3442
|
return False
|
|
2850
3443
|
_, choice, confirm_id = parts
|
|
2851
3444
|
session_key = self._slash_sessions.get(confirm_id)
|
|
2852
|
-
if
|
|
3445
|
+
if choice not in {"once", "always", "cancel"}:
|
|
2853
3446
|
return False
|
|
3447
|
+
if not session_key:
|
|
3448
|
+
await self._finish_action(event, "Prompt expired", "Confirmation expired.")
|
|
3449
|
+
return True
|
|
2854
3450
|
try:
|
|
2855
3451
|
from tools import slash_confirm as slash_confirm_mod
|
|
2856
3452
|
result = await slash_confirm_mod.resolve(session_key, confirm_id, choice)
|
|
2857
3453
|
self._slash_sessions.pop(confirm_id, None)
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
await self.send(chat_id, result)
|
|
3454
|
+
text = str(result or ("Cancelled." if choice == "cancel" else "Recorded."))
|
|
3455
|
+
await self._finish_action(event, "Recorded", text)
|
|
2861
3456
|
return True
|
|
2862
3457
|
except Exception:
|
|
2863
3458
|
logger.exception("[inline] slash confirm action failed")
|
|
2864
3459
|
return True
|
|
2865
3460
|
|
|
3461
|
+
async def _finish_action(self, event: Dict[str, Any], toast: str, text: str) -> None:
|
|
3462
|
+
await self._answer_action(str(event.get("interactionId") or ""), toast)
|
|
3463
|
+
await self._edit_action_message(event, text, {"rows": []})
|
|
3464
|
+
|
|
2866
3465
|
async def _handle_thread_action(self, event: Dict[str, Any]) -> bool:
|
|
2867
3466
|
action_id = str(event.get("actionId") or "")
|
|
2868
3467
|
chat_id = str(event.get("chatId") or "")
|
|
@@ -3381,19 +3980,36 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3381
3980
|
"actions": {"rows": [{"actions": actions}]},
|
|
3382
3981
|
})
|
|
3383
3982
|
|
|
3384
|
-
async def send_exec_approval(
|
|
3983
|
+
async def send_exec_approval(
|
|
3984
|
+
self,
|
|
3985
|
+
chat_id: str,
|
|
3986
|
+
command: str,
|
|
3987
|
+
session_key: str,
|
|
3988
|
+
description: str = "dangerous command",
|
|
3989
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
3990
|
+
allow_permanent: bool = True,
|
|
3991
|
+
allow_session: bool = True,
|
|
3992
|
+
smart_denied: bool = False,
|
|
3993
|
+
) -> SendResult:
|
|
3385
3994
|
approval_id = secrets.token_hex(6)
|
|
3386
|
-
self._remember(self._approval_sessions, approval_id, session_key)
|
|
3387
3995
|
text = f"Command approval required\n\n```\n{command[:2000]}\n```\n\nReason: {description}"
|
|
3388
|
-
|
|
3996
|
+
if smart_denied:
|
|
3997
|
+
text += "\n\nSmart DENY: owner override applies to this one operation only."
|
|
3998
|
+
actions = [self._action(f"appr:{approval_id}:once", "Allow Once")]
|
|
3999
|
+
if not smart_denied and allow_session:
|
|
4000
|
+
actions.append(self._action(f"appr:{approval_id}:session", "Allow for Session"))
|
|
4001
|
+
if allow_permanent:
|
|
4002
|
+
actions.append(self._action(f"appr:{approval_id}:always", "Always Allow"))
|
|
4003
|
+
actions.append(self._action(f"appr:{approval_id}:deny", "Deny"))
|
|
4004
|
+
result = await self._send_sidecar("/send", {
|
|
3389
4005
|
"target": self._target_for(chat_id, metadata),
|
|
3390
4006
|
"text": text,
|
|
3391
4007
|
"parseMarkdown": self._parse_markdown,
|
|
3392
|
-
"actions": {"rows":
|
|
3393
|
-
{"id": f"appr:{approval_id}:approve", "text": "Approve", "callback": f"appr:{approval_id}:approve"},
|
|
3394
|
-
{"id": f"appr:{approval_id}:deny", "text": "Deny", "callback": f"appr:{approval_id}:deny"},
|
|
3395
|
-
]}]},
|
|
4008
|
+
"actions": {"rows": self._action_rows(actions)},
|
|
3396
4009
|
})
|
|
4010
|
+
if result.success:
|
|
4011
|
+
self._remember(self._approval_sessions, approval_id, session_key)
|
|
4012
|
+
return result
|
|
3397
4013
|
|
|
3398
4014
|
async def send_slash_confirm(self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
3399
4015
|
self._remember(self._slash_sessions, confirm_id, session_key)
|