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.
@@ -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.