hermes-irc-extras 0.2.0__py3-none-any.whl
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.
- hermes_irc_extras/__init__.py +50 -0
- hermes_irc_extras/patches.py +583 -0
- hermes_irc_extras/py.typed +1 -0
- hermes_irc_extras/storage.py +580 -0
- hermes_irc_extras/tools.py +385 -0
- hermes_irc_extras-0.2.0.dist-info/METADATA +137 -0
- hermes_irc_extras-0.2.0.dist-info/RECORD +11 -0
- hermes_irc_extras-0.2.0.dist-info/WHEEL +5 -0
- hermes_irc_extras-0.2.0.dist-info/entry_points.txt +2 -0
- hermes_irc_extras-0.2.0.dist-info/licenses/LICENSE +21 -0
- hermes_irc_extras-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""hermes-irc-extras — Additional features and security extensions for the IRC relay.
|
|
2
|
+
|
|
3
|
+
A standalone, fork-free Hermes Agent plugin.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
__version__ = "0.2.0"
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(ctx=None) -> None:
|
|
16
|
+
"""Plugin entry point — called once at startup by the Hermes plugin loader.
|
|
17
|
+
|
|
18
|
+
Safely installs the IRC extras monkeypatches onto the core adapter modules
|
|
19
|
+
without modifying the core files on disk. If the host environment does not
|
|
20
|
+
support these extensions, logs a warning and exits cleanly without throwing.
|
|
21
|
+
|
|
22
|
+
When channel logging is enabled — in the environment or in ``config.yaml``
|
|
23
|
+
— also registers the log inspection tools so the agent can query scrollback
|
|
24
|
+
on demand. With logging off (the default) no tools are registered at all.
|
|
25
|
+
The context is handed to the patch module as well: an adapter whose own
|
|
26
|
+
config turns logging on can then register the tools itself, late.
|
|
27
|
+
"""
|
|
28
|
+
from .patches import apply_patches, note_plugin_context
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
active = apply_patches()
|
|
32
|
+
except Exception:
|
|
33
|
+
logger.exception("hermes-irc-extras: unexpected error during initialization")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from .tools import register_tools
|
|
38
|
+
|
|
39
|
+
note_plugin_context(ctx, register_tools(ctx))
|
|
40
|
+
except Exception:
|
|
41
|
+
# A tool-registration failure must not cost the host the SSL patches.
|
|
42
|
+
logger.exception("hermes-irc-extras: failed to register IRC channel log tools")
|
|
43
|
+
|
|
44
|
+
if active:
|
|
45
|
+
logger.info("hermes-irc-extras v%s active", __version__)
|
|
46
|
+
else:
|
|
47
|
+
logger.warning(
|
|
48
|
+
"hermes-irc-extras v%s loaded but INACTIVE (host unavailable "
|
|
49
|
+
"or unsupported adapter signature)", __version__
|
|
50
|
+
)
|
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""Monkeypatches injecting the IRC extras into the core IRC adapter.
|
|
2
|
+
|
|
3
|
+
Two features live here:
|
|
4
|
+
|
|
5
|
+
* SSL verification bypass (``IRC_ALLOW_INVALID_SSL``).
|
|
6
|
+
* Opt-in passive channel logging (``IRC_ENABLE_CHANNEL_LOGGING``), which taps
|
|
7
|
+
``IRCAdapter._handle_line`` *ahead* of the adapter's own addressing and
|
|
8
|
+
authorization gates. Every PRIVMSG is written to SQLite; the original method
|
|
9
|
+
then applies its unchanged early-returns, so unaddressed and unauthorized
|
|
10
|
+
traffic is recorded without ever reaching the turn machinery — zero LLM
|
|
11
|
+
cost for chatter the agent is only observing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import ssl
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_patched = False
|
|
27
|
+
_warned = False
|
|
28
|
+
|
|
29
|
+
#: The host registration context and whether the log tools were already
|
|
30
|
+
#: registered with it, remembered by :func:`note_plugin_context`. Channel
|
|
31
|
+
#: logging can be enabled in ``config.yaml`` alone, and the adapter's config —
|
|
32
|
+
#: the authoritative source for that — only exists long after the plugin was
|
|
33
|
+
#: loaded, so the tools may have to be registered late.
|
|
34
|
+
_plugin_ctx: Any = None
|
|
35
|
+
_log_tools_registered = False
|
|
36
|
+
|
|
37
|
+
#: Minimum wall-clock gap between opportunistic retention prunes, per adapter.
|
|
38
|
+
_PRUNE_INTERVAL_SECONDS = 3600.0
|
|
39
|
+
|
|
40
|
+
#: Marker set on our wrappers so re-applying the patches (the unit tests reset
|
|
41
|
+
#: ``_patched`` and re-patch) cannot chain a wrapper onto itself — for the
|
|
42
|
+
#: logging tap that would mean every message stored twice.
|
|
43
|
+
_WRAPPED_ATTR = "_irc_extras_wrapped"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def apply_patches() -> bool:
|
|
47
|
+
"""Register lazy patches to execute exactly when the IRC platform is loaded.
|
|
48
|
+
|
|
49
|
+
Returns True if successfully registered.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
from gateway.platform_registry import platform_registry
|
|
53
|
+
except ImportError:
|
|
54
|
+
# Host is unavailable (e.g. running mock unit tests)
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
_orig_get = platform_registry.get
|
|
58
|
+
|
|
59
|
+
def _wrapped_get(name: str) -> Any | None:
|
|
60
|
+
entry = _orig_get(name)
|
|
61
|
+
if name == "irc":
|
|
62
|
+
_apply_irc_patches()
|
|
63
|
+
return entry
|
|
64
|
+
|
|
65
|
+
platform_registry.get = _wrapped_get
|
|
66
|
+
|
|
67
|
+
# Register the environment metadata on load
|
|
68
|
+
_inject_metadata_declarations()
|
|
69
|
+
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def note_plugin_context(ctx: Any, registered: bool) -> None:
|
|
74
|
+
"""Remember the host context and whether the log tools went on it.
|
|
75
|
+
|
|
76
|
+
Called once by :func:`hermes_irc_extras.register`. Keeping the context
|
|
77
|
+
lets an adapter that turns out to have channel logging enabled — in
|
|
78
|
+
``config.yaml``, which the plugin loader could not see — still expose the
|
|
79
|
+
query tools instead of logging into a database nothing can read.
|
|
80
|
+
"""
|
|
81
|
+
global _plugin_ctx, _log_tools_registered
|
|
82
|
+
_plugin_ctx = ctx
|
|
83
|
+
_log_tools_registered = bool(registered)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
#: Env vars this plugin contributes to the WebUI / Desktop UI config surface.
|
|
87
|
+
_OPTIONAL_ENV_VAR_METADATA: dict[str, dict[str, Any]] = {
|
|
88
|
+
"IRC_ALLOW_INVALID_SSL": {
|
|
89
|
+
"description": (
|
|
90
|
+
"Accept invalid/self-signed IRC TLS certificates (true/false). "
|
|
91
|
+
"Disables certificate and hostname verification — the connection "
|
|
92
|
+
"stays encrypted but is no longer authenticated. Default: false."
|
|
93
|
+
),
|
|
94
|
+
"prompt": "Accept invalid/self-signed SSL certs (true/false)",
|
|
95
|
+
"url": None,
|
|
96
|
+
"password": False,
|
|
97
|
+
"category": "messaging",
|
|
98
|
+
"advanced": True,
|
|
99
|
+
},
|
|
100
|
+
"IRC_ENABLE_CHANNEL_LOGGING": {
|
|
101
|
+
"description": (
|
|
102
|
+
"Passively log all IRC channel messages to a local SQLite database "
|
|
103
|
+
"(true/false), including messages that are not addressed to the bot "
|
|
104
|
+
"and messages from users outside IRC_ALLOWED_USERS. Direct messages "
|
|
105
|
+
"to the bot are logged too, and every record keeps the sender's nick "
|
|
106
|
+
"and user@host (their hostname or cloak) alongside the message text. "
|
|
107
|
+
"Unaddressed traffic is still never answered and costs no agent "
|
|
108
|
+
"turns; the log is only readable via the search_irc_logs and "
|
|
109
|
+
"get_channel_history tools. Records third parties' chat and "
|
|
110
|
+
"identifying metadata — check local expectations, and any network "
|
|
111
|
+
"or data-protection policy, before enabling. Default: false."
|
|
112
|
+
),
|
|
113
|
+
"prompt": "Passively log IRC channel messages to SQLite (true/false)",
|
|
114
|
+
"url": None,
|
|
115
|
+
"password": False,
|
|
116
|
+
"category": "messaging",
|
|
117
|
+
"advanced": True,
|
|
118
|
+
},
|
|
119
|
+
"IRC_CHANNEL_LOG_DB_PATH": {
|
|
120
|
+
"description": (
|
|
121
|
+
"Absolute path to the IRC channel log SQLite database. Holds "
|
|
122
|
+
"channel and direct-message text plus each sender's user@host; new "
|
|
123
|
+
"database files are created with owner-only (0600) permissions. "
|
|
124
|
+
"Default: {profile}/state/irc_channel_logs.db."
|
|
125
|
+
),
|
|
126
|
+
"prompt": "IRC channel log database path (blank for default)",
|
|
127
|
+
"url": None,
|
|
128
|
+
"password": False,
|
|
129
|
+
"category": "messaging",
|
|
130
|
+
"advanced": True,
|
|
131
|
+
},
|
|
132
|
+
"IRC_CHANNEL_LOG_RETENTION_DAYS": {
|
|
133
|
+
"description": (
|
|
134
|
+
"Days of IRC channel and direct-message scrollback to retain; older "
|
|
135
|
+
"records (message text and sender user@host alike) are pruned "
|
|
136
|
+
"automatically. Use 0 to keep everything forever. Default: 14."
|
|
137
|
+
),
|
|
138
|
+
"prompt": "IRC channel log retention in days",
|
|
139
|
+
"url": None,
|
|
140
|
+
"password": False,
|
|
141
|
+
"category": "messaging",
|
|
142
|
+
"advanced": True,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _inject_metadata_declarations() -> None:
|
|
148
|
+
"""Inject the new environment variable metadata into OPTIONAL_ENV_VARS dynamically.
|
|
149
|
+
|
|
150
|
+
This ensures that the WebUI and Desktop UI can render the checkmark option
|
|
151
|
+
on the Channels page for IRC.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
from hermes_cli.config_defaults import OPTIONAL_ENV_VARS
|
|
155
|
+
|
|
156
|
+
for name, metadata in _OPTIONAL_ENV_VAR_METADATA.items():
|
|
157
|
+
if name not in OPTIONAL_ENV_VARS:
|
|
158
|
+
OPTIONAL_ENV_VARS[name] = dict(metadata)
|
|
159
|
+
except Exception:
|
|
160
|
+
logger.debug("Could not inject optional env var metadata", exc_info=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _apply_irc_patches() -> None:
|
|
164
|
+
"""Surgically apply patches to the IRC adapter module(s)."""
|
|
165
|
+
global _patched
|
|
166
|
+
if _patched:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
target_mods = []
|
|
170
|
+
# Check if loaded under hermes_plugins namespace
|
|
171
|
+
if "hermes_plugins.irc_platform.adapter" in sys.modules:
|
|
172
|
+
target_mods.append(sys.modules["hermes_plugins.irc_platform.adapter"])
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
import plugins.platforms.irc.adapter as adapter_mod
|
|
176
|
+
if adapter_mod not in target_mods:
|
|
177
|
+
target_mods.append(adapter_mod)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
for mod in list(sys.modules.values()):
|
|
182
|
+
name = getattr(mod, "__name__", "")
|
|
183
|
+
if name and (
|
|
184
|
+
name.endswith("irc_platform.adapter") or name.endswith("platforms.irc.adapter")
|
|
185
|
+
):
|
|
186
|
+
if mod not in target_mods:
|
|
187
|
+
target_mods.append(mod)
|
|
188
|
+
|
|
189
|
+
if not target_mods:
|
|
190
|
+
logger.warning("hermes-irc-extras: Could not locate IRC adapter module")
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
logger.debug(
|
|
194
|
+
"hermes-irc-extras: Applying lazy patches to %d IRC adapter module(s)",
|
|
195
|
+
len(target_mods),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
patched_any = False
|
|
199
|
+
for mod in target_mods:
|
|
200
|
+
try:
|
|
201
|
+
_patch_adapter_module(mod)
|
|
202
|
+
patched_any = True
|
|
203
|
+
except Exception:
|
|
204
|
+
logger.warning(
|
|
205
|
+
"hermes-irc-extras: Failed to apply IRC adapter patches to %s",
|
|
206
|
+
getattr(mod, "__name__", "unknown"),
|
|
207
|
+
exc_info=True,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if patched_any:
|
|
211
|
+
_patched = True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _patch_adapter_module(adapter_mod: Any) -> None:
|
|
215
|
+
"""Apply every individual patch, skipping any whose target has drifted away."""
|
|
216
|
+
# 1. Define custom context builder. The original is taken from the real `ssl`
|
|
217
|
+
# module so that re-applying the patches can never chain onto our own shim.
|
|
218
|
+
_orig_create_default_context = ssl.create_default_context
|
|
219
|
+
|
|
220
|
+
def _new_build_ssl_context(allow_invalid: bool, *args: Any, **kwargs: Any) -> Any:
|
|
221
|
+
global _warned
|
|
222
|
+
ctx = _orig_create_default_context(*args, **kwargs)
|
|
223
|
+
if allow_invalid:
|
|
224
|
+
ctx.check_hostname = False
|
|
225
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
226
|
+
if not _warned:
|
|
227
|
+
_warned = True
|
|
228
|
+
# Use module logger to match output expectations
|
|
229
|
+
logging.getLogger("plugins.platforms.irc.adapter").warning(
|
|
230
|
+
"IRC: IRC_ALLOW_INVALID_SSL is enabled — TLS certificate and "
|
|
231
|
+
"hostname verification are disabled. The connection is encrypted "
|
|
232
|
+
"but not authenticated (vulnerable to MITM); use only with "
|
|
233
|
+
"servers you control."
|
|
234
|
+
)
|
|
235
|
+
return ctx
|
|
236
|
+
|
|
237
|
+
def _resolve_allow_invalid() -> bool:
|
|
238
|
+
"""Resolve the flag from the environment first, then from caller frame locals."""
|
|
239
|
+
env = (os.getenv("IRC_ALLOW_INVALID_SSL") or "").strip()
|
|
240
|
+
if env:
|
|
241
|
+
return env.lower() in {"1", "true", "yes"}
|
|
242
|
+
try:
|
|
243
|
+
frame = sys._getframe(1)
|
|
244
|
+
while frame:
|
|
245
|
+
locals_ = frame.f_locals
|
|
246
|
+
if "self" in locals_:
|
|
247
|
+
self_obj = locals_["self"]
|
|
248
|
+
if self_obj.__class__.__name__ == "IRCAdapter":
|
|
249
|
+
return bool(getattr(self_obj, "allow_invalid_ssl", False))
|
|
250
|
+
if "config" in locals_:
|
|
251
|
+
config_obj = locals_["config"]
|
|
252
|
+
if hasattr(config_obj, "extra"):
|
|
253
|
+
extra = config_obj.extra or {}
|
|
254
|
+
val = extra.get("allow_invalid_ssl", False)
|
|
255
|
+
if isinstance(val, str):
|
|
256
|
+
return val.strip().lower() in {"1", "true", "yes"}
|
|
257
|
+
return bool(val)
|
|
258
|
+
frame = frame.f_back
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
class _IRCSSLShim:
|
|
264
|
+
"""Stand-in for the `ssl` name inside the IRC adapter module ONLY.
|
|
265
|
+
|
|
266
|
+
Every attribute except ``create_default_context`` is delegated to the real
|
|
267
|
+
``ssl`` module, so the global module is never mutated and other subsystems
|
|
268
|
+
(provider auth, email, ...) keep full certificate verification.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
__slots__ = ("_real_ssl",)
|
|
272
|
+
|
|
273
|
+
def __init__(self, real_ssl: Any) -> None:
|
|
274
|
+
self._real_ssl = real_ssl
|
|
275
|
+
|
|
276
|
+
def create_default_context(self, *args: Any, **kwargs: Any) -> Any:
|
|
277
|
+
return _new_build_ssl_context(_resolve_allow_invalid(), *args, **kwargs)
|
|
278
|
+
|
|
279
|
+
def __getattr__(self, name: str) -> Any:
|
|
280
|
+
return getattr(object.__getattribute__(self, "_real_ssl"), name)
|
|
281
|
+
|
|
282
|
+
# 2. Shadow the adapter module's `ssl` name with the shim. The global `ssl`
|
|
283
|
+
# module is deliberately left untouched.
|
|
284
|
+
if hasattr(adapter_mod, "ssl"):
|
|
285
|
+
adapter_mod.ssl = _IRCSSLShim(ssl)
|
|
286
|
+
else:
|
|
287
|
+
logger.warning("hermes-irc-extras: skipping ssl shim — adapter has no `ssl` attribute")
|
|
288
|
+
|
|
289
|
+
# 3. Define helper for config resolution
|
|
290
|
+
def _new_allow_invalid_ssl(extra: dict[str, Any] | None = None) -> bool:
|
|
291
|
+
env = (os.getenv("IRC_ALLOW_INVALID_SSL") or "").strip()
|
|
292
|
+
if env:
|
|
293
|
+
return env.lower() in {"1", "true", "yes"}
|
|
294
|
+
val = (extra or {}).get("allow_invalid_ssl", False)
|
|
295
|
+
if isinstance(val, str):
|
|
296
|
+
return val.strip().lower() in {"1", "true", "yes"}
|
|
297
|
+
return bool(val)
|
|
298
|
+
|
|
299
|
+
adapter_mod._allow_invalid_ssl = _new_allow_invalid_ssl
|
|
300
|
+
adapter_mod._build_ssl_context = _new_build_ssl_context
|
|
301
|
+
|
|
302
|
+
# 4. Wrap IRCAdapter.__init__ to store self.allow_invalid_ssl and resolve
|
|
303
|
+
# the channel-logging settings, then install the ingestion tap.
|
|
304
|
+
if hasattr(adapter_mod, "IRCAdapter"):
|
|
305
|
+
_orig_init = adapter_mod.IRCAdapter.__init__
|
|
306
|
+
|
|
307
|
+
if not getattr(_orig_init, _WRAPPED_ATTR, False):
|
|
308
|
+
|
|
309
|
+
def _wrapped_init(self, config: Any, **kwargs: Any) -> None:
|
|
310
|
+
_orig_init(self, config, **kwargs)
|
|
311
|
+
extra = getattr(config, "extra", {}) or {}
|
|
312
|
+
self.allow_invalid_ssl = _new_allow_invalid_ssl(extra)
|
|
313
|
+
_init_channel_logging(self, extra)
|
|
314
|
+
|
|
315
|
+
setattr(_wrapped_init, _WRAPPED_ATTR, True)
|
|
316
|
+
adapter_mod.IRCAdapter.__init__ = _wrapped_init
|
|
317
|
+
|
|
318
|
+
_patch_handle_line(adapter_mod)
|
|
319
|
+
else:
|
|
320
|
+
logger.warning("hermes-irc-extras: skipping IRCAdapter.__init__ patch — class not found")
|
|
321
|
+
|
|
322
|
+
# 5. Wrap _env_enablement to inject allow_invalid_ssl into the registry seed
|
|
323
|
+
if hasattr(adapter_mod, "_env_enablement"):
|
|
324
|
+
_orig_env_enablement = adapter_mod._env_enablement
|
|
325
|
+
|
|
326
|
+
def _wrapped_env_enablement() -> dict[str, Any] | None:
|
|
327
|
+
seed = _orig_env_enablement()
|
|
328
|
+
if seed is None:
|
|
329
|
+
seed = {}
|
|
330
|
+
allow_invalid_ssl = os.getenv("IRC_ALLOW_INVALID_SSL", "").strip().lower()
|
|
331
|
+
if allow_invalid_ssl:
|
|
332
|
+
seed["allow_invalid_ssl"] = allow_invalid_ssl in {"1", "true", "yes"}
|
|
333
|
+
return seed
|
|
334
|
+
|
|
335
|
+
adapter_mod._env_enablement = _wrapped_env_enablement
|
|
336
|
+
else:
|
|
337
|
+
logger.warning("hermes-irc-extras: skipping _env_enablement patch — function not found")
|
|
338
|
+
|
|
339
|
+
# 6. Wrap interactive_setup to prompt during `hermes gateway setup`
|
|
340
|
+
if not hasattr(adapter_mod, "interactive_setup"):
|
|
341
|
+
logger.warning("hermes-irc-extras: skipping interactive_setup patch — function not found")
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
_orig_setup = adapter_mod.interactive_setup
|
|
345
|
+
|
|
346
|
+
def _wrapped_setup() -> None:
|
|
347
|
+
_orig_setup()
|
|
348
|
+
try:
|
|
349
|
+
from hermes_cli.setup_helpers import (
|
|
350
|
+
get_env_value,
|
|
351
|
+
print_info,
|
|
352
|
+
print_warning,
|
|
353
|
+
prompt_yes_no,
|
|
354
|
+
save_env_value,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
use_tls = (get_env_value("IRC_USE_TLS") or "").lower() in {"1", "true", "yes"}
|
|
358
|
+
if use_tls:
|
|
359
|
+
print_info(" Self-signed certificate? (private ZNC / InspIRCd / ergo)")
|
|
360
|
+
allow_invalid_ssl = prompt_yes_no(
|
|
361
|
+
"Accept invalid/self-signed TLS certificates?",
|
|
362
|
+
(get_env_value("IRC_ALLOW_INVALID_SSL") or "").lower() in {"1", "true", "yes"},
|
|
363
|
+
)
|
|
364
|
+
save_env_value("IRC_ALLOW_INVALID_SSL", "true" if allow_invalid_ssl else "false")
|
|
365
|
+
if allow_invalid_ssl:
|
|
366
|
+
print_warning(
|
|
367
|
+
"⚠️ Certificate verification disabled — the connection is "
|
|
368
|
+
"encrypted but not authenticated. Use only with servers you control."
|
|
369
|
+
)
|
|
370
|
+
else:
|
|
371
|
+
save_env_value("IRC_ALLOW_INVALID_SSL", "false")
|
|
372
|
+
except Exception:
|
|
373
|
+
logger.debug("hermes-irc-extras: interactive setup prompt failed", exc_info=True)
|
|
374
|
+
|
|
375
|
+
adapter_mod.interactive_setup = _wrapped_setup
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Passive channel logging (opt-in via IRC_ENABLE_CHANNEL_LOGGING)
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def _init_channel_logging(adapter: Any, extra: dict[str, Any]) -> None:
|
|
383
|
+
"""Resolve and stash this adapter's channel-logging settings.
|
|
384
|
+
|
|
385
|
+
Resolution happens once per adapter (env first, then ``config.yaml``) so
|
|
386
|
+
the per-line hot path is a single attribute read. The resolved values are
|
|
387
|
+
then published to the shared plugin state and, if the tools were not
|
|
388
|
+
registered at load time, registered now — the adapter's config is the one
|
|
389
|
+
authoritative view of a YAML-only setup, and without this the gateway
|
|
390
|
+
would write a log the agent has no tool to read. Failure here leaves
|
|
391
|
+
logging off rather than breaking adapter construction.
|
|
392
|
+
"""
|
|
393
|
+
adapter._irc_extras_logging = False
|
|
394
|
+
adapter._irc_extras_db_path = None
|
|
395
|
+
adapter._irc_extras_retention_days = 0.0
|
|
396
|
+
adapter._irc_extras_last_prune = 0.0
|
|
397
|
+
try:
|
|
398
|
+
from . import storage
|
|
399
|
+
|
|
400
|
+
if not storage.logging_enabled(extra):
|
|
401
|
+
storage.reset_shared_config()
|
|
402
|
+
return
|
|
403
|
+
adapter._irc_extras_logging = True
|
|
404
|
+
adapter._irc_extras_db_path = storage.resolve_db_path(extra)
|
|
405
|
+
adapter._irc_extras_retention_days = storage.resolve_retention_days(extra)
|
|
406
|
+
storage.set_shared_config(
|
|
407
|
+
{
|
|
408
|
+
"enable_channel_logging": True,
|
|
409
|
+
"channel_log_db_path": str(adapter._irc_extras_db_path),
|
|
410
|
+
"channel_log_retention_days": adapter._irc_extras_retention_days,
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
_register_log_tools(extra)
|
|
414
|
+
logger.info(
|
|
415
|
+
"hermes-irc-extras: IRC channel logging enabled (db=%s, retention=%sd)",
|
|
416
|
+
adapter._irc_extras_db_path,
|
|
417
|
+
adapter._irc_extras_retention_days or "unlimited",
|
|
418
|
+
)
|
|
419
|
+
except Exception:
|
|
420
|
+
adapter._irc_extras_logging = False
|
|
421
|
+
logger.warning(
|
|
422
|
+
"hermes-irc-extras: could not initialize channel logging; it stays disabled",
|
|
423
|
+
exc_info=True,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _register_log_tools(extra: dict[str, Any]) -> None:
|
|
428
|
+
"""Register the query tools for an adapter that enabled logging in YAML.
|
|
429
|
+
|
|
430
|
+
A no-op when the plugin loader already registered them (the env-var case)
|
|
431
|
+
or when the host gave us no context to register with.
|
|
432
|
+
"""
|
|
433
|
+
global _log_tools_registered
|
|
434
|
+
if _log_tools_registered or _plugin_ctx is None:
|
|
435
|
+
return
|
|
436
|
+
try:
|
|
437
|
+
from .tools import register_tools
|
|
438
|
+
|
|
439
|
+
_log_tools_registered = register_tools(_plugin_ctx, extra)
|
|
440
|
+
except Exception:
|
|
441
|
+
logger.warning(
|
|
442
|
+
"hermes-irc-extras: could not register IRC channel log tools", exc_info=True
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _patch_handle_line(adapter_mod: Any) -> None:
|
|
447
|
+
"""Tap ``IRCAdapter._handle_line`` to record PRIVMSGs before the gates.
|
|
448
|
+
|
|
449
|
+
The tap is installed unconditionally but is inert unless the adapter
|
|
450
|
+
resolved logging as enabled, so a default install performs no disk writes.
|
|
451
|
+
Installing it always (rather than only when the env var is set at patch
|
|
452
|
+
time) is what lets ``config.yaml`` enable the feature too — the patches run
|
|
453
|
+
before any adapter, and therefore any config, exists.
|
|
454
|
+
"""
|
|
455
|
+
if not hasattr(adapter_mod.IRCAdapter, "_handle_line"):
|
|
456
|
+
logger.warning(
|
|
457
|
+
"hermes-irc-extras: skipping channel logging — IRCAdapter has no _handle_line"
|
|
458
|
+
)
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
_orig_handle_line = adapter_mod.IRCAdapter._handle_line
|
|
462
|
+
if getattr(_orig_handle_line, _WRAPPED_ATTR, False):
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
async def _wrapped_handle_line(self, raw: str) -> None:
|
|
466
|
+
if getattr(self, "_irc_extras_logging", False):
|
|
467
|
+
try:
|
|
468
|
+
await _log_line(self, adapter_mod, raw)
|
|
469
|
+
except Exception:
|
|
470
|
+
# Logging is strictly best-effort: never break the receive loop.
|
|
471
|
+
logger.debug("hermes-irc-extras: channel log write failed", exc_info=True)
|
|
472
|
+
# The original keeps its own early-returns for unaddressed and
|
|
473
|
+
# unauthorized messages, so those still cost zero agent turns.
|
|
474
|
+
return await _orig_handle_line(self, raw)
|
|
475
|
+
|
|
476
|
+
setattr(_wrapped_handle_line, _WRAPPED_ATTR, True)
|
|
477
|
+
adapter_mod.IRCAdapter._handle_line = _wrapped_handle_line
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _extract_privmsg(adapter: Any, adapter_mod: Any, raw: str) -> dict[str, Any] | None:
|
|
481
|
+
"""Turn a raw IRC line into a log record, or None if it is not loggable.
|
|
482
|
+
|
|
483
|
+
Mirrors the adapter's own PRIVMSG handling (own-message and CTCP skips,
|
|
484
|
+
``/me`` rendering, channel-vs-DM routing, addressing detection) so the
|
|
485
|
+
stored ``is_addressed`` flag matches the dispatch decision the original
|
|
486
|
+
method is about to make.
|
|
487
|
+
"""
|
|
488
|
+
parse = getattr(adapter_mod, "_parse_irc_message", None)
|
|
489
|
+
extract_nick = getattr(adapter_mod, "_extract_nick", None)
|
|
490
|
+
if parse is None or extract_nick is None:
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
msg = parse(raw)
|
|
494
|
+
if msg.get("command") != "PRIVMSG":
|
|
495
|
+
return None
|
|
496
|
+
params = msg.get("params") or []
|
|
497
|
+
if len(params) < 2:
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
prefix = msg.get("prefix") or ""
|
|
501
|
+
sender_nick = extract_nick(prefix)
|
|
502
|
+
if not sender_nick:
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
own_nick = getattr(adapter, "_current_nick", "") or getattr(adapter, "nickname", "")
|
|
506
|
+
if own_nick and sender_nick.lower() == own_nick.lower():
|
|
507
|
+
return None # our own echo
|
|
508
|
+
|
|
509
|
+
target = params[0]
|
|
510
|
+
text = params[1]
|
|
511
|
+
|
|
512
|
+
# CTCP ACTION (/me) reads as narration; every other CTCP is protocol noise.
|
|
513
|
+
if text.startswith("\x01ACTION ") and text.endswith("\x01"):
|
|
514
|
+
text = f"* {sender_nick} {text[8:-1]}"
|
|
515
|
+
elif text.startswith("\x01"):
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
is_channel = target.startswith("#") or target.startswith("&")
|
|
519
|
+
if is_channel:
|
|
520
|
+
addressed = any(
|
|
521
|
+
text.lower().startswith(candidate.lower())
|
|
522
|
+
for candidate in (f"{own_nick}:", f"{own_nick},", f"{own_nick} ")
|
|
523
|
+
if own_nick
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
# A DM is addressed to us by definition.
|
|
527
|
+
addressed = True
|
|
528
|
+
|
|
529
|
+
strip = getattr(adapter_mod, "_strip_irc_control_chars", None)
|
|
530
|
+
if callable(strip):
|
|
531
|
+
try:
|
|
532
|
+
text = strip(text)
|
|
533
|
+
except Exception:
|
|
534
|
+
pass
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
"server": str(getattr(adapter, "server", "") or ""),
|
|
538
|
+
# DMs are filed under the sender's nick, matching the adapter's chat_id.
|
|
539
|
+
"channel": target if is_channel else sender_nick,
|
|
540
|
+
"nick": sender_nick,
|
|
541
|
+
"userhost": prefix.split("!", 1)[1] if "!" in prefix else "",
|
|
542
|
+
"message": text,
|
|
543
|
+
"is_addressed": addressed,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def _log_line(adapter: Any, adapter_mod: Any, raw: str) -> None:
|
|
548
|
+
"""Write one line to the channel log off the event loop thread."""
|
|
549
|
+
db_path = getattr(adapter, "_irc_extras_db_path", None)
|
|
550
|
+
if db_path is None:
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
record = _extract_privmsg(adapter, adapter_mod, raw)
|
|
554
|
+
if record is None:
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
from . import storage
|
|
558
|
+
|
|
559
|
+
# sqlite3 is blocking; keep the IRC receive loop responsive.
|
|
560
|
+
await asyncio.to_thread(storage.log_message, db_path, **record)
|
|
561
|
+
await _maybe_prune(adapter, db_path)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
async def _maybe_prune(adapter: Any, db_path: Any) -> None:
|
|
565
|
+
"""Enforce retention at most once an hour, piggybacking on ingestion."""
|
|
566
|
+
retention_days = getattr(adapter, "_irc_extras_retention_days", 0.0) or 0.0
|
|
567
|
+
if retention_days <= 0:
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
now = time.monotonic()
|
|
571
|
+
last = getattr(adapter, "_irc_extras_last_prune", 0.0)
|
|
572
|
+
if last and (now - last) < _PRUNE_INTERVAL_SECONDS:
|
|
573
|
+
return
|
|
574
|
+
adapter._irc_extras_last_prune = now
|
|
575
|
+
|
|
576
|
+
from . import storage
|
|
577
|
+
|
|
578
|
+
removed = await asyncio.to_thread(storage.prune_old_records, db_path, retention_days)
|
|
579
|
+
if removed:
|
|
580
|
+
logger.info(
|
|
581
|
+
"hermes-irc-extras: pruned %d IRC log record(s) older than %s day(s)",
|
|
582
|
+
removed, retention_days,
|
|
583
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561. This package uses inline types.
|