2026-02-22 20:44:15 -08:00
|
|
|
"""
|
|
|
|
|
Channel directory -- cached map of reachable channels/contacts per platform.
|
|
|
|
|
|
|
|
|
|
Built on gateway startup, refreshed periodically (every 5 min), and saved to
|
|
|
|
|
~/.hermes/channel_directory.json. The send_message tool reads this file for
|
|
|
|
|
action="list" and for resolving human-friendly channel names to numeric IDs.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
DIRECTORY_PATH = Path.home() / ".hermes" / "channel_directory.json"
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 09:15:34 +01:00
|
|
|
def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]:
|
|
|
|
|
chat_id = origin.get("chat_id")
|
|
|
|
|
if not chat_id:
|
|
|
|
|
return None
|
|
|
|
|
thread_id = origin.get("thread_id")
|
|
|
|
|
if thread_id:
|
|
|
|
|
return f"{chat_id}:{thread_id}"
|
|
|
|
|
return str(chat_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _session_entry_name(origin: Dict[str, Any]) -> str:
|
|
|
|
|
base_name = origin.get("chat_name") or origin.get("user_name") or str(origin.get("chat_id"))
|
|
|
|
|
thread_id = origin.get("thread_id")
|
|
|
|
|
if not thread_id:
|
|
|
|
|
return base_name
|
|
|
|
|
|
|
|
|
|
topic_label = origin.get("chat_topic") or f"topic {thread_id}"
|
|
|
|
|
return f"{base_name} / {topic_label}"
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 20:44:15 -08:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Build / refresh
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Build a channel directory from connected platform adapters and session data.
|
|
|
|
|
|
|
|
|
|
Returns the directory dict and writes it to DIRECTORY_PATH.
|
|
|
|
|
"""
|
|
|
|
|
from gateway.config import Platform
|
|
|
|
|
|
|
|
|
|
platforms: Dict[str, List[Dict[str, str]]] = {}
|
|
|
|
|
|
|
|
|
|
for platform, adapter in adapters.items():
|
|
|
|
|
try:
|
|
|
|
|
if platform == Platform.DISCORD:
|
|
|
|
|
platforms["discord"] = _build_discord(adapter)
|
|
|
|
|
elif platform == Platform.SLACK:
|
|
|
|
|
platforms["slack"] = _build_slack(adapter)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
|
|
|
|
|
|
fix: Signal adapter parity pass — integration gaps, clawdbot features, env var simplification
Integration gaps fixed (7 files missing Signal):
- cron/scheduler.py: Signal in platform_map (cron delivery was broken)
- agent/prompt_builder.py: PLATFORM_HINTS for Signal (agent knows it's on Signal)
- toolsets.py: hermes-signal toolset + added to hermes-gateway composite
- hermes_cli/status.py: Signal + Slack in platform status display
- tools/send_message_tool.py: Signal example in target description
- tools/cronjob_tools.py: Signal in delivery option docs + schema
- gateway/channel_directory.py: Signal in session-based channel discovery
Clawdbot parity features added to signal.py:
- Self-message filtering: prevents reply loops by checking sender != account
- SyncMessage filtering: ignores sync envelopes (sent transcripts, read receipts)
- Edit message support: reads dataMessage from editMessage envelope
- Mention rendering: replaces \uFFFC placeholders with @identifier text
- Jitter in SSE reconnection backoff (20% randomization, prevents thundering herd)
Env var simplification (7 → 4):
- Removed SIGNAL_DM_POLICY (DM auth follows standard platform pattern via
SIGNAL_ALLOWED_USERS + DM pairing, same as Telegram/Discord)
- Removed SIGNAL_GROUP_POLICY (derived from SIGNAL_GROUP_ALLOWED_USERS:
not set = disabled, set with IDs = allowlist, set with * = open)
- Removed SIGNAL_DEBUG (was setting root logger, removed entirely)
- Remaining: SIGNAL_HTTP_URL, SIGNAL_ACCOUNT (required),
SIGNAL_ALLOWED_USERS, SIGNAL_GROUP_ALLOWED_USERS (optional)
Updated all docs (website, AGENTS.md, signal.md) to match.
2026-03-08 21:00:21 -07:00
|
|
|
# Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history
|
feat: add email gateway platform (IMAP/SMTP)
Allow users to interact with Hermes by sending and receiving emails.
Uses IMAP polling for incoming messages and SMTP for replies with
proper threading (In-Reply-To, References headers).
Integrates with all 14 gateway extension points: config, adapter
factory, authorization, send_message tool, cron delivery, toolsets,
prompt hints, channel directory, setup wizard, status display, and
env example.
65 tests covering config, parsing, dispatch, threading, IMAP fetch,
SMTP send, attachments, and all integration points.
2026-03-10 03:15:38 +03:00
|
|
|
for plat_name in ("telegram", "whatsapp", "signal", "email"):
|
2026-02-22 20:44:15 -08:00
|
|
|
if plat_name not in platforms:
|
|
|
|
|
platforms[plat_name] = _build_from_sessions(plat_name)
|
|
|
|
|
|
|
|
|
|
directory = {
|
|
|
|
|
"updated_at": datetime.now().isoformat(),
|
|
|
|
|
"platforms": platforms,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
DIRECTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
fix(gateway): add missing UTF-8 encoding to file I/O preventing crashes on Windows
On Windows, Python's open() defaults to the system locale encoding
(e.g. cp1254 for Turkish, cp1252 for Western European) instead of
UTF-8. The gateway already uses ensure_ascii=False in json.dumps()
to preserve Unicode characters in chat messages, but the
corresponding open() calls lack encoding="utf-8". This mismatch
causes UnicodeEncodeError / UnicodeDecodeError when users send
non-ASCII messages (Turkish, Japanese, Arabic, emoji, etc.) through
Telegram, Discord, WhatsApp, or Slack on Windows.
The project already fixed this for .env files in hermes_cli/config.py
(line 624) but the gateway module was missed.
Files fixed:
- gateway/session.py: session index + JSONL transcript read/write (5 calls)
- gateway/channel_directory.py: channel directory read/write (3 calls)
- gateway/mirror.py: session index read + transcript append (2 calls)
2026-03-04 11:32:57 +03:00
|
|
|
with open(DIRECTORY_PATH, "w", encoding="utf-8") as f:
|
2026-02-22 20:44:15 -08:00
|
|
|
json.dump(directory, f, indent=2, ensure_ascii=False)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("Channel directory: failed to write: %s", e)
|
|
|
|
|
|
|
|
|
|
return directory
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_discord(adapter) -> List[Dict[str, str]]:
|
|
|
|
|
"""Enumerate all text channels the Discord bot can see."""
|
|
|
|
|
channels = []
|
|
|
|
|
client = getattr(adapter, "_client", None)
|
|
|
|
|
if not client:
|
|
|
|
|
return channels
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import discord as _discord
|
|
|
|
|
except ImportError:
|
|
|
|
|
return channels
|
|
|
|
|
|
|
|
|
|
for guild in client.guilds:
|
|
|
|
|
for ch in guild.text_channels:
|
|
|
|
|
channels.append({
|
|
|
|
|
"id": str(ch.id),
|
|
|
|
|
"name": ch.name,
|
|
|
|
|
"guild": guild.name,
|
|
|
|
|
"type": "channel",
|
|
|
|
|
})
|
|
|
|
|
# Also include DM-capable users we've interacted with is not
|
|
|
|
|
# feasible via guild enumeration; those come from sessions.
|
|
|
|
|
|
|
|
|
|
# Merge any DMs from session history
|
|
|
|
|
channels.extend(_build_from_sessions("discord"))
|
|
|
|
|
return channels
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_slack(adapter) -> List[Dict[str, str]]:
|
|
|
|
|
"""List Slack channels the bot has joined."""
|
|
|
|
|
channels = []
|
|
|
|
|
# Slack adapter may expose a web client
|
|
|
|
|
client = getattr(adapter, "_app", None) or getattr(adapter, "_client", None)
|
|
|
|
|
if not client:
|
|
|
|
|
return _build_from_sessions("slack")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import asyncio
|
|
|
|
|
from tools.send_message_tool import _send_slack # noqa: F401
|
|
|
|
|
# Use the Slack Web API directly if available
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Fallback to session data
|
|
|
|
|
return _build_from_sessions("slack")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
|
|
|
|
|
"""Pull known channels/contacts from sessions.json origin data."""
|
|
|
|
|
sessions_path = Path.home() / ".hermes" / "sessions" / "sessions.json"
|
|
|
|
|
if not sessions_path.exists():
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
entries = []
|
|
|
|
|
try:
|
fix(gateway): add missing UTF-8 encoding to file I/O preventing crashes on Windows
On Windows, Python's open() defaults to the system locale encoding
(e.g. cp1254 for Turkish, cp1252 for Western European) instead of
UTF-8. The gateway already uses ensure_ascii=False in json.dumps()
to preserve Unicode characters in chat messages, but the
corresponding open() calls lack encoding="utf-8". This mismatch
causes UnicodeEncodeError / UnicodeDecodeError when users send
non-ASCII messages (Turkish, Japanese, Arabic, emoji, etc.) through
Telegram, Discord, WhatsApp, or Slack on Windows.
The project already fixed this for .env files in hermes_cli/config.py
(line 624) but the gateway module was missed.
Files fixed:
- gateway/session.py: session index + JSONL transcript read/write (5 calls)
- gateway/channel_directory.py: channel directory read/write (3 calls)
- gateway/mirror.py: session index read + transcript append (2 calls)
2026-03-04 11:32:57 +03:00
|
|
|
with open(sessions_path, encoding="utf-8") as f:
|
2026-02-22 20:44:15 -08:00
|
|
|
data = json.load(f)
|
|
|
|
|
|
|
|
|
|
seen_ids = set()
|
|
|
|
|
for _key, session in data.items():
|
|
|
|
|
origin = session.get("origin") or {}
|
|
|
|
|
if origin.get("platform") != platform_name:
|
|
|
|
|
continue
|
2026-03-11 09:15:34 +01:00
|
|
|
entry_id = _session_entry_id(origin)
|
|
|
|
|
if not entry_id or entry_id in seen_ids:
|
2026-02-22 20:44:15 -08:00
|
|
|
continue
|
2026-03-11 09:15:34 +01:00
|
|
|
seen_ids.add(entry_id)
|
2026-02-22 20:44:15 -08:00
|
|
|
entries.append({
|
2026-03-11 09:15:34 +01:00
|
|
|
"id": entry_id,
|
|
|
|
|
"name": _session_entry_name(origin),
|
2026-02-22 20:44:15 -08:00
|
|
|
"type": session.get("chat_type", "dm"),
|
2026-03-11 09:15:34 +01:00
|
|
|
"thread_id": origin.get("thread_id"),
|
2026-02-22 20:44:15 -08:00
|
|
|
})
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug("Channel directory: failed to read sessions for %s: %s", platform_name, e)
|
|
|
|
|
|
|
|
|
|
return entries
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Read / resolve
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def load_directory() -> Dict[str, Any]:
|
|
|
|
|
"""Load the cached channel directory from disk."""
|
|
|
|
|
if not DIRECTORY_PATH.exists():
|
|
|
|
|
return {"updated_at": None, "platforms": {}}
|
|
|
|
|
try:
|
fix(gateway): add missing UTF-8 encoding to file I/O preventing crashes on Windows
On Windows, Python's open() defaults to the system locale encoding
(e.g. cp1254 for Turkish, cp1252 for Western European) instead of
UTF-8. The gateway already uses ensure_ascii=False in json.dumps()
to preserve Unicode characters in chat messages, but the
corresponding open() calls lack encoding="utf-8". This mismatch
causes UnicodeEncodeError / UnicodeDecodeError when users send
non-ASCII messages (Turkish, Japanese, Arabic, emoji, etc.) through
Telegram, Discord, WhatsApp, or Slack on Windows.
The project already fixed this for .env files in hermes_cli/config.py
(line 624) but the gateway module was missed.
Files fixed:
- gateway/session.py: session index + JSONL transcript read/write (5 calls)
- gateway/channel_directory.py: channel directory read/write (3 calls)
- gateway/mirror.py: session index read + transcript append (2 calls)
2026-03-04 11:32:57 +03:00
|
|
|
with open(DIRECTORY_PATH, encoding="utf-8") as f:
|
2026-02-22 20:44:15 -08:00
|
|
|
return json.load(f)
|
|
|
|
|
except Exception:
|
|
|
|
|
return {"updated_at": None, "platforms": {}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
|
|
|
|
|
"""
|
|
|
|
|
Resolve a human-friendly channel name to a numeric ID.
|
|
|
|
|
|
|
|
|
|
Matching strategy (case-insensitive, first match wins):
|
|
|
|
|
- Discord: "bot-home", "#bot-home", "GuildName/bot-home"
|
|
|
|
|
- Telegram: display name or group name
|
|
|
|
|
- Slack: "engineering", "#engineering"
|
|
|
|
|
"""
|
|
|
|
|
directory = load_directory()
|
|
|
|
|
channels = directory.get("platforms", {}).get(platform_name, [])
|
|
|
|
|
if not channels:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
query = name.lstrip("#").lower()
|
|
|
|
|
|
|
|
|
|
# 1. Exact name match
|
|
|
|
|
for ch in channels:
|
|
|
|
|
if ch["name"].lower() == query:
|
|
|
|
|
return ch["id"]
|
|
|
|
|
|
|
|
|
|
# 2. Guild-qualified match for Discord ("GuildName/channel")
|
|
|
|
|
if "/" in query:
|
|
|
|
|
guild_part, ch_part = query.rsplit("/", 1)
|
|
|
|
|
for ch in channels:
|
|
|
|
|
guild = ch.get("guild", "").lower()
|
|
|
|
|
if guild == guild_part and ch["name"].lower() == ch_part:
|
|
|
|
|
return ch["id"]
|
|
|
|
|
|
|
|
|
|
# 3. Partial prefix match (only if unambiguous)
|
|
|
|
|
matches = [ch for ch in channels if ch["name"].lower().startswith(query)]
|
|
|
|
|
if len(matches) == 1:
|
|
|
|
|
return matches[0]["id"]
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_directory_for_display() -> str:
|
|
|
|
|
"""Format the channel directory as a human-readable list for the model."""
|
|
|
|
|
directory = load_directory()
|
|
|
|
|
platforms = directory.get("platforms", {})
|
|
|
|
|
|
|
|
|
|
if not any(platforms.values()):
|
|
|
|
|
return "No messaging platforms connected or no channels discovered yet."
|
|
|
|
|
|
|
|
|
|
lines = ["Available messaging targets:\n"]
|
|
|
|
|
|
|
|
|
|
for plat_name, channels in sorted(platforms.items()):
|
|
|
|
|
if not channels:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Group Discord channels by guild
|
|
|
|
|
if plat_name == "discord":
|
|
|
|
|
guilds: Dict[str, List] = {}
|
|
|
|
|
dms: List = []
|
|
|
|
|
for ch in channels:
|
|
|
|
|
guild = ch.get("guild")
|
|
|
|
|
if guild:
|
|
|
|
|
guilds.setdefault(guild, []).append(ch)
|
|
|
|
|
else:
|
|
|
|
|
dms.append(ch)
|
|
|
|
|
|
|
|
|
|
for guild_name, guild_channels in sorted(guilds.items()):
|
|
|
|
|
lines.append(f"Discord ({guild_name}):")
|
|
|
|
|
for ch in sorted(guild_channels, key=lambda c: c["name"]):
|
|
|
|
|
lines.append(f" discord:#{ch['name']}")
|
|
|
|
|
if dms:
|
|
|
|
|
lines.append("Discord (DMs):")
|
|
|
|
|
for ch in dms:
|
|
|
|
|
lines.append(f" discord:{ch['name']}")
|
|
|
|
|
lines.append("")
|
|
|
|
|
else:
|
|
|
|
|
lines.append(f"{plat_name.title()}:")
|
|
|
|
|
for ch in channels:
|
|
|
|
|
type_label = f" ({ch['type']})" if ch.get("type") else ""
|
|
|
|
|
lines.append(f" {plat_name}:{ch['name']}{type_label}")
|
|
|
|
|
lines.append("")
|
|
|
|
|
|
|
|
|
|
lines.append('Use these as the "target" parameter when sending.')
|
|
|
|
|
lines.append('Bare platform name (e.g. "telegram") sends to home channel.')
|
|
|
|
|
|
|
|
|
|
return "\n".join(lines)
|