@inline-chat/hermes-agent-adapter 0.0.22 → 1.0.0-alpha.1
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 +41 -29
- package/dist/install.js +18 -3
- package/package.json +10 -4
- package/plugin/inline/adapter.py +396 -242
- package/plugin/inline/cli.py +73 -7
- package/plugin/inline/plugin.yaml +6 -10
- package/plugin/inline/profile.py +86 -0
- package/plugin/inline/sidecar/index.mjs +2464 -160
- package/plugin/inline/tools.py +255 -30
package/plugin/inline/adapter.py
CHANGED
|
@@ -55,11 +55,13 @@ from .message_actions import (
|
|
|
55
55
|
parse_inline_agent_action_reply_target,
|
|
56
56
|
resolve_inline_message_action_ownership,
|
|
57
57
|
)
|
|
58
|
+
from .profile import bot_state_key, inline_env, legacy_state_dir, may_adopt_legacy_state, state_dir
|
|
58
59
|
from .telemetry import capture_plugin_error
|
|
59
60
|
|
|
60
61
|
logger = logging.getLogger(__name__)
|
|
61
62
|
|
|
62
63
|
_DEFAULT_SIDECAR_PORT = 8794
|
|
64
|
+
_ACTION_EDIT_RETRY_DELAY_SECONDS = 0.5
|
|
63
65
|
_DEFAULT_SIDECAR_BIND = "127.0.0.1"
|
|
64
66
|
_MAX_MESSAGE_LENGTH = 4000
|
|
65
67
|
_MODEL_PAGE_SIZE = 8
|
|
@@ -106,7 +108,7 @@ _INLINE_COMMAND_DESCRIPTION_LIMIT = 256
|
|
|
106
108
|
_INLINE_COMMAND_RETRY_RATIO = 0.8
|
|
107
109
|
_INLINE_COMMAND_RE = re.compile(r"^[a-z0-9_]{1,32}$")
|
|
108
110
|
_INLINE_THREADS_COMMAND_DESCRIPTION = "Configure Inline reply-thread routing"
|
|
109
|
-
_INLINE_THREADS_COMMAND_ARGS = "[status|on|
|
|
111
|
+
_INLINE_THREADS_COMMAND_ARGS = "[status|on|auto|reset]"
|
|
110
112
|
_INLINE_FOLLOW_COMMAND_DESCRIPTION = "Explicitly follow this Inline chat or thread"
|
|
111
113
|
_INLINE_UNFOLLOW_COMMAND_DESCRIPTION = "Explicitly unfollow this Inline chat or thread"
|
|
112
114
|
_INLINE_SYNC_COMMAND_DESCRIPTION = "Resync Inline commands and skills"
|
|
@@ -119,26 +121,8 @@ _INLINE_MAINTENANCE_COMMAND_RE = re.compile(
|
|
|
119
121
|
r"^/(inline[_-](?:sync|version))(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$",
|
|
120
122
|
re.IGNORECASE,
|
|
121
123
|
)
|
|
122
|
-
_INLINE_REPLY_THREAD_NEGATION_RE = re.compile(
|
|
123
|
-
r"\b(?:do\s+not|don't|dont|please\s+don't|please\s+dont|no\s+need\s+to)\s+"
|
|
124
|
-
r"(?:create|start|open|make|use|move|take|reply|respond|answer|send|thread)\b[^.!?\n]*\bthread\b|"
|
|
125
|
-
r"\b(?:reply|respond|answer|keep)\s+(?:here|in\s+the\s+main\s+chat|in\s+main\s+chat|"
|
|
126
|
-
r"in\s+the\s+parent\s+chat|in\s+parent\s+chat)\b",
|
|
127
|
-
re.IGNORECASE,
|
|
128
|
-
)
|
|
129
|
-
_INLINE_REPLY_THREAD_INTENT_RE = re.compile(
|
|
130
|
-
r"\b(?:reply|respond|answer|send)\s+(?:in|inside|into|to)\s+(?:a\s+)?"
|
|
131
|
-
r"(?:(?:new|child|reply)\s+)?thread\b|"
|
|
132
|
-
r"\b(?:create|start|open|make|use)\s+(?:a\s+)?(?:(?:new|child|reply)\s+)?thread\b|"
|
|
133
|
-
r"\bkeep\s+(?:(?:this|it|the\s+answer|the\s+reply|the\s+response)\s+)?"
|
|
134
|
-
r"(?:in|inside|into)\s+(?:a\s+)?(?:(?:new|child|reply)\s+)?thread\b|"
|
|
135
|
-
r"\b(?:move|take)\s+(?:(?:this|it|the\s+answer|the\s+reply|the\s+response)\s+)?"
|
|
136
|
-
r"(?:to|into)\s+(?:a\s+)?(?:(?:new|child|reply)\s+)?thread\b|"
|
|
137
|
-
r"\bthread\s+(?:this|the\s+answer|the\s+reply|the\s+response)\b|"
|
|
138
|
-
r"\b(?:threaded\s+(?:reply|response)|(?:reply|respond|answer)\s+threaded)\b",
|
|
139
|
-
re.IGNORECASE,
|
|
140
|
-
)
|
|
141
124
|
_INLINE_SETTINGS_VERSION = 1
|
|
125
|
+
_TURN_THREADS_MAX = 512
|
|
142
126
|
_INLINE_BOT_SETTINGS_VERSION = 1
|
|
143
127
|
_INLINE_BOT_SETTINGS_UNAVAILABLE = 1
|
|
144
128
|
_INLINE_BOT_SETTINGS_INVALID_VALUE = 2
|
|
@@ -174,11 +158,6 @@ _INLINE_ENTITY_TYPE_NAMES = {
|
|
|
174
158
|
14: "group_mention",
|
|
175
159
|
}
|
|
176
160
|
|
|
177
|
-
_DEFAULT_MENTION_PATTERNS = [
|
|
178
|
-
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
|
|
179
|
-
r"(?<![\w@])@?hermes\b[,:\-]?",
|
|
180
|
-
r"(?<![\w@])@?inline\s+agent\b[,:\-]?",
|
|
181
|
-
]
|
|
182
161
|
_ENV_REF_PATTERN = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
183
162
|
_SIDECAR_RETRYABLE_KINDS = {"transient"}
|
|
184
163
|
_INLINE_DISPLAY_DEFAULTS = {
|
|
@@ -241,15 +220,15 @@ def _install_inline_display_defaults() -> None:
|
|
|
241
220
|
|
|
242
221
|
|
|
243
222
|
def _reply_thread_mode(value: Any, default: str = "auto") -> str:
|
|
223
|
+
"""Auto answers in the chat and threads only when asked; On answers in a reply thread.
|
|
224
|
+
The old Off reads as Auto, which no longer threads on its own."""
|
|
244
225
|
if value is None or str(value).strip() == "":
|
|
245
226
|
return default
|
|
246
227
|
text = str(value).strip().lower()
|
|
247
|
-
if text in {"auto", "default", "reset", "config"}:
|
|
228
|
+
if text in {"auto", "default", "reset", "config", "0", "false", "no", "off", "never", "flat", "channel", "main"}:
|
|
248
229
|
return "auto"
|
|
249
230
|
if text in {"1", "true", "yes", "on", "always", "thread", "threads"}:
|
|
250
231
|
return "on"
|
|
251
|
-
if text in {"0", "false", "no", "off", "never", "flat", "channel", "main"}:
|
|
252
|
-
return "off"
|
|
253
232
|
logger.warning("[inline] unknown reply_threads value %r; using %s", value, default)
|
|
254
233
|
return default
|
|
255
234
|
|
|
@@ -551,6 +530,27 @@ def _resolve_inline_targeted_command(text: str, bot_username: Optional[str]) ->
|
|
|
551
530
|
return f"/{match.group(1)}{match.group(3) or ''}", True, True
|
|
552
531
|
|
|
553
532
|
|
|
533
|
+
def _session_chat_id(chat_id: str, chat_type: str, thread_id: Optional[str], parent_chat_id: Optional[str]) -> str:
|
|
534
|
+
"""The chat a turn's native session is keyed on.
|
|
535
|
+
|
|
536
|
+
Inline reply threads are chats of their own, and follow-ups inside one are keyed on
|
|
537
|
+
the thread (``group:C:C``), as native Hermes keys a Discord thread. A group message
|
|
538
|
+
that opens or continues a thread from its parent chat is keyed the same way, so the
|
|
539
|
+
opener and its follow-ups share one conversation. A thread under the bot's DM is keyed
|
|
540
|
+
like a Slack DM thread (``dm:D:C``) from its opener on, for the DM's person.
|
|
541
|
+
"""
|
|
542
|
+
if chat_type == "group" and thread_id and parent_chat_id == chat_id and thread_id != chat_id:
|
|
543
|
+
return str(thread_id)
|
|
544
|
+
if chat_type == "dm" and thread_id and parent_chat_id and thread_id == chat_id:
|
|
545
|
+
return str(parent_chat_id)
|
|
546
|
+
return chat_id
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _inline_command_name(text: str) -> str:
|
|
550
|
+
match = re.match(r"^\s*/([a-z0-9_][a-z0-9_-]*)", str(text or ""), re.IGNORECASE)
|
|
551
|
+
return match.group(1).lower() if match else ""
|
|
552
|
+
|
|
553
|
+
|
|
554
554
|
def _normalize_inline_plugin_command_text(text: str) -> str:
|
|
555
555
|
match = re.match(r"^/([a-z0-9_]+)(@[A-Za-z0-9_]+)?(\s.*)?$", str(text or ""), re.IGNORECASE | re.DOTALL)
|
|
556
556
|
if not match or "_" not in match.group(1):
|
|
@@ -651,8 +651,8 @@ def _token_value(raw: Any) -> str:
|
|
|
651
651
|
def _config_token(cfg: Optional[PlatformConfig] = None) -> str:
|
|
652
652
|
extra = (cfg.extra if cfg else None) or {}
|
|
653
653
|
for raw in [
|
|
654
|
-
|
|
655
|
-
|
|
654
|
+
inline_env("INLINE_TOKEN"),
|
|
655
|
+
inline_env("INLINE_BOT_TOKEN"),
|
|
656
656
|
getattr(cfg, "token", None),
|
|
657
657
|
extra.get("token"),
|
|
658
658
|
extra.get("bot_token"),
|
|
@@ -664,7 +664,7 @@ def _config_token(cfg: Optional[PlatformConfig] = None) -> str:
|
|
|
664
664
|
|
|
665
665
|
|
|
666
666
|
def _find_node_bin() -> Optional[str]:
|
|
667
|
-
configured =
|
|
667
|
+
configured = inline_env("INLINE_NODE_BIN")
|
|
668
668
|
if configured:
|
|
669
669
|
return configured
|
|
670
670
|
try:
|
|
@@ -746,13 +746,13 @@ def _env_enablement() -> Optional[dict]:
|
|
|
746
746
|
return None
|
|
747
747
|
seed: dict = {
|
|
748
748
|
"token": _config_token(),
|
|
749
|
-
"base_url":
|
|
749
|
+
"base_url": inline_env("INLINE_BASE_URL", "https://api.inline.chat"),
|
|
750
750
|
}
|
|
751
|
-
home =
|
|
751
|
+
home = inline_env("INLINE_HOME_CHANNEL", "").strip()
|
|
752
752
|
if home:
|
|
753
753
|
seed["home_channel"] = {
|
|
754
754
|
"chat_id": home,
|
|
755
|
-
"name":
|
|
755
|
+
"name": inline_env("INLINE_HOME_CHANNEL_NAME", "Inline home"),
|
|
756
756
|
}
|
|
757
757
|
return seed
|
|
758
758
|
|
|
@@ -770,7 +770,6 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
|
|
|
770
770
|
"parse_markdown",
|
|
771
771
|
"require_mention",
|
|
772
772
|
"strict_mention",
|
|
773
|
-
"mention_patterns",
|
|
774
773
|
"allowed_chats",
|
|
775
774
|
"free_response_chats",
|
|
776
775
|
"group_policy",
|
|
@@ -813,62 +812,64 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
813
812
|
super().__init__(config, Platform("inline"))
|
|
814
813
|
extra = config.extra or {}
|
|
815
814
|
|
|
816
|
-
if _truthy(
|
|
815
|
+
if _truthy(inline_env("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")):
|
|
817
816
|
logger.warning("[inline] experimental tool timelines are unavailable; ignoring INLINE_EXPERIMENTAL_AGENT_ACTIVITY and using ordinary typing")
|
|
818
817
|
self._token = _config_token(config)
|
|
819
|
-
self._base_url =
|
|
820
|
-
self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or
|
|
821
|
-
sidecar_port = extra.get("sidecar_port") if "sidecar_port" in extra else
|
|
818
|
+
self._base_url = inline_env("INLINE_BASE_URL") or extra.get("base_url") or "https://api.inline.chat"
|
|
819
|
+
self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or inline_env("INLINE_SIDECAR_BIND"))
|
|
820
|
+
sidecar_port = extra.get("sidecar_port") if "sidecar_port" in extra else inline_env("INLINE_SIDECAR_PORT")
|
|
821
|
+
self._autostart_sidecar = _truthy(inline_env("INLINE_SIDECAR_AUTOSTART"), True)
|
|
822
|
+
# A gateway can run several profiles in one process, so a sidecar this
|
|
823
|
+
# adapter starts itself takes a free port unless one is configured.
|
|
822
824
|
self._sidecar_port = (
|
|
823
825
|
_available_loopback_port(self._sidecar_bind)
|
|
824
|
-
if use_ephemeral_sidecar_port
|
|
826
|
+
if use_ephemeral_sidecar_port or (self._autostart_sidecar and not str(sidecar_port or "").strip())
|
|
825
827
|
else _normalize_sidecar_port(sidecar_port)
|
|
826
828
|
)
|
|
827
829
|
self._connect_timeout_ms = _normalize_positive_float(
|
|
828
|
-
extra.get("connect_timeout_ms") if "connect_timeout_ms" in extra else
|
|
830
|
+
extra.get("connect_timeout_ms") if "connect_timeout_ms" in extra else inline_env("INLINE_CONNECT_TIMEOUT_MS"),
|
|
829
831
|
_DEFAULT_CONNECT_TIMEOUT_MS,
|
|
830
832
|
"INLINE_CONNECT_TIMEOUT_MS",
|
|
831
833
|
)
|
|
832
|
-
self._sidecar_token =
|
|
834
|
+
self._sidecar_token = inline_env("INLINE_SIDECAR_TOKEN") or secrets.token_hex(16)
|
|
833
835
|
_configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
|
|
834
836
|
self._node_bin = _find_node_bin() or "node"
|
|
835
|
-
self.
|
|
836
|
-
self._parse_markdown = _truthy(extra.get("parse_markdown") if "parse_markdown" in extra else os.getenv("INLINE_PARSE_MARKDOWN"), True)
|
|
837
|
+
self._parse_markdown = _truthy(extra.get("parse_markdown") if "parse_markdown" in extra else inline_env("INLINE_PARSE_MARKDOWN"), True)
|
|
837
838
|
self._media_max_bytes = int(
|
|
838
839
|
_normalize_positive_float(
|
|
839
|
-
extra.get("media_max_mb") if "media_max_mb" in extra else
|
|
840
|
+
extra.get("media_max_mb") if "media_max_mb" in extra else inline_env("INLINE_MEDIA_MAX_MB"),
|
|
840
841
|
_DEFAULT_MEDIA_MAX_MB,
|
|
841
842
|
"INLINE_MEDIA_MAX_MB",
|
|
842
843
|
) * 1024 * 1024
|
|
843
844
|
)
|
|
844
845
|
self._upload_max_mb = _normalize_positive_float(
|
|
845
|
-
extra.get("upload_max_mb") if "upload_max_mb" in extra else
|
|
846
|
+
extra.get("upload_max_mb") if "upload_max_mb" in extra else inline_env("INLINE_UPLOAD_MAX_MB"),
|
|
846
847
|
_DEFAULT_UPLOAD_MAX_MB,
|
|
847
848
|
"INLINE_UPLOAD_MAX_MB",
|
|
848
849
|
)
|
|
849
850
|
self._upload_max_bytes = int(self._upload_max_mb * 1024 * 1024)
|
|
850
851
|
self._system_events = _truthy(
|
|
851
|
-
extra.get("system_events") if "system_events" in extra else
|
|
852
|
+
extra.get("system_events") if "system_events" in extra else inline_env("INLINE_SYSTEM_EVENTS"),
|
|
852
853
|
False,
|
|
853
854
|
)
|
|
854
855
|
self._processing_reactions = _truthy(
|
|
855
|
-
extra.get("reactions") if "reactions" in extra else
|
|
856
|
+
extra.get("reactions") if "reactions" in extra else inline_env("INLINE_REACTIONS"),
|
|
856
857
|
False,
|
|
857
858
|
)
|
|
858
859
|
self._sync_commands = _truthy(
|
|
859
|
-
extra.get("sync_commands") if "sync_commands" in extra else
|
|
860
|
+
extra.get("sync_commands") if "sync_commands" in extra else inline_env("INLINE_SYNC_COMMANDS"),
|
|
860
861
|
True,
|
|
861
862
|
)
|
|
862
863
|
self._command_limit = _normalize_command_limit(
|
|
863
|
-
extra.get("command_limit") if "command_limit" in extra else
|
|
864
|
+
extra.get("command_limit") if "command_limit" in extra else inline_env("INLINE_COMMAND_LIMIT")
|
|
864
865
|
)
|
|
865
866
|
context_backfill_raw = (
|
|
866
|
-
extra.get("context_backfill") if "context_backfill" in extra else
|
|
867
|
+
extra.get("context_backfill") if "context_backfill" in extra else inline_env("INLINE_CONTEXT_BACKFILL")
|
|
867
868
|
)
|
|
868
869
|
history_limit_raw = (
|
|
869
870
|
extra.get("context_history_limit")
|
|
870
871
|
if "context_history_limit" in extra
|
|
871
|
-
else
|
|
872
|
+
else inline_env("INLINE_CONTEXT_HISTORY_LIMIT")
|
|
872
873
|
)
|
|
873
874
|
history_limit_configured = history_limit_raw is not None and str(history_limit_raw).strip() != ""
|
|
874
875
|
legacy_history_limit = _normalize_context_history_limit(history_limit_raw) if history_limit_configured else None
|
|
@@ -877,7 +878,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
877
878
|
self._thread_context_limit = _normalize_context_limit(
|
|
878
879
|
extra.get("thread_context_limit")
|
|
879
880
|
if "thread_context_limit" in extra
|
|
880
|
-
else
|
|
881
|
+
else inline_env("INLINE_THREAD_CONTEXT_LIMIT"),
|
|
881
882
|
default=_DEFAULT_THREAD_CONTEXT_LIMIT,
|
|
882
883
|
maximum=_MAX_THREAD_CONTEXT_LIMIT,
|
|
883
884
|
name="INLINE_THREAD_CONTEXT_LIMIT",
|
|
@@ -885,7 +886,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
885
886
|
self._reply_context_limit = _normalize_context_limit(
|
|
886
887
|
extra.get("reply_context_limit")
|
|
887
888
|
if "reply_context_limit" in extra
|
|
888
|
-
else
|
|
889
|
+
else inline_env("INLINE_REPLY_CONTEXT_LIMIT"),
|
|
889
890
|
default=_DEFAULT_REPLY_CONTEXT_LIMIT,
|
|
890
891
|
maximum=_MAX_REPLY_CONTEXT_LIMIT,
|
|
891
892
|
name="INLINE_REPLY_CONTEXT_LIMIT",
|
|
@@ -893,7 +894,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
893
894
|
self._observed_context_limit = _normalize_context_limit(
|
|
894
895
|
extra.get("observed_context_limit")
|
|
895
896
|
if "observed_context_limit" in extra
|
|
896
|
-
else
|
|
897
|
+
else inline_env("INLINE_OBSERVED_CONTEXT_LIMIT"),
|
|
897
898
|
default=_DEFAULT_OBSERVED_CONTEXT_LIMIT,
|
|
898
899
|
maximum=_MAX_OBSERVED_CONTEXT_LIMIT,
|
|
899
900
|
name="INLINE_OBSERVED_CONTEXT_LIMIT",
|
|
@@ -905,74 +906,80 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
905
906
|
self._context_backfill == "always"
|
|
906
907
|
and legacy_history_limit is not None
|
|
907
908
|
and "thread_context_limit" not in extra
|
|
908
|
-
and not
|
|
909
|
+
and not inline_env("INLINE_THREAD_CONTEXT_LIMIT")
|
|
909
910
|
):
|
|
910
911
|
self._thread_context_limit = min(legacy_history_limit, _MAX_THREAD_CONTEXT_LIMIT)
|
|
911
912
|
self._observe_unmentioned_messages = _truthy(
|
|
912
913
|
extra.get("observe_unmentioned_messages")
|
|
913
914
|
if "observe_unmentioned_messages" in extra
|
|
914
|
-
else
|
|
915
|
+
else inline_env("INLINE_OBSERVE_UNMENTIONED_MESSAGES"),
|
|
915
916
|
True,
|
|
916
917
|
)
|
|
917
918
|
self._reply_thread_mode = _reply_thread_mode(
|
|
918
|
-
extra.get("reply_threads") if "reply_threads" in extra else
|
|
919
|
+
extra.get("reply_threads") if "reply_threads" in extra else inline_env("INLINE_REPLY_THREADS"),
|
|
919
920
|
"auto",
|
|
920
921
|
)
|
|
922
|
+
if self._reply_thread_mode == "on":
|
|
923
|
+
logger.warning(
|
|
924
|
+
"[inline] reply_threads=on now applies per chat; using auto as the default. "
|
|
925
|
+
"Turn it on in a chat with /threads on, the agent settings, or by asking the agent."
|
|
926
|
+
)
|
|
927
|
+
self._reply_thread_mode = "auto"
|
|
921
928
|
|
|
922
|
-
state_path = extra.get("state_path") or
|
|
929
|
+
state_path = extra.get("state_path") or inline_env("INLINE_STATE_PATH")
|
|
923
930
|
if state_path:
|
|
924
931
|
self._state_path = Path(str(state_path)).expanduser()
|
|
925
932
|
else:
|
|
926
|
-
|
|
927
|
-
|
|
933
|
+
# One state file per profile and bot: two writers on one file replay
|
|
934
|
+
# each other's answered messages.
|
|
935
|
+
self._state_path = state_dir() / f"sdk-state-{bot_state_key(self._base_url, self._token)}.json"
|
|
936
|
+
self._state_path_configured = bool(state_path)
|
|
937
|
+
settings_path = extra.get("settings_path") or inline_env("INLINE_SETTINGS_PATH")
|
|
928
938
|
if settings_path:
|
|
929
939
|
self._settings_path = Path(str(settings_path)).expanduser()
|
|
930
940
|
else:
|
|
931
941
|
self._settings_path = self._state_path.with_name("adapter-settings.json")
|
|
932
942
|
|
|
933
943
|
self.require_mention = _truthy(
|
|
934
|
-
extra.get("require_mention") if "require_mention" in extra else
|
|
944
|
+
extra.get("require_mention") if "require_mention" in extra else inline_env("INLINE_REQUIRE_MENTION"),
|
|
935
945
|
True,
|
|
936
946
|
)
|
|
937
947
|
self._strict_mention = _truthy(
|
|
938
|
-
extra.get("strict_mention") if "strict_mention" in extra else
|
|
948
|
+
extra.get("strict_mention") if "strict_mention" in extra else inline_env("INLINE_STRICT_MENTION"),
|
|
939
949
|
False,
|
|
940
950
|
)
|
|
941
|
-
self._mention_patterns = self._compile_mention_patterns(
|
|
942
|
-
extra["mention_patterns"] if "mention_patterns" in extra else os.getenv("INLINE_MENTION_PATTERNS")
|
|
943
|
-
)
|
|
944
951
|
self._allowed_chats = self._parse_chat_set(
|
|
945
952
|
extra.get("allowed_chats")
|
|
946
953
|
if "allowed_chats" in extra
|
|
947
|
-
else extra.get("allowedChannels") if "allowedChannels" in extra else
|
|
954
|
+
else extra.get("allowedChannels") if "allowedChannels" in extra else inline_env("INLINE_ALLOWED_CHATS")
|
|
948
955
|
)
|
|
949
956
|
self._free_response_chats = self._parse_chat_set(
|
|
950
957
|
extra.get("free_response_chats")
|
|
951
958
|
if "free_response_chats" in extra
|
|
952
|
-
else extra.get("freeResponseChats") if "freeResponseChats" in extra else
|
|
959
|
+
else extra.get("freeResponseChats") if "freeResponseChats" in extra else inline_env("INLINE_FREE_RESPONSE_CHATS")
|
|
953
960
|
)
|
|
954
961
|
allow_all_raw = (
|
|
955
962
|
extra.get("allow_all")
|
|
956
963
|
if "allow_all" in extra
|
|
957
|
-
else extra.get("allow_all_users") if "allow_all_users" in extra else
|
|
964
|
+
else extra.get("allow_all_users") if "allow_all_users" in extra else inline_env("INLINE_ALLOW_ALL_USERS")
|
|
958
965
|
)
|
|
959
966
|
self._allow_all = _truthy(allow_all_raw, False)
|
|
960
967
|
self._allow_from = self._parse_id_set(
|
|
961
968
|
extra.get("allow_from")
|
|
962
969
|
if "allow_from" in extra
|
|
963
|
-
else extra.get("allowed_users") if "allowed_users" in extra else
|
|
970
|
+
else extra.get("allowed_users") if "allowed_users" in extra else inline_env("INLINE_ALLOWED_USERS")
|
|
964
971
|
)
|
|
965
972
|
self._group_allow_from = self._parse_id_set(
|
|
966
973
|
extra.get("group_allow_from")
|
|
967
974
|
if "group_allow_from" in extra
|
|
968
|
-
else extra.get("groupAllowFrom") if "groupAllowFrom" in extra else
|
|
975
|
+
else extra.get("groupAllowFrom") if "groupAllowFrom" in extra else inline_env("INLINE_GROUP_ALLOW_FROM")
|
|
969
976
|
)
|
|
970
977
|
self._dm_policy = _normalize_policy(
|
|
971
|
-
extra.get("dm_policy") if "dm_policy" in extra else
|
|
978
|
+
extra.get("dm_policy") if "dm_policy" in extra else inline_env("INLINE_DM_POLICY"),
|
|
972
979
|
"allowlist" if self._allow_from and not self._allow_all else "open",
|
|
973
980
|
)
|
|
974
981
|
self._group_policy = _normalize_policy(
|
|
975
|
-
extra.get("group_policy") if "group_policy" in extra else
|
|
982
|
+
extra.get("group_policy") if "group_policy" in extra else inline_env("INLINE_GROUP_POLICY"),
|
|
976
983
|
"allowlist" if self._group_allow_from and not self._allow_all else "open",
|
|
977
984
|
)
|
|
978
985
|
|
|
@@ -1007,12 +1014,23 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1007
1014
|
self._bot_agent_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
|
|
1008
1015
|
self._reply_thread_cache: "OrderedDict[str, str]" = OrderedDict()
|
|
1009
1016
|
self._reply_thread_parent_reply_ids: "OrderedDict[str, set[str]]" = OrderedDict()
|
|
1017
|
+
# Whether each request came from a bot, so answers to bots reply to them and reach them.
|
|
1018
|
+
self._request_sender_is_bot: "OrderedDict[str, bool]" = OrderedDict()
|
|
1019
|
+
# Commands this bot advertises in Inline's command menu, as last synced.
|
|
1020
|
+
self._advertised_commands: set[str] = set()
|
|
1010
1021
|
self._reply_thread_parent_typing_targets: "OrderedDict[str, str]" = OrderedDict()
|
|
1011
1022
|
self._visible_reply_thread_targets: "OrderedDict[str, None]" = OrderedDict()
|
|
1012
1023
|
self._active_typing_targets: Dict[str, Dict[str, str]] = {}
|
|
1013
1024
|
self._observed_context: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
|
|
1014
1025
|
self._context_backfill_seen: "OrderedDict[str, float]" = OrderedDict()
|
|
1015
1026
|
self._reply_thread_overrides = self._load_reply_thread_overrides()
|
|
1027
|
+
# Threads the agent made for the message it is answering: (chat, message) -> thread.
|
|
1028
|
+
self._turn_threads: "OrderedDict[tuple[str, str], str]" = OrderedDict()
|
|
1029
|
+
try:
|
|
1030
|
+
from . import tools as _inline_tools
|
|
1031
|
+
_inline_tools.configure_settings(self)
|
|
1032
|
+
except Exception:
|
|
1033
|
+
logger.debug("[inline] failed to register Inline settings for tools", exc_info=True)
|
|
1016
1034
|
|
|
1017
1035
|
def _report_error(self, operation: str, error: BaseException, *, handled: bool = True) -> None:
|
|
1018
1036
|
try:
|
|
@@ -1081,7 +1099,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1081
1099
|
if not key:
|
|
1082
1100
|
continue
|
|
1083
1101
|
if isinstance(enabled, bool):
|
|
1084
|
-
overrides[key] = "on" if enabled else "
|
|
1102
|
+
overrides[key] = "on" if enabled else "auto"
|
|
1085
1103
|
continue
|
|
1086
1104
|
mode = _reply_thread_mode(enabled, "")
|
|
1087
1105
|
if mode:
|
|
@@ -1110,9 +1128,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1110
1128
|
return self._reply_thread_overrides[key]
|
|
1111
1129
|
return self._reply_thread_mode
|
|
1112
1130
|
|
|
1113
|
-
def _reply_threads_for_chat(self, chat_id: str, parent_chat_id: Optional[str] = None) -> bool:
|
|
1114
|
-
return self._reply_thread_mode_for_chat(chat_id, parent_chat_id) != "off"
|
|
1115
|
-
|
|
1116
1131
|
def _set_reply_threads_for_chat(self, chat_id: str, value: Optional[str]) -> None:
|
|
1117
1132
|
key = self._chat_key(chat_id)
|
|
1118
1133
|
if not key:
|
|
@@ -1123,6 +1138,57 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1123
1138
|
self._reply_thread_overrides[key] = _reply_thread_mode(value, "auto")
|
|
1124
1139
|
self._save_reply_thread_overrides()
|
|
1125
1140
|
|
|
1141
|
+
def _slash_access_policy(self, chat_type: str) -> Any:
|
|
1142
|
+
"""Hermes' own slash-command access for this platform; None on hosts without it."""
|
|
1143
|
+
try:
|
|
1144
|
+
from gateway.slash_access import policy_from_extra
|
|
1145
|
+
except Exception:
|
|
1146
|
+
return None
|
|
1147
|
+
try:
|
|
1148
|
+
return policy_from_extra(dict(self.config.extra or {}), "dm" if chat_type == "dm" else "group")
|
|
1149
|
+
except Exception:
|
|
1150
|
+
logger.debug("[inline] slash access policy unavailable", exc_info=True)
|
|
1151
|
+
return None
|
|
1152
|
+
|
|
1153
|
+
def _may_change_reply_threads(self, actor_id: str, chat_type: str) -> bool:
|
|
1154
|
+
"""Who may change a chat's reply threads follows Hermes' admin lists: the same people
|
|
1155
|
+
who may run /threads. Without admin lists Hermes allows everyone, and so does this."""
|
|
1156
|
+
policy = self._slash_access_policy(chat_type)
|
|
1157
|
+
if policy is None:
|
|
1158
|
+
return True
|
|
1159
|
+
return policy.can_run(actor_id, "threads")
|
|
1160
|
+
|
|
1161
|
+
def note_turn_thread(self, chat_id: str, message_id: str, thread_id: str) -> None:
|
|
1162
|
+
"""The agent opened a thread on the message it is answering; the answer goes there."""
|
|
1163
|
+
key = (self._chat_key(chat_id), str(message_id))
|
|
1164
|
+
self._turn_threads[key] = self._chat_key(thread_id)
|
|
1165
|
+
self._turn_threads.move_to_end(key)
|
|
1166
|
+
while len(self._turn_threads) > _TURN_THREADS_MAX:
|
|
1167
|
+
self._turn_threads.popitem(last=False)
|
|
1168
|
+
|
|
1169
|
+
def turn_thread(self, chat_id: str, message_id: str) -> str:
|
|
1170
|
+
return self._turn_threads.get((self._chat_key(chat_id), str(message_id)), "")
|
|
1171
|
+
|
|
1172
|
+
def _with_turn_thread(self, chat_id: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
1173
|
+
if not reply_to or (metadata or {}).get("thread_id"):
|
|
1174
|
+
return metadata
|
|
1175
|
+
thread_id = self.turn_thread(chat_id, str(reply_to))
|
|
1176
|
+
return {**(metadata or {}), "thread_id": thread_id} if thread_id else metadata
|
|
1177
|
+
|
|
1178
|
+
def set_reply_threads_from_tool(
|
|
1179
|
+
self, *, chat_id: str, chat_type: str, in_reply_thread: bool, actor_id: str, actor_is_bot: bool, mode: str,
|
|
1180
|
+
) -> Dict[str, Any]:
|
|
1181
|
+
"""Change this chat's reply threads because the person talking to the agent asked,
|
|
1182
|
+
with the access /threads needs."""
|
|
1183
|
+
if actor_is_bot or not actor_id:
|
|
1184
|
+
raise PermissionError("only a person can change reply threads")
|
|
1185
|
+
if in_reply_thread:
|
|
1186
|
+
raise PermissionError("change reply threads from the parent chat, not inside a thread")
|
|
1187
|
+
if not self._may_change_reply_threads(actor_id, chat_type):
|
|
1188
|
+
raise PermissionError("changing reply threads needs access to the threads command")
|
|
1189
|
+
self._set_reply_threads_for_chat(chat_id, None if mode in {"default", "reset"} else _reply_thread_mode(mode))
|
|
1190
|
+
return {"chat_id": self._chat_key(chat_id), "mode": self._reply_thread_mode_for_chat(chat_id)}
|
|
1191
|
+
|
|
1126
1192
|
@staticmethod
|
|
1127
1193
|
def _bot_settings_chat_type(info: Dict[str, Any]) -> str:
|
|
1128
1194
|
peer = info.get("peer")
|
|
@@ -1194,7 +1260,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1194
1260
|
)
|
|
1195
1261
|
runner = self._bot_settings_runner()
|
|
1196
1262
|
can_modify = can_inspect
|
|
1197
|
-
can_set_reply_threads =
|
|
1263
|
+
can_set_reply_threads = (
|
|
1264
|
+
not is_reply_thread or await self._chat_actor_authorized(scope_chat_id, actor_id)
|
|
1265
|
+
) and self._may_change_reply_threads(actor_id, chat_type)
|
|
1198
1266
|
context: Dict[str, Any] = {
|
|
1199
1267
|
"access": "full" if can_modify else "readOnly",
|
|
1200
1268
|
"scope_id": scope_chat_id,
|
|
@@ -1407,9 +1475,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1407
1475
|
"control": {"oneofKind": "select", "select": {
|
|
1408
1476
|
"value": context.get("reply_threads") or "auto",
|
|
1409
1477
|
"options": [
|
|
1410
|
-
{"value": "auto", "label": "Auto", "description": "
|
|
1411
|
-
{"value": "on", "label": "On", "description": "
|
|
1412
|
-
{"value": "off", "label": "Off", "description": "Stay in chat.", "disabled": False},
|
|
1478
|
+
{"value": "auto", "label": "Auto", "description": "Answer in the chat; a thread when asked.", "disabled": False},
|
|
1479
|
+
{"value": "on", "label": "On", "description": "Answer each message in a thread.", "disabled": False},
|
|
1413
1480
|
],
|
|
1414
1481
|
}},
|
|
1415
1482
|
}]})
|
|
@@ -1521,7 +1588,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1521
1588
|
if not context.get("can_set_reply_threads"):
|
|
1522
1589
|
raise ValueError("parent chat thread settings are not authorized")
|
|
1523
1590
|
mode = str(value_body.get("stringValue") or "")
|
|
1524
|
-
if mode not in {"auto", "on"
|
|
1591
|
+
if mode not in {"auto", "on"}:
|
|
1525
1592
|
raise ValueError("invalid reply mode")
|
|
1526
1593
|
self._set_reply_threads_for_chat(context["scope_id"], mode)
|
|
1527
1594
|
return
|
|
@@ -1619,30 +1686,10 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1619
1686
|
int((time.monotonic() - started_at) * 1000),
|
|
1620
1687
|
)
|
|
1621
1688
|
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
return False
|
|
1627
|
-
if _INLINE_REPLY_THREAD_NEGATION_RE.search(normalized):
|
|
1628
|
-
return False
|
|
1629
|
-
return bool(_INLINE_REPLY_THREAD_INTENT_RE.search(normalized))
|
|
1630
|
-
|
|
1631
|
-
def _should_create_reply_thread_for_message(
|
|
1632
|
-
self,
|
|
1633
|
-
*,
|
|
1634
|
-
chat_id: str,
|
|
1635
|
-
parent_chat_id: Optional[str],
|
|
1636
|
-
text: str,
|
|
1637
|
-
) -> bool:
|
|
1638
|
-
mode = self._reply_thread_mode_for_chat(chat_id, parent_chat_id)
|
|
1639
|
-
if mode == "off":
|
|
1640
|
-
return False
|
|
1641
|
-
if mode == "on":
|
|
1642
|
-
return True
|
|
1643
|
-
if self._has_reply_thread_intent(text):
|
|
1644
|
-
return True
|
|
1645
|
-
return False
|
|
1689
|
+
def _should_create_reply_thread_for_message(self, *, chat_id: str, parent_chat_id: Optional[str]) -> bool:
|
|
1690
|
+
# Answers stay in the chat unless someone turned reply threads on for it. Threads asked
|
|
1691
|
+
# for in conversation come from the agent's create_thread tool.
|
|
1692
|
+
return self._reply_thread_mode_for_chat(chat_id, parent_chat_id) == "on"
|
|
1646
1693
|
|
|
1647
1694
|
@staticmethod
|
|
1648
1695
|
def _id_allowed(entries: set[str], value: str) -> bool:
|
|
@@ -1653,49 +1700,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1653
1700
|
return True
|
|
1654
1701
|
return False
|
|
1655
1702
|
|
|
1656
|
-
@staticmethod
|
|
1657
|
-
def _compile_mention_patterns(raw: Any) -> "list[re.Pattern]":
|
|
1658
|
-
if raw is None:
|
|
1659
|
-
patterns = list(_DEFAULT_MENTION_PATTERNS)
|
|
1660
|
-
elif isinstance(raw, str):
|
|
1661
|
-
text = raw.strip()
|
|
1662
|
-
try:
|
|
1663
|
-
loaded = json.loads(text) if text else []
|
|
1664
|
-
except Exception:
|
|
1665
|
-
loaded = None
|
|
1666
|
-
patterns = loaded if isinstance(loaded, list) else [
|
|
1667
|
-
part.strip()
|
|
1668
|
-
for line in text.splitlines()
|
|
1669
|
-
for part in line.split(",")
|
|
1670
|
-
]
|
|
1671
|
-
elif isinstance(raw, list):
|
|
1672
|
-
patterns = raw
|
|
1673
|
-
else:
|
|
1674
|
-
patterns = [raw]
|
|
1675
|
-
compiled = []
|
|
1676
|
-
for pattern in patterns:
|
|
1677
|
-
text = str(pattern).strip()
|
|
1678
|
-
if not text:
|
|
1679
|
-
continue
|
|
1680
|
-
try:
|
|
1681
|
-
compiled.append(re.compile(text, re.IGNORECASE))
|
|
1682
|
-
except re.error as exc:
|
|
1683
|
-
logger.warning("[inline] invalid mention pattern %r: %s", text, exc)
|
|
1684
|
-
return compiled
|
|
1685
|
-
|
|
1686
|
-
def _matches_mention(self, text: str) -> bool:
|
|
1687
|
-
return bool(text and any(pattern.search(text) for pattern in self._mention_patterns))
|
|
1688
|
-
|
|
1689
|
-
def _clean_mention(self, text: str) -> str:
|
|
1690
|
-
if not text:
|
|
1691
|
-
return text
|
|
1692
|
-
stripped = text.lstrip()
|
|
1693
|
-
for pattern in self._mention_patterns:
|
|
1694
|
-
match = pattern.match(stripped)
|
|
1695
|
-
if match:
|
|
1696
|
-
return stripped[match.end():].lstrip(" ,:-") or text
|
|
1697
|
-
return text
|
|
1698
|
-
|
|
1699
1703
|
@staticmethod
|
|
1700
1704
|
def _thread_command_action(text: str) -> Optional[str]:
|
|
1701
1705
|
match = _INLINE_THREAD_COMMAND_RE.match(str(text or "").strip())
|
|
@@ -1709,9 +1713,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1709
1713
|
return "status"
|
|
1710
1714
|
if action in {"on", "enable", "enabled", "true", "thread", "threads"}:
|
|
1711
1715
|
return "on"
|
|
1712
|
-
if action in {"off", "disable", "disabled", "false", "flat", "channel"}:
|
|
1713
|
-
return "off"
|
|
1714
|
-
if action in {"auto", "config"}:
|
|
1716
|
+
if action in {"auto", "config", "off", "disable", "disabled", "false", "flat", "channel"}:
|
|
1715
1717
|
return "auto"
|
|
1716
1718
|
if action in {"reset", "default", "inherit", "clear"}:
|
|
1717
1719
|
return "reset"
|
|
@@ -1723,17 +1725,15 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1723
1725
|
has_override = key in self._reply_thread_overrides
|
|
1724
1726
|
scope = "chat override" if has_override else "global default"
|
|
1725
1727
|
if mode == "on":
|
|
1726
|
-
behavior = "
|
|
1727
|
-
elif mode == "auto":
|
|
1728
|
-
behavior = "Top-level replies stay in the parent chat unless a thread is explicitly requested."
|
|
1728
|
+
behavior = "Each top-level message gets its answer in a reply thread."
|
|
1729
1729
|
else:
|
|
1730
|
-
behavior = "
|
|
1730
|
+
behavior = "Answers stay in the chat; a thread starts only when someone asks for one."
|
|
1731
1731
|
first_line = f"Inline reply threads are {mode} for this chat ({scope})."
|
|
1732
1732
|
if updated:
|
|
1733
1733
|
first_line = f"Inline reply threads updated: {mode} for this chat ({scope})."
|
|
1734
1734
|
footer = "Existing Inline reply threads are always preserved."
|
|
1735
1735
|
if controls:
|
|
1736
|
-
footer = f"{footer} Use Auto, On,
|
|
1736
|
+
footer = f"{footer} Use Auto, On, or Reset."
|
|
1737
1737
|
return (
|
|
1738
1738
|
f"{first_line}\n"
|
|
1739
1739
|
f"{behavior}\n"
|
|
@@ -1769,7 +1769,6 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1769
1769
|
rows = [{"actions": [
|
|
1770
1770
|
self._action(f"{prefix}auto", "Auto"),
|
|
1771
1771
|
self._action(f"{prefix}on", "On"),
|
|
1772
|
-
self._action(f"{prefix}off", "Off"),
|
|
1773
1772
|
]}]
|
|
1774
1773
|
if self._chat_key(target_chat_id) in self._reply_thread_overrides:
|
|
1775
1774
|
rows.append({"actions": [self._action(f"{prefix}reset", "Reset")]})
|
|
@@ -1811,17 +1810,18 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1811
1810
|
if target_chat_id != chat_id and not await self._chat_actor_authorized(target_chat_id, from_id):
|
|
1812
1811
|
await self.send(chat_id, "Parent chat access could not be confirmed. Check access and try again.", reply_to=msg_id, metadata=metadata)
|
|
1813
1812
|
return True
|
|
1813
|
+
if action in {"on", "auto", "reset"} and not self._may_change_reply_threads(from_id, chat_type):
|
|
1814
|
+
await self.send(chat_id, "Changing reply threads needs access to /threads in this bot's Hermes settings.", reply_to=msg_id, metadata=metadata)
|
|
1815
|
+
return True
|
|
1814
1816
|
if action == "on":
|
|
1815
1817
|
self._set_reply_threads_for_chat(target_chat_id, "on")
|
|
1816
|
-
elif action == "off":
|
|
1817
|
-
self._set_reply_threads_for_chat(target_chat_id, "off")
|
|
1818
1818
|
elif action == "auto":
|
|
1819
1819
|
self._set_reply_threads_for_chat(target_chat_id, "auto")
|
|
1820
1820
|
elif action == "reset":
|
|
1821
1821
|
self._set_reply_threads_for_chat(target_chat_id, None)
|
|
1822
1822
|
|
|
1823
1823
|
if action == "help":
|
|
1824
|
-
body = "Usage: /threads status, /threads auto, /threads on,
|
|
1824
|
+
body = "Usage: /threads status, /threads auto, /threads on, or /threads reset."
|
|
1825
1825
|
await self.send(chat_id, body, reply_to=msg_id, metadata=metadata)
|
|
1826
1826
|
else:
|
|
1827
1827
|
await self._send_thread_status(
|
|
@@ -1925,9 +1925,16 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1925
1925
|
if not self._token:
|
|
1926
1926
|
self._set_fatal_error("MISSING_TOKEN", "Inline token is required in INLINE_TOKEN, INLINE_BOT_TOKEN, or Hermes Inline config", retryable=False)
|
|
1927
1927
|
return False
|
|
1928
|
+
# One gateway per bot, the native way: Hermes' machine-wide lock names the profile
|
|
1929
|
+
# already using this bot, across gateways and multiplexed profiles.
|
|
1930
|
+
if not self._acquire_bot_lock():
|
|
1931
|
+
return False
|
|
1932
|
+
# Re-register under the profile scope the gateway connects this adapter in.
|
|
1933
|
+
_configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
|
|
1928
1934
|
node_error = _node_version_error(self._node_bin)
|
|
1929
1935
|
if node_error:
|
|
1930
1936
|
self._set_fatal_error("NODE_UNSUPPORTED", node_error, retryable=False)
|
|
1937
|
+
self._release_bot_lock()
|
|
1931
1938
|
return False
|
|
1932
1939
|
self._http_client = httpx.AsyncClient(timeout=30.0, trust_env=False)
|
|
1933
1940
|
if self._autostart_sidecar:
|
|
@@ -1939,6 +1946,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1939
1946
|
await self._stop_sidecar()
|
|
1940
1947
|
await self._http_client.aclose()
|
|
1941
1948
|
self._http_client = None
|
|
1949
|
+
self._release_bot_lock()
|
|
1942
1950
|
return False
|
|
1943
1951
|
self._inbound_running = True
|
|
1944
1952
|
self._inbound_task = asyncio.get_event_loop().create_task(self._inbound_loop())
|
|
@@ -1947,6 +1955,18 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1947
1955
|
self._schedule_bot_command_sync()
|
|
1948
1956
|
return True
|
|
1949
1957
|
|
|
1958
|
+
def _acquire_bot_lock(self) -> bool:
|
|
1959
|
+
acquire = getattr(self, "_acquire_platform_lock", None)
|
|
1960
|
+
if acquire is None:
|
|
1961
|
+
return True
|
|
1962
|
+
identity = f"{self._base_url}\n{bot_state_key(self._base_url, self._token)}"
|
|
1963
|
+
return bool(acquire("inline-bot", identity, "Inline bot"))
|
|
1964
|
+
|
|
1965
|
+
def _release_bot_lock(self) -> None:
|
|
1966
|
+
release = getattr(self, "_release_platform_lock", None)
|
|
1967
|
+
if release is not None:
|
|
1968
|
+
release()
|
|
1969
|
+
|
|
1950
1970
|
async def disconnect(self) -> None:
|
|
1951
1971
|
self._inbound_running = False
|
|
1952
1972
|
if self._command_sync_task is not None:
|
|
@@ -1982,11 +2002,14 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1982
2002
|
if self._http_client is not None:
|
|
1983
2003
|
await self._http_client.aclose()
|
|
1984
2004
|
self._http_client = None
|
|
2005
|
+
self._release_bot_lock()
|
|
1985
2006
|
self._mark_disconnected()
|
|
1986
2007
|
|
|
1987
2008
|
async def _start_sidecar(self) -> None:
|
|
1988
2009
|
if not _SIDECAR_ENTRY.exists():
|
|
1989
2010
|
raise RuntimeError(f"Inline sidecar not found at {_SIDECAR_ENTRY}; run inline-hermes install again")
|
|
2011
|
+
if not self._state_path_configured:
|
|
2012
|
+
self._adopt_legacy_state()
|
|
1990
2013
|
self._state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1991
2014
|
env = _with_hermes_node_path(os.environ.copy())
|
|
1992
2015
|
env["INLINE_TOKEN"] = self._token
|
|
@@ -2137,6 +2160,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2137
2160
|
logger.warning("[inline] bot command sync skipped: no valid Hermes commands resolved")
|
|
2138
2161
|
return {"state": "failed", "count": 0}
|
|
2139
2162
|
synced = await self._set_bot_commands_with_retry(commands)
|
|
2163
|
+
self._advertised_commands = {
|
|
2164
|
+
str(command.get("command") or "").strip().lower() for command in synced if isinstance(command, dict)
|
|
2165
|
+
}
|
|
2140
2166
|
hidden_suffix = f", {hidden_count} hidden" if hidden_count else ""
|
|
2141
2167
|
logger.info("[inline] bot commands synced (%d command%s%s)", len(synced), "" if len(synced) == 1 else "s", hidden_suffix)
|
|
2142
2168
|
return {"state": "synced", "count": len(synced), "hidden": hidden_count}
|
|
@@ -2480,7 +2506,11 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2480
2506
|
|
|
2481
2507
|
raw_message_text = str(msg.get("message") or "")
|
|
2482
2508
|
text = raw_message_text.strip()
|
|
2483
|
-
|
|
2509
|
+
# The run of mentions a message starts with names who it is for; a later
|
|
2510
|
+
# mention of this bot does not override it.
|
|
2511
|
+
leading_mention_ids = self._leading_mention_user_ids(msg, raw_message_text)
|
|
2512
|
+
addressed_elsewhere = bool(self._me_id and leading_mention_ids) and self._me_id not in leading_mention_ids
|
|
2513
|
+
explicitly_mentions_me = self._message_entity_mentions_me(msg) and not addressed_elsewhere
|
|
2484
2514
|
|
|
2485
2515
|
chat_type = self._chat_type_from_message(msg)
|
|
2486
2516
|
thread_id = self._thread_id_from_message(msg)
|
|
@@ -2513,6 +2543,11 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2513
2543
|
thread_id = chat_id
|
|
2514
2544
|
parent_chat_id = info_parent_chat_id
|
|
2515
2545
|
parent_message_id = parent_message_id or info_parent_message_id
|
|
2546
|
+
# A thread under this bot's DM stays that DM for its person, like a Slack DM
|
|
2547
|
+
# thread in native Hermes: DM access, every message answered, and one session
|
|
2548
|
+
# with the turn that opened it. Anyone else in the thread keeps group rules.
|
|
2549
|
+
if from_id and from_id == await self._dm_peer_of(info_parent_chat_id):
|
|
2550
|
+
chat_type = "dm"
|
|
2516
2551
|
if not self._allowed(chat_type, from_id):
|
|
2517
2552
|
return
|
|
2518
2553
|
if chat_type == "group" and not self._chat_allowed(chat_id, thread_id, parent_chat_id):
|
|
@@ -2523,6 +2558,23 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2523
2558
|
)
|
|
2524
2559
|
if has_command_target and not command_addressed_to_me:
|
|
2525
2560
|
return
|
|
2561
|
+
# The app attaches the bot you picked a command for, and the server fills one in when it
|
|
2562
|
+
# can tell (only one bot offers it, you replied to one, or one follows the thread).
|
|
2563
|
+
starts_with_command, command_target = self._leading_command_target(msg, raw_message_text)
|
|
2564
|
+
if command_target and self._me_id:
|
|
2565
|
+
if command_target != self._me_id:
|
|
2566
|
+
return
|
|
2567
|
+
command_addressed_to_me = True
|
|
2568
|
+
elif (
|
|
2569
|
+
starts_with_command
|
|
2570
|
+
and not has_command_target
|
|
2571
|
+
and chat_type == "group"
|
|
2572
|
+
and _inline_command_name(text) in self._advertised_commands
|
|
2573
|
+
):
|
|
2574
|
+
# Several bots offer this command and nothing says which one was meant, so the
|
|
2575
|
+
# server picked none; running it here could reset or steer the wrong conversation.
|
|
2576
|
+
self._remember_observed_context(chat_id, msg, text)
|
|
2577
|
+
return
|
|
2526
2578
|
actor_authorized = self._actor_authorization(
|
|
2527
2579
|
chat_type, from_id, chat_id, thread_id=thread_id,
|
|
2528
2580
|
parent_chat_id=parent_chat_id, is_bot=sender_profile.get("bot") is True,
|
|
@@ -2533,8 +2585,26 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2533
2585
|
return
|
|
2534
2586
|
if not edit and self._is_duplicate_message_instance(chat_id, msg, event):
|
|
2535
2587
|
return
|
|
2588
|
+
reply_to_is_own = False
|
|
2589
|
+
reply_to_someone_else = False
|
|
2590
|
+
reply_to_text = None
|
|
2591
|
+
reply_to_author = None
|
|
2592
|
+
reply_to_id = str(msg.get("replyToMsgId") or "") or None
|
|
2593
|
+
if reply_to_id:
|
|
2594
|
+
reply = await self._fetch_message(chat_id, reply_to_id)
|
|
2595
|
+
if reply:
|
|
2596
|
+
reply_to_text = str(reply.get("message") or "") or None
|
|
2597
|
+
reply_to_author = str(reply.get("fromId") or "") or None
|
|
2598
|
+
reply_to_is_own = bool(self._me_id and reply_to_author == self._me_id)
|
|
2599
|
+
# Replying to your own earlier message continues your own point.
|
|
2600
|
+
reply_to_someone_else = bool(reply_to_author) and not reply_to_is_own and reply_to_author != from_id
|
|
2601
|
+
provenance_verified = event.get("_inlineSenderProvenanceVerified") is not False
|
|
2602
|
+
# Another bot reaches this one only by addressing it: a mention, or a reply to
|
|
2603
|
+
# this bot's message (how a worker hands its result back).
|
|
2604
|
+
bot_replies_to_me = provenance_verified and reply_to_is_own and not addressed_elsewhere
|
|
2536
2605
|
context_only = not explicitly_mentions_me and (
|
|
2537
|
-
|
|
2606
|
+
not provenance_verified
|
|
2607
|
+
or (sender_profile.get("bot") is True and not bot_replies_to_me)
|
|
2538
2608
|
)
|
|
2539
2609
|
if not actor_authorized:
|
|
2540
2610
|
if context_only:
|
|
@@ -2592,58 +2662,35 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2592
2662
|
):
|
|
2593
2663
|
return
|
|
2594
2664
|
text = _normalize_inline_plugin_command_text(text)
|
|
2595
|
-
reply_to_is_own = False
|
|
2596
|
-
reply_to_text = None
|
|
2597
|
-
reply_to_author = None
|
|
2598
|
-
reply_to_id = str(msg.get("replyToMsgId") or "") or None
|
|
2599
|
-
if reply_to_id:
|
|
2600
|
-
reply = await self._fetch_message(chat_id, reply_to_id)
|
|
2601
|
-
if reply:
|
|
2602
|
-
reply_to_text = str(reply.get("message") or "") or None
|
|
2603
|
-
reply_to_author = str(reply.get("fromId") or "") or None
|
|
2604
|
-
reply_to_is_own = bool(self._me_id and reply_to_author == self._me_id)
|
|
2605
|
-
|
|
2606
2665
|
mentioned = False
|
|
2607
2666
|
mention_gate_active = (
|
|
2608
2667
|
chat_type == "group"
|
|
2609
2668
|
and self.require_mention
|
|
2610
2669
|
and not self._free_response_chat(chat_id, thread_id, parent_chat_id)
|
|
2611
2670
|
)
|
|
2612
|
-
if
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
#
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2671
|
+
if chat_type == "group":
|
|
2672
|
+
follow_mode = self._chat_follow_mode(chat_info)
|
|
2673
|
+
mentioned = command_addressed_to_me or (bool(msg.get("mentioned")) and not addressed_elsewhere)
|
|
2674
|
+
reply_wakes_thread = reply_to_is_own and not addressed_elsewhere and not self._strict_mention
|
|
2675
|
+
# A reply is addressed to its author, like a leading mention: a following
|
|
2676
|
+
# bot stays out of replies to other people and bots unless it is mentioned.
|
|
2677
|
+
addressed_to_others = addressed_elsewhere or (reply_to_someone_else and not mentioned)
|
|
2678
|
+
# Ambient participation: a followed thread, or a free-response room. An
|
|
2679
|
+
# explicit unfollow always wins; mentions and replies still reach the bot.
|
|
2680
|
+
ambient = follow_mode != "unfollowed" and (
|
|
2681
|
+
not mention_gate_active or (follow_mode == "following" and not self._strict_mention)
|
|
2620
2682
|
)
|
|
2621
|
-
if not mentioned and not reply_wakes_thread and not
|
|
2622
|
-
self._remember_observed_context(chat_id, msg, text)
|
|
2623
|
-
return
|
|
2624
|
-
# A leading explicit address to another person overwhelmingly means
|
|
2625
|
-
# the turn is for them. It overrides only inferred follow/reply
|
|
2626
|
-
# attention; an explicit address to this bot still wins.
|
|
2627
|
-
if (
|
|
2628
|
-
not mentioned
|
|
2629
|
-
and (reply_wakes_thread or follow_mode_wakes_thread)
|
|
2630
|
-
and self._starts_with_other_user_mention(msg, raw_message_text)
|
|
2631
|
-
):
|
|
2683
|
+
if not mentioned and not reply_wakes_thread and (addressed_to_others or not ambient):
|
|
2632
2684
|
self._remember_observed_context(chat_id, msg, text)
|
|
2633
2685
|
return
|
|
2634
|
-
|
|
2635
|
-
text = self._clean_mention(text)
|
|
2686
|
+
self._remember_request_sender(chat_id, msg_id, sender_profile.get("bot") is True)
|
|
2636
2687
|
if edit:
|
|
2637
2688
|
text = f"message:edited:{text}" if text else "message:edited"
|
|
2638
2689
|
if (
|
|
2639
2690
|
not edit
|
|
2640
2691
|
and not agent_action
|
|
2641
2692
|
and not thread_id
|
|
2642
|
-
and self._should_create_reply_thread_for_message(
|
|
2643
|
-
chat_id=chat_id,
|
|
2644
|
-
parent_chat_id=parent_chat_id,
|
|
2645
|
-
text=text,
|
|
2646
|
-
)
|
|
2693
|
+
and self._should_create_reply_thread_for_message(chat_id=chat_id, parent_chat_id=parent_chat_id)
|
|
2647
2694
|
):
|
|
2648
2695
|
created_thread_id = await self._create_reply_thread(chat_id, msg_id, text, reply_to_id)
|
|
2649
2696
|
if created_thread_id:
|
|
@@ -2742,8 +2789,18 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2742
2789
|
"callback_data_base64": str(agent_action.get("dataBase64") or ""),
|
|
2743
2790
|
}
|
|
2744
2791
|
|
|
2792
|
+
session_chat_id = _session_chat_id(chat_id, chat_type, thread_id, parent_chat_id)
|
|
2793
|
+
try:
|
|
2794
|
+
from . import tools as _inline_tools
|
|
2795
|
+
_inline_tools.remember_turn_message(
|
|
2796
|
+
session_chat_id=session_chat_id, thread_id=str(thread_id or ""), message_id=msg_id,
|
|
2797
|
+
chat_id=chat_id, sender_id=from_id, sender_is_bot=sender_profile.get("bot") is True,
|
|
2798
|
+
chat_type=chat_type, in_reply_thread=bool(parent_chat_id) and parent_chat_id != chat_id,
|
|
2799
|
+
)
|
|
2800
|
+
except Exception:
|
|
2801
|
+
logger.debug("[inline] failed to record the turn's message for tools", exc_info=True)
|
|
2745
2802
|
source = self.build_source(
|
|
2746
|
-
chat_id=
|
|
2803
|
+
chat_id=session_chat_id,
|
|
2747
2804
|
chat_name=chat_name,
|
|
2748
2805
|
chat_type=chat_type,
|
|
2749
2806
|
user_id=from_id,
|
|
@@ -3330,28 +3387,45 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3330
3387
|
text = str(value).strip()
|
|
3331
3388
|
return text or None
|
|
3332
3389
|
|
|
3333
|
-
def
|
|
3334
|
-
if
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3390
|
+
def _leading_command_target(self, msg: Dict[str, Any], message_text: str) -> tuple[bool, Optional[str]]:
|
|
3391
|
+
"""Whether the message starts with a bot command, and the bot it names if any."""
|
|
3392
|
+
units = message_text.encode("utf-16-le")
|
|
3393
|
+
for entity in self._message_entities(msg):
|
|
3394
|
+
if self._entity_kind(entity) not in {"bot_command", "botcommand"}:
|
|
3395
|
+
continue
|
|
3338
3396
|
offset = _to_int(entity.get("offset"))
|
|
3339
|
-
if offset is
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3397
|
+
if offset is None or offset < 0:
|
|
3398
|
+
continue
|
|
3399
|
+
if units[: offset * 2].decode("utf-16-le", errors="ignore").strip():
|
|
3400
|
+
continue
|
|
3401
|
+
return True, self._entity_id(self._entity_payload(entity, "botCommand"), "botUserId")
|
|
3402
|
+
return False, None
|
|
3403
|
+
|
|
3404
|
+
def _leading_mention_user_ids(self, msg: Dict[str, Any], message_text: str) -> List[str]:
|
|
3405
|
+
"""User ids in the run of mentions a message starts with, in order."""
|
|
3406
|
+
mentions: List[tuple[int, int, str]] = []
|
|
3407
|
+
for entity in self._message_entities(msg):
|
|
3408
|
+
if self._entity_kind(entity) != "mention":
|
|
3409
|
+
continue
|
|
3410
|
+
offset = _to_int(entity.get("offset"))
|
|
3411
|
+
length = _to_int(entity.get("length"))
|
|
3412
|
+
user_id = self._entity_id(self._entity_payload(entity, "mention"), "userId")
|
|
3413
|
+
if offset is None or length is None or offset < 0 or length <= 0 or not user_id:
|
|
3414
|
+
continue
|
|
3415
|
+
mentions.append((offset, length, user_id))
|
|
3416
|
+
units = message_text.encode("utf-16-le")
|
|
3417
|
+
leading: List[str] = []
|
|
3418
|
+
cursor = 0
|
|
3419
|
+
for offset, length, user_id in sorted(mentions):
|
|
3420
|
+
try:
|
|
3421
|
+
between = units[cursor * 2 : offset * 2].decode("utf-16-le")
|
|
3422
|
+
except UnicodeDecodeError:
|
|
3423
|
+
break
|
|
3424
|
+
if offset < cursor or between.replace(",", "").strip():
|
|
3425
|
+
break
|
|
3426
|
+
leading.append(user_id)
|
|
3427
|
+
cursor = offset + length
|
|
3428
|
+
return leading
|
|
3355
3429
|
|
|
3356
3430
|
def _format_entity_summary(self, entity: Dict[str, Any], message_text: str) -> Optional[str]:
|
|
3357
3431
|
kind = self._entity_kind(entity)
|
|
@@ -3844,15 +3918,60 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3844
3918
|
if key:
|
|
3845
3919
|
self._chat_info_cache.pop(key, None)
|
|
3846
3920
|
|
|
3847
|
-
def
|
|
3921
|
+
def _adopt_legacy_state(self) -> None:
|
|
3922
|
+
legacy = legacy_state_dir() / "sdk-state.json"
|
|
3923
|
+
if self._state_path.exists() or not legacy.exists():
|
|
3924
|
+
return
|
|
3925
|
+
if not may_adopt_legacy_state():
|
|
3926
|
+
logger.warning(
|
|
3927
|
+
"[inline] not reusing shared legacy state %s for this profile; catching up from now", legacy,
|
|
3928
|
+
)
|
|
3929
|
+
return
|
|
3930
|
+
try:
|
|
3931
|
+
self._state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3932
|
+
shutil.copy2(legacy, self._state_path)
|
|
3933
|
+
logger.info("[inline] adopted legacy state %s as %s", legacy, self._state_path)
|
|
3934
|
+
except OSError as exc:
|
|
3935
|
+
logger.warning("[inline] could not adopt legacy state %s: %s", legacy, exc)
|
|
3936
|
+
|
|
3937
|
+
async def _reply_to_for_target(
|
|
3938
|
+
self, reply_to: Optional[str], target: Dict[str, str], *, request_chat_id: str = "", anchored: bool = False,
|
|
3939
|
+
) -> Optional[str]:
|
|
3940
|
+
"""Answers quote the message only when it came from a bot: a bot wakes on replies to its
|
|
3941
|
+
own messages, so that is how an answer reaches it. People get plain answers."""
|
|
3848
3942
|
if not reply_to:
|
|
3849
3943
|
return None
|
|
3850
|
-
target_chat_id = target.get("chatId")
|
|
3944
|
+
target_chat_id = target.get("chatId") or ""
|
|
3851
3945
|
if target_chat_id:
|
|
3852
3946
|
suppressed = self._reply_thread_parent_reply_ids.get(self._chat_key(target_chat_id), set())
|
|
3853
3947
|
if str(reply_to) in suppressed:
|
|
3854
3948
|
return None
|
|
3855
|
-
|
|
3949
|
+
if anchored:
|
|
3950
|
+
return str(reply_to)
|
|
3951
|
+
key = f"{self._chat_key(target_chat_id)}:{reply_to}"
|
|
3952
|
+
sender_is_bot = self._request_sender_is_bot.get(key)
|
|
3953
|
+
if sender_is_bot is None and target_chat_id and self._chat_key(request_chat_id) == self._chat_key(target_chat_id):
|
|
3954
|
+
info = await self._get_chat_info(target_chat_id)
|
|
3955
|
+
if not info:
|
|
3956
|
+
return None
|
|
3957
|
+
if self._chat_info_id(info, "parentChatId") and self._chat_info_id(info, "parentMessageId") == str(reply_to):
|
|
3958
|
+
# Hermes keys an opener on its child session chat. After a restart that is
|
|
3959
|
+
# indistinguishable from an equal message id in the child; do not guess.
|
|
3960
|
+
return None
|
|
3961
|
+
# Not seen since this process started, such as an answer finishing after a restart.
|
|
3962
|
+
# Message ids are per chat, so only look it up where the request was sent; an answer
|
|
3963
|
+
# moved into a thread would otherwise read the thread's message with the same id.
|
|
3964
|
+
fetched = await self._fetch_message(target_chat_id, str(reply_to))
|
|
3965
|
+
sender_is_bot = _inline_sender_profile({}, fetched).get("bot") is True if fetched else False
|
|
3966
|
+
self._remember_request_sender(target_chat_id, str(reply_to), sender_is_bot)
|
|
3967
|
+
return str(reply_to) if sender_is_bot else None
|
|
3968
|
+
|
|
3969
|
+
def _remember_request_sender(self, chat_id: str, msg_id: str, sender_is_bot: bool) -> None:
|
|
3970
|
+
key = f"{self._chat_key(chat_id)}:{msg_id}"
|
|
3971
|
+
self._request_sender_is_bot[key] = sender_is_bot
|
|
3972
|
+
self._request_sender_is_bot.move_to_end(key)
|
|
3973
|
+
while len(self._request_sender_is_bot) > _CHAT_INFO_CACHE_MAX_SIZE:
|
|
3974
|
+
self._request_sender_is_bot.popitem(last=False)
|
|
3856
3975
|
|
|
3857
3976
|
async def _get_chat_info(self, chat_id: str, *, required: bool = False) -> Dict[str, Any]:
|
|
3858
3977
|
target = _target_from_chat_id(chat_id)
|
|
@@ -3885,6 +4004,22 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3885
4004
|
self._chat_info_cache.popitem(last=False)
|
|
3886
4005
|
return info
|
|
3887
4006
|
|
|
4007
|
+
async def _dm_peer_of(self, chat_id: str) -> Optional[str]:
|
|
4008
|
+
"""The person in this bot's DM, when chat_id is one."""
|
|
4009
|
+
try:
|
|
4010
|
+
info = await asyncio.wait_for(self._get_chat_info(chat_id), timeout=_INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS)
|
|
4011
|
+
except Exception:
|
|
4012
|
+
return None
|
|
4013
|
+
if not info or self._bot_settings_chat_type(info) != "dm":
|
|
4014
|
+
return None
|
|
4015
|
+
peer = info.get("peer")
|
|
4016
|
+
if peer is None and isinstance(info.get("chat"), dict):
|
|
4017
|
+
peer = info["chat"].get("peerId")
|
|
4018
|
+
peer_type = (peer.get("peer") or peer.get("type") or {}) if isinstance(peer, dict) else {}
|
|
4019
|
+
user = peer_type.get("user") if isinstance(peer_type, dict) else None
|
|
4020
|
+
user_id = str((user or {}).get("userId") or "").strip() if isinstance(user, dict) else ""
|
|
4021
|
+
return user_id or None
|
|
4022
|
+
|
|
3888
4023
|
@staticmethod
|
|
3889
4024
|
def _chat_info_id(info: Dict[str, Any], key: str) -> Optional[str]:
|
|
3890
4025
|
value = info.get(key)
|
|
@@ -3896,21 +4031,28 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
3896
4031
|
return text or None
|
|
3897
4032
|
|
|
3898
4033
|
@staticmethod
|
|
3899
|
-
def
|
|
4034
|
+
def _chat_follow_mode(info: Dict[str, Any]) -> Optional[str]:
|
|
4035
|
+
"""This bot's own follow state in the chat: "following", "unfollowed" or None."""
|
|
3900
4036
|
value = info.get("dialogFollowMode")
|
|
3901
4037
|
dialog = info.get("dialog")
|
|
3902
4038
|
if value is None and isinstance(dialog, dict):
|
|
3903
4039
|
value = dialog.get("followMode")
|
|
3904
4040
|
if value is None and isinstance(dialog, dict):
|
|
3905
4041
|
value = dialog.get("follow_mode")
|
|
3906
|
-
if value is None:
|
|
3907
|
-
return
|
|
3908
|
-
if isinstance(value, bool):
|
|
3909
|
-
return False
|
|
4042
|
+
if value is None or isinstance(value, bool):
|
|
4043
|
+
return None
|
|
3910
4044
|
if isinstance(value, int):
|
|
3911
|
-
return
|
|
4045
|
+
return {1: "following", 2: "unfollowed"}.get(value)
|
|
3912
4046
|
text = str(value).strip().lower()
|
|
3913
|
-
|
|
4047
|
+
if text in {"1", "following", "follow_mode_following", "dialog_following"}:
|
|
4048
|
+
return "following"
|
|
4049
|
+
if text in {"2", "unfollowed", "follow_mode_unfollowed", "dialog_unfollowed"}:
|
|
4050
|
+
return "unfollowed"
|
|
4051
|
+
return None
|
|
4052
|
+
|
|
4053
|
+
@classmethod
|
|
4054
|
+
def _chat_follow_mode_following(cls, info: Dict[str, Any]) -> bool:
|
|
4055
|
+
return cls._chat_follow_mode(info) == "following"
|
|
3914
4056
|
|
|
3915
4057
|
@staticmethod
|
|
3916
4058
|
def _chat_title_from_info(info: Dict[str, Any]) -> Optional[str]:
|
|
@@ -4253,7 +4395,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4253
4395
|
await self._answer_action(interaction_id, "Thread controls expired")
|
|
4254
4396
|
return True
|
|
4255
4397
|
_, session_id, choice = parts
|
|
4256
|
-
if choice not in {"auto", "on", "off", "reset"}:
|
|
4398
|
+
if choice not in {"auto", "on", "off", "reset"}: # off: buttons sent before 1.0
|
|
4257
4399
|
await self._answer_action(interaction_id, "Thread controls expired")
|
|
4258
4400
|
return True
|
|
4259
4401
|
state = self._thread_action_state(session_id, chat_id)
|
|
@@ -4266,11 +4408,14 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4266
4408
|
return True
|
|
4267
4409
|
if not await self._action_allowed(event):
|
|
4268
4410
|
return True
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
):
|
|
4411
|
+
actor_id = str(event.get("actorUserId") or "")
|
|
4412
|
+
if target_chat_id != chat_id and not await self._chat_actor_authorized(target_chat_id, actor_id):
|
|
4272
4413
|
await self._answer_action(interaction_id, "Parent chat access could not be confirmed. Check access and try again.")
|
|
4273
4414
|
return True
|
|
4415
|
+
scope = await self._message_scope(chat_id, await self._fetch_message(chat_id, str(event.get("messageId") or "")) or {})
|
|
4416
|
+
if not self._may_change_reply_threads(actor_id, scope[0] if scope else "group"):
|
|
4417
|
+
await self._answer_action(interaction_id, "Not authorized")
|
|
4418
|
+
return True
|
|
4274
4419
|
try:
|
|
4275
4420
|
if choice == "reset":
|
|
4276
4421
|
self._set_reply_threads_for_chat(target_chat_id, None)
|
|
@@ -4603,9 +4748,13 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4603
4748
|
if actions is not None:
|
|
4604
4749
|
body["actions"] = actions
|
|
4605
4750
|
result = await self._send_sidecar("/edit", body)
|
|
4751
|
+
if not result.success and getattr(result, "retryable", False):
|
|
4752
|
+
# A transient failure would leave the answered card's buttons in place.
|
|
4753
|
+
await asyncio.sleep(_ACTION_EDIT_RETRY_DELAY_SECONDS)
|
|
4754
|
+
result = await self._send_sidecar("/edit", body)
|
|
4606
4755
|
if result.success:
|
|
4607
4756
|
return result
|
|
4608
|
-
logger.
|
|
4757
|
+
logger.warning("[inline] could not update the answered action card; posting the outcome instead: %s", result.error)
|
|
4609
4758
|
return await self.send(chat_id, text)
|
|
4610
4759
|
|
|
4611
4760
|
async def _answer_action(self, interaction_id: str, toast: str) -> None:
|
|
@@ -4624,6 +4773,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4624
4773
|
metadata: Optional[Dict[str, Any]] = None,
|
|
4625
4774
|
actions: Optional[Dict[str, Any]] = None,
|
|
4626
4775
|
) -> SendResult:
|
|
4776
|
+
metadata = self._with_turn_thread(chat_id, reply_to, metadata)
|
|
4627
4777
|
target = self._target_for(chat_id, metadata)
|
|
4628
4778
|
agent_action_target = parse_inline_agent_action_reply_target(reply_to)
|
|
4629
4779
|
if agent_action_target:
|
|
@@ -4667,7 +4817,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4667
4817
|
first_result.error,
|
|
4668
4818
|
)
|
|
4669
4819
|
reply_to = agent_action_target
|
|
4670
|
-
reply_to = self._reply_to_for_target(reply_to, target)
|
|
4820
|
+
reply_to = await self._reply_to_for_target(reply_to, target, request_chat_id=chat_id, anchored=bool(agent_action_target))
|
|
4671
4821
|
chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
|
|
4672
4822
|
message_ids: List[str] = []
|
|
4673
4823
|
raw_responses: List[Any] = []
|
|
@@ -4839,7 +4989,8 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4839
4989
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
4840
4990
|
try:
|
|
4841
4991
|
target = self._typing_target_for(chat_id, metadata)
|
|
4842
|
-
|
|
4992
|
+
# Agents show "working", not "typing"; clients without it see typing.
|
|
4993
|
+
await self._sidecar_call("/typing", {"target": target, "state": "working"})
|
|
4843
4994
|
self._active_typing_targets[self._typing_target_key(chat_id, metadata)] = target
|
|
4844
4995
|
except Exception as exc:
|
|
4845
4996
|
logger.debug("[inline] typing failed: %s", exc)
|
|
@@ -4868,7 +5019,10 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4868
5019
|
if (kind and kind != "message.new") or raw_message.get("_inlineAgentAction"):
|
|
4869
5020
|
return None
|
|
4870
5021
|
source = getattr(event, "source", None)
|
|
4871
|
-
|
|
5022
|
+
# React where the message physically is: an opener that starts a reply thread is keyed
|
|
5023
|
+
# to the thread's session but still lives in the parent chat.
|
|
5024
|
+
physical_chat_id = raw_message.get("chatId") if isinstance(raw_message, dict) else None
|
|
5025
|
+
chat_id = str(physical_chat_id or getattr(source, "chat_id", None) or "").strip()
|
|
4872
5026
|
message_id = str(getattr(event, "message_id", None) or "").strip()
|
|
4873
5027
|
if not chat_id or not message_id:
|
|
4874
5028
|
return None
|
|
@@ -4979,7 +5133,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4979
5133
|
return _send_result(success=False, error=f"attachment exceeds Inline upload cap ({actual} > {limit})", error_kind="too_long")
|
|
4980
5134
|
mime_type, _ = mimetypes.guess_type(safe_path)
|
|
4981
5135
|
target = self._target_for(chat_id, metadata)
|
|
4982
|
-
reply_to = self._reply_to_for_target(reply_to, target)
|
|
5136
|
+
reply_to = await self._reply_to_for_target(reply_to, target, request_chat_id=chat_id)
|
|
4983
5137
|
body = {
|
|
4984
5138
|
"target": target,
|
|
4985
5139
|
"path": safe_path,
|
|
@@ -5540,7 +5694,7 @@ def _standalone_attachment_kind(path: str, is_voice: bool, force_document: bool)
|
|
|
5540
5694
|
def _inline_threads_command_handler(raw_args: str) -> str:
|
|
5541
5695
|
text = f"/threads {str(raw_args or '').strip()}".strip()
|
|
5542
5696
|
action = InlineAdapter._thread_command_action(text) or "help"
|
|
5543
|
-
usage = "Usage: /threads status, /threads auto, /threads on,
|
|
5697
|
+
usage = "Usage: /threads status, /threads auto, /threads on, or /threads reset."
|
|
5544
5698
|
if action == "help":
|
|
5545
5699
|
return usage
|
|
5546
5700
|
return (
|