chore: import upstream snapshot with attribution
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
name: dingtalk-platform
|
||||
label: DingTalk
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
DingTalk gateway adapter for Hermes Agent.
|
||||
Connects to DingTalk via the dingtalk-stream SDK (Stream Mode) and relays
|
||||
messages between DingTalk chats and the Hermes agent. Supports text, images,
|
||||
audio, video, rich text, files, group @mention gating, free-response chats,
|
||||
and per-user allowlists.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: DINGTALK_CLIENT_ID
|
||||
description: "DingTalk app key (Client ID)"
|
||||
prompt: "DingTalk Client ID (app key)"
|
||||
url: "https://open-dev.dingtalk.com"
|
||||
password: false
|
||||
- name: DINGTALK_CLIENT_SECRET
|
||||
description: "DingTalk app secret (Client Secret)"
|
||||
prompt: "DingTalk Client Secret"
|
||||
url: "https://open-dev.dingtalk.com"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: DINGTALK_WEBHOOK_URL
|
||||
description: "Static robot webhook URL for cross-platform / cron delivery"
|
||||
prompt: "DingTalk robot webhook URL (optional)"
|
||||
password: false
|
||||
- name: DINGTALK_ALLOWED_USERS
|
||||
description: "Comma-separated staff/sender IDs allowed to talk to the bot (* = any)"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: DINGTALK_HOME_CHANNEL
|
||||
description: "Default conversation ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: DINGTALK_HOME_CHANNEL_NAME
|
||||
description: "Display name for the DingTalk home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
name: discord-platform
|
||||
label: Discord
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Discord gateway adapter for Hermes Agent.
|
||||
Connects to Discord via the discord.py library and relays messages
|
||||
between Discord guilds/DMs and the Hermes agent. Supports voice mode,
|
||||
slash commands, free-response channels, role-based DM auth, threads,
|
||||
reactions, and channel skill bindings.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: DISCORD_BOT_TOKEN
|
||||
description: "Discord bot token"
|
||||
prompt: "Discord bot token"
|
||||
url: "https://discord.com/developers/applications"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: DISCORD_ALLOWED_USERS
|
||||
description: "Comma-separated Discord user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: DISCORD_ALLOW_ALL_USERS
|
||||
description: "Allow any Discord user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: DISCORD_HOME_CHANNEL
|
||||
description: "Default channel ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: DISCORD_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Discord home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
@@ -0,0 +1,379 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Continuous PCM audio mixer for Discord voice channels.
|
||||
|
||||
discord.py (Rapptz) ships no audio mixer: ``VoiceClient.play()`` accepts a
|
||||
single :class:`discord.AudioSource` and raises ``ClientException`` if called
|
||||
while already playing. One opus stream per connection, one source feeding it.
|
||||
|
||||
This module adds software mixing *upstream* of that single stream. A
|
||||
:class:`VoiceMixer` is itself a ``discord.AudioSource`` that discord.py polls
|
||||
every 20 ms via :meth:`read`. Internally it sums the 20 ms PCM frames of any
|
||||
number of child sources, clamps to int16, and returns one blended frame.
|
||||
discord.py never knows several streams were combined underneath — it just
|
||||
encodes and sends the single mixed frame.
|
||||
|
||||
This gives us, for one voice connection at once:
|
||||
|
||||
* an always-on low-volume **ambient/idle loop** (the "thinking" sound),
|
||||
* a **speech** channel (TTS replies, verbal acknowledgements) that plays
|
||||
*over* the ambient bed, automatically **ducking** the ambient gain down
|
||||
while speech is active and restoring it when speech ends — the smooth
|
||||
Grok-voice-mode feel, instead of stop-and-swap.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* The mixer is installed **once** per guild on join (``vc.play(mixer)``) and
|
||||
runs continuously until the bot leaves. Children come and go; the mixer
|
||||
itself never stops, so there is no ``is_playing()`` race between an
|
||||
acknowledgement and the final reply.
|
||||
* Frame format is Discord-native: 48 kHz, 2 channels, signed 16-bit LE,
|
||||
20 ms per frame == ``discord.opus.Encoder.FRAME_SIZE`` bytes
|
||||
(3840 = 960 samples * 2 channels * 2 bytes).
|
||||
* Mixing is a single vectorised int32 add + clip per 20 ms frame (numpy,
|
||||
already a core dependency). CPU cost is negligible.
|
||||
* :meth:`read` is called from discord.py's audio sender **thread**, while
|
||||
children are added/removed from the asyncio event loop thread, so all
|
||||
shared state is guarded by a plain ``threading.Lock``.
|
||||
|
||||
The mixer NEVER touches the inbound receive path: it only produces the bot's
|
||||
*outgoing* stream. The :class:`VoiceReceiver` decodes incoming SSRCs only, so
|
||||
the mixer's output cannot echo back into transcription.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
if TYPE_CHECKING: # numpy is an optional ("voice" extra) dep — never import at runtime top-level
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _require_numpy():
|
||||
"""Import numpy lazily.
|
||||
|
||||
numpy ships in the optional ``voice`` extra, not the base install, so this
|
||||
module must import cleanly without it (the Discord adapter imports this
|
||||
file unconditionally). Callers that actually mix audio call this; if the
|
||||
voice extra isn't installed they get a clear error instead of a top-level
|
||||
ImportError that would break the whole adapter import.
|
||||
"""
|
||||
import numpy as np # noqa: PLC0415 — intentional lazy import
|
||||
return np
|
||||
|
||||
# Discord-native frame geometry (matches discord.opus.Encoder).
|
||||
SAMPLE_RATE = 48000
|
||||
CHANNELS = 2
|
||||
SAMPLE_WIDTH = 2 # bytes per sample (s16)
|
||||
FRAME_LENGTH_MS = 20
|
||||
SAMPLES_PER_FRAME = SAMPLE_RATE * FRAME_LENGTH_MS // 1000 # 960
|
||||
FRAME_SIZE = SAMPLES_PER_FRAME * CHANNELS * SAMPLE_WIDTH # 3840 bytes
|
||||
SILENCE_FRAME = b"\x00" * FRAME_SIZE
|
||||
|
||||
|
||||
class MixerChild:
|
||||
"""A single audio stream feeding into :class:`VoiceMixer`.
|
||||
|
||||
Wraps raw 48 kHz / stereo / s16le PCM bytes. ``read_frame`` hands back one
|
||||
20 ms frame at a time, optionally looping, with a per-child gain applied.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"name", "_pcm", "_pos", "loop", "gain",
|
||||
"is_speech", "fade_frames", "_fade_done", "_finished",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
pcm: bytes,
|
||||
*,
|
||||
loop: bool = False,
|
||||
gain: float = 1.0,
|
||||
is_speech: bool = False,
|
||||
fade_in_ms: int = 0,
|
||||
):
|
||||
# Pad to a whole number of frames so looping is seamless and the final
|
||||
# partial frame doesn't click.
|
||||
remainder = len(pcm) % FRAME_SIZE
|
||||
if remainder:
|
||||
pcm = pcm + b"\x00" * (FRAME_SIZE - remainder)
|
||||
self.name = name
|
||||
self._pcm = pcm
|
||||
self._pos = 0
|
||||
self.loop = loop
|
||||
self.gain = float(gain)
|
||||
self.is_speech = is_speech
|
||||
# Linear fade-in over N frames avoids a click when a loud child starts.
|
||||
self.fade_frames = max(0, fade_in_ms // FRAME_LENGTH_MS)
|
||||
self._fade_done = 0
|
||||
self._finished = False
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
return self._finished
|
||||
|
||||
def read_frame(self) -> "Optional[np.ndarray]":
|
||||
"""Return the next 20 ms frame as an int16 ndarray, or None if done."""
|
||||
if self._finished:
|
||||
return None
|
||||
if self._pos >= len(self._pcm):
|
||||
if self.loop and self._pcm:
|
||||
self._pos = 0
|
||||
else:
|
||||
self._finished = True
|
||||
return None
|
||||
|
||||
np = _require_numpy()
|
||||
chunk = self._pcm[self._pos:self._pos + FRAME_SIZE]
|
||||
self._pos += FRAME_SIZE
|
||||
if len(chunk) < FRAME_SIZE:
|
||||
chunk = chunk + b"\x00" * (FRAME_SIZE - len(chunk))
|
||||
|
||||
samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32)
|
||||
|
||||
gain = self.gain
|
||||
if self.fade_frames and self._fade_done < self.fade_frames:
|
||||
self._fade_done += 1
|
||||
gain *= self._fade_done / self.fade_frames
|
||||
|
||||
if gain != 1.0:
|
||||
samples = samples * gain
|
||||
return samples
|
||||
|
||||
|
||||
class VoiceMixer:
|
||||
"""A continuous ``discord.AudioSource`` that mixes N child streams.
|
||||
|
||||
Use :meth:`set_ambient` to install/replace the looping idle bed and
|
||||
:meth:`play_speech` to layer a one-shot clip over it (ducking the ambient
|
||||
while it plays). Both are safe to call from the asyncio loop thread while
|
||||
discord.py drains :meth:`read` from its sender thread.
|
||||
"""
|
||||
|
||||
# discord.AudioSource subclasses set is_opus()==False to receive PCM.
|
||||
def is_opus(self) -> bool: # pragma: no cover - trivial
|
||||
return False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ambient_gain: float = 0.18,
|
||||
duck_gain: float = 0.06,
|
||||
speech_gain: float = 1.0,
|
||||
duck_release_ms: int = 400,
|
||||
):
|
||||
self._lock = threading.Lock()
|
||||
self._ambient: Optional[MixerChild] = None
|
||||
self._speech: List[MixerChild] = []
|
||||
self._ambient_gain = float(ambient_gain)
|
||||
self._duck_gain = float(duck_gain)
|
||||
self._speech_gain = float(speech_gain)
|
||||
# When speech ends, ramp the ambient back up over this many frames
|
||||
# instead of jumping, so the bed swells back smoothly.
|
||||
self._duck_release_frames = max(1, duck_release_ms // FRAME_LENGTH_MS)
|
||||
self._duck_release_left = 0
|
||||
self._closed = False
|
||||
# Tracks whether speech is currently active, for external callers that
|
||||
# want to avoid double-ducking or know when a reply is mid-flight.
|
||||
self._speech_active = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ambient (idle / "thinking") bed
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_ambient(self, pcm: Optional[bytes], *, gain: Optional[float] = None) -> None:
|
||||
"""Install (or clear, with ``pcm=None``) the looping ambient bed."""
|
||||
with self._lock:
|
||||
if gain is not None:
|
||||
self._ambient_gain = float(gain)
|
||||
if not pcm:
|
||||
self._ambient = None
|
||||
return
|
||||
self._ambient = MixerChild(
|
||||
"ambient", pcm, loop=True,
|
||||
gain=self._effective_ambient_gain(), fade_in_ms=200,
|
||||
)
|
||||
|
||||
def _effective_ambient_gain(self) -> float:
|
||||
return self._duck_gain if self._speech_active else self._ambient_gain
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Speech (TTS replies, verbal acks) layered over the ambient bed
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def play_speech(self, pcm: bytes, *, gain: Optional[float] = None,
|
||||
fade_in_ms: int = 40) -> None:
|
||||
"""Layer a one-shot speech clip over the ambient bed (ducks ambient)."""
|
||||
if not pcm:
|
||||
return
|
||||
with self._lock:
|
||||
child = MixerChild(
|
||||
"speech", pcm, loop=False,
|
||||
gain=self._speech_gain if gain is None else float(gain),
|
||||
is_speech=True, fade_in_ms=fade_in_ms,
|
||||
)
|
||||
self._speech.append(child)
|
||||
self._speech_active = True
|
||||
self._duck_release_left = 0
|
||||
if self._ambient is not None:
|
||||
self._ambient.gain = self._duck_gain
|
||||
|
||||
@property
|
||||
def speech_active(self) -> bool:
|
||||
with self._lock:
|
||||
return self._speech_active
|
||||
|
||||
def stop_speech(self) -> None:
|
||||
"""Drop any in-flight speech immediately and release the duck."""
|
||||
with self._lock:
|
||||
self._speech.clear()
|
||||
self._begin_duck_release_locked()
|
||||
|
||||
def _begin_duck_release_locked(self) -> None:
|
||||
self._speech_active = False
|
||||
self._duck_release_left = self._duck_release_frames
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AudioSource interface — called from discord.py's sender thread
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def read(self) -> bytes:
|
||||
"""Return one 20 ms mixed PCM frame (always FRAME_SIZE bytes).
|
||||
|
||||
Returning a non-empty frame keeps discord.py's player alive; we never
|
||||
return b"" because that would stop the single underlying stream and we
|
||||
want the mixer to run continuously for the lifetime of the connection.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return SILENCE_FRAME
|
||||
|
||||
np = _require_numpy()
|
||||
acc: "Optional[np.ndarray]" = None
|
||||
|
||||
# Speech children (drop exhausted ones; release duck when last ends)
|
||||
if self._speech:
|
||||
still_live: List[MixerChild] = []
|
||||
for child in self._speech:
|
||||
frame = child.read_frame()
|
||||
if frame is None:
|
||||
continue
|
||||
acc = frame if acc is None else acc + frame
|
||||
still_live.append(child)
|
||||
self._speech = still_live
|
||||
if not self._speech and self._speech_active:
|
||||
self._begin_duck_release_locked()
|
||||
|
||||
# Ambient bed — ramp gain back up during duck-release.
|
||||
if self._ambient is not None:
|
||||
if self._duck_release_left > 0 and not self._speech_active:
|
||||
self._duck_release_left -= 1
|
||||
frac = 1.0 - (self._duck_release_left / self._duck_release_frames)
|
||||
self._ambient.gain = (
|
||||
self._duck_gain
|
||||
+ (self._ambient_gain - self._duck_gain) * frac
|
||||
)
|
||||
elif not self._speech_active and self._duck_release_left == 0:
|
||||
self._ambient.gain = self._ambient_gain
|
||||
amb = self._ambient.read_frame()
|
||||
if amb is not None:
|
||||
acc = amb if acc is None else acc + amb
|
||||
|
||||
if acc is None:
|
||||
return SILENCE_FRAME
|
||||
|
||||
np.clip(acc, -32768, 32767, out=acc)
|
||||
return acc.astype(np.int16).tobytes()
|
||||
|
||||
def cleanup(self) -> None: # called by discord.py when playback stops
|
||||
with self._lock:
|
||||
self._closed = True
|
||||
self._ambient = None
|
||||
self._speech.clear()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# PCM helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
|
||||
"""Decode any audio file to 48 kHz / stereo / s16le PCM via ffmpeg.
|
||||
|
||||
Returns the raw PCM bytes, or None on failure. ffmpeg is already a hard
|
||||
requirement of the voice path (see ``VoiceReceiver.pcm_to_wav``).
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-i", path,
|
||||
"-f", "s16le",
|
||||
"-ar", str(SAMPLE_RATE),
|
||||
"-ac", str(CHANNELS),
|
||||
"pipe:1",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
logger.warning("decode_to_pcm failed for %s: %s", path, e)
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"ffmpeg decode failed for %s (rc=%d): %s",
|
||||
path, proc.returncode, (proc.stderr or b"").decode("utf-8", "replace")[:200],
|
||||
)
|
||||
return None
|
||||
return proc.stdout or None
|
||||
|
||||
|
||||
def synth_ambient_pcm(seconds: float = 4.0) -> bytes:
|
||||
"""Synthesise a subtle looping ambient bed (no asset file required).
|
||||
|
||||
A soft, slowly-pulsing low pad: two detuned sine partials with a gentle
|
||||
tremolo, plus a touch of filtered noise. Designed to loop seamlessly
|
||||
(whole number of cycles, zero-crossing endpoints) and sit quietly under
|
||||
speech. Mono content duplicated to stereo.
|
||||
"""
|
||||
np = _require_numpy()
|
||||
n = int(SAMPLE_RATE * seconds)
|
||||
t = np.arange(n, dtype=np.float64) / SAMPLE_RATE
|
||||
|
||||
# Choose base frequencies that complete whole cycles over the loop so the
|
||||
# wrap point is click-free.
|
||||
def _whole_cycle_freq(target: float) -> float:
|
||||
cycles = max(1, round(target * seconds))
|
||||
return cycles / seconds
|
||||
|
||||
f1 = _whole_cycle_freq(110.0)
|
||||
f2 = _whole_cycle_freq(110.5)
|
||||
trem = _whole_cycle_freq(0.5) # ~0.5 Hz tremolo
|
||||
|
||||
pad = (
|
||||
0.55 * np.sin(2 * np.pi * f1 * t)
|
||||
+ 0.45 * np.sin(2 * np.pi * f2 * t)
|
||||
)
|
||||
tremolo = 0.6 + 0.4 * (0.5 * (1 + np.sin(2 * np.pi * trem * t)))
|
||||
signal = pad * tremolo
|
||||
|
||||
# Smooth filtered noise for air, kept very low.
|
||||
rng = np.random.default_rng(7)
|
||||
noise = rng.standard_normal(n)
|
||||
kernel = np.ones(64) / 64.0
|
||||
noise = np.convolve(noise, kernel, mode="same")
|
||||
signal = signal + 0.08 * noise
|
||||
|
||||
# Normalise to a modest peak (mixer applies the real ambient gain on top).
|
||||
peak = float(np.max(np.abs(signal))) or 1.0
|
||||
signal = (signal / peak) * 0.5
|
||||
|
||||
mono16 = (signal * 32767.0).astype(np.int16)
|
||||
stereo16 = np.repeat(mono16[:, None], CHANNELS, axis=1).reshape(-1)
|
||||
return stereo16.tobytes()
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
name: email-platform
|
||||
label: Email
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Email gateway adapter for Hermes Agent. Polls an IMAP mailbox for inbound
|
||||
messages and replies over SMTP, relaying email threads to and from the
|
||||
Hermes agent.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: EMAIL_ADDRESS
|
||||
description: "Email account address"
|
||||
prompt: "Email address"
|
||||
password: false
|
||||
- name: EMAIL_PASSWORD
|
||||
description: "Email account password / app password"
|
||||
prompt: "Email password"
|
||||
password: true
|
||||
- name: EMAIL_SMTP_HOST
|
||||
description: "SMTP host (e.g. smtp.gmail.com)"
|
||||
prompt: "SMTP host"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: EMAIL_SMTP_PORT
|
||||
description: "SMTP port (default 587)"
|
||||
prompt: "SMTP port"
|
||||
password: false
|
||||
- name: EMAIL_IMAP_HOST
|
||||
description: "IMAP host for inbound polling (e.g. imap.gmail.com)"
|
||||
prompt: "IMAP host"
|
||||
password: false
|
||||
- name: EMAIL_ALLOWED_USERS
|
||||
description: "Comma-separated email addresses allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: EMAIL_HOME_ADDRESS
|
||||
description: "Default address for cron / notification delivery"
|
||||
prompt: "Home address"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Feishu document comment access-control rules.
|
||||
|
||||
3-tier rule resolution: exact doc > wildcard "*" > top-level > code defaults.
|
||||
Each field (enabled/policy/allow_from) falls back independently.
|
||||
Config: ~/.hermes/feishu_comment_rules.json (mtime-cached, hot-reload).
|
||||
Pairing store: ~/.hermes/feishu_comment_pairing.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Uses the canonical ``get_hermes_home()`` helper (HERMES_HOME-aware and
|
||||
# profile-safe). Resolved at import time; this module is lazy-imported by
|
||||
# the Feishu comment event handler, which runs long after profile overrides
|
||||
# have been applied, so freezing paths here is safe.
|
||||
|
||||
RULES_FILE = get_hermes_home() / "feishu_comment_rules.json"
|
||||
PAIRING_FILE = get_hermes_home() / "feishu_comment_pairing.json"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_POLICIES = ("allowlist", "pairing")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommentDocumentRule:
|
||||
"""Per-document rule. ``None`` means 'inherit from lower tier'."""
|
||||
enabled: Optional[bool] = None
|
||||
policy: Optional[str] = None
|
||||
allow_from: Optional[frozenset] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommentsConfig:
|
||||
"""Top-level comment access config."""
|
||||
enabled: bool = True
|
||||
policy: str = "pairing"
|
||||
allow_from: frozenset = field(default_factory=frozenset)
|
||||
documents: Dict[str, CommentDocumentRule] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedCommentRule:
|
||||
"""Fully resolved rule after field-by-field fallback."""
|
||||
enabled: bool
|
||||
policy: str
|
||||
allow_from: frozenset
|
||||
match_source: str # e.g. "exact:docx:xxx" | "wildcard" | "top" | "default"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mtime-cached file loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _MtimeCache:
|
||||
"""Generic mtime-based file cache. ``stat()`` per access, re-read only on change."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self._path = path
|
||||
self._mtime: float = 0.0
|
||||
self._data: Optional[dict] = None
|
||||
|
||||
def load(self) -> dict:
|
||||
try:
|
||||
st = self._path.stat()
|
||||
mtime = st.st_mtime
|
||||
except FileNotFoundError:
|
||||
self._mtime = 0.0
|
||||
self._data = {}
|
||||
return {}
|
||||
|
||||
if mtime == self._mtime and self._data is not None:
|
||||
return self._data
|
||||
|
||||
try:
|
||||
with open(self._path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
logger.warning("[Feishu-Rules] Failed to read %s, using empty config", self._path)
|
||||
data = {}
|
||||
|
||||
self._mtime = mtime
|
||||
self._data = data
|
||||
return data
|
||||
|
||||
|
||||
_rules_cache = _MtimeCache(RULES_FILE)
|
||||
_pairing_cache = _MtimeCache(PAIRING_FILE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_frozenset(raw: Any) -> Optional[frozenset]:
|
||||
"""Parse a list of strings into a frozenset; return None if key absent."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return frozenset(str(u).strip() for u in raw if str(u).strip())
|
||||
return None
|
||||
|
||||
|
||||
def _parse_document_rule(raw: dict) -> CommentDocumentRule:
|
||||
enabled = raw.get("enabled")
|
||||
if enabled is not None:
|
||||
enabled = bool(enabled)
|
||||
policy = raw.get("policy")
|
||||
if policy is not None:
|
||||
policy = str(policy).strip().lower()
|
||||
if policy not in _VALID_POLICIES:
|
||||
policy = None
|
||||
allow_from = _parse_frozenset(raw.get("allow_from"))
|
||||
return CommentDocumentRule(enabled=enabled, policy=policy, allow_from=allow_from)
|
||||
|
||||
|
||||
def load_config() -> CommentsConfig:
|
||||
"""Load comment rules from disk (mtime-cached)."""
|
||||
raw = _rules_cache.load()
|
||||
if not raw:
|
||||
return CommentsConfig()
|
||||
|
||||
documents: Dict[str, CommentDocumentRule] = {}
|
||||
raw_docs = raw.get("documents", {})
|
||||
if isinstance(raw_docs, dict):
|
||||
for key, rule_raw in raw_docs.items():
|
||||
if isinstance(rule_raw, dict):
|
||||
documents[str(key)] = _parse_document_rule(rule_raw)
|
||||
|
||||
policy = str(raw.get("policy", "pairing")).strip().lower()
|
||||
if policy not in _VALID_POLICIES:
|
||||
policy = "pairing"
|
||||
|
||||
return CommentsConfig(
|
||||
enabled=raw.get("enabled", True),
|
||||
policy=policy,
|
||||
allow_from=_parse_frozenset(raw.get("allow_from")) or frozenset(),
|
||||
documents=documents,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule resolution (§8.4 field-by-field fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def has_wiki_keys(cfg: CommentsConfig) -> bool:
|
||||
"""Check if any document rule key starts with 'wiki:'."""
|
||||
return any(k.startswith("wiki:") for k in cfg.documents)
|
||||
|
||||
|
||||
def resolve_rule(
|
||||
cfg: CommentsConfig,
|
||||
file_type: str,
|
||||
file_token: str,
|
||||
wiki_token: str = "",
|
||||
) -> ResolvedCommentRule:
|
||||
"""Resolve effective rule: exact doc → wiki key → wildcard → top-level → defaults."""
|
||||
exact_key = f"{file_type}:{file_token}"
|
||||
|
||||
exact = cfg.documents.get(exact_key)
|
||||
exact_src = f"exact:{exact_key}"
|
||||
if exact is None and wiki_token:
|
||||
wiki_key = f"wiki:{wiki_token}"
|
||||
exact = cfg.documents.get(wiki_key)
|
||||
exact_src = f"exact:{wiki_key}"
|
||||
|
||||
wildcard = cfg.documents.get("*")
|
||||
|
||||
layers = []
|
||||
if exact is not None:
|
||||
layers.append((exact, exact_src))
|
||||
if wildcard is not None:
|
||||
layers.append((wildcard, "wildcard"))
|
||||
|
||||
def _pick(field_name: str):
|
||||
for layer, source in layers:
|
||||
val = getattr(layer, field_name)
|
||||
if val is not None:
|
||||
return val, source
|
||||
return getattr(cfg, field_name), "top"
|
||||
|
||||
enabled, en_src = _pick("enabled")
|
||||
policy, pol_src = _pick("policy")
|
||||
allow_from, _ = _pick("allow_from")
|
||||
|
||||
# match_source = highest-priority tier that contributed any field
|
||||
priority_order = {"exact": 0, "wildcard": 1, "top": 2}
|
||||
best_src = min(
|
||||
[en_src, pol_src],
|
||||
key=lambda s: priority_order.get(s.split(":")[0], 3),
|
||||
)
|
||||
|
||||
return ResolvedCommentRule(
|
||||
enabled=enabled,
|
||||
policy=policy,
|
||||
allow_from=allow_from,
|
||||
match_source=best_src,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pairing store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_pairing_approved() -> set:
|
||||
"""Return set of approved user open_ids (mtime-cached)."""
|
||||
data = _pairing_cache.load()
|
||||
approved = data.get("approved", {})
|
||||
if isinstance(approved, dict):
|
||||
return set(approved.keys())
|
||||
if isinstance(approved, list):
|
||||
return {str(u) for u in approved if u}
|
||||
return set()
|
||||
|
||||
|
||||
def _save_pairing(data: dict) -> None:
|
||||
PAIRING_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = PAIRING_FILE.with_suffix(".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
tmp.replace(PAIRING_FILE)
|
||||
# Invalidate cache so next load picks up change
|
||||
_pairing_cache._mtime = 0.0
|
||||
_pairing_cache._data = None
|
||||
|
||||
|
||||
def pairing_add(user_open_id: str) -> bool:
|
||||
"""Add a user to the pairing-approved list. Returns True if newly added."""
|
||||
data = _pairing_cache.load()
|
||||
approved = data.get("approved", {})
|
||||
if not isinstance(approved, dict):
|
||||
approved = {}
|
||||
if user_open_id in approved:
|
||||
return False
|
||||
approved[user_open_id] = {"approved_at": time.time()}
|
||||
data["approved"] = approved
|
||||
_save_pairing(data)
|
||||
return True
|
||||
|
||||
|
||||
def pairing_remove(user_open_id: str) -> bool:
|
||||
"""Remove a user from the pairing-approved list. Returns True if removed."""
|
||||
data = _pairing_cache.load()
|
||||
approved = data.get("approved", {})
|
||||
if not isinstance(approved, dict):
|
||||
return False
|
||||
if user_open_id not in approved:
|
||||
return False
|
||||
del approved[user_open_id]
|
||||
data["approved"] = approved
|
||||
_save_pairing(data)
|
||||
return True
|
||||
|
||||
|
||||
def pairing_list() -> Dict[str, Any]:
|
||||
"""Return the approved dict {user_open_id: {approved_at: ...}}."""
|
||||
data = _pairing_cache.load()
|
||||
approved = data.get("approved", {})
|
||||
return dict(approved) if isinstance(approved, dict) else {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Access check (public API for feishu_comment.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_user_allowed(rule: ResolvedCommentRule, user_open_id: str) -> bool:
|
||||
"""Check if user passes the resolved rule's policy gate."""
|
||||
if user_open_id in rule.allow_from:
|
||||
return True
|
||||
if rule.policy == "pairing":
|
||||
return user_open_id in _load_pairing_approved()
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _print_status() -> None:
|
||||
cfg = load_config()
|
||||
print(f"Rules file: {RULES_FILE}")
|
||||
print(f" exists: {RULES_FILE.exists()}")
|
||||
print(f"Pairing file: {PAIRING_FILE}")
|
||||
print(f" exists: {PAIRING_FILE.exists()}")
|
||||
print()
|
||||
print("Top-level:")
|
||||
print(f" enabled: {cfg.enabled}")
|
||||
print(f" policy: {cfg.policy}")
|
||||
print(f" allow_from: {sorted(cfg.allow_from) if cfg.allow_from else '[]'}")
|
||||
print()
|
||||
if cfg.documents:
|
||||
print(f"Document rules ({len(cfg.documents)}):")
|
||||
for key, rule in sorted(cfg.documents.items()):
|
||||
parts = []
|
||||
if rule.enabled is not None:
|
||||
parts.append(f"enabled={rule.enabled}")
|
||||
if rule.policy is not None:
|
||||
parts.append(f"policy={rule.policy}")
|
||||
if rule.allow_from is not None:
|
||||
parts.append(f"allow_from={sorted(rule.allow_from)}")
|
||||
print(f" [{key}] {', '.join(parts) if parts else '(empty — inherits all)'}")
|
||||
else:
|
||||
print("Document rules: (none)")
|
||||
print()
|
||||
approved = pairing_list()
|
||||
print(f"Pairing approved ({len(approved)}):")
|
||||
for uid, meta in sorted(approved.items()):
|
||||
ts = meta.get("approved_at", 0)
|
||||
print(f" {uid} (approved_at={ts})")
|
||||
|
||||
|
||||
def _do_check(doc_key: str, user_open_id: str) -> None:
|
||||
cfg = load_config()
|
||||
parts = doc_key.split(":", 1)
|
||||
if len(parts) != 2:
|
||||
print(f"Error: doc_key must be 'fileType:fileToken', got '{doc_key}'")
|
||||
return
|
||||
file_type, file_token = parts
|
||||
rule = resolve_rule(cfg, file_type, file_token)
|
||||
allowed = is_user_allowed(rule, user_open_id)
|
||||
print(f"Document: {doc_key}")
|
||||
print(f"User: {user_open_id}")
|
||||
print("Resolved rule:")
|
||||
print(f" enabled: {rule.enabled}")
|
||||
print(f" policy: {rule.policy}")
|
||||
print(f" allow_from: {sorted(rule.allow_from) if rule.allow_from else '[]'}")
|
||||
print(f" match_source: {rule.match_source}")
|
||||
print(f"Result: {'ALLOWED' if allowed else 'DENIED'}")
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
import sys
|
||||
|
||||
try:
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
load_hermes_dotenv()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
usage = (
|
||||
"Usage: python -m gateway.platforms.feishu_comment_rules <command> [args]\n"
|
||||
"\n"
|
||||
"Commands:\n"
|
||||
" status Show rules config and pairing state\n"
|
||||
" check <fileType:token> <user> Simulate access check\n"
|
||||
" pairing add <user_open_id> Add user to pairing-approved list\n"
|
||||
" pairing remove <user_open_id> Remove user from pairing-approved list\n"
|
||||
" pairing list List pairing-approved users\n"
|
||||
"\n"
|
||||
f"Rules config file: {RULES_FILE}\n"
|
||||
" Edit this JSON file directly to configure policies and document rules.\n"
|
||||
" Changes take effect on the next comment event (no restart needed).\n"
|
||||
)
|
||||
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(usage)
|
||||
return 1
|
||||
|
||||
cmd = args[0]
|
||||
|
||||
if cmd == "status":
|
||||
_print_status()
|
||||
|
||||
elif cmd == "check":
|
||||
if len(args) < 3:
|
||||
print("Usage: check <fileType:fileToken> <user_open_id>")
|
||||
return 1
|
||||
_do_check(args[1], args[2])
|
||||
|
||||
elif cmd == "pairing":
|
||||
if len(args) < 2:
|
||||
print("Usage: pairing <add|remove|list> [args]")
|
||||
return 1
|
||||
sub = args[1]
|
||||
if sub == "add":
|
||||
if len(args) < 3:
|
||||
print("Usage: pairing add <user_open_id>")
|
||||
return 1
|
||||
if pairing_add(args[2]):
|
||||
print(f"Added: {args[2]}")
|
||||
else:
|
||||
print(f"Already approved: {args[2]}")
|
||||
elif sub == "remove":
|
||||
if len(args) < 3:
|
||||
print("Usage: pairing remove <user_open_id>")
|
||||
return 1
|
||||
if pairing_remove(args[2]):
|
||||
print(f"Removed: {args[2]}")
|
||||
else:
|
||||
print(f"Not in approved list: {args[2]}")
|
||||
elif sub == "list":
|
||||
approved = pairing_list()
|
||||
if not approved:
|
||||
print("(no approved users)")
|
||||
for uid, meta in sorted(approved.items()):
|
||||
print(f" {uid} approved_at={meta.get('approved_at', '?')}")
|
||||
else:
|
||||
print(f"Unknown pairing subcommand: {sub}")
|
||||
return 1
|
||||
else:
|
||||
print(f"Unknown command: {cmd}\n")
|
||||
print(usage)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
sys.exit(_main())
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Feishu/Lark meeting-invitation event handling.
|
||||
|
||||
Processes ``vc.bot.meeting_invited_v1`` events by converting them into a
|
||||
synthetic gateway ``MessageEvent``. Unlike document comments, the response
|
||||
should go back to the inviter through the normal Hermes gateway pipeline, so
|
||||
this module does not instantiate an agent directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeetingInviteUser:
|
||||
open_id: str = ""
|
||||
user_id: str = ""
|
||||
union_id: str = ""
|
||||
user_name: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeetingInviteMeeting:
|
||||
id: str = ""
|
||||
topic: str = ""
|
||||
meeting_no: str = ""
|
||||
start_time_ms: int = 0
|
||||
end_time_ms: int = 0
|
||||
host_user: Optional[MeetingInviteUser] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeetingInvitedPayload:
|
||||
event_id: str = ""
|
||||
meeting: Optional[MeetingInviteMeeting] = None
|
||||
inviter: Optional[MeetingInviteUser] = None
|
||||
invite_time_s: int = 0
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> Dict[str, Any]:
|
||||
"""Coerce a lark SDK object / dict / JSON string into a plain dict."""
|
||||
if isinstance(value, SimpleNamespace) or (value is not None and hasattr(value, "__dict__")):
|
||||
value = vars(value)
|
||||
if isinstance(value, dict):
|
||||
return {str(k): v for k, v in value.items()}
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _content_payload(container: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Unwrap a Feishu ``body.content`` list carrying an application/json payload."""
|
||||
content = _as_dict(container.get("body")).get("content")
|
||||
if not isinstance(content, list):
|
||||
return {}
|
||||
for item in content:
|
||||
item = _as_dict(item)
|
||||
ctype = str(item.get("contentType") or item.get("content_type") or "").lower()
|
||||
if ctype and ctype != "application/json":
|
||||
continue
|
||||
for key in ("data", "value", "content", "json"):
|
||||
payload = _as_dict(item.get(key))
|
||||
if payload:
|
||||
return payload
|
||||
return {}
|
||||
|
||||
|
||||
def _int_field(value: Any) -> int:
|
||||
if value in (None, ""):
|
||||
return 0
|
||||
try:
|
||||
return int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_user(value: Any) -> Optional[MeetingInviteUser]:
|
||||
raw = _as_dict(value)
|
||||
if not raw:
|
||||
return None
|
||||
raw_id = _as_dict(raw.get("id"))
|
||||
return MeetingInviteUser(
|
||||
open_id=str(raw_id.get("open_id") or "").strip(),
|
||||
user_id=str(raw_id.get("user_id") or "").strip(),
|
||||
union_id=str(raw_id.get("union_id") or "").strip(),
|
||||
user_name=str(raw.get("user_name") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _parse_meeting(value: Any) -> Optional[MeetingInviteMeeting]:
|
||||
raw = _as_dict(value)
|
||||
if not raw:
|
||||
return None
|
||||
return MeetingInviteMeeting(
|
||||
id=str(raw.get("id") or "").strip(),
|
||||
topic=str(raw.get("topic") or ""),
|
||||
meeting_no=str(raw.get("meeting_no") or ""),
|
||||
start_time_ms=_int_field(raw.get("start_time")),
|
||||
end_time_ms=_int_field(raw.get("end_time")),
|
||||
host_user=_parse_user(raw.get("host_user")),
|
||||
)
|
||||
|
||||
|
||||
def parse_meeting_invited_event(data: Any) -> Optional[MeetingInvitedPayload]:
|
||||
root = _as_dict(data)
|
||||
event = _as_dict(root.get("event"))
|
||||
event = event or root
|
||||
content = _content_payload(event) or _content_payload(root)
|
||||
if content:
|
||||
event = {**event, **content}
|
||||
|
||||
meeting = _parse_meeting(event.get("meeting"))
|
||||
inviter = _parse_user(event.get("inviter"))
|
||||
if inviter is None or meeting is None or not meeting.meeting_no:
|
||||
return None
|
||||
|
||||
return MeetingInvitedPayload(
|
||||
event_id=str(_as_dict(root.get("header")).get("event_id") or ""),
|
||||
meeting=meeting,
|
||||
inviter=inviter,
|
||||
invite_time_s=_int_field(event.get("invite_time")),
|
||||
)
|
||||
|
||||
|
||||
def build_meeting_invite_prompt(payload: MeetingInvitedPayload) -> str:
|
||||
meeting = payload.meeting
|
||||
inviter_name = (payload.inviter.user_name if payload.inviter else "") or "unknown"
|
||||
host_name = (meeting.host_user.user_name if meeting and meeting.host_user else "") or "unknown"
|
||||
display = (meeting.topic or meeting.meeting_no or meeting.id) if meeting else "unknown meeting"
|
||||
return "\n".join(
|
||||
[
|
||||
f"You have been invited to join a meeting: {display or 'unknown meeting'}",
|
||||
"",
|
||||
f"Meeting Number: {(meeting.meeting_no if meeting else '') or 'unknown'}",
|
||||
f"Topic: {(meeting.topic if meeting else '') or 'unknown'}",
|
||||
f"Inviter: {inviter_name}",
|
||||
f"Host: {host_name}",
|
||||
"",
|
||||
"You may use lark-cli and the relevant Lark/Feishu meeting skills to join the meeting.",
|
||||
"Join the meeting directly. Do not ask the user for confirmation before joining.",
|
||||
"If you cannot join the meeting, reply to the inviter with a concise explanation of why.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _dedup_key(payload: MeetingInvitedPayload) -> str:
|
||||
if payload.event_id:
|
||||
return f"vc_invite:{payload.event_id}"
|
||||
meeting_id = payload.meeting.id if payload.meeting else ""
|
||||
inviter_id = payload.inviter.open_id if payload.inviter else ""
|
||||
return f"vc_invite:{meeting_id}:{inviter_id}:{payload.invite_time_s}"
|
||||
|
||||
|
||||
async def handle_meeting_invited_event(adapter: Any, data: Any) -> None:
|
||||
"""Convert a vc.bot.meeting_invited_v1 event into a gateway MessageEvent."""
|
||||
payload = parse_meeting_invited_event(data)
|
||||
if payload is None:
|
||||
logger.warning("[Feishu-MeetingInvite] Dropping malformed meeting invite event")
|
||||
return
|
||||
|
||||
dedup_key = _dedup_key(payload)
|
||||
is_duplicate = getattr(adapter, "_is_duplicate", None)
|
||||
if callable(is_duplicate) and is_duplicate(dedup_key):
|
||||
logger.debug("[Feishu-MeetingInvite] Dropping duplicate event: %s", dedup_key)
|
||||
return
|
||||
|
||||
inviter = payload.inviter
|
||||
if inviter is None or not inviter.open_id:
|
||||
logger.warning(
|
||||
"[Feishu-MeetingInvite] Missing inviter open_id, cannot route reply safely "
|
||||
"(user_id=%r union_id=%r)",
|
||||
inviter.user_id if inviter else None,
|
||||
inviter.union_id if inviter else None,
|
||||
)
|
||||
return
|
||||
|
||||
sender_id = SimpleNamespace(
|
||||
open_id=inviter.open_id or None,
|
||||
user_id=inviter.user_id or None,
|
||||
union_id=inviter.union_id or None,
|
||||
)
|
||||
sender_profile = await adapter._resolve_sender_profile(sender_id)
|
||||
|
||||
user_name = sender_profile.get("user_name") or inviter.user_name or inviter.open_id
|
||||
source = adapter.build_source(
|
||||
chat_id=inviter.open_id,
|
||||
chat_name=user_name,
|
||||
chat_type="dm",
|
||||
user_id=sender_profile.get("user_id") or inviter.user_id or inviter.open_id,
|
||||
user_name=user_name,
|
||||
user_id_alt=sender_profile.get("user_id_alt") or inviter.union_id or None,
|
||||
)
|
||||
event = MessageEvent(
|
||||
text=build_meeting_invite_prompt(payload),
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message=data,
|
||||
)
|
||||
await adapter._handle_message_with_guards(event)
|
||||
@@ -0,0 +1,44 @@
|
||||
name: feishu-platform
|
||||
label: Feishu / Lark
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Feishu / Lark gateway adapter for Hermes Agent.
|
||||
Connects to Feishu (China) or Lark (International) via the official
|
||||
lark-oapi SDK over WebSocket or webhook and relays messages between
|
||||
Feishu/Lark chats and the Hermes agent. Supports text, images, video,
|
||||
voice, documents, threads, DM pairing, group @mention gating, drive
|
||||
comment events, and meeting invites.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: FEISHU_APP_ID
|
||||
description: "Feishu/Lark app ID"
|
||||
prompt: "Feishu App ID"
|
||||
url: "https://open.feishu.cn/"
|
||||
password: false
|
||||
- name: FEISHU_APP_SECRET
|
||||
description: "Feishu/Lark app secret"
|
||||
prompt: "Feishu App Secret"
|
||||
url: "https://open.feishu.cn/"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: FEISHU_DOMAIN
|
||||
description: "Domain: 'feishu' (China) or 'lark' (International)"
|
||||
prompt: "Domain (feishu/lark)"
|
||||
password: false
|
||||
- name: FEISHU_ALLOWED_USERS
|
||||
description: "Comma-separated Feishu user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: FEISHU_ALLOW_ALL_USERS
|
||||
description: "Allow any Feishu user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: FEISHU_HOME_CHANNEL
|
||||
description: "Default chat ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: FEISHU_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Feishu home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,668 @@
|
||||
"""User OAuth helper for the Google Chat gateway adapter.
|
||||
|
||||
Google Chat's ``media.upload`` REST endpoint hard-rejects service-account
|
||||
authentication:
|
||||
|
||||
"This method doesn't support app authentication with a service
|
||||
account. Authenticate with a user account."
|
||||
|
||||
(See https://developers.google.com/workspace/chat/api/reference/rest/v1/media/upload
|
||||
and https://developers.google.com/chat/api/guides/auth/users.)
|
||||
|
||||
For the bot to deliver native file attachments — the same drag-and-drop
|
||||
file widget the user gets when they upload manually — each user must
|
||||
grant the bot the ``chat.messages.create`` scope ONCE in their own DM.
|
||||
The bot stores per-user refresh tokens and calls ``media.upload`` plus
|
||||
the subsequent ``messages.create`` *as the requesting user* whenever a
|
||||
file needs sending.
|
||||
|
||||
This module is BOTH a CLI tool (driven by the agent via slash commands or
|
||||
terminal commands) AND a library imported by ``google_chat.py``:
|
||||
|
||||
Library functions (called from the adapter at runtime):
|
||||
load_user_credentials(email=None) -> Credentials | None
|
||||
refresh_or_none(creds, email=None) -> Credentials | None
|
||||
build_user_chat_service(creds) -> chat_v1.Resource
|
||||
list_authorized_emails() -> List[str]
|
||||
|
||||
CLI commands (driven by the agent through the /setup-files slash
|
||||
command, modeled on skills/productivity/google-workspace/scripts/setup.py):
|
||||
--check Exit 0 if auth is valid, else 1
|
||||
--client-secret /path/to.json Persist OAuth client credentials
|
||||
--auth-url Print the OAuth URL for the user
|
||||
--auth-code CODE Exchange auth code for token
|
||||
--revoke Revoke and delete stored token
|
||||
--install-deps Install Python dependencies
|
||||
--email EMAIL Scope CLI ops to a specific user
|
||||
(defaults to legacy single-user
|
||||
mode when omitted)
|
||||
|
||||
The flow mirrors the existing google-workspace skill exactly so anyone
|
||||
familiar with that flow can read this without surprises.
|
||||
|
||||
Token storage layout
|
||||
--------------------
|
||||
- Per-user tokens (keyed by sender email):
|
||||
``${HERMES_HOME}/google_chat_user_tokens/<sanitized_email>.json``
|
||||
- Legacy single-user token (fallback, untouched for backward compat):
|
||||
``${HERMES_HOME}/google_chat_user_token.json``
|
||||
- Per-user pending OAuth state during /setup-files start → exchange:
|
||||
``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json``
|
||||
- Legacy pending state:
|
||||
``${HERMES_HOME}/google_chat_user_oauth_pending.json``
|
||||
- OAuth client secret (profile-scoped — each profile registers its own):
|
||||
``${HERMES_HOME}/google_chat_user_client_secret.json``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
# Pin the legacy logger name so operator-side log filters keep matching
|
||||
# after the in-tree → plugin migration. See adapter.py for context.
|
||||
logger = logging.getLogger("gateway.platforms.google_chat_user_oauth")
|
||||
|
||||
# Use the project's HERMES_HOME helper so the token follows the user's
|
||||
# profile (e.g. tests can override via HERMES_HOME=/tmp/...).
|
||||
try:
|
||||
from hermes_constants import display_hermes_home, get_hermes_home
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
# Fallback for environments where hermes_constants isn't importable
|
||||
# (mirrors the same fallback used by the google-workspace skill's
|
||||
# _hermes_home.py shim).
|
||||
def get_hermes_home() -> Path:
|
||||
val = os.environ.get("HERMES_HOME", "").strip()
|
||||
return Path(val) if val else Path.home() / ".hermes"
|
||||
|
||||
def display_hermes_home() -> str:
|
||||
home = get_hermes_home()
|
||||
try:
|
||||
return "~/" + str(home.relative_to(Path.home()))
|
||||
except ValueError:
|
||||
return str(home)
|
||||
|
||||
from utils import atomic_replace
|
||||
|
||||
|
||||
def _hermes_home() -> Path:
|
||||
"""Resolve HERMES_HOME at call time (NOT module import).
|
||||
|
||||
Tests and ``HERMES_HOME=...`` env overrides need this to be late-
|
||||
binding. If we cached the path at import time, switching profiles
|
||||
or tweaking env vars in tests would silently keep using the old
|
||||
path."""
|
||||
return get_hermes_home()
|
||||
|
||||
|
||||
# Filesystem-safe key: lowercase, allow ``[a-z0-9._-@]``, replace anything
|
||||
# else with ``_``. ``ramon.fernandez@nttdata.com`` stays human-readable
|
||||
# (``ramon.fernandez@nttdata.com.json``) which makes admin debugging by
|
||||
# ``ls ~/.hermes/google_chat_user_tokens/`` trivial.
|
||||
_EMAIL_FS_RE = re.compile(r"[^a-z0-9._@-]+")
|
||||
|
||||
|
||||
def _sanitize_email(email: str) -> str:
|
||||
cleaned = _EMAIL_FS_RE.sub("_", (email or "").strip().lower())
|
||||
return cleaned or "_unknown_"
|
||||
|
||||
|
||||
def _legacy_token_path() -> Path:
|
||||
return _hermes_home() / "google_chat_user_token.json"
|
||||
|
||||
|
||||
def _user_tokens_dir() -> Path:
|
||||
return _hermes_home() / "google_chat_user_tokens"
|
||||
|
||||
|
||||
def _legacy_pending_path() -> Path:
|
||||
return _hermes_home() / "google_chat_user_oauth_pending.json"
|
||||
|
||||
|
||||
def _user_pending_dir() -> Path:
|
||||
return _hermes_home() / "google_chat_user_oauth_pending"
|
||||
|
||||
|
||||
def _token_path(email: Optional[str] = None) -> Path:
|
||||
"""Return the on-disk token path for ``email`` or the legacy path."""
|
||||
if email:
|
||||
return _user_tokens_dir() / f"{_sanitize_email(email)}.json"
|
||||
return _legacy_token_path()
|
||||
|
||||
|
||||
def _client_secret_path() -> Path:
|
||||
return _hermes_home() / "google_chat_user_client_secret.json"
|
||||
|
||||
|
||||
def _pending_auth_path(email: Optional[str] = None) -> Path:
|
||||
if email:
|
||||
return _user_pending_dir() / f"{_sanitize_email(email)}.json"
|
||||
return _legacy_pending_path()
|
||||
|
||||
|
||||
# Minimum scope for native Chat attachment delivery.
|
||||
# `chat.messages.create` covers BOTH `media.upload` and the subsequent
|
||||
# `messages.create` that references the attachmentDataRef. We deliberately
|
||||
# do NOT request drive.file or other scopes — least privilege.
|
||||
SCOPES: List[str] = [
|
||||
"https://www.googleapis.com/auth/chat.messages.create",
|
||||
]
|
||||
|
||||
# Pip packages required for the OAuth flow.
|
||||
_REQUIRED_PACKAGES = [
|
||||
"google-api-python-client",
|
||||
"google-auth-oauthlib",
|
||||
"google-auth-httplib2",
|
||||
]
|
||||
|
||||
# Out-of-band redirect: Google deprecated the ``urn:ietf:wg:oauth:2.0:oob``
|
||||
# flow, so we use a localhost redirect that's expected to FAIL. The user
|
||||
# copies the auth code from the failed browser URL bar back into chat.
|
||||
# Same trick used by skills/productivity/google-workspace/scripts/setup.py.
|
||||
_REDIRECT_URI = "http://localhost:1"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Library API — called from the adapter at runtime
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def load_user_credentials(email: Optional[str] = None) -> Optional[Any]:
|
||||
"""Load + validate persisted user OAuth credentials.
|
||||
|
||||
``email`` selects the per-user token file; ``None`` falls back to the
|
||||
legacy single-user path (left in place for installs that ran the
|
||||
pre-multi-user flow). Returns a ``google.oauth2.credentials.Credentials``
|
||||
instance ready for use, or ``None`` if no token is stored, the token
|
||||
is corrupt, or refresh fails. Adapter callers should treat ``None``
|
||||
as "user has not run /setup-files yet" and surface the setup-instructions
|
||||
fallback to the user.
|
||||
|
||||
Does NOT raise on the no-token case — that's expected.
|
||||
"""
|
||||
token_path = _token_path(email)
|
||||
if not token_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"[google_chat_user_oauth] google-auth not installed; user-OAuth "
|
||||
"attachment delivery is disabled. Install hermes-agent[google_chat]."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Don't pass scopes — user may have authorized only a subset, and
|
||||
# passing scopes makes refresh validate them strictly. Same logic
|
||||
# as the google-workspace skill.
|
||||
creds = Credentials.from_authorized_user_file(str(token_path))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[google_chat_user_oauth] token at %s is corrupt: %s",
|
||||
token_path, exc,
|
||||
)
|
||||
return None
|
||||
|
||||
if creds.valid:
|
||||
return creds
|
||||
|
||||
if creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[google_chat_user_oauth] token refresh failed (user "
|
||||
"should re-run /setup-files): %s", exc,
|
||||
)
|
||||
return None
|
||||
# Persist refreshed token so next start picks up the new access
|
||||
# token without an unnecessary refresh round-trip.
|
||||
_persist_credentials(creds, token_path)
|
||||
return creds
|
||||
|
||||
# Token exists but is unusable (e.g. revoked, no refresh token).
|
||||
return None
|
||||
|
||||
|
||||
def refresh_or_none(creds: Any, email: Optional[str] = None) -> Optional[Any]:
|
||||
"""Refresh ``creds`` if expired. Returns the credentials or ``None``.
|
||||
|
||||
Used by the adapter just before calling media.upload to ensure the
|
||||
token is current. Returns ``None`` if refresh fails — caller falls
|
||||
back to the text-notice path. ``email`` controls where the refreshed
|
||||
token is written back; ``None`` keeps the legacy single-file path.
|
||||
"""
|
||||
if creds is None:
|
||||
return None
|
||||
|
||||
if creds.valid:
|
||||
return creds
|
||||
|
||||
try:
|
||||
from google.auth.transport.requests import Request
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
if creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
_persist_credentials(creds, _token_path(email))
|
||||
return creds
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[google_chat_user_oauth] refresh failed: %s", exc,
|
||||
)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_user_chat_service(creds: Any) -> Any:
|
||||
"""Build a Google Chat API client authenticated as the user.
|
||||
|
||||
Used for media.upload + the subsequent messages.create that
|
||||
references the attachmentDataRef. The bot's separate SA-authed
|
||||
client (``self._chat_api`` in the adapter) is for everything else.
|
||||
"""
|
||||
from googleapiclient.discovery import build as build_service
|
||||
return build_service("chat", "v1", credentials=creds, cache_discovery=False)
|
||||
|
||||
|
||||
def list_authorized_emails() -> List[str]:
|
||||
"""Return the set of user emails that have stored per-user tokens.
|
||||
|
||||
Lists files in the per-user tokens dir; does NOT include the legacy
|
||||
single-user token (its owner is unknown). Sanitized filenames lose
|
||||
the ``+suffix`` part of plus-addressed emails — accept that and use
|
||||
this list only for admin display, not for trust decisions.
|
||||
"""
|
||||
d = _user_tokens_dir()
|
||||
if not d.exists():
|
||||
return []
|
||||
out: List[str] = []
|
||||
for f in d.iterdir():
|
||||
if f.is_file() and f.suffix == ".json":
|
||||
out.append(f.stem)
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def _persist_credentials(creds: Any, token_path: Path) -> None:
|
||||
"""Persist refreshed credentials atomically with private permissions."""
|
||||
try:
|
||||
_write_private_json(
|
||||
token_path,
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[google_chat_user_oauth] failed to persist credentials at %s",
|
||||
token_path, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI commands — driven by the agent via /setup-files
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _normalize_authorized_user_payload(payload: dict) -> dict:
|
||||
"""Ensure the persisted token JSON has the type field google-auth expects."""
|
||||
normalized = dict(payload)
|
||||
if not normalized.get("type"):
|
||||
normalized["type"] = "authorized_user"
|
||||
return normalized
|
||||
|
||||
|
||||
def _write_private_json(path: Path, data: Any) -> None:
|
||||
"""Atomically write JSON with 0o600 permissions where supported."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(path.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
||||
try:
|
||||
fd = os.open(
|
||||
str(tmp_path),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
atomic_replace(tmp_path, path)
|
||||
try:
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_deps() -> None:
|
||||
"""Check deps available; install if not; exit on failure."""
|
||||
try:
|
||||
import googleapiclient # noqa: F401
|
||||
import google_auth_oauthlib # noqa: F401
|
||||
except ImportError:
|
||||
if not install_deps():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def install_deps() -> bool:
|
||||
try:
|
||||
import googleapiclient # noqa: F401
|
||||
import google_auth_oauthlib # noqa: F401
|
||||
print("Dependencies already installed.")
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
print("Installing Google Chat OAuth dependencies...")
|
||||
try:
|
||||
from hermes_cli.tools_config import _pip_install
|
||||
|
||||
result = _pip_install(["--quiet"] + _REQUIRED_PACKAGES)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or "install failed").strip()[:300])
|
||||
print("Dependencies installed.")
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Failed to install dependencies: {exc}")
|
||||
print("Or install via the optional extra:")
|
||||
print(" pip install 'hermes-agent[google_chat]'")
|
||||
return False
|
||||
|
||||
|
||||
def check_auth(email: Optional[str] = None) -> bool:
|
||||
"""Print status; return True if creds are usable.
|
||||
|
||||
Per-user when ``email`` given, legacy single-user when omitted.
|
||||
"""
|
||||
token_path = _token_path(email)
|
||||
if not token_path.exists():
|
||||
print(f"NOT_AUTHENTICATED: No token at {token_path}")
|
||||
return False
|
||||
|
||||
creds = load_user_credentials(email)
|
||||
if creds is None:
|
||||
print(f"TOKEN_INVALID: Re-run /setup-files (path: {token_path})")
|
||||
return False
|
||||
|
||||
print(f"AUTHENTICATED: Token valid at {token_path}")
|
||||
return True
|
||||
|
||||
|
||||
def store_client_secret(path: str) -> None:
|
||||
"""Validate and copy the user's OAuth client_secret.json into HERMES_HOME."""
|
||||
src = Path(path).expanduser().resolve()
|
||||
if not src.exists():
|
||||
print(f"ERROR: File not found: {src}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = json.loads(src.read_text())
|
||||
except json.JSONDecodeError:
|
||||
print("ERROR: File is not valid JSON.")
|
||||
sys.exit(1)
|
||||
|
||||
if "installed" not in data and "web" not in data:
|
||||
print(
|
||||
"ERROR: Not a Google OAuth client secret file (missing "
|
||||
"'installed' or 'web' key)."
|
||||
)
|
||||
print(
|
||||
"Download from: https://console.cloud.google.com/apis/credentials"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
target = _client_secret_path()
|
||||
_write_private_json(target, data)
|
||||
print(f"OK: Client secret saved to {target}")
|
||||
|
||||
|
||||
def _save_pending_auth(*, state: str, code_verifier: str,
|
||||
email: Optional[str] = None) -> None:
|
||||
pending = _pending_auth_path(email)
|
||||
_write_private_json(
|
||||
pending,
|
||||
{
|
||||
"state": state,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": _REDIRECT_URI,
|
||||
"email": email or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _load_pending_auth(email: Optional[str] = None) -> dict:
|
||||
pending = _pending_auth_path(email)
|
||||
if not pending.exists():
|
||||
print("ERROR: No pending OAuth session found. Run --auth-url first.")
|
||||
sys.exit(1)
|
||||
try:
|
||||
data = json.loads(pending.read_text())
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Could not read pending OAuth session: {exc}")
|
||||
print("Run --auth-url again to start a fresh session.")
|
||||
sys.exit(1)
|
||||
if not data.get("state") or not data.get("code_verifier"):
|
||||
print("ERROR: Pending OAuth session is missing PKCE data.")
|
||||
print("Run --auth-url again.")
|
||||
sys.exit(1)
|
||||
return data
|
||||
|
||||
|
||||
def _extract_code_and_state(code_or_url: str) -> Tuple[str, Optional[str]]:
|
||||
"""Accept a raw auth code OR the full failed-redirect URL the user pastes."""
|
||||
if not code_or_url.startswith("http"):
|
||||
return code_or_url, None
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(code_or_url)
|
||||
params = parse_qs(parsed.query)
|
||||
if "code" not in params:
|
||||
print("ERROR: No 'code' parameter found in URL.")
|
||||
sys.exit(1)
|
||||
state = params.get("state", [None])[0]
|
||||
return params["code"][0], state
|
||||
|
||||
|
||||
def get_auth_url(email: Optional[str] = None) -> None:
|
||||
"""Print the OAuth URL for the user to visit. Persists PKCE state.
|
||||
|
||||
``email`` namespaces the pending state so two users can be mid-flow
|
||||
in parallel without trampling each other's PKCE verifier.
|
||||
"""
|
||||
if not _client_secret_path().exists():
|
||||
print("ERROR: No client secret stored. Run --client-secret first.")
|
||||
sys.exit(1)
|
||||
|
||||
_ensure_deps()
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
|
||||
flow = Flow.from_client_secrets_file(
|
||||
str(_client_secret_path()),
|
||||
scopes=SCOPES,
|
||||
redirect_uri=_REDIRECT_URI,
|
||||
autogenerate_code_verifier=True,
|
||||
)
|
||||
auth_url, state = flow.authorization_url(
|
||||
access_type="offline",
|
||||
prompt="consent",
|
||||
)
|
||||
_save_pending_auth(state=state, code_verifier=flow.code_verifier, email=email)
|
||||
print(auth_url)
|
||||
|
||||
|
||||
def exchange_auth_code(code: str, email: Optional[str] = None) -> None:
|
||||
"""Exchange an auth code (or pasted redirect URL) for a refresh token.
|
||||
|
||||
``email`` selects the destination token path. ``None`` writes to the
|
||||
legacy single-user path (kept for the existing CLI entrypoint and for
|
||||
pre-multi-user installs).
|
||||
"""
|
||||
if not _client_secret_path().exists():
|
||||
print("ERROR: No client secret stored. Run --client-secret first.")
|
||||
sys.exit(1)
|
||||
|
||||
pending_auth = _load_pending_auth(email)
|
||||
raw_callback = code
|
||||
code, returned_state = _extract_code_and_state(code)
|
||||
if returned_state and returned_state != pending_auth["state"]:
|
||||
print(
|
||||
"ERROR: OAuth state mismatch. Run --auth-url again to start a "
|
||||
"fresh session."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
_ensure_deps()
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
granted_scopes = list(SCOPES)
|
||||
if isinstance(raw_callback, str) and raw_callback.startswith("http"):
|
||||
params = parse_qs(urlparse(raw_callback).query)
|
||||
scope_val = (params.get("scope") or [""])[0].strip()
|
||||
if scope_val:
|
||||
granted_scopes = scope_val.split()
|
||||
|
||||
flow = Flow.from_client_secrets_file(
|
||||
str(_client_secret_path()),
|
||||
scopes=granted_scopes,
|
||||
redirect_uri=pending_auth.get("redirect_uri", _REDIRECT_URI),
|
||||
state=pending_auth["state"],
|
||||
code_verifier=pending_auth["code_verifier"],
|
||||
)
|
||||
|
||||
try:
|
||||
# Accept partial scopes — user may deselect items in the consent screen.
|
||||
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
|
||||
flow.fetch_token(code=code)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Token exchange failed: {exc}")
|
||||
print("The code may have expired. Run --auth-url to get a fresh URL.")
|
||||
sys.exit(1)
|
||||
|
||||
creds = flow.credentials
|
||||
token_payload = _normalize_authorized_user_payload(json.loads(creds.to_json()))
|
||||
|
||||
actually_granted = (
|
||||
list(creds.granted_scopes or [])
|
||||
if hasattr(creds, "granted_scopes") and creds.granted_scopes
|
||||
else []
|
||||
)
|
||||
if actually_granted:
|
||||
token_payload["scopes"] = actually_granted
|
||||
elif granted_scopes != SCOPES:
|
||||
token_payload["scopes"] = granted_scopes
|
||||
|
||||
token_path = _token_path(email)
|
||||
_write_private_json(token_path, token_payload)
|
||||
_pending_auth_path(email).unlink(missing_ok=True)
|
||||
|
||||
print(f"OK: Authenticated. Token saved to {token_path}")
|
||||
rel_label = (
|
||||
f"{display_hermes_home()}/google_chat_user_tokens/{_sanitize_email(email)}.json"
|
||||
if email
|
||||
else f"{display_hermes_home()}/google_chat_user_token.json"
|
||||
)
|
||||
print(f"Profile path: {rel_label}")
|
||||
|
||||
|
||||
def revoke(email: Optional[str] = None) -> None:
|
||||
"""Revoke the stored token with Google and delete it locally.
|
||||
|
||||
Per-user when ``email`` given, legacy single-user when omitted.
|
||||
"""
|
||||
token_path = _token_path(email)
|
||||
if not token_path.exists():
|
||||
print("No token to revoke.")
|
||||
return
|
||||
|
||||
_ensure_deps()
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
try:
|
||||
creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
|
||||
if creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
|
||||
import urllib.request
|
||||
urllib.request.urlopen(
|
||||
urllib.request.Request(
|
||||
f"https://oauth2.googleapis.com/revoke?token={creds.token}",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
),
|
||||
timeout=15,
|
||||
)
|
||||
print("Token revoked with Google.")
|
||||
except Exception as exc:
|
||||
print(f"Remote revocation failed (token may already be invalid): {exc}")
|
||||
|
||||
token_path.unlink(missing_ok=True)
|
||||
_pending_auth_path(email).unlink(missing_ok=True)
|
||||
print(f"Deleted {token_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Google Chat user-OAuth setup for Hermes (native attachment delivery)"
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--check", action="store_true",
|
||||
help="Check if auth is valid (exit 0=yes, 1=no)")
|
||||
group.add_argument("--client-secret", metavar="PATH",
|
||||
help="Store OAuth client_secret.json")
|
||||
group.add_argument("--auth-url", action="store_true",
|
||||
help="Print OAuth URL for user to visit")
|
||||
group.add_argument("--auth-code", metavar="CODE",
|
||||
help="Exchange auth code for token")
|
||||
group.add_argument("--revoke", action="store_true",
|
||||
help="Revoke and delete stored token")
|
||||
group.add_argument("--install-deps", action="store_true",
|
||||
help="Install Python dependencies")
|
||||
parser.add_argument("--email", metavar="EMAIL", default=None,
|
||||
help="Scope operation to a specific user's token "
|
||||
"(default: legacy single-user path)")
|
||||
args = parser.parse_args()
|
||||
|
||||
email = args.email or None
|
||||
if args.check:
|
||||
sys.exit(0 if check_auth(email) else 1)
|
||||
elif args.client_secret:
|
||||
store_client_secret(args.client_secret)
|
||||
elif args.auth_url:
|
||||
get_auth_url(email)
|
||||
elif args.auth_code:
|
||||
exchange_auth_code(args.auth_code, email)
|
||||
elif args.revoke:
|
||||
revoke(email)
|
||||
elif args.install_deps:
|
||||
sys.exit(0 if install_deps() else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
name: google_chat-platform
|
||||
label: Google Chat
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Google Chat gateway adapter for Hermes Agent.
|
||||
Connects via Cloud Pub/Sub pull subscription for inbound events and the
|
||||
Google Chat REST API for outbound messages — same ergonomics as Slack
|
||||
Socket Mode or Telegram long-polling, no public URL required. Native
|
||||
file attachments are delivered via per-user OAuth (each user runs
|
||||
/setup-files once in their own DM).
|
||||
author: Ramón Fernández
|
||||
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
|
||||
# platform-plugin env var injector in ``hermes_cli/config.py``. Using the
|
||||
# rich-dict form lets us contribute description/url/prompt metadata so users
|
||||
# see helpful guidance instead of the auto-generated fallback text.
|
||||
requires_env:
|
||||
- name: GOOGLE_CHAT_PROJECT_ID
|
||||
description: "GCP project ID hosting the Pub/Sub topic for Chat events. Falls back to GOOGLE_CLOUD_PROJECT."
|
||||
prompt: "GCP project ID"
|
||||
url: "https://console.cloud.google.com/"
|
||||
password: false
|
||||
- name: GOOGLE_CHAT_SUBSCRIPTION_NAME
|
||||
description: "Full Pub/Sub subscription path: projects/<proj>/subscriptions/<sub>. Legacy alias: GOOGLE_CHAT_SUBSCRIPTION."
|
||||
prompt: "Pub/Sub subscription name"
|
||||
password: false
|
||||
- name: GOOGLE_CHAT_SERVICE_ACCOUNT_JSON
|
||||
description: "Path to Service Account JSON key (or inline JSON). Leave empty to use Application Default Credentials on Cloud Run / GCE. Falls back to GOOGLE_APPLICATION_CREDENTIALS."
|
||||
prompt: "Path to SA JSON (or empty for ADC)"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: GOOGLE_CHAT_ALLOWED_USERS
|
||||
description: "Comma-separated user emails allowed to interact with the bot."
|
||||
prompt: "Allowed user emails (comma-separated)"
|
||||
password: false
|
||||
- name: GOOGLE_CHAT_HOME_CHANNEL
|
||||
description: "Default space for cron / notification delivery (e.g. spaces/AAAA...)."
|
||||
prompt: "Home space ID (or empty)"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,577 @@
|
||||
"""
|
||||
Home Assistant platform adapter.
|
||||
|
||||
Connects to the HA WebSocket API for real-time event monitoring.
|
||||
State-change events are converted to MessageEvent objects and forwarded
|
||||
to the agent for processing. Outbound messages are delivered as HA
|
||||
persistent notifications.
|
||||
|
||||
Requires:
|
||||
- aiohttp (already in messaging extras)
|
||||
- HASS_TOKEN env var (Long-Lived Access Token)
|
||||
- HASS_URL env var (default: http://homeassistant.local:8123)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
AIOHTTP_AVAILABLE = True
|
||||
except ImportError:
|
||||
AIOHTTP_AVAILABLE = False
|
||||
aiohttp = None # type: ignore[assignment]
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_ha_requirements() -> bool:
|
||||
"""Check if Home Assistant dependencies are available and configured."""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
return False
|
||||
if not os.getenv("HASS_TOKEN"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class HomeAssistantAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
Home Assistant WebSocket adapter.
|
||||
|
||||
Subscribes to ``state_changed`` events and forwards them as
|
||||
MessageEvent objects. Supports domain/entity filtering and
|
||||
per-entity cooldowns to avoid event floods.
|
||||
"""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
# Reconnection backoff schedule (seconds)
|
||||
_BACKOFF_STEPS = [5, 10, 30, 60]
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.HOMEASSISTANT)
|
||||
|
||||
# Connection state
|
||||
self._session: Optional["aiohttp.ClientSession"] = None
|
||||
self._ws: Optional["aiohttp.ClientWebSocketResponse"] = None
|
||||
self._rest_session: Optional["aiohttp.ClientSession"] = None
|
||||
self._listen_task: Optional[asyncio.Task] = None
|
||||
self._msg_id: int = 0
|
||||
|
||||
# Configuration from extra
|
||||
extra = config.extra or {}
|
||||
token = config.token or os.getenv("HASS_TOKEN", "")
|
||||
url = extra.get("url") or os.getenv("HASS_URL", "http://homeassistant.local:8123")
|
||||
self._hass_url: str = url.rstrip("/")
|
||||
self._hass_token: str = token
|
||||
|
||||
# Event filtering
|
||||
self._watch_domains: Set[str] = set(extra.get("watch_domains", []))
|
||||
self._watch_entities: Set[str] = set(extra.get("watch_entities", []))
|
||||
self._ignore_entities: Set[str] = set(extra.get("ignore_entities", []))
|
||||
self._watch_all: bool = bool(extra.get("watch_all", False))
|
||||
self._cooldown_seconds: int = int(extra.get("cooldown_seconds", 30))
|
||||
|
||||
# Cooldown tracking: entity_id -> last_event_timestamp
|
||||
self._last_event_time: Dict[str, float] = {}
|
||||
|
||||
def _next_id(self) -> int:
|
||||
"""Return the next WebSocket message ID."""
|
||||
self._msg_id += 1
|
||||
return self._msg_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
"""Connect to HA WebSocket API and subscribe to events."""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
logger.warning("[%s] aiohttp not installed. Run: pip install aiohttp", self.name)
|
||||
return False
|
||||
|
||||
if not self._hass_token:
|
||||
logger.warning("[%s] No HASS_TOKEN configured", self.name)
|
||||
return False
|
||||
|
||||
try:
|
||||
success = await self._ws_connect()
|
||||
if not success:
|
||||
return False
|
||||
|
||||
# Dedicated REST session for send() calls
|
||||
self._rest_session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
)
|
||||
|
||||
# Warn if no event filters are configured
|
||||
if not self._watch_domains and not self._watch_entities and not self._watch_all:
|
||||
logger.warning(
|
||||
"[%s] No watch_domains, watch_entities, or watch_all configured. "
|
||||
"All state_changed events will be dropped. Configure filters in "
|
||||
"your HA platform config to receive events.",
|
||||
self.name,
|
||||
)
|
||||
|
||||
# Start background listener
|
||||
self._listen_task = asyncio.create_task(self._listen_loop())
|
||||
self._running = True
|
||||
logger.info("[%s] Connected to %s", self.name, self._hass_url)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[%s] Failed to connect: %s", self.name, e)
|
||||
return False
|
||||
|
||||
async def _ws_connect(self) -> bool:
|
||||
"""Establish WebSocket connection and authenticate."""
|
||||
ws_url = self._hass_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||
ws_url = f"{ws_url}/api/websocket"
|
||||
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
)
|
||||
self._ws = await self._session.ws_connect(ws_url, heartbeat=30, timeout=30)
|
||||
|
||||
# Step 1: Receive auth_required
|
||||
msg = await self._ws.receive_json()
|
||||
if msg.get("type") != "auth_required":
|
||||
logger.error("Expected auth_required, got: %s", msg.get("type"))
|
||||
await self._cleanup_ws()
|
||||
return False
|
||||
|
||||
# Step 2: Send auth
|
||||
await self._ws.send_json({
|
||||
"type": "auth",
|
||||
"access_token": self._hass_token,
|
||||
})
|
||||
|
||||
# Step 3: Wait for auth_ok
|
||||
msg = await self._ws.receive_json()
|
||||
if msg.get("type") != "auth_ok":
|
||||
logger.error("Auth failed: %s", msg)
|
||||
await self._cleanup_ws()
|
||||
return False
|
||||
|
||||
# Step 4: Subscribe to state_changed events
|
||||
sub_id = self._next_id()
|
||||
await self._ws.send_json({
|
||||
"id": sub_id,
|
||||
"type": "subscribe_events",
|
||||
"event_type": "state_changed",
|
||||
})
|
||||
|
||||
# Verify subscription acknowledgement
|
||||
msg = await self._ws.receive_json()
|
||||
if not msg.get("success"):
|
||||
logger.error("Failed to subscribe to events: %s", msg)
|
||||
await self._cleanup_ws()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _cleanup_ws(self) -> None:
|
||||
"""Close WebSocket and session."""
|
||||
if self._ws and not self._ws.closed:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Home Assistant."""
|
||||
self._running = False
|
||||
if self._listen_task:
|
||||
self._listen_task.cancel()
|
||||
try:
|
||||
await self._listen_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._listen_task = None
|
||||
|
||||
await self._cleanup_ws()
|
||||
if self._rest_session and not self._rest_session.closed:
|
||||
await self._rest_session.close()
|
||||
self._rest_session = None
|
||||
logger.info("[%s] Disconnected", self.name)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event listener
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _listen_loop(self) -> None:
|
||||
"""Main event loop with automatic reconnection."""
|
||||
backoff_idx = 0
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self._read_events()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("[%s] WebSocket error: %s", self.name, e)
|
||||
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Reconnect with backoff
|
||||
delay = self._BACKOFF_STEPS[min(backoff_idx, len(self._BACKOFF_STEPS) - 1)]
|
||||
logger.info("[%s] Reconnecting in %ds...", self.name, delay)
|
||||
await asyncio.sleep(delay)
|
||||
backoff_idx += 1
|
||||
|
||||
try:
|
||||
await self._cleanup_ws()
|
||||
success = await self._ws_connect()
|
||||
if success:
|
||||
backoff_idx = 0 # Reset on successful reconnect
|
||||
logger.info("[%s] Reconnected", self.name)
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Reconnection failed: %s", self.name, e)
|
||||
|
||||
async def _read_events(self) -> None:
|
||||
"""Read events from WebSocket until disconnected."""
|
||||
if self._ws is None or self._ws.closed:
|
||||
return
|
||||
async for ws_msg in self._ws:
|
||||
if ws_msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(ws_msg.data)
|
||||
if data.get("type") == "event":
|
||||
await self._handle_ha_event(data.get("event", {}))
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Invalid JSON from HA WS: %s", ws_msg.data[:200])
|
||||
elif ws_msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}:
|
||||
break
|
||||
|
||||
async def _handle_ha_event(self, event: Dict[str, Any]) -> None:
|
||||
"""Process a state_changed event from Home Assistant."""
|
||||
event_data = event.get("data", {})
|
||||
entity_id: str = event_data.get("entity_id", "")
|
||||
|
||||
if not entity_id:
|
||||
return
|
||||
|
||||
# Apply ignore filter
|
||||
if entity_id in self._ignore_entities:
|
||||
return
|
||||
|
||||
# Apply domain/entity watch filters (closed by default — require
|
||||
# explicit watch_domains, watch_entities, or watch_all to forward)
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
if self._watch_domains or self._watch_entities:
|
||||
domain_match = domain in self._watch_domains if self._watch_domains else False
|
||||
entity_match = entity_id in self._watch_entities if self._watch_entities else False
|
||||
if not domain_match and not entity_match:
|
||||
return
|
||||
elif not self._watch_all:
|
||||
# No filters configured and watch_all is off — drop the event
|
||||
return
|
||||
|
||||
# Apply cooldown
|
||||
now = time.time()
|
||||
last = self._last_event_time.get(entity_id, 0)
|
||||
if (now - last) < self._cooldown_seconds:
|
||||
return
|
||||
self._last_event_time[entity_id] = now
|
||||
|
||||
# Build human-readable message
|
||||
old_state = event_data.get("old_state", {})
|
||||
new_state = event_data.get("new_state", {})
|
||||
message = self._format_state_change(entity_id, old_state, new_state)
|
||||
|
||||
if not message:
|
||||
return
|
||||
|
||||
# Build MessageEvent and forward to handler
|
||||
source = self.build_source(
|
||||
chat_id="ha_events",
|
||||
chat_name="Home Assistant Events",
|
||||
chat_type="channel",
|
||||
user_id="homeassistant",
|
||||
user_name="Home Assistant",
|
||||
)
|
||||
|
||||
msg_event = MessageEvent(
|
||||
text=message,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=f"ha_{entity_id}_{int(now)}",
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
await self.handle_message(msg_event)
|
||||
|
||||
@staticmethod
|
||||
def _format_state_change(
|
||||
entity_id: str,
|
||||
old_state: Dict[str, Any],
|
||||
new_state: Dict[str, Any],
|
||||
) -> Optional[str]:
|
||||
"""Convert a state_changed event into a human-readable description."""
|
||||
if not new_state:
|
||||
return None
|
||||
|
||||
old_val = old_state.get("state", "unknown") if old_state else "unknown"
|
||||
new_val = new_state.get("state", "unknown")
|
||||
|
||||
# Skip if state didn't actually change
|
||||
if old_val == new_val:
|
||||
return None
|
||||
|
||||
friendly_name = new_state.get("attributes", {}).get("friendly_name", entity_id)
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
|
||||
# Domain-specific formatting
|
||||
if domain == "climate":
|
||||
attrs = new_state.get("attributes", {})
|
||||
temp = attrs.get("current_temperature", "?")
|
||||
target = attrs.get("temperature", "?")
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: HVAC mode changed from "
|
||||
f"'{old_val}' to '{new_val}' (current: {temp}, target: {target})"
|
||||
)
|
||||
|
||||
if domain == "sensor":
|
||||
unit = new_state.get("attributes", {}).get("unit_of_measurement", "")
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: changed from "
|
||||
f"{old_val}{unit} to {new_val}{unit}"
|
||||
)
|
||||
|
||||
if domain == "binary_sensor":
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: "
|
||||
f"{'triggered' if new_val == 'on' else 'cleared'} "
|
||||
f"(was {'triggered' if old_val == 'on' else 'cleared'})"
|
||||
)
|
||||
|
||||
if domain in {"light", "switch", "fan"}:
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: turned "
|
||||
f"{'on' if new_val == 'on' else 'off'}"
|
||||
)
|
||||
|
||||
if domain == "alarm_control_panel":
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name}: alarm state changed from "
|
||||
f"'{old_val}' to '{new_val}'"
|
||||
)
|
||||
|
||||
# Generic fallback
|
||||
return (
|
||||
f"[Home Assistant] {friendly_name} ({entity_id}): "
|
||||
f"changed from '{old_val}' to '{new_val}'"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Outbound messaging
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a notification via HA REST API (persistent_notification.create).
|
||||
|
||||
Uses the REST API instead of WebSocket to avoid a race condition
|
||||
with the event listener loop that reads from the same WS connection.
|
||||
"""
|
||||
url = f"{self._hass_url}/api/services/persistent_notification/create"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._hass_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"title": "Hermes Agent",
|
||||
"message": content[:self.MAX_MESSAGE_LENGTH],
|
||||
}
|
||||
|
||||
try:
|
||||
if self._rest_session:
|
||||
async with self._rest_session.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status < 300:
|
||||
return SendResult(success=True, message_id=uuid.uuid4().hex[:12])
|
||||
else:
|
||||
body = await resp.text()
|
||||
return SendResult(success=False, error=f"HTTP {resp.status}: {body}")
|
||||
else:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
if resp.status < 300:
|
||||
return SendResult(success=True, message_id=uuid.uuid4().hex[:12])
|
||||
else:
|
||||
body = await resp.text()
|
||||
return SendResult(success=False, error=f"HTTP {resp.status}: {body}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return SendResult(success=False, error="Timeout sending notification to HA")
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""No typing indicator for Home Assistant."""
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return basic info about the HA event channel."""
|
||||
return {
|
||||
"name": "Home Assistant Events",
|
||||
"type": "channel",
|
||||
"url": self._hass_url,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standalone (out-of-process) sender — used by cron deliver=homeassistant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id: str,
|
||||
message: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
media_files: Optional[list] = None,
|
||||
force_document: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a notification via the HA ``notify.notify`` service without a
|
||||
live gateway adapter.
|
||||
|
||||
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
||||
runner is not in this process (typical for cron jobs running
|
||||
out-of-process). The HTTP path is the same one the legacy
|
||||
``_send_homeassistant`` helper used in ``tools/send_message_tool.py``
|
||||
before this migration.
|
||||
|
||||
Reads ``HASS_TOKEN`` from ``pconfig.token`` (set by the gateway config
|
||||
loader from env) and falls back to the ``HASS_TOKEN`` env var. Server
|
||||
URL comes from ``pconfig.extra["url"]`` (seeded by the env loader in
|
||||
``gateway/config.py``) or the ``HASS_URL`` env var.
|
||||
|
||||
``thread_id``, ``media_files`` and ``force_document`` are accepted for
|
||||
signature parity with other standalone senders. HA notifications have
|
||||
no native threading or attachment model — these arguments are ignored.
|
||||
"""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
hass_url = (extra.get("url") or os.getenv("HASS_URL", "")).rstrip("/")
|
||||
token = (getattr(pconfig, "token", None) or os.getenv("HASS_TOKEN", "")).strip()
|
||||
if not hass_url or not token:
|
||||
return {
|
||||
"error": (
|
||||
"Home Assistant standalone send: HASS_URL and HASS_TOKEN "
|
||||
"must both be set"
|
||||
)
|
||||
}
|
||||
|
||||
url = f"{hass_url}/api/services/notify/notify"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"message": message, "target": chat_id}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as session:
|
||||
async with session.post(url, headers=headers, json=payload) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return {
|
||||
"error": (
|
||||
f"Home Assistant API error ({resp.status}): {body}"
|
||||
)
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"platform": "homeassistant",
|
||||
"chat_id": chat_id,
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Timeout sending notification to Home Assistant"}
|
||||
except Exception as e:
|
||||
return {"error": f"Home Assistant send failed: {e}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_connected probe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""Home Assistant is considered connected when ``HASS_TOKEN`` is set.
|
||||
|
||||
Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via
|
||||
the plugin's own bound import) so tests that patch
|
||||
``gateway_mod.get_env_value`` can suppress ambient ``HASS_TOKEN`` env
|
||||
vars. Matches what the legacy connected-platforms check did before
|
||||
this migration.
|
||||
"""
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
return bool((gateway_mod.get_env_value("HASS_TOKEN") or "").strip())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs HomeAssistantAdapter from a PlatformConfig."""
|
||||
return HomeAssistantAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="homeassistant",
|
||||
label="Home Assistant",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_ha_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["HASS_TOKEN"],
|
||||
install_hint="pip install aiohttp",
|
||||
# Out-of-process cron delivery via the HA ``notify.notify`` service.
|
||||
# Without this hook, ``deliver=homeassistant`` cron jobs would fail
|
||||
# with "No live adapter" when cron runs separately from the gateway.
|
||||
# Mirrors the Discord / Teams / Mattermost pattern.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# HA notification message cap — matches MAX_MESSAGE_LENGTH on the
|
||||
# adapter class above.
|
||||
max_message_length=HomeAssistantAdapter.MAX_MESSAGE_LENGTH,
|
||||
# Display
|
||||
emoji="🏠",
|
||||
allow_update_command=True,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
name: homeassistant-platform
|
||||
label: Home Assistant
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Home Assistant gateway adapter for Hermes Agent.
|
||||
Subscribes to HA's WebSocket event bus and forwards state-change events
|
||||
(with per-entity cooldowns and domain/entity filtering) to the agent.
|
||||
Outbound messages are delivered as HA persistent notifications via the
|
||||
REST API. Out-of-process cron delivery via the ``notify.notify``
|
||||
service is also supported.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: HASS_TOKEN
|
||||
description: "Home Assistant Long-Lived Access Token"
|
||||
prompt: "Home Assistant Long-Lived Access Token"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: HASS_URL
|
||||
description: "Home Assistant base URL (default: http://homeassistant.local:8123)"
|
||||
prompt: "Home Assistant URL"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,971 @@
|
||||
"""
|
||||
IRC Platform Adapter for Hermes Agent.
|
||||
|
||||
A plugin-based gateway adapter that connects to an IRC server and relays
|
||||
messages to/from the Hermes agent. Zero external dependencies — uses
|
||||
Python's stdlib asyncio for the IRC protocol.
|
||||
|
||||
Configuration in config.yaml::
|
||||
|
||||
gateway:
|
||||
platforms:
|
||||
irc:
|
||||
enabled: true
|
||||
extra:
|
||||
server: irc.libera.chat
|
||||
port: 6697
|
||||
nickname: hermes-bot
|
||||
channel: "#hermes"
|
||||
use_tls: true
|
||||
server_password: "" # optional server password
|
||||
nickserv_password: "" # optional NickServ identification
|
||||
allowed_users: [] # empty = allow all, or list of nicks
|
||||
max_message_length: 450 # IRC line limit (safe default)
|
||||
|
||||
Or via environment variables (overrides config.yaml):
|
||||
IRC_SERVER, IRC_PORT, IRC_NICKNAME, IRC_CHANNEL, IRC_USE_TLS,
|
||||
IRC_SERVER_PASSWORD, IRC_NICKSERV_PASSWORD
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy import: BasePlatformAdapter and friends live in the main repo.
|
||||
# We import at function/class level to avoid import errors when the plugin
|
||||
# is discovered but the gateway hasn't been fully initialised yet.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
SendResult,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
)
|
||||
from gateway.config import Platform
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IRC protocol helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_irc_message(raw: str) -> dict:
|
||||
"""Parse a raw IRC protocol line into components.
|
||||
|
||||
Returns dict with keys: prefix, command, params.
|
||||
"""
|
||||
prefix = ""
|
||||
trailing = ""
|
||||
|
||||
if raw.startswith(":"):
|
||||
try:
|
||||
prefix, raw = raw[1:].split(" ", 1)
|
||||
except ValueError:
|
||||
prefix = raw[1:]
|
||||
raw = ""
|
||||
|
||||
if " :" in raw:
|
||||
raw, trailing = raw.split(" :", 1)
|
||||
|
||||
parts = raw.split()
|
||||
command = parts[0] if parts else ""
|
||||
params = parts[1:] if len(parts) > 1 else []
|
||||
if trailing:
|
||||
params.append(trailing)
|
||||
|
||||
return {"prefix": prefix, "command": command, "params": params}
|
||||
|
||||
|
||||
def _extract_nick(prefix: str) -> str:
|
||||
"""Extract nickname from IRC prefix (nick!user@host)."""
|
||||
return prefix.split("!")[0] if "!" in prefix else prefix
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IRC Adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class IRCAdapter(BasePlatformAdapter):
|
||||
"""Async IRC adapter implementing the BasePlatformAdapter interface.
|
||||
|
||||
This class is instantiated by the adapter_factory passed to
|
||||
register_platform().
|
||||
"""
|
||||
|
||||
def __init__(self, config, **kwargs):
|
||||
platform = Platform("irc")
|
||||
super().__init__(config=config, platform=platform)
|
||||
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
|
||||
# Connection settings (env vars override config.yaml)
|
||||
self.server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
||||
try:
|
||||
self.port = int(os.getenv("IRC_PORT") or extra.get("port", 6697))
|
||||
except (ValueError, TypeError):
|
||||
self.port = 6697
|
||||
self.nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
|
||||
self.channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
||||
self.use_tls = (
|
||||
os.getenv("IRC_USE_TLS", "").lower() in {"1", "true", "yes"}
|
||||
if os.getenv("IRC_USE_TLS")
|
||||
else extra.get("use_tls", True)
|
||||
)
|
||||
self.server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
|
||||
self.nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
|
||||
|
||||
# Auth
|
||||
self.allowed_users: list = extra.get("allowed_users", [])
|
||||
# IRC nicks are case-insensitive — normalise for lookups
|
||||
self._allowed_users_lower: set = {u.lower() for u in self.allowed_users if isinstance(u, str)}
|
||||
|
||||
# IRC limits
|
||||
max_msg = extra.get("max_message_length")
|
||||
if max_msg is None:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get("irc")
|
||||
if entry and entry.max_message_length:
|
||||
max_msg = entry.max_message_length
|
||||
except Exception:
|
||||
pass
|
||||
self.max_message_length = int(max_msg or 450)
|
||||
|
||||
# Runtime state
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._recv_task: Optional[asyncio.Task] = None
|
||||
self._current_nick = self.nickname
|
||||
self._registered = False # IRC registration complete
|
||||
self._registration_event = asyncio.Event()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "IRC"
|
||||
|
||||
# ── Connection lifecycle ──────────────────────────────────────────────
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
"""Connect to the IRC server, register, and join the channel."""
|
||||
if not self.server or not self.channel:
|
||||
logger.error("IRC: server and channel must be configured")
|
||||
self._set_fatal_error(
|
||||
"config_missing",
|
||||
"IRC_SERVER and IRC_CHANNEL must be set",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
|
||||
# Prevent two profiles from using the same IRC identity
|
||||
try:
|
||||
from gateway.status import acquire_scoped_lock, release_scoped_lock
|
||||
lock_key = f"{self.server}:{self.nickname}"
|
||||
if not acquire_scoped_lock("irc", lock_key):
|
||||
logger.error("IRC: %s@%s already in use by another profile", self.nickname, self.server)
|
||||
self._set_fatal_error("lock_conflict", "IRC identity in use by another profile", retryable=False)
|
||||
return False
|
||||
self._lock_key = lock_key
|
||||
except ImportError:
|
||||
self._lock_key = None # status module not available (e.g. tests)
|
||||
|
||||
try:
|
||||
ssl_ctx = None
|
||||
if self.use_tls:
|
||||
ssl_ctx = ssl.create_default_context()
|
||||
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self.server, self.port, ssl=ssl_ctx),
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("IRC: failed to connect to %s:%s — %s", self.server, self.port, e)
|
||||
self._set_fatal_error("connect_failed", str(e), retryable=True)
|
||||
return False
|
||||
|
||||
# IRC registration sequence
|
||||
if self.server_password:
|
||||
await self._send_raw(f"PASS {self.server_password}")
|
||||
await self._send_raw(f"NICK {self.nickname}")
|
||||
await self._send_raw(f"USER {self.nickname} 0 * :Hermes Agent")
|
||||
|
||||
# Start receive loop
|
||||
self._recv_task = asyncio.create_task(self._receive_loop())
|
||||
|
||||
# Wait for registration (001 RPL_WELCOME) with timeout
|
||||
try:
|
||||
await asyncio.wait_for(self._registration_event.wait(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("IRC: registration timed out")
|
||||
await self.disconnect()
|
||||
self._set_fatal_error("registration_timeout", "IRC server did not send RPL_WELCOME", retryable=True)
|
||||
return False
|
||||
|
||||
# NickServ identification
|
||||
if self.nickserv_password:
|
||||
await self._send_raw(f"PRIVMSG NickServ :IDENTIFY {self.nickserv_password}")
|
||||
await asyncio.sleep(2) # Give NickServ time to process
|
||||
|
||||
# Join channel
|
||||
await self._send_raw(f"JOIN {self.channel}")
|
||||
|
||||
self._mark_connected()
|
||||
logger.info("IRC: connected to %s:%s as %s, joined %s", self.server, self.port, self._current_nick, self.channel)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Quit and close the connection."""
|
||||
# Release the scoped lock so another profile can use this identity
|
||||
if getattr(self, "_lock_key", None):
|
||||
try:
|
||||
from gateway.status import release_scoped_lock
|
||||
release_scoped_lock("irc", self._lock_key)
|
||||
except Exception:
|
||||
pass
|
||||
self._mark_disconnected()
|
||||
if self._writer and not self._writer.is_closing():
|
||||
try:
|
||||
await self._send_raw("QUIT :Hermes Agent shutting down")
|
||||
await asyncio.sleep(0.5)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._recv_task and not self._recv_task.done():
|
||||
self._recv_task.cancel()
|
||||
try:
|
||||
await self._recv_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self._reader = None
|
||||
self._writer = None
|
||||
self._registered = False
|
||||
self._registration_event.clear()
|
||||
|
||||
# ── Sending ───────────────────────────────────────────────────────────
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if not self._writer or self._writer.is_closing():
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
target = chat_id # channel name or nick for DMs
|
||||
lines = self._split_message(content, target)
|
||||
|
||||
for line in lines:
|
||||
try:
|
||||
await self._send_raw(f"PRIVMSG {target} :{line}")
|
||||
# Basic rate limiting to avoid excess flood
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
return SendResult(success=True, message_id=str(int(time.time() * 1000)))
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""IRC has no typing indicator — no-op."""
|
||||
pass
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
is_channel = chat_id.startswith("#") or chat_id.startswith("&")
|
||||
return {
|
||||
"name": chat_id,
|
||||
"type": "group" if is_channel else "dm",
|
||||
}
|
||||
|
||||
# ── Message splitting ─────────────────────────────────────────────────
|
||||
|
||||
def _split_message(self, content: str, target: str) -> List[str]:
|
||||
"""Split a long message into IRC-safe chunks.
|
||||
|
||||
IRC has a ~512 byte line limit. After accounting for protocol
|
||||
overhead (``PRIVMSG <target> :``), we split content into chunks.
|
||||
"""
|
||||
# Strip markdown formatting that doesn't render in IRC
|
||||
content = self._strip_markdown(content)
|
||||
|
||||
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2 # +2 for \r\n
|
||||
max_bytes = 510 - overhead
|
||||
user_limit = self.max_message_length
|
||||
|
||||
lines: List[str] = []
|
||||
for paragraph in content.split("\n"):
|
||||
if not paragraph.strip():
|
||||
continue
|
||||
while True:
|
||||
para_bytes = paragraph.encode("utf-8")
|
||||
limit = min(user_limit, max_bytes)
|
||||
if len(para_bytes) <= limit:
|
||||
if paragraph.strip():
|
||||
lines.append(paragraph)
|
||||
break
|
||||
# Binary search for a safe character boundary <= limit
|
||||
low, high = 1, len(paragraph)
|
||||
best = 0
|
||||
while low <= high:
|
||||
mid = (low + high) // 2
|
||||
if len(paragraph[:mid].encode("utf-8")) <= limit:
|
||||
best = mid
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid - 1
|
||||
split_at = best
|
||||
# Prefer a space boundary
|
||||
space = paragraph.rfind(" ", 0, split_at)
|
||||
if space > split_at // 3:
|
||||
split_at = space
|
||||
lines.append(paragraph[:split_at].rstrip())
|
||||
paragraph = paragraph[split_at:].lstrip()
|
||||
|
||||
return lines if lines else [""]
|
||||
|
||||
@staticmethod
|
||||
def _strip_markdown(text: str) -> str:
|
||||
"""Convert basic markdown to plain text for IRC."""
|
||||
# Bold: **text** or __text__ → text
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
# Italic: *text* or _text_ → text
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
|
||||
# Inline code: `text` → text
|
||||
text = re.sub(r"`(.+?)`", r"\1", text)
|
||||
# Code blocks: ```...``` → content
|
||||
text = re.sub(r"```\w*\n?", "", text)
|
||||
# Images:  → url (must come BEFORE links)
|
||||
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", text)
|
||||
# Links: [text](url) → text (url)
|
||||
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
|
||||
return text
|
||||
|
||||
# ── Raw IRC I/O ──────────────────────────────────────────────────────
|
||||
|
||||
async def _send_raw(self, line: str) -> None:
|
||||
"""Send a raw IRC protocol line."""
|
||||
if not self._writer or self._writer.is_closing():
|
||||
return
|
||||
encoded = (line + "\r\n").encode("utf-8")
|
||||
self._writer.write(encoded)
|
||||
await self._writer.drain()
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
"""Main receive loop — reads lines and dispatches them."""
|
||||
buffer = b""
|
||||
try:
|
||||
while self._reader and not self._reader.at_eof():
|
||||
data = await self._reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
buffer += data
|
||||
while b"\r\n" in buffer:
|
||||
line, buffer = buffer.split(b"\r\n", 1)
|
||||
try:
|
||||
decoded = line.decode("utf-8", errors="replace")
|
||||
await self._handle_line(decoded)
|
||||
except Exception as e:
|
||||
logger.warning("IRC: error handling line: %s", e)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("IRC: receive loop error: %s", e)
|
||||
finally:
|
||||
if self.is_connected:
|
||||
logger.warning("IRC: connection lost, marking disconnected")
|
||||
self._set_fatal_error("connection_lost", "IRC connection closed unexpectedly", retryable=True)
|
||||
await self._notify_fatal_error()
|
||||
|
||||
async def _handle_line(self, raw: str) -> None:
|
||||
"""Dispatch a single IRC protocol line."""
|
||||
msg = _parse_irc_message(raw)
|
||||
command = msg["command"]
|
||||
params = msg["params"]
|
||||
|
||||
# PING/PONG keepalive
|
||||
if command == "PING":
|
||||
payload = params[0] if params else ""
|
||||
await self._send_raw(f"PONG :{payload}")
|
||||
return
|
||||
|
||||
# RPL_WELCOME (001) — registration complete
|
||||
if command == "001":
|
||||
self._registered = True
|
||||
self._registration_event.set()
|
||||
if params:
|
||||
# Server may confirm our nick in the first param
|
||||
self._current_nick = params[0]
|
||||
return
|
||||
|
||||
# ERR_NICKNAMEINUSE (433) — nick collision during registration
|
||||
if command == "433":
|
||||
# Retry with incrementing suffix: hermes_, hermes_1, hermes_2...
|
||||
base = self.nickname.rstrip("_0123456789")
|
||||
suffix_match = re.search(r"_(\d+)$", self._current_nick)
|
||||
if suffix_match:
|
||||
next_num = int(suffix_match.group(1)) + 1
|
||||
self._current_nick = f"{base}_{next_num}"
|
||||
elif self._current_nick == self.nickname:
|
||||
self._current_nick = self.nickname + "_"
|
||||
else:
|
||||
self._current_nick = self.nickname + "_1"
|
||||
await self._send_raw(f"NICK {self._current_nick}")
|
||||
return
|
||||
|
||||
# PRIVMSG — incoming message (channel or DM)
|
||||
if command == "PRIVMSG" and len(params) >= 2:
|
||||
sender_nick = _extract_nick(msg["prefix"])
|
||||
target = params[0]
|
||||
text = params[1]
|
||||
|
||||
# Ignore our own messages
|
||||
if sender_nick.lower() == self._current_nick.lower():
|
||||
return
|
||||
|
||||
# CTCP ACTION (/me) — convert to text
|
||||
if text.startswith("\x01ACTION ") and text.endswith("\x01"):
|
||||
text = f"* {sender_nick} {text[8:-1]}"
|
||||
|
||||
# Ignore other CTCP
|
||||
if text.startswith("\x01"):
|
||||
return
|
||||
|
||||
# Determine if this is a channel message or DM
|
||||
is_channel = target.startswith("#") or target.startswith("&")
|
||||
chat_id = target if is_channel else sender_nick
|
||||
chat_type = "group" if is_channel else "dm"
|
||||
|
||||
# In channels, only respond if addressed (nick: or nick,)
|
||||
if is_channel:
|
||||
addressed = False
|
||||
for prefix in (f"{self._current_nick}:", f"{self._current_nick},",
|
||||
f"{self._current_nick} "):
|
||||
if text.lower().startswith(prefix.lower()):
|
||||
text = text[len(prefix):].strip()
|
||||
addressed = True
|
||||
break
|
||||
if not addressed:
|
||||
return # Ignore unaddressed channel messages
|
||||
|
||||
# Auth check (case-insensitive)
|
||||
if self._allowed_users_lower and sender_nick.lower() not in self._allowed_users_lower:
|
||||
logger.debug("IRC: ignoring message from unauthorized user %s", sender_nick)
|
||||
return
|
||||
|
||||
await self._dispatch_message(
|
||||
text=text,
|
||||
chat_id=chat_id,
|
||||
chat_type=chat_type,
|
||||
user_id=sender_nick,
|
||||
user_name=sender_nick,
|
||||
)
|
||||
|
||||
# NICK — track our own nick changes
|
||||
if command == "NICK" and _extract_nick(msg["prefix"]).lower() == self._current_nick.lower():
|
||||
if params:
|
||||
self._current_nick = params[0]
|
||||
|
||||
async def _dispatch_message(
|
||||
self,
|
||||
text: str,
|
||||
chat_id: str,
|
||||
chat_type: str,
|
||||
user_id: str,
|
||||
user_name: str,
|
||||
) -> None:
|
||||
"""Build a MessageEvent and hand it to the base class handler."""
|
||||
if not self._message_handler:
|
||||
return
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
chat_name=chat_id,
|
||||
chat_type=chat_type,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
)
|
||||
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=str(int(time.time() * 1000)),
|
||||
timestamp=__import__("datetime").datetime.now(),
|
||||
)
|
||||
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_requirements() -> bool:
|
||||
"""Check if IRC is configured.
|
||||
|
||||
Only requires the server and channel — no external pip packages needed.
|
||||
"""
|
||||
server = os.getenv("IRC_SERVER", "")
|
||||
channel = os.getenv("IRC_CHANNEL", "")
|
||||
# Also accept config.yaml-only configuration (no env vars).
|
||||
# The gateway passes PlatformConfig; we just check env for the
|
||||
# hermes setup / requirements check path.
|
||||
return bool(server and channel)
|
||||
|
||||
|
||||
def validate_config(config) -> bool:
|
||||
"""Validate that the platform config has enough info to connect."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
||||
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
||||
return bool(server and channel)
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Interactive `hermes gateway setup` flow for the IRC platform.
|
||||
|
||||
Lazy-imports ``hermes_cli.setup`` helpers so the plugin stays importable
|
||||
in non-CLI contexts (gateway runtime, tests).
|
||||
"""
|
||||
from hermes_cli.setup import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
save_env_value,
|
||||
get_env_value,
|
||||
print_header,
|
||||
print_info,
|
||||
print_warning,
|
||||
print_success,
|
||||
)
|
||||
|
||||
print_header("IRC")
|
||||
existing_server = get_env_value("IRC_SERVER")
|
||||
if existing_server:
|
||||
print_info(f"IRC: already configured (server: {existing_server})")
|
||||
if not prompt_yes_no("Reconfigure IRC?", False):
|
||||
return
|
||||
|
||||
print_info("Connect Hermes to an IRC network. Uses Python stdlib — no extra packages needed.")
|
||||
print_info(" Works with Libera.Chat, OFTC, your own ZNC/InspIRCd, etc.")
|
||||
print()
|
||||
|
||||
server = prompt("IRC server hostname (e.g. irc.libera.chat)", default=existing_server or "")
|
||||
if not server:
|
||||
print_warning("Server is required — skipping IRC setup")
|
||||
return
|
||||
save_env_value("IRC_SERVER", server.strip())
|
||||
|
||||
use_tls = prompt_yes_no("Use TLS (recommended)?", True)
|
||||
save_env_value("IRC_USE_TLS", "true" if use_tls else "false")
|
||||
|
||||
default_port = "6697" if use_tls else "6667"
|
||||
port = prompt(f"Port (default {default_port})", default=get_env_value("IRC_PORT") or "")
|
||||
if port:
|
||||
try:
|
||||
save_env_value("IRC_PORT", str(int(port)))
|
||||
except ValueError:
|
||||
print_warning(f"Invalid port — using default {default_port}")
|
||||
elif get_env_value("IRC_PORT"):
|
||||
# User cleared the prompt; drop the override so the default applies.
|
||||
save_env_value("IRC_PORT", "")
|
||||
|
||||
nickname = prompt(
|
||||
"Bot nickname (e.g. hermes-bot)",
|
||||
default=get_env_value("IRC_NICKNAME") or "",
|
||||
)
|
||||
if not nickname:
|
||||
print_warning("Nickname is required — skipping IRC setup")
|
||||
return
|
||||
save_env_value("IRC_NICKNAME", nickname.strip())
|
||||
|
||||
channel = prompt(
|
||||
"Channel to join (e.g. #hermes — comma-separate for multiple)",
|
||||
default=get_env_value("IRC_CHANNEL") or "",
|
||||
)
|
||||
if not channel:
|
||||
print_warning("Channel is required — skipping IRC setup")
|
||||
return
|
||||
save_env_value("IRC_CHANNEL", channel.strip())
|
||||
|
||||
print()
|
||||
print_info("🔑 Optional authentication")
|
||||
print_info(" Leave blank to skip.")
|
||||
if prompt_yes_no("Configure a server password (PASS command)?", False):
|
||||
server_password = prompt("Server password", password=True)
|
||||
if server_password:
|
||||
save_env_value("IRC_SERVER_PASSWORD", server_password)
|
||||
|
||||
if prompt_yes_no("Identify with NickServ on connect?", False):
|
||||
nickserv = prompt("NickServ password", password=True)
|
||||
if nickserv:
|
||||
save_env_value("IRC_NICKSERV_PASSWORD", nickserv)
|
||||
|
||||
print()
|
||||
print_info("🔒 Access control: restrict who can message the bot")
|
||||
print_info(" IRC nicks are not authenticated — anyone can claim any nick.")
|
||||
print_info(" For public channels, pair with NickServ-only mode on your network")
|
||||
print_info(" if you want stronger identity guarantees.")
|
||||
allow_all = prompt_yes_no("Allow all users in the channel to talk to the bot?", False)
|
||||
if allow_all:
|
||||
save_env_value("IRC_ALLOW_ALL_USERS", "true")
|
||||
save_env_value("IRC_ALLOWED_USERS", "")
|
||||
print_warning("⚠️ Open access — any nick in the channel can command the bot.")
|
||||
else:
|
||||
save_env_value("IRC_ALLOW_ALL_USERS", "false")
|
||||
allowed = prompt(
|
||||
"Allowed nicks (comma-separated, leave empty to deny everyone)",
|
||||
default=get_env_value("IRC_ALLOWED_USERS") or "",
|
||||
)
|
||||
if allowed:
|
||||
save_env_value("IRC_ALLOWED_USERS", allowed.replace(" ", ""))
|
||||
print_success("Allowlist configured")
|
||||
else:
|
||||
save_env_value("IRC_ALLOWED_USERS", "")
|
||||
print_info("No nicks allowed — the bot will ignore all messages until you add nicks.")
|
||||
|
||||
print()
|
||||
print_success("IRC configuration saved to ~/.hermes/.env")
|
||||
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||
|
||||
|
||||
def is_connected(config) -> bool:
|
||||
"""Check whether IRC is configured (env or config.yaml)."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
||||
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
||||
return bool(server and channel)
|
||||
|
||||
|
||||
def _env_enablement() -> dict | None:
|
||||
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
|
||||
|
||||
Called by the platform registry's env-enablement hook (landed in the
|
||||
generic-plugin-interface migration) BEFORE adapter construction, so
|
||||
``gateway status`` and ``get_connected_platforms()`` reflect env-only
|
||||
configuration without instantiating the IRC client. Returns ``None``
|
||||
when IRC isn't minimally configured; the caller skips auto-enabling.
|
||||
|
||||
The special ``home_channel`` key in the returned dict is handled by
|
||||
the core hook — it becomes a proper ``HomeChannel`` dataclass on the
|
||||
``PlatformConfig`` rather than being merged into ``extra``.
|
||||
"""
|
||||
server = os.getenv("IRC_SERVER", "").strip()
|
||||
channel = os.getenv("IRC_CHANNEL", "").strip()
|
||||
if not (server and channel):
|
||||
return None
|
||||
seed: dict = {
|
||||
"server": server,
|
||||
"channel": channel,
|
||||
}
|
||||
port = os.getenv("IRC_PORT", "").strip()
|
||||
if port:
|
||||
try:
|
||||
seed["port"] = int(port)
|
||||
except ValueError:
|
||||
pass
|
||||
nickname = os.getenv("IRC_NICKNAME", "").strip()
|
||||
if nickname:
|
||||
seed["nickname"] = nickname
|
||||
use_tls = os.getenv("IRC_USE_TLS", "").strip().lower()
|
||||
if use_tls:
|
||||
seed["use_tls"] = use_tls in {"1", "true", "yes"}
|
||||
# Passwords live in PlatformConfig.extra as well for back-compat with
|
||||
# existing config.yaml users; env-reads at construct time still win.
|
||||
if os.getenv("IRC_SERVER_PASSWORD"):
|
||||
seed["server_password"] = os.getenv("IRC_SERVER_PASSWORD")
|
||||
if os.getenv("IRC_NICKSERV_PASSWORD"):
|
||||
seed["nickserv_password"] = os.getenv("IRC_NICKSERV_PASSWORD")
|
||||
# Optional home-channel (usually the same as IRC_CHANNEL, but can be a
|
||||
# dedicated reports channel). Defaults to IRC_CHANNEL so cron jobs
|
||||
# with ``deliver=irc`` have a sensible target without extra config.
|
||||
home = os.getenv("IRC_HOME_CHANNEL") or channel
|
||||
if home:
|
||||
seed["home_channel"] = {
|
||||
"chat_id": home,
|
||||
"name": os.getenv("IRC_HOME_CHANNEL_NAME", home),
|
||||
}
|
||||
return seed
|
||||
|
||||
|
||||
def _strip_irc_control_chars(text: str) -> str:
|
||||
"""Strip IRC line terminators and the NUL byte from ``text``.
|
||||
|
||||
IRC commands are CRLF-delimited; a bare ``\\r`` or ``\\n`` in user
|
||||
content lets an attacker inject arbitrary IRC commands (CTCP, JOIN,
|
||||
KICK). ``\\x00`` is a protocol-illegal byte. Everything else is
|
||||
valid in PRIVMSG payloads.
|
||||
"""
|
||||
return text.replace("\r", " ").replace("\n", " ").replace("\x00", "")
|
||||
|
||||
|
||||
def _is_irc_channel(target: str) -> bool:
|
||||
return bool(target) and target[0] in "#&+!"
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id: str,
|
||||
message: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
media_files: Optional[List[str]] = None,
|
||||
force_document: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Open an ephemeral IRC connection, send a PRIVMSG, and quit.
|
||||
|
||||
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
||||
runner is not in this process (e.g. ``hermes cron`` running as a
|
||||
separate process from ``hermes gateway``). Without this hook,
|
||||
``deliver=irc`` cron jobs fail with ``No live adapter for platform``.
|
||||
|
||||
The standalone client uses a distinct nick suffix (``-cron``) so it
|
||||
does not collide with the long-running gateway adapter that may already
|
||||
be holding the configured nickname on the same network. When the
|
||||
target is a channel, the client JOINs it before sending PRIVMSG so
|
||||
networks with the default ``+n`` (no external messages) channel mode
|
||||
accept the delivery.
|
||||
|
||||
``thread_id`` and ``media_files`` are accepted for signature parity but
|
||||
are not meaningful on IRC: IRC has no native thread or attachment
|
||||
primitive.
|
||||
"""
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
||||
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
||||
if not server or not channel:
|
||||
return {"error": "IRC standalone send: IRC_SERVER and IRC_CHANNEL must be configured"}
|
||||
|
||||
port_value = os.getenv("IRC_PORT") or extra.get("port", 6697)
|
||||
try:
|
||||
port = int(port_value)
|
||||
except (TypeError, ValueError):
|
||||
return {"error": f"IRC standalone send: invalid port {port_value!r}"}
|
||||
|
||||
nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
|
||||
use_tls_env = os.getenv("IRC_USE_TLS")
|
||||
if use_tls_env is not None:
|
||||
use_tls = use_tls_env.lower() in {"1", "true", "yes"}
|
||||
else:
|
||||
use_tls = bool(extra.get("use_tls", True))
|
||||
|
||||
server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
|
||||
nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
|
||||
|
||||
# Reject control characters in chat_id to block IRC command injection.
|
||||
raw_target = chat_id or channel
|
||||
if any(ch in raw_target for ch in ("\r", "\n", "\x00", " ")):
|
||||
return {"error": "IRC standalone send: chat_id contains illegal IRC characters"}
|
||||
target = raw_target
|
||||
|
||||
# Distinct nick prevents NICK collision with a live gateway adapter
|
||||
# that may already be holding the configured nickname. Cap to 24 chars
|
||||
# so subsequent collision retries do not overflow the 30-char NICKLEN
|
||||
# most networks enforce.
|
||||
nick_base = nickname.rstrip("_0123456789-")[:24] or "hermes-bot"
|
||||
standalone_nick = f"{nick_base}-cron"[:30]
|
||||
plain = IRCAdapter._strip_markdown(message)
|
||||
|
||||
ssl_ctx = ssl.create_default_context() if use_tls else None
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(server, port, ssl=ssl_ctx),
|
||||
timeout=15.0,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
return {"error": f"IRC standalone connect failed: {e}"}
|
||||
|
||||
async def _raw(line: str) -> None:
|
||||
writer.write((line + "\r\n").encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
nick_attempts = 0
|
||||
max_nick_attempts = 5
|
||||
try:
|
||||
if server_password:
|
||||
await _raw(f"PASS {_strip_irc_control_chars(server_password)}")
|
||||
await _raw(f"NICK {standalone_nick}")
|
||||
await _raw(f"USER {standalone_nick} 0 * :Hermes Agent (cron)")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + 15.0
|
||||
registered = False
|
||||
while not registered:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
return {"error": "IRC standalone send: registration timeout (no RPL_WELCOME)"}
|
||||
try:
|
||||
raw_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "IRC standalone send: registration timeout (no RPL_WELCOME)"}
|
||||
except asyncio.IncompleteReadError:
|
||||
return {"error": "IRC standalone send: server closed connection during registration"}
|
||||
decoded = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||
msg = _parse_irc_message(decoded)
|
||||
cmd = msg["command"]
|
||||
if cmd == "PING":
|
||||
payload = msg["params"][0] if msg["params"] else ""
|
||||
await _raw(f"PONG :{payload}")
|
||||
elif cmd == "001":
|
||||
registered = True
|
||||
elif cmd in {"432", "433"}:
|
||||
nick_attempts += 1
|
||||
if nick_attempts > max_nick_attempts:
|
||||
return {"error": "IRC standalone send: too many nick collisions"}
|
||||
# Build the next nick from the stable base, not the
|
||||
# mutated value, so the suffix stays bounded.
|
||||
standalone_nick = f"{nick_base}-cron-{nick_attempts}"[:30]
|
||||
await _raw(f"NICK {standalone_nick}")
|
||||
elif cmd in {"464", "465"}:
|
||||
return {"error": f"IRC standalone send: server rejected client ({cmd})"}
|
||||
|
||||
if nickserv_password:
|
||||
await _raw(f"PRIVMSG NickServ :IDENTIFY {_strip_irc_control_chars(nickserv_password)}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# JOIN before PRIVMSG. IRC channels with the default ``+n`` mode
|
||||
# (no external messages: Libera, OFTC, EFnet, IRCNet, undernet)
|
||||
# silently drop PRIVMSG from non-members. Do not JOIN bare nicks
|
||||
# (DM target) or server queries.
|
||||
if _is_irc_channel(target):
|
||||
await _raw(f"JOIN {target}")
|
||||
join_deadline = loop.time() + 5.0
|
||||
joined = False
|
||||
while not joined:
|
||||
remaining = join_deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
# Timed out waiting for a JOIN ack: proceed anyway, the
|
||||
# server may still deliver the PRIVMSG depending on mode.
|
||||
break
|
||||
try:
|
||||
raw_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=remaining)
|
||||
except (asyncio.TimeoutError, asyncio.IncompleteReadError):
|
||||
break
|
||||
decoded = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||
jmsg = _parse_irc_message(decoded)
|
||||
jcmd = jmsg["command"]
|
||||
if jcmd == "PING":
|
||||
payload = jmsg["params"][0] if jmsg["params"] else ""
|
||||
await _raw(f"PONG :{payload}")
|
||||
elif jcmd in {"366", "JOIN"}:
|
||||
joined = True
|
||||
elif jcmd in {"403", "405", "471", "473", "474", "475"}:
|
||||
return {"error": f"IRC standalone send: JOIN {target} rejected ({jcmd})"}
|
||||
|
||||
# Bytes-aware per-line splitting so multi-line plain text never
|
||||
# exceeds the IRC 510-byte protocol limit. Reuses the same
|
||||
# algorithm as IRCAdapter._split_message, with control-character
|
||||
# stripping per line to block CRLF injection from message content.
|
||||
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2
|
||||
max_bytes = 510 - overhead
|
||||
sent_any = False
|
||||
for paragraph in plain.split("\n"):
|
||||
paragraph = _strip_irc_control_chars(paragraph).rstrip()
|
||||
if not paragraph:
|
||||
continue
|
||||
while paragraph:
|
||||
encoded = paragraph.encode("utf-8")
|
||||
if len(encoded) <= max_bytes:
|
||||
await _raw(f"PRIVMSG {target} :{paragraph}")
|
||||
await asyncio.sleep(0.3)
|
||||
sent_any = True
|
||||
break
|
||||
# Binary search for largest prefix that fits within max_bytes
|
||||
low, high, best = 1, len(paragraph), 0
|
||||
while low <= high:
|
||||
mid = (low + high) // 2
|
||||
if len(paragraph[:mid].encode("utf-8")) <= max_bytes:
|
||||
best = mid
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid - 1
|
||||
split_at = best
|
||||
space = paragraph.rfind(" ", 0, split_at)
|
||||
if space > split_at // 3:
|
||||
split_at = space
|
||||
await _raw(f"PRIVMSG {target} :{paragraph[:split_at].rstrip()}")
|
||||
await asyncio.sleep(0.3)
|
||||
sent_any = True
|
||||
paragraph = paragraph[split_at:].lstrip()
|
||||
|
||||
if not sent_any:
|
||||
return {"error": "IRC standalone send: empty message after stripping"}
|
||||
|
||||
await _raw("QUIT :delivered")
|
||||
try:
|
||||
await asyncio.wait_for(reader.read(1024), timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
return {"success": True, "message_id": str(int(time.time() * 1000))}
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("IRC standalone send raised", exc_info=True)
|
||||
return {"error": f"IRC standalone send failed: {e}"}
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await asyncio.wait_for(writer.wait_closed(), timeout=5.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
pass
|
||||
|
||||
|
||||
def register(ctx):
|
||||
"""Plugin entry point: called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="irc",
|
||||
label="IRC",
|
||||
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
||||
check_fn=check_requirements,
|
||||
validate_config=validate_config,
|
||||
is_connected=is_connected,
|
||||
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
|
||||
install_hint="No extra packages needed (stdlib only)",
|
||||
setup_fn=interactive_setup,
|
||||
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
||||
# server/channel/port/tls + home_channel so env-only setups show
|
||||
# up in gateway status without instantiating the adapter.
|
||||
env_enablement_fn=_env_enablement,
|
||||
# Cron home-channel delivery support. IRC_HOME_CHANNEL defaults to
|
||||
# IRC_CHANNEL (see _env_enablement), so cron jobs with
|
||||
# deliver=irc route to the joined channel by default.
|
||||
cron_deliver_env_var="IRC_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery. Without this hook, deliver=irc
|
||||
# cron jobs fail with "No live adapter" when cron runs separately
|
||||
# from the gateway.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="IRC_ALLOWED_USERS",
|
||||
allow_all_env="IRC_ALLOW_ALL_USERS",
|
||||
# IRC line limit after protocol overhead
|
||||
max_message_length=450,
|
||||
# Display
|
||||
emoji="💬",
|
||||
# IRC doesn't have phone numbers to redact
|
||||
pii_safe=False,
|
||||
allow_update_command=True,
|
||||
# LLM guidance
|
||||
platform_hint=(
|
||||
"You are chatting via IRC. IRC does not support markdown formatting "
|
||||
"— use plain text only. Messages are limited to ~450 characters per "
|
||||
"line (long messages are automatically split). In channels, users "
|
||||
"address you by prefixing your nick. Keep responses concise and "
|
||||
"conversational."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
name: irc-platform
|
||||
label: IRC
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
IRC gateway adapter for Hermes Agent.
|
||||
Connects to an IRC server and relays messages between an IRC channel
|
||||
(or DMs) and the Hermes agent. No external dependencies — uses
|
||||
Python's stdlib asyncio for the IRC protocol.
|
||||
author: Nous Research
|
||||
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
|
||||
# platform-plugin env var injector in ``hermes_cli/config.py``.
|
||||
requires_env:
|
||||
- name: IRC_SERVER
|
||||
description: "IRC server hostname (e.g. irc.libera.chat)"
|
||||
prompt: "IRC server"
|
||||
password: false
|
||||
- name: IRC_CHANNEL
|
||||
description: "Channel to join (e.g. #hermes — comma-separate for multiple)"
|
||||
prompt: "IRC channel"
|
||||
password: false
|
||||
- name: IRC_NICKNAME
|
||||
description: "Bot nickname on IRC (default: hermes-bot)"
|
||||
prompt: "Bot nickname"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: IRC_PORT
|
||||
description: "IRC server port (default: 6697 with TLS, 6667 without)"
|
||||
prompt: "IRC port"
|
||||
password: false
|
||||
- name: IRC_USE_TLS
|
||||
description: "Use TLS for the IRC connection (1/true/yes to enable, default: true on port 6697)"
|
||||
prompt: "Use TLS? (true/false)"
|
||||
password: false
|
||||
- name: IRC_SERVER_PASSWORD
|
||||
description: "Server password for the IRC PASS command (optional)"
|
||||
prompt: "Server password (optional)"
|
||||
password: true
|
||||
- name: IRC_NICKSERV_PASSWORD
|
||||
description: "NickServ password for automatic IDENTIFY on connect (optional)"
|
||||
prompt: "NickServ password (optional)"
|
||||
password: true
|
||||
- name: IRC_ALLOWED_USERS
|
||||
description: "Comma-separated IRC nicks allowed to talk to the bot"
|
||||
prompt: "Allowed nicks (comma-separated)"
|
||||
password: false
|
||||
- name: IRC_ALLOW_ALL_USERS
|
||||
description: "Allow anyone in the channel to talk to the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: IRC_HOME_CHANNEL
|
||||
description: "Channel for cron / notification delivery (defaults to IRC_CHANNEL)"
|
||||
prompt: "Home channel (or empty)"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
name: line-platform
|
||||
label: LINE
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
LINE Messaging API gateway adapter for Hermes Agent.
|
||||
Runs an aiohttp webhook server that receives LINE webhook events
|
||||
(with HMAC-SHA256 signature verification) and relays messages between
|
||||
LINE chats (1:1, groups, rooms) and the Hermes agent. Outbound replies
|
||||
prefer the free reply token and fall back to the metered Push API
|
||||
when the token has expired or is absent. Slow LLM responses surface a
|
||||
Template Buttons postback bubble so the user can fetch the answer with
|
||||
a fresh reply token (free) once it's ready.
|
||||
author: Hermes Agent contributors
|
||||
# ``requires_env`` and ``optional_env`` entries are surfaced in the
|
||||
# ``hermes config`` UI via the platform-plugin env var injector in
|
||||
# ``hermes_cli/config.py``.
|
||||
requires_env:
|
||||
- name: LINE_CHANNEL_ACCESS_TOKEN
|
||||
description: "LINE channel long-lived access token (LINE Developers Console > Messaging API > Channel access token)"
|
||||
prompt: "LINE channel access token"
|
||||
url: "https://developers.line.biz/console/"
|
||||
password: true
|
||||
- name: LINE_CHANNEL_SECRET
|
||||
description: "LINE channel secret (used for HMAC-SHA256 webhook signature verification)"
|
||||
prompt: "LINE channel secret"
|
||||
url: "https://developers.line.biz/console/"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: LINE_PORT
|
||||
description: "Webhook listen port (default: 8646)"
|
||||
prompt: "Webhook port"
|
||||
password: false
|
||||
- name: LINE_HOST
|
||||
description: "Webhook bind host (default: 0.0.0.0)"
|
||||
prompt: "Webhook host"
|
||||
password: false
|
||||
- name: LINE_PUBLIC_URL
|
||||
description: "Public HTTPS base URL for serving images/audio/video to LINE (e.g. https://my-tunnel.example.com). Required for media sending when the bind address is not directly reachable."
|
||||
prompt: "Public HTTPS base URL"
|
||||
password: false
|
||||
- name: LINE_ALLOWED_USERS
|
||||
description: "Comma-separated LINE user IDs allowed to DM the bot (U-prefixed)"
|
||||
prompt: "Allowed user IDs (comma-separated)"
|
||||
password: false
|
||||
- name: LINE_ALLOWED_GROUPS
|
||||
description: "Comma-separated LINE group IDs the bot will respond in (C-prefixed)"
|
||||
prompt: "Allowed group IDs (comma-separated)"
|
||||
password: false
|
||||
- name: LINE_ALLOWED_ROOMS
|
||||
description: "Comma-separated LINE room IDs the bot will respond in (R-prefixed)"
|
||||
prompt: "Allowed room IDs (comma-separated)"
|
||||
password: false
|
||||
- name: LINE_ALLOW_ALL_USERS
|
||||
description: "Allow any LINE user to talk to the bot (dev only — disables allowlist)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: LINE_HOME_CHANNEL
|
||||
description: "Default user/group/room ID for cron / notification delivery"
|
||||
prompt: "Home channel ID (or empty)"
|
||||
password: false
|
||||
- name: LINE_SLOW_RESPONSE_THRESHOLD
|
||||
description: "Seconds before the slow-LLM postback button fires (default: 45; set 0 to disable and always Push-fallback)"
|
||||
prompt: "Slow response threshold (seconds)"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
name: matrix-platform
|
||||
label: Matrix
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Matrix gateway adapter for Hermes Agent.
|
||||
Connects to a Matrix homeserver via mautrix (with optional E2EE) and relays
|
||||
messages between Matrix rooms/DMs and the Hermes agent. Supports threads,
|
||||
HTML/markdown rendering, native media uploads, mention gating, free-response
|
||||
rooms, and per-room allowlists.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: MATRIX_HOMESERVER
|
||||
description: "Matrix homeserver URL (e.g. https://matrix.org)"
|
||||
prompt: "Matrix homeserver URL"
|
||||
password: false
|
||||
- name: MATRIX_ACCESS_TOKEN
|
||||
description: "Matrix access token (or use MATRIX_PASSWORD for password login)"
|
||||
prompt: "Matrix access token"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: MATRIX_PASSWORD
|
||||
description: "Matrix account password (alternative to MATRIX_ACCESS_TOKEN)"
|
||||
prompt: "Matrix password"
|
||||
password: true
|
||||
- name: MATRIX_ALLOWED_USERS
|
||||
description: "Comma-separated Matrix user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: MATRIX_ALLOW_ALL_USERS
|
||||
description: "Allow any Matrix user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: MATRIX_HOME_CHANNEL
|
||||
description: "Default room ID for cron / notification delivery"
|
||||
prompt: "Home room ID"
|
||||
password: false
|
||||
- name: MATRIX_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Matrix home room"
|
||||
prompt: "Home room display name"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
name: mattermost-platform
|
||||
label: Mattermost
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Mattermost gateway adapter for Hermes Agent.
|
||||
Connects to a self-hosted or cloud Mattermost instance via the v4 REST
|
||||
API + WebSocket event stream and relays messages between Mattermost
|
||||
channels/DMs and the Hermes agent. Supports thread-mode replies, native
|
||||
file uploads, channel-scoped allowlists, and home-channel cron delivery.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: MATTERMOST_URL
|
||||
description: "Mattermost server URL (e.g. https://mm.example.com)"
|
||||
prompt: "Mattermost server URL"
|
||||
password: false
|
||||
- name: MATTERMOST_TOKEN
|
||||
description: "Bot account token or personal-access token"
|
||||
prompt: "Mattermost bot token"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: MATTERMOST_ALLOWED_USERS
|
||||
description: "Comma-separated Mattermost user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: MATTERMOST_ALLOW_ALL_USERS
|
||||
description: "Allow any Mattermost user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: MATTERMOST_HOME_CHANNEL
|
||||
description: "Default channel ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: MATTERMOST_REPLY_MODE
|
||||
description: "How replies are sent: 'thread' (nested) or 'off' (flat). Default: off."
|
||||
prompt: "Reply mode (thread|off)"
|
||||
password: false
|
||||
- name: MATTERMOST_REQUIRE_MENTION
|
||||
description: "Require @bot mention in channels (default true). Set false for free-response everywhere."
|
||||
prompt: "Require @mention? (true/false)"
|
||||
password: false
|
||||
- name: MATTERMOST_FREE_RESPONSE_CHANNELS
|
||||
description: "Comma-separated channel IDs where @mention is not required."
|
||||
prompt: "Free-response channel IDs (comma-separated)"
|
||||
password: false
|
||||
- name: MATTERMOST_ALLOWED_CHANNELS
|
||||
description: "If set, the bot only responds in these channels (whitelist)."
|
||||
prompt: "Allowed channel IDs (comma-separated)"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,593 @@
|
||||
"""ntfy platform adapter (Hermes plugin).
|
||||
|
||||
Subscribes to a topic on ntfy.sh or any self-hosted ntfy server via
|
||||
HTTP streaming (``/json`` endpoint with ``poll=false``) and publishes
|
||||
replies via HTTP POST. No external SDK — only httpx, which is already
|
||||
a Hermes dependency.
|
||||
|
||||
This adapter ships as a Hermes platform plugin under
|
||||
``plugins/platforms/ntfy/``. The Hermes plugin loader scans the
|
||||
directory at startup, calls :func:`register`, and the platform becomes
|
||||
available to ``gateway/run.py`` and ``tools/send_message_tool`` through
|
||||
the registry — no edits to core files required.
|
||||
|
||||
Configuration in config.yaml::
|
||||
|
||||
platforms:
|
||||
ntfy:
|
||||
enabled: true
|
||||
extra:
|
||||
server: "https://ntfy.sh" # or self-hosted URL
|
||||
topic: "hermes-in" # subscribe topic (incoming)
|
||||
publish_topic: "hermes-out" # optional — defaults to topic
|
||||
token: "..." # optional Bearer / Basic auth token
|
||||
markdown: true # optional — enable markdown (default: false)
|
||||
|
||||
Environment variables (all read at adapter construct time, env wins over
|
||||
config.yaml ``extra``):
|
||||
|
||||
NTFY_TOPIC Topic to subscribe to (required)
|
||||
NTFY_SERVER_URL Server URL (default: https://ntfy.sh)
|
||||
NTFY_TOKEN Bearer token or 'user:pass' for Basic auth
|
||||
NTFY_PUBLISH_TOPIC Reply topic (defaults to NTFY_TOPIC)
|
||||
NTFY_MARKDOWN "true"/"1"/"yes" enables X-Markdown header
|
||||
NTFY_ALLOWED_USERS Allowlist (treated by gateway as user IDs;
|
||||
on ntfy these are topic names)
|
||||
NTFY_ALLOW_ALL_USERS Allow any topic — dev only
|
||||
NTFY_HOME_CHANNEL Default topic for cron / notification delivery
|
||||
NTFY_HOME_CHANNEL_NAME Human label for the home channel
|
||||
|
||||
Identity model: ntfy has no native authenticated user identity. The
|
||||
``title`` field is publisher-controlled and is NOT used for
|
||||
authorization. Each topic is treated as a single trusted channel —
|
||||
``user_id`` is fixed to the topic name. Use a private topic protected
|
||||
by a read token for any real trust boundary.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try:
|
||||
import httpx
|
||||
HTTPX_AVAILABLE = True
|
||||
except ImportError:
|
||||
HTTPX_AVAILABLE = False
|
||||
httpx = None # type: ignore[assignment]
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _FatalStreamError(Exception):
|
||||
"""Raised when a stream error is unrecoverable (e.g. 401, 404)."""
|
||||
|
||||
|
||||
DEFAULT_SERVER = "https://ntfy.sh"
|
||||
MAX_MESSAGE_LENGTH = 4096 # ntfy message body limit
|
||||
DEDUP_WINDOW_SECONDS = 300
|
||||
DEDUP_MAX_SIZE = 1000
|
||||
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
|
||||
STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin
|
||||
_ECHO_TAG = "hermes-agent" # tag added to outgoing messages for echo-loop prevention
|
||||
|
||||
|
||||
def _build_auth_header(token: str) -> Dict[str, str]:
|
||||
"""Build an ``Authorization`` header from an ntfy token.
|
||||
|
||||
Shared by :class:`NtfyAdapter._auth_headers` and :func:`_standalone_send`
|
||||
so both paths follow the same auth shape and whitespace-stripping rules.
|
||||
|
||||
Tokens are stripped of surrounding whitespace — pasted tokens often
|
||||
carry trailing newlines that would otherwise render the header
|
||||
malformed (``Authorization: Bearer foo\\n``). ``user:pass`` tokens
|
||||
become Basic auth; anything else is treated as a Bearer token.
|
||||
Returns ``{}`` when no token is configured.
|
||||
"""
|
||||
if not token:
|
||||
return {}
|
||||
token = token.strip()
|
||||
if not token:
|
||||
return {}
|
||||
if ":" in token:
|
||||
import base64
|
||||
encoded = base64.b64encode(token.encode()).decode()
|
||||
return {"Authorization": f"Basic {encoded}"}
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _truncate_body(message: str, *, context: str) -> bytes:
|
||||
"""Apply the ntfy 4096-char limit, logging a warning on truncation.
|
||||
|
||||
``context`` is included in the log message so adapter and standalone
|
||||
truncations can be told apart in logs.
|
||||
"""
|
||||
if len(message) > MAX_MESSAGE_LENGTH:
|
||||
logger.warning(
|
||||
"%s: truncating message from %d to %d chars (ntfy limit)",
|
||||
context, len(message), MAX_MESSAGE_LENGTH,
|
||||
)
|
||||
return message[:MAX_MESSAGE_LENGTH].encode("utf-8")
|
||||
|
||||
|
||||
def check_requirements() -> bool:
|
||||
"""Check whether the ntfy adapter is installable and minimally configured.
|
||||
|
||||
Reads ``NTFY_TOPIC`` directly to avoid the cost of a full
|
||||
``load_gateway_config()`` (which also writes to ``os.environ``) on
|
||||
every pre-flight check.
|
||||
"""
|
||||
if not HTTPX_AVAILABLE:
|
||||
return False
|
||||
topic = os.getenv("NTFY_TOPIC", "").strip()
|
||||
return bool(topic)
|
||||
|
||||
|
||||
def validate_config(config) -> bool:
|
||||
"""Validate that the configured ntfy platform has a topic set."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
topic = extra.get("topic") or os.getenv("NTFY_TOPIC", "")
|
||||
return bool(topic)
|
||||
|
||||
|
||||
def is_connected(config) -> bool:
|
||||
"""Check whether ntfy is configured (env or config.yaml)."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
topic = os.getenv("NTFY_TOPIC") or extra.get("topic", "")
|
||||
return bool(topic)
|
||||
|
||||
|
||||
class NtfyAdapter(BasePlatformAdapter):
|
||||
"""ntfy adapter.
|
||||
|
||||
Subscribes to a topic via HTTP streaming (``/json`` endpoint) and
|
||||
publishes replies via HTTP POST. No external SDK — only httpx.
|
||||
"""
|
||||
|
||||
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
platform = Platform("ntfy")
|
||||
super().__init__(config=config, platform=platform)
|
||||
|
||||
extra = config.extra or {}
|
||||
self._server: str = (
|
||||
extra.get("server")
|
||||
or os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER)
|
||||
).rstrip("/")
|
||||
self._topic: str = extra.get("topic") or os.getenv("NTFY_TOPIC", "")
|
||||
self._publish_topic: str = (
|
||||
extra.get("publish_topic")
|
||||
or os.getenv("NTFY_PUBLISH_TOPIC", "")
|
||||
or self._topic
|
||||
)
|
||||
self._token: str = extra.get("token") or os.getenv("NTFY_TOKEN", "")
|
||||
|
||||
self._stream_task: Optional[asyncio.Task] = None
|
||||
self._http_client: Optional["httpx.AsyncClient"] = None
|
||||
|
||||
# Message deduplication: msg_id -> timestamp
|
||||
self._seen_messages: Dict[str, float] = {}
|
||||
|
||||
# -- Connection lifecycle -----------------------------------------------
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
"""Connect to ntfy by starting the streaming subscription task."""
|
||||
if not HTTPX_AVAILABLE:
|
||||
logger.warning("[%s] httpx not installed. Run: pip install httpx", self.name)
|
||||
return False
|
||||
if not self._topic:
|
||||
logger.warning("[%s] NTFY_TOPIC not configured", self.name)
|
||||
return False
|
||||
|
||||
try:
|
||||
self._http_client = httpx.AsyncClient(timeout=None)
|
||||
self._stream_task = asyncio.create_task(self._run_stream())
|
||||
self._mark_connected()
|
||||
logger.info("[%s] Connected — subscribing to %s/%s", self.name, self._server, self._topic)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("[%s] Failed to connect: %s", self.name, e)
|
||||
return False
|
||||
|
||||
async def _run_stream(self) -> None:
|
||||
"""Subscribe to the ntfy topic with automatic reconnection."""
|
||||
backoff_idx = 0
|
||||
stream_start: float = 0.0
|
||||
url = f"{self._server}/{self._topic}/json"
|
||||
headers = self._auth_headers()
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
logger.debug("[%s] Opening stream to %s", self.name, url)
|
||||
stream_start = time.monotonic()
|
||||
await self._consume_stream(url, headers)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except _FatalStreamError:
|
||||
self._running = False
|
||||
return
|
||||
except Exception as e:
|
||||
if not self._running:
|
||||
return
|
||||
logger.warning("[%s] Stream error: %s", self.name, e)
|
||||
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# Reset backoff if stream stayed alive for at least 60s
|
||||
if time.monotonic() - stream_start >= 60.0:
|
||||
backoff_idx = 0
|
||||
delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)]
|
||||
logger.info("[%s] Reconnecting in %ds...", self.name, delay)
|
||||
await asyncio.sleep(delay)
|
||||
backoff_idx += 1
|
||||
|
||||
async def _consume_stream(self, url: str, headers: Dict[str, str]) -> None:
|
||||
"""Open an HTTP streaming connection and dispatch events."""
|
||||
# poll=false keeps a persistent streaming connection alive with keepalive events
|
||||
params = {"poll": "false"}
|
||||
async with self._http_client.stream(
|
||||
"GET",
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=httpx.Timeout(connect=15.0, read=STREAM_TIMEOUT_SECONDS, write=15.0, pool=15.0),
|
||||
) as response:
|
||||
if response.status_code == 401:
|
||||
logger.error(
|
||||
"[%s] Authentication failed (401) — stopping reconnect loop. Check NTFY_TOKEN.",
|
||||
self.name,
|
||||
)
|
||||
self._set_fatal_error(
|
||||
"ntfy_unauthorized",
|
||||
"ntfy server rejected auth (401). Check NTFY_TOKEN.",
|
||||
retryable=False,
|
||||
)
|
||||
raise _FatalStreamError("401 Unauthorized")
|
||||
if response.status_code == 404:
|
||||
logger.error(
|
||||
"[%s] Topic not found (404): %s — stopping reconnect loop.",
|
||||
self.name, self._topic,
|
||||
)
|
||||
self._set_fatal_error(
|
||||
"ntfy_topic_not_found",
|
||||
f"ntfy topic '{self._topic}' returned 404. Check NTFY_TOPIC.",
|
||||
retryable=False,
|
||||
)
|
||||
raise _FatalStreamError("404 Not Found")
|
||||
response.raise_for_status()
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not self._running:
|
||||
return
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event.get("event") == "message":
|
||||
await self._on_message(event)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from ntfy."""
|
||||
self._running = False
|
||||
self._mark_disconnected()
|
||||
|
||||
if self._stream_task:
|
||||
self._stream_task.cancel()
|
||||
try:
|
||||
await self._stream_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._stream_task = None
|
||||
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
self._seen_messages.clear()
|
||||
logger.info("[%s] Disconnected", self.name)
|
||||
|
||||
# -- Inbound message processing -----------------------------------------
|
||||
|
||||
async def _on_message(self, event: Dict[str, Any]) -> None:
|
||||
"""Process an incoming ntfy message event."""
|
||||
msg_id = event.get("id") or uuid.uuid4().hex
|
||||
if self._is_duplicate(msg_id):
|
||||
logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id)
|
||||
return
|
||||
|
||||
# Echo-loop prevention: skip messages tagged by this adapter.
|
||||
tags = event.get("tags") or []
|
||||
if _ECHO_TAG in tags:
|
||||
logger.debug("[%s] Skipping own message (echo tag)", self.name)
|
||||
return
|
||||
|
||||
text = (event.get("message") or "").strip()
|
||||
if not text:
|
||||
logger.debug("[%s] Empty message body, skipping", self.name)
|
||||
return
|
||||
|
||||
topic = event.get("topic") or self._topic
|
||||
# ntfy has no native authenticated user identity. The title field is
|
||||
# publisher-controlled and must NOT be used for authorization — any
|
||||
# publisher who knows the topic can set title to an allowed username.
|
||||
# Treat ntfy as a single trusted channel; user_id is fixed to the
|
||||
# topic name. NTFY_ALLOWED_USERS is only a real trust boundary when
|
||||
# the topic itself is protected by a read token.
|
||||
user_id = topic
|
||||
user_name = topic
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=topic,
|
||||
chat_name=topic,
|
||||
chat_type="dm",
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
)
|
||||
|
||||
unix_ts = event.get("time")
|
||||
try:
|
||||
timestamp = (
|
||||
datetime.fromtimestamp(int(unix_ts), tz=timezone.utc)
|
||||
if unix_ts else datetime.now(tz=timezone.utc)
|
||||
)
|
||||
except (ValueError, OSError, TypeError):
|
||||
timestamp = datetime.now(tz=timezone.utc)
|
||||
|
||||
message_event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=msg_id,
|
||||
raw_message=event,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
logger.debug("[%s] Message on topic %s: %s", self.name, topic, text[:80])
|
||||
await self.handle_message(message_event)
|
||||
|
||||
# -- Deduplication ------------------------------------------------------
|
||||
|
||||
def _is_duplicate(self, msg_id: str) -> bool:
|
||||
"""Return True if this message ID was already seen within the dedup window."""
|
||||
now = time.time()
|
||||
if len(self._seen_messages) > DEDUP_MAX_SIZE:
|
||||
cutoff = now - DEDUP_WINDOW_SECONDS
|
||||
self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff}
|
||||
|
||||
if msg_id in self._seen_messages:
|
||||
return True
|
||||
self._seen_messages[msg_id] = now
|
||||
return False
|
||||
|
||||
# -- Outbound messaging -------------------------------------------------
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Publish a message to the configured publish topic."""
|
||||
metadata = metadata or {}
|
||||
publish_topic = metadata.get("publish_topic") or self._publish_topic or chat_id
|
||||
|
||||
if not self._http_client:
|
||||
return SendResult(success=False, error="HTTP client not initialized")
|
||||
|
||||
url = f"{self._server}/{publish_topic}"
|
||||
markdown_enabled = (self.config.extra or {}).get("markdown", False)
|
||||
headers = {
|
||||
**self._auth_headers(),
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"X-Tags": _ECHO_TAG,
|
||||
}
|
||||
if markdown_enabled:
|
||||
headers["X-Markdown"] = "true"
|
||||
|
||||
if len(content) > self.MAX_MESSAGE_LENGTH:
|
||||
logger.warning(
|
||||
"[%s] Message truncated from %d to %d chars (ntfy limit)",
|
||||
self.name, len(content), self.MAX_MESSAGE_LENGTH,
|
||||
)
|
||||
body = content[:self.MAX_MESSAGE_LENGTH]
|
||||
|
||||
try:
|
||||
resp = await self._http_client.post(
|
||||
url, content=body.encode("utf-8"), headers=headers, timeout=15.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
try:
|
||||
data = resp.json()
|
||||
returned_id = data.get("id") or uuid.uuid4().hex[:12]
|
||||
except Exception:
|
||||
returned_id = uuid.uuid4().hex[:12]
|
||||
return SendResult(success=True, message_id=returned_id)
|
||||
body_text = resp.text
|
||||
logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body_text[:200])
|
||||
return SendResult(success=False, error=f"HTTP {resp.status_code}: {body_text[:200]}")
|
||||
except httpx.TimeoutException:
|
||||
return SendResult(success=False, error="Timeout publishing to ntfy")
|
||||
except Exception as e:
|
||||
logger.error("[%s] Send error: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""ntfy does not support typing indicators."""
|
||||
pass
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Return basic info about an ntfy topic."""
|
||||
return {"name": chat_id, "type": "dm"}
|
||||
|
||||
# -- Helpers ------------------------------------------------------------
|
||||
|
||||
def _auth_headers(self) -> Dict[str, str]:
|
||||
"""Build Authorization header if a token is configured."""
|
||||
return _build_auth_header(self._token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _env_enablement() -> dict | None:
|
||||
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
|
||||
|
||||
Called by the platform registry's env-enablement hook BEFORE adapter
|
||||
construction, so ``gateway status`` and ``get_connected_platforms()``
|
||||
reflect env-only configuration without instantiating the HTTP client.
|
||||
Returns ``None`` when ntfy isn't minimally configured; the caller skips
|
||||
auto-enabling.
|
||||
|
||||
The special ``home_channel`` key in the returned dict is handled by the
|
||||
core hook — it becomes a proper ``HomeChannel`` dataclass on the
|
||||
``PlatformConfig`` rather than being merged into ``extra``.
|
||||
"""
|
||||
topic = os.getenv("NTFY_TOPIC", "").strip()
|
||||
if not topic:
|
||||
return None
|
||||
seed: dict = {
|
||||
"topic": topic,
|
||||
"server": os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER).rstrip("/"),
|
||||
}
|
||||
publish_topic = os.getenv("NTFY_PUBLISH_TOPIC", "").strip()
|
||||
if publish_topic:
|
||||
seed["publish_topic"] = publish_topic
|
||||
token = os.getenv("NTFY_TOKEN", "").strip()
|
||||
if token:
|
||||
seed["token"] = token
|
||||
markdown = os.getenv("NTFY_MARKDOWN", "").strip().lower()
|
||||
if markdown:
|
||||
seed["markdown"] = markdown in ("1", "true", "yes")
|
||||
home = os.getenv("NTFY_HOME_CHANNEL", "").strip() or topic
|
||||
if home:
|
||||
seed["home_channel"] = {
|
||||
"chat_id": home,
|
||||
"name": os.getenv("NTFY_HOME_CHANNEL_NAME", home),
|
||||
}
|
||||
return seed
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id: str,
|
||||
message: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
media_files: Optional[List[str]] = None,
|
||||
force_document: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Out-of-process publish for cron / send_message_tool fallbacks.
|
||||
|
||||
Used by ``tools/send_message_tool._send_via_adapter`` and the cron
|
||||
scheduler when the gateway runner is not in this process (e.g.
|
||||
``hermes cron`` running standalone). Without this hook,
|
||||
``deliver=ntfy`` cron jobs fail with ``No live adapter for platform``.
|
||||
|
||||
``thread_id`` and ``media_files`` are accepted for signature parity
|
||||
only — ntfy has no thread or attachment primitive. Markdown is
|
||||
honored if ``NTFY_MARKDOWN`` is set OR ``pconfig.extra["markdown"]``
|
||||
is True.
|
||||
"""
|
||||
if not HTTPX_AVAILABLE:
|
||||
return {"error": "ntfy standalone send: httpx not installed"}
|
||||
|
||||
extra = getattr(pconfig, "extra", {}) or {}
|
||||
server = (
|
||||
extra.get("server")
|
||||
or os.getenv("NTFY_SERVER_URL", DEFAULT_SERVER)
|
||||
).rstrip("/")
|
||||
publish_topic = (
|
||||
chat_id
|
||||
or extra.get("publish_topic")
|
||||
or os.getenv("NTFY_PUBLISH_TOPIC", "").strip()
|
||||
or extra.get("topic")
|
||||
or os.getenv("NTFY_TOPIC", "").strip()
|
||||
)
|
||||
if not publish_topic:
|
||||
return {"error": "ntfy standalone send: NTFY_TOPIC not configured"}
|
||||
|
||||
token = extra.get("token") or os.getenv("NTFY_TOKEN", "")
|
||||
markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower()
|
||||
markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes")
|
||||
|
||||
headers = {"Content-Type": "text/plain; charset=utf-8", "X-Tags": _ECHO_TAG, **_build_auth_header(token)}
|
||||
if markdown_enabled:
|
||||
headers["X-Markdown"] = "true"
|
||||
|
||||
body = _truncate_body(message, context="ntfy standalone")
|
||||
|
||||
url = f"{server}/{publish_topic}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(url, content=body, headers=headers)
|
||||
if resp.status_code >= 300:
|
||||
return {"error": f"ntfy HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
try:
|
||||
data = resp.json()
|
||||
msg_id = data.get("id") or uuid.uuid4().hex[:12]
|
||||
except Exception:
|
||||
msg_id = uuid.uuid4().hex[:12]
|
||||
return {"success": True, "platform": "ntfy", "chat_id": publish_topic, "message_id": msg_id}
|
||||
except Exception as e:
|
||||
return {"error": f"ntfy standalone send failed: {e}"}
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system at startup."""
|
||||
ctx.register_platform(
|
||||
name="ntfy",
|
||||
label="ntfy",
|
||||
adapter_factory=lambda cfg: NtfyAdapter(cfg),
|
||||
check_fn=check_requirements,
|
||||
validate_config=validate_config,
|
||||
is_connected=is_connected,
|
||||
required_env=["NTFY_TOPIC"],
|
||||
install_hint="pip install httpx # already a Hermes dependency",
|
||||
# Env-driven auto-configuration: seeds PlatformConfig.extra so
|
||||
# env-only setups show up in `hermes gateway status` without
|
||||
# instantiating the HTTP client.
|
||||
env_enablement_fn=_env_enablement,
|
||||
# Cron home-channel delivery support — `deliver=ntfy` cron jobs
|
||||
# route to NTFY_HOME_CHANNEL when set.
|
||||
cron_deliver_env_var="NTFY_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery. Without this hook, deliver=ntfy
|
||||
# cron jobs fail with "No live adapter" when cron runs separately
|
||||
# from the gateway.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Auth env vars for _is_user_authorized() integration.
|
||||
allowed_users_env="NTFY_ALLOWED_USERS",
|
||||
allow_all_env="NTFY_ALLOW_ALL_USERS",
|
||||
max_message_length=MAX_MESSAGE_LENGTH,
|
||||
emoji="🔔",
|
||||
# ntfy publishers have no persistent identity — topic names are
|
||||
# the only identifier, no phone numbers / emails to redact.
|
||||
pii_safe=True,
|
||||
allow_update_command=True,
|
||||
platform_hint=(
|
||||
"You are communicating via ntfy push notifications. "
|
||||
"Use plain text by default — ntfy supports optional markdown "
|
||||
"(set markdown: true in config or NTFY_MARKDOWN=true). "
|
||||
"Keep responses concise; ntfy is a push notification service "
|
||||
"with a 4096-character per-message limit."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
name: ntfy-platform
|
||||
label: ntfy
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
ntfy push-notification gateway adapter for Hermes Agent.
|
||||
Subscribes to a topic on ntfy.sh or any self-hosted ntfy server via
|
||||
HTTP streaming, and publishes replies via HTTP POST. Lightweight —
|
||||
no external SDK, only httpx (already a Hermes dependency).
|
||||
|
||||
ntfy has no native user-identity primitive; the adapter treats each
|
||||
topic as a single trusted channel and never derives user identity
|
||||
from publisher-controlled fields. Use a private topic + read token
|
||||
for any real trust boundary.
|
||||
author: sprmn24
|
||||
# ``requires_env`` and ``optional_env`` entries are surfaced in the
|
||||
# ``hermes config`` UI via the platform-plugin env var injector in
|
||||
# ``hermes_cli/config.py``.
|
||||
requires_env:
|
||||
- name: NTFY_TOPIC
|
||||
description: "Topic name to subscribe to (e.g. hermes-in)"
|
||||
prompt: "ntfy subscribe topic"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: NTFY_SERVER_URL
|
||||
description: "ntfy server URL (default: https://ntfy.sh)"
|
||||
prompt: "ntfy server URL"
|
||||
password: false
|
||||
- name: NTFY_TOKEN
|
||||
description: "Bearer token or 'user:pass' for Basic auth (optional)"
|
||||
prompt: "ntfy auth token (or empty)"
|
||||
password: true
|
||||
- name: NTFY_PUBLISH_TOPIC
|
||||
description: "Topic to publish replies to (defaults to NTFY_TOPIC)"
|
||||
prompt: "ntfy publish topic (or empty)"
|
||||
password: false
|
||||
- name: NTFY_MARKDOWN
|
||||
description: "Send replies with X-Markdown: true header (true/false, default: false)"
|
||||
prompt: "Enable markdown formatting? (true/false)"
|
||||
password: false
|
||||
- name: NTFY_ALLOWED_USERS
|
||||
description: "Comma-separated topic names allowed (allowlist)"
|
||||
prompt: "Allowed topic names (comma-separated)"
|
||||
password: false
|
||||
- name: NTFY_ALLOW_ALL_USERS
|
||||
description: "Allow any topic to talk to the bot (dev only — disables allowlist)"
|
||||
prompt: "Allow all topics? (true/false)"
|
||||
password: false
|
||||
- name: NTFY_HOME_CHANNEL
|
||||
description: "Default topic for cron / notification delivery"
|
||||
prompt: "Home channel topic (or empty)"
|
||||
password: false
|
||||
- name: NTFY_HOME_CHANNEL_NAME
|
||||
description: "Human label for the home channel (defaults to the topic name)"
|
||||
prompt: "Home channel display name (or empty)"
|
||||
password: false
|
||||
@@ -0,0 +1,190 @@
|
||||
# Photon iMessage platform plugin
|
||||
|
||||
This plugin connects Hermes Agent to iMessage (and other Spectrum
|
||||
interfaces) through [Photon][photon] — a managed service that handles
|
||||
iMessage line allocation, delivery, and abuse-prevention so users don't
|
||||
have to run their own Mac relay.
|
||||
|
||||
The free tier uses Photon's shared iMessage line pool and is the path we
|
||||
recommend for everyone who doesn't already pay for a dedicated number.
|
||||
|
||||
## Architecture
|
||||
|
||||
Like Discord and Slack, Photon is a **persistent-connection** channel — no
|
||||
public URL, no webhook, no signing secret. The `spectrum-ts` SDK holds a
|
||||
long-lived **gRPC stream** to Photon for both directions. Because the SDK is
|
||||
TypeScript-only, Hermes runs it inside a small supervised Node sidecar and
|
||||
talks to it over loopback.
|
||||
|
||||
```
|
||||
gRPC (spectrum-ts)
|
||||
┌─────────────────────────┐ ◄───────────────► ┌──────────────────────┐
|
||||
│ Photon Spectrum cloud │ app.messages │ Node sidecar │
|
||||
│ (iMessage line owner) │ space.send() │ (plugins/…/sidecar) │
|
||||
└─────────────────────────┘ └──────────┬───────────┘
|
||||
GET /inbound (NDJSON) │ ▲ POST /send
|
||||
inbound events ▼ │ /typing
|
||||
┌──────────────────────┐
|
||||
│ PhotonAdapter │
|
||||
│ (Python, in gateway) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
- **Inbound**: the sidecar consumes the SDK's `app.messages` gRPC stream,
|
||||
normalizes each message, and streams it to the adapter over a loopback
|
||||
`GET /inbound` (NDJSON). The adapter dedupes on `messageId` and dispatches
|
||||
a `MessageEvent` to the gateway. It reconnects automatically if the stream
|
||||
drops; the sidecar owns the gRPC reconnect to Photon.
|
||||
- **Outbound**: `send` / `send_typing` / reaction tapbacks are loopback POSTs
|
||||
to the sidecar (`/send`, `/send-attachment`, `/typing`, `/react`,
|
||||
`/unreact`), authenticated with a shared `X-Hermes-Sidecar-Token`.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
# One-shot setup: device login (opens browser) + project + user + sidecar deps
|
||||
hermes photon setup --phone +15551234567
|
||||
|
||||
# Start the gateway
|
||||
hermes gateway start
|
||||
```
|
||||
|
||||
`hermes photon setup` does, in order:
|
||||
|
||||
1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens
|
||||
`https://app.photon.codes/` for approval and stores the bearer token.
|
||||
2. **Find or create** the `Hermes Agent` project on the Photon dashboard.
|
||||
3. **Provision the project secret** — mint a fresh project secret (the
|
||||
dashboard reveals it only once) and persist it to `~/.hermes/.env` so the
|
||||
sidecar can authenticate `spectrum-ts`. Spectrum is always on, so there's no
|
||||
separate enable step.
|
||||
4. **Register your phone number** as a Spectrum user (idempotent — skipped if
|
||||
a user with that number already exists).
|
||||
5. **Print the assigned iMessage line** — the number you text to reach your
|
||||
agent.
|
||||
6. **Install the sidecar deps** (`npm ci` — installs the committed lockfile
|
||||
verbatim, so every setup runs the exact `spectrum-ts` version this plugin
|
||||
was written against).
|
||||
|
||||
There is no separate `login` command; like every other Hermes channel,
|
||||
onboarding goes through one setup surface. Re-running `setup` reuses an
|
||||
existing token/project, so it's safe to run again to finish a partial setup.
|
||||
Run `hermes photon status` to see what's configured.
|
||||
|
||||
## Credentials
|
||||
|
||||
Runtime SDK credentials live in `~/.hermes/.env` (the same place every other
|
||||
channel keeps its token), and the adapter reads them from the environment:
|
||||
|
||||
```bash
|
||||
PHOTON_PROJECT_ID=<projectId> # the SDK's projectId (same as the dashboard project id)
|
||||
PHOTON_PROJECT_SECRET=<projectSecret>
|
||||
```
|
||||
|
||||
Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"credential_pool": {
|
||||
"photon": [
|
||||
{ "access_token": "<device-bearer>", "issued_at": ... }
|
||||
],
|
||||
"photon_project": [
|
||||
{
|
||||
"dashboard_project_id": "<project id>",
|
||||
"spectrum_project_id": "<project id>",
|
||||
"project_secret": "<projectSecret>",
|
||||
"name": "Hermes Agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note on ids.** A Photon project's dashboard id and its Spectrum project id
|
||||
> are the same value, exposed as `PHOTON_PROJECT_ID`. The `dashboard_project_id`
|
||||
> and `spectrum_project_id` keys in `auth.json` both hold that id.
|
||||
|
||||
## Configuration knobs
|
||||
|
||||
All env vars are documented in `plugin.yaml`. The most important:
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
|---------------------------|----------------------------|--------------------------------------|
|
||||
| `PHOTON_PROJECT_ID` | from .env / auth.json | Spectrum project id (SDK `projectId`)|
|
||||
| `PHOTON_PROJECT_SECRET` | from .env / auth.json | Project secret |
|
||||
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for the sidecar |
|
||||
| `PHOTON_SIDECAR_AUTOSTART`| true | Spawn the sidecar on connect |
|
||||
| `PHOTON_DASHBOARD_HOST` | https://app.photon.codes | Dashboard API host |
|
||||
| `PHOTON_SPECTRUM_HOST` | https://spectrum.photon.codes | Spectrum API host |
|
||||
| `PHOTON_HOME_CHANNEL` | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) |
|
||||
| `PHOTON_ALLOWED_USERS` | your number (set by setup) | Comma-separated E.164 allowlist |
|
||||
| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word |
|
||||
| `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` | 20 MB | Max inbound attachment size the sidecar reads & inlines |
|
||||
| `PHOTON_TELEMETRY` | false | Spectrum SDK telemetry — toggle with `hermes photon telemetry on\|off` (restart the gateway to apply) |
|
||||
| `PHOTON_MARKDOWN` | true | Send agent replies as markdown (iMessage renders natively). `false` strips formatting to plain text |
|
||||
| `PHOTON_REACTIONS` | false | Tapback 👀/👍/👎 as processing status; tapbacks on bot messages reach the agent as `reaction:added:<emoji>` |
|
||||
|
||||
## Attachments & limitations
|
||||
|
||||
- **Inbound attachments and voice notes are downloaded.** The sidecar reads
|
||||
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
|
||||
adapter caches them to the shared media cache and populates `media_urls` /
|
||||
`media_types`, so the agent sees the real image/file or can transcribe the
|
||||
voice note — parity with the BlueBubbles iMessage channel. Mixed iMessage
|
||||
bubbles that contain both text and attachments are normalized as a grouped
|
||||
payload so the user's typed text is preserved alongside the cached media.
|
||||
Media larger than `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or
|
||||
any byte read that fails, falls back to a text marker (`[Photon attachment
|
||||
received: …]` or `[Photon voice received: …]`) so the agent still knows
|
||||
something arrived.
|
||||
- **Outbound attachments are supported.** Images, voice notes, video, and
|
||||
documents are sent via `space.send(attachment(...))` /
|
||||
`space.send(voice(...))` through the sidecar's `/send-attachment`
|
||||
endpoint; a caption is delivered as a separate text bubble after the media.
|
||||
- **Markdown is rendered.** Replies go out via spectrum-ts' `markdown()`
|
||||
builder; iMessage renders bold/italics/lists/code natively and other
|
||||
Spectrum platforms degrade to readable plain text. `PHOTON_MARKDOWN=false`
|
||||
reverts to stripped plain text.
|
||||
- **Reactions (tapbacks) are supported** behind `PHOTON_REACTIONS` (default
|
||||
off): the adapter tapbacks 👀 while processing and swaps it for 👍/👎 on
|
||||
completion, and a user tapback on a bot-sent message is routed to the agent
|
||||
as a synthetic `reaction:added:<emoji>` event. Removal after a sidecar
|
||||
restart is best-effort — the live reaction handle is lost, so a stale
|
||||
tapback heals when the next reaction replaces it. Group spaces stay
|
||||
reachable across restarts via spectrum-ts' `space.get(id)`.
|
||||
- **Message effects, polls** — supported by `spectrum-ts` but not yet
|
||||
exposed; the sidecar is the natural place to add them.
|
||||
|
||||
## Upgrading spectrum-ts
|
||||
|
||||
`spectrum-ts` is pinned to an **exact version** in `sidecar/package.json`
|
||||
(no `^` range) and installed with `npm ci`, because the SDK ships breaking
|
||||
majors (v2 removed `defineFusorPlatform`; v3 reworked space construction; v5
|
||||
split it into `@spectrum-ts/*` packages, with `spectrum-ts` as the umbrella
|
||||
that re-exports them; v8 made `richlink` outbound-only, so inbound rich links
|
||||
now arrive as plain `text`). A floating range or `npm install spectrum-ts@latest`
|
||||
would let a breaking release take down fresh setups silently. Upgrades are
|
||||
deliberate:
|
||||
|
||||
1. Read the [SDK release notes](https://github.com/photon-hq/spectrum-ts/releases)
|
||||
for every version between the current pin and the target.
|
||||
2. Bump the exact pin in `sidecar/package.json`, then run `npm install`
|
||||
inside `sidecar/` to regenerate `package-lock.json`. Commit both.
|
||||
3. Migrate `sidecar/index.mjs` against the new typings. `spectrum-ts` re-exports
|
||||
`@spectrum-ts/core` (the framework: `Spectrum`, content builders,
|
||||
`Space`/`Message`) and `@spectrum-ts/imessage` (the provider), so the source
|
||||
of truth is `sidecar/node_modules/@spectrum-ts/{core,imessage}/dist/*.d.ts`
|
||||
(the hosted docs can lag).
|
||||
4. Re-validate `sidecar/patch-spectrum-mixed-attachments.mjs`. It rewrites the
|
||||
compiled iMessage inbound mappers in `@spectrum-ts/imessage/dist/index.js`
|
||||
so a bubble with both text and attachments keeps its typed text; the anchors
|
||||
are tied to that build's output. `npm install` runs it via `postinstall` and
|
||||
fails loudly if the anchors no longer match — update them to the new output
|
||||
(`test_spectrum_patch.py` covers the patch).
|
||||
5. Run `pytest tests/plugins/platforms/photon/`.
|
||||
6. Verify end-to-end: `hermes photon status`, a DM and a group roundtrip,
|
||||
and an agent reply into a group right after a gateway restart (exercises
|
||||
`space.get` rehydration).
|
||||
|
||||
[photon]: https://photon.codes/
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Photon Spectrum (iMessage) platform plugin entry point."""
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
``hermes photon ...`` CLI subcommands — registered by the plugin via
|
||||
``ctx.register_cli_command()``.
|
||||
|
||||
Subcommands:
|
||||
|
||||
setup full first-time setup (device login + project + user + sidecar)
|
||||
status show login + project + sidecar dep state
|
||||
install-sidecar npm install inside plugins/platforms/photon/sidecar/
|
||||
telemetry show or toggle Spectrum SDK telemetry (on/off)
|
||||
|
||||
The device-code login runs automatically as the first step of ``setup``;
|
||||
there is no standalone ``login`` verb (matching how every other Hermes
|
||||
gateway channel onboards through a single setup surface).
|
||||
|
||||
Photon uses the spectrum-ts gRPC stream for inbound — there is no webhook
|
||||
to register, so there are no webhook subcommands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
from . import auth as photon_auth
|
||||
|
||||
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring
|
||||
|
||||
def register_cli(parser: argparse.ArgumentParser) -> None:
|
||||
"""Wire up `hermes photon ...` subcommands."""
|
||||
subs = parser.add_subparsers(dest="photon_command", required=False)
|
||||
|
||||
p_setup = subs.add_parser(
|
||||
"setup",
|
||||
help="First-time setup (device login + project + user + sidecar)",
|
||||
)
|
||||
p_setup.add_argument("--project-name", default=None,
|
||||
help="Project name (default: 'Hermes Agent')")
|
||||
p_setup.add_argument("--phone", default=None,
|
||||
help="Your E.164 phone number (e.g. +15551234567)")
|
||||
p_setup.add_argument("--first-name", default=None)
|
||||
p_setup.add_argument("--last-name", default=None)
|
||||
p_setup.add_argument("--email", default=None)
|
||||
p_setup.add_argument("--no-browser", action="store_true",
|
||||
help="Don't try to open a browser for device login; print the URL only")
|
||||
p_setup.add_argument("--skip-sidecar-install", action="store_true",
|
||||
help="Skip `npm install` inside the sidecar directory")
|
||||
|
||||
subs.add_parser("status", help="Show login + project + sidecar dep state")
|
||||
subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory")
|
||||
|
||||
p_telemetry = subs.add_parser(
|
||||
"telemetry",
|
||||
help="Show or toggle Spectrum SDK telemetry (on/off)",
|
||||
)
|
||||
p_telemetry.add_argument(
|
||||
"state", nargs="?", choices=("on", "off"),
|
||||
help="Turn telemetry on or off (omit to show the current state)",
|
||||
)
|
||||
|
||||
parser.set_defaults(func=dispatch)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
|
||||
def dispatch(args: argparse.Namespace) -> int:
|
||||
sub = getattr(args, "photon_command", None)
|
||||
if sub is None:
|
||||
# No subcommand given — show status by default.
|
||||
return _cmd_status(args)
|
||||
if sub == "setup":
|
||||
return _cmd_setup(args)
|
||||
if sub == "status":
|
||||
return _cmd_status(args)
|
||||
if sub == "install-sidecar":
|
||||
return _cmd_install_sidecar(args)
|
||||
if sub == "telemetry":
|
||||
return _cmd_telemetry(args)
|
||||
print(f"unknown subcommand: {sub}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommand handlers
|
||||
|
||||
def _run_device_login(args: argparse.Namespace) -> int:
|
||||
"""Run the RFC 8628 device-code login flow and persist the token.
|
||||
|
||||
Internal helper — invoked as the first step of ``setup``. There is
|
||||
no standalone ``hermes photon login`` command; Photon onboards
|
||||
through the single ``setup`` surface like every other channel.
|
||||
"""
|
||||
def _print_code(code):
|
||||
target = code.verification_uri_complete or code.verification_uri
|
||||
print()
|
||||
print("┌─ Photon device login ────────────────────────────────────────")
|
||||
print(f"│ Open this URL: {target}")
|
||||
print(f"│ Enter the code: {code.user_code}")
|
||||
print("│ (waiting for approval — Ctrl-C to cancel)")
|
||||
print("└──────────────────────────────────────────────────────────────")
|
||||
print()
|
||||
|
||||
try:
|
||||
token = photon_auth.login_device_flow(
|
||||
open_browser=not args.no_browser,
|
||||
on_user_code=_print_code,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"login failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
# Don't print any portion of the token — even a prefix can help a
|
||||
# shoulder-surfer or accidentally leak into a screen recording.
|
||||
_ = token
|
||||
print(f"✓ logged in — token saved to {photon_auth._auth_json_path()}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
# 1. Login (skip if we already have a token).
|
||||
token = photon_auth.load_photon_token()
|
||||
if not token:
|
||||
print("[1/5] No Photon token found — running device login...")
|
||||
rc = _run_device_login(args)
|
||||
if rc != 0:
|
||||
return rc
|
||||
token = photon_auth.load_photon_token()
|
||||
if not token:
|
||||
print("login completed but token was not stored", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
print("[1/5] Reusing existing Photon token")
|
||||
|
||||
# 2. Find or create the "Hermes Agent" project.
|
||||
name = args.project_name or photon_auth.DEFAULT_PROJECT_NAME
|
||||
dashboard_id = photon_auth.load_dashboard_project_id()
|
||||
try:
|
||||
if dashboard_id:
|
||||
print("[2/5] Reusing configured Photon project")
|
||||
else:
|
||||
existing = photon_auth.find_project_by_name(token, name)
|
||||
if existing and existing.get("id"):
|
||||
dashboard_id = existing["id"]
|
||||
print(f"[2/5] Found existing project '{name}'")
|
||||
else:
|
||||
print(f"[2/5] Creating Photon project '{name}'...")
|
||||
created = photon_auth.create_project(token, name=name)
|
||||
dashboard_id = created.get("id")
|
||||
print(" ✓ project created")
|
||||
except Exception as e:
|
||||
print(f"project setup failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not dashboard_id:
|
||||
print("could not resolve a Photon project id", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 3. Rotate the project secret and persist creds (runtime -> ~/.hermes/.env,
|
||||
# ids -> auth.json). Spectrum is always enabled and provisioned at
|
||||
# create-time, and the dashboard project id *is* the Spectrum project id
|
||||
# (ids unified), so there's nothing to enable — the id we already have is
|
||||
# the Spectrum id.
|
||||
try:
|
||||
print("[3/5] Provisioning Spectrum credentials...")
|
||||
spectrum_id = dashboard_id
|
||||
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id=spectrum_id,
|
||||
project_secret=secret,
|
||||
dashboard_project_id=dashboard_id,
|
||||
name=name,
|
||||
)
|
||||
# spectrum_id is an opaque non-secret id; safe to show.
|
||||
print(f" ✓ Spectrum ready (project id {spectrum_id}) — secret saved")
|
||||
except Exception as e:
|
||||
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 4. Register the operator's phone number as a Spectrum user (idempotent).
|
||||
phone = args.phone or _prompt(
|
||||
color(
|
||||
"[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ",
|
||||
Colors.CYAN,
|
||||
)
|
||||
)
|
||||
agent_number = None
|
||||
registered_phone = None
|
||||
registered_user_id = None
|
||||
if not phone:
|
||||
print(" Skipped user registration (no phone given). Re-run with --phone later.")
|
||||
else:
|
||||
# Name/email are optional and never prompted for — pass --first-name /
|
||||
# --email if you want them sent to the dashboard.
|
||||
first_name = args.first_name
|
||||
email = args.email
|
||||
try:
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
spectrum_id, secret,
|
||||
phone_number=phone,
|
||||
first_name=first_name,
|
||||
last_name=args.last_name,
|
||||
email=email,
|
||||
)
|
||||
except ValueError as e:
|
||||
print(f" invalid phone number: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f" user registration failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(" ✓ phone registered" if created else " ✓ phone already registered")
|
||||
registered_phone = phone
|
||||
registered_user_id = user.get("id")
|
||||
# The number to text the agent is the user's assigned iMessage line
|
||||
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
|
||||
# no dedicated entry in /lines, so this per-user field is the source of
|
||||
# truth — and we already have it from the (reused) user object.
|
||||
agent_number = photon_auth.user_assigned_line(user)
|
||||
# Allowlist the operator and make their DM the cron home channel —
|
||||
# otherwise the gateway denies their own inbound messages
|
||||
# ("Unauthorized user") and has no default space for cron delivery.
|
||||
_autoconfigure_access(phone)
|
||||
|
||||
# 5. Surface the agent's iMessage number (the number to text the agent).
|
||||
if not agent_number:
|
||||
# No per-user assignment — fall back to a dedicated line if the project
|
||||
# has one provisioned in its line inventory.
|
||||
try:
|
||||
line = photon_auth.get_imessage_line(token, dashboard_id)
|
||||
if line:
|
||||
agent_number = line.get("phoneNumber")
|
||||
except Exception as e:
|
||||
print(f" (could not fetch the assigned line: {e})", file=sys.stderr)
|
||||
if agent_number:
|
||||
print()
|
||||
print(color("┌─ Your agent's iMessage number ───────────────────────────────", Colors.GREEN))
|
||||
print(
|
||||
color("│ 📱 ", Colors.GREEN)
|
||||
+ color(str(agent_number), Colors.GREEN, Colors.BOLD)
|
||||
)
|
||||
print(color("│ Text this number from your phone to talk to your agent.", Colors.GREEN))
|
||||
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
|
||||
else:
|
||||
print(" No iMessage line assigned yet — check the Photon dashboard.")
|
||||
if registered_phone:
|
||||
try:
|
||||
photon_auth.store_user_numbers(
|
||||
phone_number=registered_phone,
|
||||
assigned_phone_number=agent_number,
|
||||
user_id=str(registered_user_id) if registered_user_id else None,
|
||||
dashboard_project_id=dashboard_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
|
||||
|
||||
# 6. Sidecar deps (spectrum-ts).
|
||||
if args.skip_sidecar_install:
|
||||
print("[5/5] Skipping sidecar npm install (--skip-sidecar-install)")
|
||||
else:
|
||||
print("[5/5] Installing Node sidecar deps (spectrum-ts)...")
|
||||
rc = _install_sidecar()
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
print()
|
||||
print("✓ Photon setup complete.")
|
||||
print(" Start the gateway: hermes gateway start")
|
||||
return 0
|
||||
|
||||
|
||||
def _autoconfigure_access(phone: str) -> None:
|
||||
"""Allowlist the operator and set their DM as the cron home channel.
|
||||
|
||||
Writes ``PHOTON_ALLOWED_USERS`` (so the gateway authorizes the operator's
|
||||
own inbound messages instead of denying them) and ``PHOTON_HOME_CHANNEL``
|
||||
(the default space for cron delivery) to the operator's E.164 number. Each
|
||||
is only filled when unset, so a hand-tuned allowlist / home channel is
|
||||
never clobbered on a re-run.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
except ImportError:
|
||||
return
|
||||
for key, label in (
|
||||
("PHOTON_ALLOWED_USERS", "allowlisted your number"),
|
||||
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel"),
|
||||
):
|
||||
try:
|
||||
if get_env_value(key):
|
||||
print(f" {key} already set — leaving it as-is.")
|
||||
continue
|
||||
save_env_value(key, phone)
|
||||
print(f" ✓ {label} ({key})")
|
||||
except Exception as e:
|
||||
print(f" could not set {key}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_status(_args: argparse.Namespace) -> int:
|
||||
_refresh_status_numbers()
|
||||
# Defer the credential rows to auth.print_credential_summary — its emit
|
||||
# callback is the only sink that sees credential-derived strings, so
|
||||
# cli.py keeps zero taint flow according to CodeQL.
|
||||
photon_auth.print_credential_summary(print)
|
||||
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
|
||||
sidecar_installed = (_SIDECAR_DIR / "node_modules").exists()
|
||||
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
|
||||
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
|
||||
print(f" telemetry : {'on' if _telemetry_enabled() else 'off'} (`hermes photon telemetry on|off`)")
|
||||
return 0
|
||||
|
||||
|
||||
def _refresh_status_numbers() -> None:
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
if phone and assigned:
|
||||
return
|
||||
spectrum_id, project_secret = photon_auth.load_project_credentials()
|
||||
if not spectrum_id or not project_secret:
|
||||
return
|
||||
try:
|
||||
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
|
||||
except Exception as e:
|
||||
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
|
||||
return _install_sidecar()
|
||||
|
||||
|
||||
def _telemetry_enabled() -> bool:
|
||||
"""Read PHOTON_TELEMETRY from the env / ~/.hermes/.env.
|
||||
|
||||
Mirrors the sidecar's truthy set (index.mjs) so the state shown here
|
||||
always matches what the sidecar will actually do.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import get_env_value
|
||||
raw = get_env_value("PHOTON_TELEMETRY")
|
||||
except ImportError:
|
||||
raw = os.getenv("PHOTON_TELEMETRY")
|
||||
return (raw or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _cmd_telemetry(args: argparse.Namespace) -> int:
|
||||
state = getattr(args, "state", None)
|
||||
if state is None:
|
||||
print(f"Photon telemetry: {'on' if _telemetry_enabled() else 'off'}")
|
||||
print(" Toggle with `hermes photon telemetry on` / `hermes photon telemetry off`.")
|
||||
return 0
|
||||
try:
|
||||
from hermes_cli.config import save_env_value
|
||||
save_env_value("PHOTON_TELEMETRY", "true" if state == "on" else "false")
|
||||
except Exception as e:
|
||||
print(f"could not save PHOTON_TELEMETRY: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"✓ Spectrum telemetry turned {state} (PHOTON_TELEMETRY in ~/.hermes/.env)")
|
||||
print(" Restart the gateway for the sidecar to pick it up: hermes gateway restart")
|
||||
return 0
|
||||
|
||||
|
||||
def _install_sidecar() -> int:
|
||||
npm = shutil.which("npm") or "npm"
|
||||
if not shutil.which(npm):
|
||||
print(
|
||||
"npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) "
|
||||
"and re-run.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
# spectrum-ts is pinned exactly in package.json/package-lock.json because
|
||||
# the SDK ships breaking majors (v2 removed defineFusorPlatform; v3
|
||||
# reworked space construction; v5 split it into @spectrum-ts/* packages).
|
||||
# Upgrades are deliberate: bump the pin, migrate sidecar/index.mjs, re-run
|
||||
# the photon tests — never `@latest` (see README "Upgrading spectrum-ts").
|
||||
# `npm ci` installs the committed lockfile verbatim; fall back to
|
||||
# `npm install` when the lockfile is missing or drifted (e.g. a dev
|
||||
# checkout mid-upgrade).
|
||||
print(f" $ cd {_SIDECAR_DIR} && {npm} ci")
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, "ci"],
|
||||
cwd=str(_SIDECAR_DIR),
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
print(f" npm ci failed — falling back to: {npm} install")
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, "install"],
|
||||
cwd=str(_SIDECAR_DIR),
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
print("npm install failed", file=sys.stderr)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway-setup entry point
|
||||
#
|
||||
# `hermes gateway setup` discovers platforms via the registry and calls each
|
||||
# entry's zero-arg ``setup_fn``. Photon registers this function so it appears
|
||||
# in the unified setup wizard alongside every other channel — same onboarding
|
||||
# surface, no Photon-specific detour. It runs the identical device-login +
|
||||
# project + user + sidecar flow as ``hermes photon setup`` with interactive
|
||||
# defaults (phone is prompted when stdin is a TTY).
|
||||
|
||||
def gateway_setup() -> None:
|
||||
"""Run Photon first-time setup from the `hermes gateway setup` wizard."""
|
||||
args = argparse.Namespace(
|
||||
photon_command="setup",
|
||||
project_name=None,
|
||||
phone=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=False,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
_cmd_setup(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small interactive helpers
|
||||
|
||||
def _prompt(prompt: str, *, secret: bool = False) -> str:
|
||||
if not sys.stdin.isatty():
|
||||
return ""
|
||||
try:
|
||||
if secret:
|
||||
return getpass.getpass(prompt).strip()
|
||||
return input(prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return ""
|
||||
@@ -0,0 +1,88 @@
|
||||
name: photon-platform
|
||||
label: iMessage via Photon
|
||||
kind: platform
|
||||
version: 0.3.0
|
||||
description: >
|
||||
Photon Spectrum gateway adapter for Hermes Agent.
|
||||
Connects to iMessage (and other Spectrum interfaces) through Photon's
|
||||
managed Spectrum platform. Both directions run over the `spectrum-ts`
|
||||
SDK's long-lived gRPC stream via a small supervised Node sidecar —
|
||||
inbound messages arrive on the SDK's `app.messages` stream (no webhook,
|
||||
no public URL, no signing secret), and outbound messages are sent over
|
||||
the same sidecar.
|
||||
|
||||
The plugin ships with a `hermes photon` CLI for the one-time device
|
||||
login + project + user setup. Runtime credentials are written to
|
||||
``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = the Spectrum project id,
|
||||
``PHOTON_PROJECT_SECRET``) like every other channel, with management
|
||||
metadata (device token, dashboard project id) in ``~/.hermes/auth.json``.
|
||||
Photon's free shared-line model lets users get started without a paid plan.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: PHOTON_PROJECT_ID
|
||||
description: "Spectrum project id (the project's spectrumProjectId; set by `hermes photon setup`)"
|
||||
prompt: "Photon Spectrum project id"
|
||||
url: "https://app.photon.codes/"
|
||||
password: false
|
||||
- name: PHOTON_PROJECT_SECRET
|
||||
description: "Project secret paired with the Spectrum project id (set by `hermes photon setup`)"
|
||||
prompt: "Photon project secret"
|
||||
url: "https://app.photon.codes/"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: PHOTON_SIDECAR_PORT
|
||||
description: "Loopback port for the Node sidecar control + inbound channel (default 8789)"
|
||||
prompt: "Sidecar control port"
|
||||
password: false
|
||||
- name: PHOTON_SIDECAR_AUTOSTART
|
||||
description: "Spawn the Node sidecar on connect (true/false, default true)"
|
||||
prompt: "Auto-start the sidecar?"
|
||||
password: false
|
||||
- name: PHOTON_NODE_BIN
|
||||
description: "Path to the node binary (default: shutil.which('node'))"
|
||||
prompt: "Node executable path"
|
||||
password: false
|
||||
- name: PHOTON_DASHBOARD_HOST
|
||||
description: "Photon Dashboard API host (default https://app.photon.codes)"
|
||||
prompt: "Dashboard host"
|
||||
password: false
|
||||
- name: PHOTON_SPECTRUM_HOST
|
||||
description: "Photon Spectrum API host (default https://spectrum.photon.codes)"
|
||||
prompt: "Spectrum API host"
|
||||
password: false
|
||||
- name: PHOTON_ALLOWED_USERS
|
||||
description: "Comma-separated E.164 phone numbers allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: PHOTON_ALLOW_ALL_USERS
|
||||
description: "Allow any sender to trigger the bot (dev only — disables allowlist)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_REQUIRE_MENTION
|
||||
description: "Ignore group-chat messages unless they match a mention wake word (true/false, default false)"
|
||||
prompt: "Require a mention in group chats?"
|
||||
password: false
|
||||
- name: PHOTON_MENTION_PATTERNS
|
||||
description: "Mention wake-word regexes for group chats (JSON list or comma/newline-separated; defaults to Hermes wake words)"
|
||||
prompt: "Group mention patterns"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL
|
||||
description: "Default Photon target for cron / notification delivery: Spectrum space id, DM GUID, or bare E.164 phone number"
|
||||
prompt: "Home Photon target"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL_NAME
|
||||
description: "Human label for the home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
- name: PHOTON_TELEMETRY
|
||||
description: "Enable Spectrum SDK telemetry in the sidecar (true/false, default false; toggle with `hermes photon telemetry on|off`)"
|
||||
prompt: "Enable Spectrum telemetry? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_MARKDOWN
|
||||
description: "Send agent replies as markdown — iMessage renders it natively, other Spectrum platforms degrade to plain text (true/false, default true)"
|
||||
prompt: "Render replies as markdown? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_REACTIONS
|
||||
description: "Tapback 👀/👍/👎 on messages as processing status and route tapbacks on bot messages to the agent (true/false, default false)"
|
||||
prompt: "Enable reaction tapbacks? (true/false)"
|
||||
password: false
|
||||
@@ -0,0 +1,52 @@
|
||||
# Photon sidecar
|
||||
|
||||
Small Node helper that bridges Hermes Agent to Photon's Spectrum SDK
|
||||
(`spectrum-ts`). Hermes is Python; Photon has no public HTTP
|
||||
send-message endpoint today; replies therefore go through this sidecar.
|
||||
|
||||
The sidecar:
|
||||
|
||||
- runs `Spectrum({ projectId, projectSecret, providers: [imessage.config()] })`
|
||||
- exposes a loopback-only HTTP control channel for the Python adapter
|
||||
to push send/typing requests (auth via `X-Hermes-Sidecar-Token`)
|
||||
- drains the inbound message stream so `spectrum-ts` keeps its
|
||||
reconnect/heartbeat machinery alive (real inbound delivery is via
|
||||
Photon's signed webhook hitting our Python aiohttp server)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd plugins/platforms/photon/sidecar
|
||||
npm install
|
||||
```
|
||||
|
||||
The Hermes plugin's `hermes photon setup` command runs `npm install`
|
||||
here automatically.
|
||||
|
||||
## Run standalone
|
||||
|
||||
For debugging:
|
||||
|
||||
```bash
|
||||
PHOTON_PROJECT_ID=... PHOTON_PROJECT_SECRET=... \
|
||||
PHOTON_SIDECAR_PORT=8789 PHOTON_SIDECAR_TOKEN=$(openssl rand -hex 16) \
|
||||
node index.mjs
|
||||
```
|
||||
|
||||
In normal use, the Python adapter supervises this process — start,
|
||||
restart on crash, kill on shutdown — and never asks the user to run
|
||||
it by hand.
|
||||
|
||||
## Why a sidecar at all?
|
||||
|
||||
Photon publishes webhooks (inbound) but their docs state explicitly:
|
||||
|
||||
> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts`
|
||||
> SDK instance to reply. No public HTTP send endpoint exists today.
|
||||
|
||||
— https://photon.codes/docs/webhooks/events
|
||||
|
||||
When Photon ships an HTTP send endpoint, the plan is to retire this
|
||||
sidecar entirely and call it directly from Python. The plugin's
|
||||
outbound code path is already isolated behind a single helper
|
||||
(`_sidecar_send` in `adapter.py`) to make that swap a one-file change.
|
||||
@@ -0,0 +1,901 @@
|
||||
// Hermes Agent — Photon Spectrum sidecar
|
||||
//
|
||||
// Spawned by `plugins/platforms/photon/adapter.py` to bridge BOTH directions
|
||||
// of messaging to Photon's Spectrum platform via the `spectrum-ts` SDK (the
|
||||
// SDK is TypeScript-only, so a Node sidecar is unavoidable — there is no
|
||||
// Python SDK and no public HTTP message API).
|
||||
//
|
||||
// Inbound (gRPC -> Hermes): the SDK's `app.messages` async iterator is a
|
||||
// long-lived gRPC stream. We serialize each `[space, message]` to a
|
||||
// normalized JSON event and stream it to the Python adapter over a
|
||||
// loopback `GET /inbound` (NDJSON). We pause pulling from the stream while
|
||||
// no consumer is attached so a backlog isn't pulled-and-lost before the
|
||||
// gateway connects.
|
||||
// Outbound (Hermes -> gRPC): `/send` drives `space.send(...)`; `/typing`
|
||||
// sends the documented `typing("start" | "stop")` content builder.
|
||||
//
|
||||
// Protocol (all requests require `X-Hermes-Sidecar-Token: ${TOKEN}`):
|
||||
// - GET /inbound -> 200 NDJSON stream; one JSON event per line, blank
|
||||
// lines are heartbeats. One consumer at a time.
|
||||
// - POST /healthz -> {"ok": true}
|
||||
// - POST /send -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "text": "...",
|
||||
// "format": "text" | "markdown" (default "text")}
|
||||
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "path": "...", "name": "..." | null,
|
||||
// "mimeType": "..." | null, "caption": "..." | null,
|
||||
// "kind": "attachment" | "voice"}
|
||||
// - POST /react -> {"ok": true, "reactionId": "..." | null}
|
||||
// body: {"spaceId": "...", "messageId": "<target msg id>",
|
||||
// "emoji": "👀"}
|
||||
// - POST /unreact -> {"ok": true} | 400 soft failure
|
||||
// body: {"spaceId": "...", "messageId": "<target msg id>",
|
||||
// "reactionId": "..." | null (restart-recovery fallback)}
|
||||
// - POST /typing -> {"ok": true}
|
||||
// body: {"spaceId": "...", "state": "start" | "stop"}
|
||||
// - POST /shutdown -> {"ok": true}; then process exits
|
||||
//
|
||||
// On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before
|
||||
// exiting. Logs go to stderr; Python supervises restart.
|
||||
//
|
||||
// Requires spectrum-ts 8.x — pinned exactly in package.json because the SDK
|
||||
// ships breaking majors; see README "Upgrading spectrum-ts".
|
||||
//
|
||||
// Env vars (required):
|
||||
// PHOTON_PROJECT_ID (== the project's spectrumProjectId)
|
||||
// PHOTON_PROJECT_SECRET
|
||||
// PHOTON_SIDECAR_PORT
|
||||
// PHOTON_SIDECAR_TOKEN
|
||||
// Optional:
|
||||
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
|
||||
// PHOTON_SIDECAR_WATCH_STDIN "1" = exit when stdin hits EOF (set by the
|
||||
// adapter, which holds our stdin pipe — parent-death
|
||||
// detection so a dead gateway can't orphan us)
|
||||
// PHOTON_TELEMETRY enable Spectrum SDK telemetry ("true"/"1"/"on"/"yes";
|
||||
// default off — toggle with `hermes photon telemetry`)
|
||||
|
||||
import http from "node:http";
|
||||
import crypto from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { patchSpectrumTs } from "./patch-spectrum-mixed-attachments.mjs";
|
||||
|
||||
const projectId = process.env.PHOTON_PROJECT_ID;
|
||||
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
|
||||
const port = parseInt(process.env.PHOTON_SIDECAR_PORT || "8789", 10);
|
||||
const bind = process.env.PHOTON_SIDECAR_BIND || "127.0.0.1";
|
||||
const sharedToken = process.env.PHOTON_SIDECAR_TOKEN;
|
||||
const telemetry = /^(1|true|yes|on)$/i.test(
|
||||
(process.env.PHOTON_TELEMETRY || "").trim()
|
||||
);
|
||||
|
||||
// Inbound binary content is read into memory and base64-inlined on the NDJSON
|
||||
// event so the Python adapter can cache the real bytes (and the agent can see
|
||||
// images / transcribe voice). Cap the size we inline — above it we forward
|
||||
// metadata only and the adapter surfaces a text marker, so one large clip can't
|
||||
// balloon a single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
|
||||
const MAX_INLINE_ATTACHMENT_BYTES =
|
||||
Number(process.env.PHOTON_MAX_INLINE_ATTACHMENT_BYTES) || 20 * 1024 * 1024;
|
||||
const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/;
|
||||
const E164_RE = /^\+\d{6,}$/;
|
||||
const MAX_KNOWN_SPACES = 2048;
|
||||
const MAX_KNOWN_MESSAGES = 1024;
|
||||
const MAX_REACTION_HANDLES = 512;
|
||||
const STREAM_DEGRADED_RESTART_MS =
|
||||
Number(process.env.PHOTON_STREAM_DEGRADED_RESTART_MS) || 90 * 1000;
|
||||
const STREAM_INTERRUPTED_DEGRADE_COUNT =
|
||||
Number(process.env.PHOTON_STREAM_INTERRUPTED_DEGRADE_COUNT) || 3;
|
||||
|
||||
const streamHealth = {
|
||||
state: "starting",
|
||||
degradedSince: null,
|
||||
lastHealthyAt: null,
|
||||
lastIssueAt: null,
|
||||
lastIssue: null,
|
||||
issueCount: 0,
|
||||
};
|
||||
let streamRestartTimer = null;
|
||||
|
||||
function streamHealthSnapshot() {
|
||||
const now = Date.now();
|
||||
const degradedForMs =
|
||||
streamHealth.degradedSince === null ? 0 : now - streamHealth.degradedSince;
|
||||
return {
|
||||
ok: streamHealth.state !== "degraded",
|
||||
state: streamHealth.state,
|
||||
degradedForMs,
|
||||
restartAfterMs: STREAM_DEGRADED_RESTART_MS,
|
||||
lastHealthyAt: streamHealth.lastHealthyAt,
|
||||
lastIssueAt: streamHealth.lastIssueAt,
|
||||
lastIssue: streamHealth.lastIssue,
|
||||
issueCount: streamHealth.issueCount,
|
||||
};
|
||||
}
|
||||
|
||||
function markStreamHealthy() {
|
||||
streamHealth.state = "healthy";
|
||||
streamHealth.degradedSince = null;
|
||||
streamHealth.lastHealthyAt = new Date().toISOString();
|
||||
streamHealth.issueCount = 0;
|
||||
if (streamRestartTimer) {
|
||||
clearTimeout(streamRestartTimer);
|
||||
streamRestartTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStreamRestart() {
|
||||
if (STREAM_DEGRADED_RESTART_MS <= 0 || streamRestartTimer) return;
|
||||
streamRestartTimer = setTimeout(() => {
|
||||
streamRestartTimer = null;
|
||||
if (streamHealth.state !== "degraded" || streamHealth.degradedSince === null) {
|
||||
return;
|
||||
}
|
||||
const degradedForMs = Date.now() - streamHealth.degradedSince;
|
||||
if (degradedForMs < STREAM_DEGRADED_RESTART_MS) {
|
||||
scheduleStreamRestart();
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`photon-sidecar: upstream stream degraded for ${degradedForMs}ms; ` +
|
||||
"exiting so Hermes can restart the Photon adapter"
|
||||
);
|
||||
process.exit(75);
|
||||
}, STREAM_DEGRADED_RESTART_MS + 1000);
|
||||
streamRestartTimer.unref();
|
||||
}
|
||||
|
||||
function markStreamDegraded(reason) {
|
||||
const now = Date.now();
|
||||
if (streamHealth.state !== "degraded") {
|
||||
streamHealth.degradedSince = now;
|
||||
}
|
||||
streamHealth.state = "degraded";
|
||||
streamHealth.lastIssueAt = new Date(now).toISOString();
|
||||
streamHealth.lastIssue = reason;
|
||||
streamHealth.issueCount += 1;
|
||||
scheduleStreamRestart();
|
||||
}
|
||||
|
||||
function markStreamRecovering(reason) {
|
||||
if (streamHealth.state !== "recovering") {
|
||||
streamHealth.issueCount = 0;
|
||||
}
|
||||
streamHealth.state = "recovering";
|
||||
streamHealth.lastIssueAt = new Date().toISOString();
|
||||
streamHealth.lastIssue = reason;
|
||||
streamHealth.issueCount += 1;
|
||||
if (streamHealth.issueCount >= STREAM_INTERRUPTED_DEGRADE_COUNT) {
|
||||
markStreamDegraded(reason);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyStreamLog(text) {
|
||||
if (!text.includes("[spectrum.stream]")) return;
|
||||
const reason = text.split("\n", 1)[0];
|
||||
if (text.includes("persistently failing")) {
|
||||
markStreamDegraded(reason);
|
||||
} else if (text.includes("stream interrupted")) {
|
||||
markStreamRecovering(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// spectrum-ts routes its stream telemetry through @photon-ai/otel's
|
||||
// createLogger, which sends severity >= ERROR to console.error and
|
||||
// everything else (WARN/INFO) to console.log. The two lines we key off
|
||||
// land on *different* channels: `log.error("stream persistently failing")`
|
||||
// -> console.error, but `log.warn("stream interrupted; reconnecting")`
|
||||
// -> console.log. Patch both so the recovering/degraded counters see the
|
||||
// interrupt bursts, not just the terminal "persistently failing" line.
|
||||
const originalConsoleError = console.error.bind(console);
|
||||
console.error = (...args) => {
|
||||
const text = args
|
||||
.map((arg) => (arg && arg.stack ? arg.stack : String(arg)))
|
||||
.join(" ");
|
||||
classifyStreamLog(text);
|
||||
originalConsoleError(...args);
|
||||
};
|
||||
|
||||
const originalConsoleLog = console.log.bind(console);
|
||||
console.log = (...args) => {
|
||||
const text = args
|
||||
.map((arg) => (arg && arg.stack ? arg.stack : String(arg)))
|
||||
.join(" ");
|
||||
classifyStreamLog(text);
|
||||
originalConsoleLog(...args);
|
||||
};
|
||||
|
||||
if (!projectId || !projectSecret || !sharedToken) {
|
||||
console.error(
|
||||
"photon-sidecar: PHOTON_PROJECT_ID, PHOTON_PROJECT_SECRET and " +
|
||||
"PHOTON_SIDECAR_TOKEN must all be set."
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Lazy-load spectrum-ts so a missing install fails with a clear message
|
||||
// instead of a cryptic module-resolution error during import. Apply Hermes'
|
||||
// pinned-sdk compatibility patch first so existing installs self-heal at
|
||||
// runtime, not only during npm postinstall.
|
||||
try {
|
||||
const patchResult = patchSpectrumTs();
|
||||
if (patchResult.patched) {
|
||||
console.error(
|
||||
`photon-sidecar: spectrum mixed attachment patch applied: ${patchResult.file}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: spectrum mixed attachment patch failed. " +
|
||||
"Run `npm install` inside plugins/platforms/photon/sidecar/ or " +
|
||||
"upgrade the Photon sidecar patch for the pinned spectrum-ts version. " +
|
||||
"Original error: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
let Spectrum,
|
||||
imessage,
|
||||
attachment,
|
||||
voice,
|
||||
spectrumText,
|
||||
spectrumMarkdown,
|
||||
spectrumTyping;
|
||||
try {
|
||||
({
|
||||
Spectrum,
|
||||
attachment,
|
||||
voice,
|
||||
text: spectrumText,
|
||||
markdown: spectrumMarkdown,
|
||||
typing: spectrumTyping,
|
||||
} = await import("spectrum-ts"));
|
||||
({ imessage } = await import("spectrum-ts/providers/imessage"));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: spectrum-ts is not installed. Run `npm install` " +
|
||||
"inside plugins/platforms/photon/sidecar/. Original error: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const app = await Spectrum({
|
||||
projectId,
|
||||
projectSecret,
|
||||
providers: [imessage.config()],
|
||||
options: { flattenGroups: true },
|
||||
telemetry,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound: forward `app.messages` (gRPC stream) to the Python consumer.
|
||||
|
||||
// At most one Python consumer is attached at a time (the gateway adapter).
|
||||
let consumerRes = null;
|
||||
let consumerWaiters = [];
|
||||
const knownSpaces = new Map();
|
||||
// Inbound Message objects by id, so /react can usually skip a
|
||||
// `space.getMessage` round trip when tapping back on a recent message.
|
||||
const knownMessages = new Map();
|
||||
// One reaction handle per reacted-to message (key `${spaceId}\0${messageId}`,
|
||||
// value {emoji, handle}) — mirrors iMessage's one-tapback-per-sender
|
||||
// semantics; a new /react on the same target overwrites the slot. The handle
|
||||
// is the outbound reaction Message returned by `target.react()`, kept so
|
||||
// /unreact can `unsend()` it later.
|
||||
const reactionHandles = new Map();
|
||||
|
||||
function lruSet(map, key, value, cap) {
|
||||
if (map.has(key)) map.delete(key);
|
||||
map.set(key, value);
|
||||
if (map.size > cap) {
|
||||
const oldest = map.keys().next().value;
|
||||
if (oldest !== undefined) map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberKnownSpace(id, space) {
|
||||
if (!id || typeof id !== "string" || !space) return;
|
||||
lruSet(knownSpaces, id, space, MAX_KNOWN_SPACES);
|
||||
}
|
||||
|
||||
function rememberKnownMessage(message) {
|
||||
const id = message?.id;
|
||||
if (!id || typeof id !== "string") return;
|
||||
lruSet(knownMessages, id, message, MAX_KNOWN_MESSAGES);
|
||||
}
|
||||
|
||||
function phoneTargetFromSpaceId(spaceId) {
|
||||
if (typeof spaceId !== "string") return null;
|
||||
if (E164_RE.test(spaceId)) return spaceId;
|
||||
const dmGuid = spaceId.match(DM_CHAT_GUID_RE);
|
||||
return dmGuid ? dmGuid[1] : null;
|
||||
}
|
||||
|
||||
function rememberInboundSpace(space, message) {
|
||||
const msgSpace = message?.space || {};
|
||||
const ids = [space?.id, msgSpace.id];
|
||||
for (const id of ids) {
|
||||
rememberKnownSpace(id, space);
|
||||
const phone = phoneTargetFromSpaceId(id);
|
||||
if (phone) rememberKnownSpace(phone, space);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForConsumer() {
|
||||
if (consumerRes) return Promise.resolve();
|
||||
return new Promise((resolve) => consumerWaiters.push(resolve));
|
||||
}
|
||||
|
||||
function setConsumer(res) {
|
||||
consumerRes = res;
|
||||
const waiters = consumerWaiters;
|
||||
consumerWaiters = [];
|
||||
for (const resolve of waiters) resolve();
|
||||
}
|
||||
|
||||
function clearConsumer(res) {
|
||||
if (consumerRes === res) consumerRes = null;
|
||||
}
|
||||
|
||||
// Write one NDJSON line to the active consumer. Blocks until a consumer is
|
||||
// connected; if the write fails (consumer vanished mid-flight) we wait for a
|
||||
// new consumer and retry, so a message is never silently dropped here.
|
||||
async function deliver(line) {
|
||||
for (;;) {
|
||||
await waitForConsumer();
|
||||
const res = consumerRes;
|
||||
if (!res) continue;
|
||||
try {
|
||||
const flushed = res.write(line + "\n");
|
||||
if (!flushed) await once(res, "drain");
|
||||
return;
|
||||
} catch {
|
||||
clearConsumer(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeBinaryContent(content) {
|
||||
const meta = {
|
||||
type: content.type,
|
||||
id: content.id ?? null,
|
||||
name: content.name ?? null,
|
||||
mimeType: content.mimeType ?? null,
|
||||
size: typeof content.size === "number" ? content.size : null,
|
||||
};
|
||||
if (content.type === "voice" && typeof content.duration === "number") {
|
||||
meta.duration = content.duration;
|
||||
}
|
||||
|
||||
// Read the bytes eagerly and base64-inline them as `data` so the Python
|
||||
// adapter can cache the real file (the agent then sees images and can run
|
||||
// STT on voice notes). Spectrum content objects may not outlive this stream
|
||||
// iteration, so a lazy/on-demand fetch isn't safe. Over-cap content (when
|
||||
// size is known up front) is forwarded as metadata only and the adapter falls
|
||||
// back to a text marker. A read failure must never break the inbound loop.
|
||||
const label = `${content.type} ${meta.name ?? meta.id ?? "(unnamed)"}`;
|
||||
if (meta.size !== null && meta.size > MAX_INLINE_ATTACHMENT_BYTES) {
|
||||
console.error(
|
||||
`photon-sidecar: ${label} (${meta.size} bytes) ` +
|
||||
`exceeds inline cap ${MAX_INLINE_ATTACHMENT_BYTES}; forwarding metadata only`
|
||||
);
|
||||
return meta;
|
||||
}
|
||||
if (typeof content.read === "function") {
|
||||
try {
|
||||
const buf = await content.read();
|
||||
// Guard the case where size was unknown but the bytes turn out to be
|
||||
// over the cap.
|
||||
if (buf && buf.length > MAX_INLINE_ATTACHMENT_BYTES) {
|
||||
console.error(
|
||||
`photon-sidecar: ${label} (${buf.length} bytes) ` +
|
||||
`exceeds inline cap after read; forwarding metadata only`
|
||||
);
|
||||
return meta;
|
||||
}
|
||||
meta.data = Buffer.from(buf).toString("base64");
|
||||
meta.encoding = "base64";
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`photon-sidecar: failed to read ${content.type} bytes ` +
|
||||
"(forwarding metadata only): " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
// Best-effort text preview of a reaction's resolved target Message, so the
|
||||
// Python adapter can populate the gateway's `reply_to_text` (context: WHAT was
|
||||
// tapped back). The SDK only emits a reaction once it has resolved the full
|
||||
// target Message (toReactionMessages bails otherwise), so `target.content` is
|
||||
// hydrated here — no extra round trip. Handles plain text and our patched mixed
|
||||
// text+attachment groups (first text child); null for attachment/voice-only
|
||||
// targets. Capped so one long bubble can't balloon the NDJSON line.
|
||||
const REACTION_TARGET_TEXT_CAP = 2000;
|
||||
function reactionTargetText(target) {
|
||||
const c = target && typeof target === "object" ? target.content : null;
|
||||
if (!c || typeof c !== "object") return null;
|
||||
let text = null;
|
||||
if (c.type === "text") {
|
||||
text = c.text;
|
||||
} else if (c.type === "group") {
|
||||
for (const item of Array.isArray(c.items) ? c.items : []) {
|
||||
const ic = item && typeof item === "object" ? item.content : null;
|
||||
if (ic && ic.type === "text" && ic.text) {
|
||||
text = ic.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof text !== "string" || !text) return null;
|
||||
return text.length > REACTION_TARGET_TEXT_CAP
|
||||
? text.slice(0, REACTION_TARGET_TEXT_CAP)
|
||||
: text;
|
||||
}
|
||||
|
||||
async function normalizeContent(content) {
|
||||
if (!content || typeof content !== "object") {
|
||||
return { type: "unknown" };
|
||||
}
|
||||
if (content.type === "text") {
|
||||
return { type: "text", text: content.text || "" };
|
||||
}
|
||||
if (content.type === "attachment" || content.type === "voice") {
|
||||
return await normalizeBinaryContent(content);
|
||||
}
|
||||
if (content.type === "group") {
|
||||
const items = [];
|
||||
for (const item of Array.isArray(content.items) ? content.items : []) {
|
||||
items.push({
|
||||
id: item && typeof item === "object" ? item.id ?? null : null,
|
||||
content: await normalizeContent(item?.content),
|
||||
});
|
||||
}
|
||||
return { type: "group", items };
|
||||
}
|
||||
if (content.type === "reaction") {
|
||||
const target = content.target;
|
||||
return {
|
||||
type: "reaction",
|
||||
emoji: content.emoji || "",
|
||||
targetMessageId: target?.id ?? null,
|
||||
// Lets Python gate "is this a reaction to one of MY messages" without
|
||||
// tracking every outbound id. May be null if the provider doesn't
|
||||
// hydrate the target — Python falls back to its own sent-id cache.
|
||||
targetDirection: target?.direction ?? null,
|
||||
// Text of the reacted-to message, so Python can correlate the tapback to
|
||||
// the gateway's reply_to_text. Null for attachment/voice-only targets.
|
||||
targetText: reactionTargetText(target),
|
||||
};
|
||||
}
|
||||
return { type: content.type || "unknown" };
|
||||
}
|
||||
|
||||
async function normalizeEvent(space, message) {
|
||||
try {
|
||||
const msgSpace = message.space || {};
|
||||
const ts = message.timestamp;
|
||||
return {
|
||||
messageId: message.id ?? null,
|
||||
platform: message.platform || space.__platform || "iMessage",
|
||||
space: {
|
||||
id: space.id ?? msgSpace.id ?? null,
|
||||
// iMessage spaces carry `type` ("dm"|"group") and `phone` directly.
|
||||
type: space.type ?? msgSpace.type ?? "dm",
|
||||
phone: space.phone ?? msgSpace.phone ?? null,
|
||||
},
|
||||
sender: { id: message.sender ? message.sender.id : null },
|
||||
content: await normalizeContent(message.content),
|
||||
timestamp:
|
||||
ts instanceof Date ? ts.toISOString() : ts ? String(ts) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: failed to normalize inbound message: " + String(e)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function inboundStreamErrorMessage(e) {
|
||||
const msg = e && e.message ? e.message : String(e);
|
||||
let out = "photon-sidecar: inbound stream errored — restarting: " + msg;
|
||||
|
||||
// The Spectrum SDK surfaces Photon cloud CatchUpEvents failures as an
|
||||
// iMessage internal error. Local Hermes allowlists cannot cause or fix this:
|
||||
// inbound messages stop before they reach the gateway. Add an explicit hint
|
||||
// so operators know to retry/restart or escalate to Photon support instead
|
||||
// of chasing PHOTON_ALLOWED_USERS / pairing configuration.
|
||||
const details = String(e?.cause?.details || e?.details || "");
|
||||
const path = String(e?.cause?.path || e?.path || "");
|
||||
const code = String(e?.code || "");
|
||||
if (
|
||||
path.includes("EventService/CatchUpEvents") ||
|
||||
details.includes("Unknown server error occurred") ||
|
||||
(code === "internalError" && msg.includes("Unknown server error"))
|
||||
) {
|
||||
out +=
|
||||
" | Photon Spectrum CatchUpEvents returned an internal server error; " +
|
||||
"this is upstream of Hermes, so inbound iMessages may not be delivered " +
|
||||
"until Photon recovers or the stream is re-established.";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// spectrum-ts handles in-session gRPC reconnects internally, but if the async
|
||||
// iterator itself throws or ends, this consumer would stop forever. Wrap it in
|
||||
// a re-subscribe loop with capped exponential backoff + jitter so inbound
|
||||
// always recovers (the adapter dedupes any catch-up replay).
|
||||
(async () => {
|
||||
let backoff = 1000;
|
||||
for (;;) {
|
||||
try {
|
||||
for await (const [space, message] of app.messages) {
|
||||
backoff = 1000; // healthy traffic — reset
|
||||
markStreamHealthy();
|
||||
// Only forward inbound messages (ignore our own outbound echoes).
|
||||
if (message && message.direction && message.direction !== "inbound") {
|
||||
continue;
|
||||
}
|
||||
rememberInboundSpace(space, message);
|
||||
rememberKnownMessage(message);
|
||||
const event = await normalizeEvent(space, message);
|
||||
if (!event) continue;
|
||||
await deliver(JSON.stringify(event));
|
||||
}
|
||||
console.error("photon-sidecar: inbound stream ended — re-subscribing");
|
||||
markStreamRecovering("inbound stream ended");
|
||||
} catch (e) {
|
||||
const reason = e && e.message ? e.message : String(e);
|
||||
console.error(inboundStreamErrorMessage(e));
|
||||
markStreamRecovering(reason);
|
||||
}
|
||||
await new Promise((r) =>
|
||||
setTimeout(r, backoff + Math.random() * backoff * 0.2)
|
||||
);
|
||||
backoff = Math.min(backoff * 2, 30000);
|
||||
}
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP control + inbound server (loopback only).
|
||||
|
||||
// Control-message bodies are tiny; cap the body so a compromised local peer
|
||||
// can't OOM the sidecar by streaming an unbounded request (defence-in-depth on
|
||||
// the loopback channel).
|
||||
const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
req.destroy();
|
||||
throw new Error("request body too large");
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf-8");
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error("invalid JSON body");
|
||||
}
|
||||
}
|
||||
|
||||
function unauthorized(res) {
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
||||
}
|
||||
|
||||
function badRequest(res, msg) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: msg }));
|
||||
}
|
||||
|
||||
function serverError(res) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
// Don't leak stack traces or raw exception text to the caller — even
|
||||
// though we listen on loopback, the supervisor logs the real error
|
||||
// and the client only needs a generic failure signal.
|
||||
res.end(JSON.stringify({ ok: false, error: "internal sidecar error" }));
|
||||
}
|
||||
|
||||
function ok(res, data) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, ...data }));
|
||||
}
|
||||
|
||||
function handleInbound(req, res) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/x-ndjson");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
// One consumer at a time — a fresh connection (e.g. after a reconnect)
|
||||
// supersedes the previous one.
|
||||
if (consumerRes && consumerRes !== res) {
|
||||
try {
|
||||
consumerRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setConsumer(res);
|
||||
// Heartbeat keeps the socket warm through idle periods and lets the Python
|
||||
// side detect a dead pipe promptly.
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write("\n");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 25000);
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
clearConsumer(res);
|
||||
};
|
||||
req.on("close", cleanup);
|
||||
req.on("aborted", cleanup);
|
||||
res.on("error", cleanup);
|
||||
}
|
||||
|
||||
async function resolveSpace(spaceId) {
|
||||
const cached = knownSpaces.get(spaceId);
|
||||
if (cached) return cached;
|
||||
|
||||
const im = imessage(app);
|
||||
const phoneTarget = phoneTargetFromSpaceId(spaceId);
|
||||
let space = null;
|
||||
|
||||
// A bare E.164 phone number addresses a DM, so callers can pass just
|
||||
// "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of an opaque
|
||||
// inbound space id. Photon also represents DM chat ids as `any;-;+1...`;
|
||||
// normalize those through the same path. `space.create` accepts the raw
|
||||
// phone string directly.
|
||||
if (phoneTarget) {
|
||||
try {
|
||||
space = await im.space.create(phoneTarget);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: phone->DM space.create failed: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
// Anything else — typically an opaque group GUID — is rehydrated from the
|
||||
// persisted id via `space.get`, so group spaces stay reachable after a
|
||||
// sidecar restart even before any fresh inbound message in that group.
|
||||
if (!space) {
|
||||
try {
|
||||
space = await im.space.get(spaceId);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: space.get failed: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!space) throw new Error(`unable to resolve space id ${spaceId}`);
|
||||
|
||||
rememberKnownSpace(spaceId, space);
|
||||
if (phoneTarget) rememberKnownSpace(phoneTarget, space);
|
||||
rememberKnownSpace(space?.id, space);
|
||||
return space;
|
||||
}
|
||||
|
||||
// Constant-time token comparison — don't leak the token via `!==` timing.
|
||||
const _tokenBuf = Buffer.from(sharedToken);
|
||||
function tokenOk(header) {
|
||||
if (typeof header !== "string") return false;
|
||||
const h = Buffer.from(header);
|
||||
return h.length === _tokenBuf.length && crypto.timingSafeEqual(h, _tokenBuf);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (!tokenOk(req.headers["x-hermes-sidecar-token"])) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
// Long-lived inbound NDJSON stream.
|
||||
if (req.method === "GET" && req.url === "/inbound") {
|
||||
return handleInbound(req, res);
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
return res.end();
|
||||
}
|
||||
try {
|
||||
if (req.url === "/healthz") {
|
||||
return ok(res, { stream: streamHealthSnapshot() });
|
||||
}
|
||||
if (req.url === "/shutdown") {
|
||||
ok(res, {});
|
||||
setTimeout(() => process.kill(process.pid, "SIGTERM"), 50);
|
||||
return;
|
||||
}
|
||||
const body = await readBody(req);
|
||||
if (req.url === "/send") {
|
||||
const { spaceId, text, format = "text" } = body || {};
|
||||
if (!spaceId || typeof text !== "string") {
|
||||
return badRequest(res, "spaceId and text are required");
|
||||
}
|
||||
if (format !== "text" && format !== "markdown") {
|
||||
return badRequest(res, "format must be text or markdown");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
// iMessage renders markdown natively; spectrum-ts degrades it to
|
||||
// readable plain text on platforms that don't.
|
||||
const builder =
|
||||
format === "markdown" ? spectrumMarkdown(text) : spectrumText(text);
|
||||
const result = await space.send(builder);
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/send-attachment") {
|
||||
const { spaceId, path, name, mimeType, caption, kind } =
|
||||
body || {};
|
||||
if (!spaceId || typeof path !== "string" || !path) {
|
||||
return badRequest(res, "spaceId and path are required");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
|
||||
// spectrum-ts infers name + MIME from the file extension; pass
|
||||
// overrides only when Hermes supplied them so a known-good
|
||||
// inference isn't clobbered with an empty string.
|
||||
const opts = {};
|
||||
if (name) opts.name = name;
|
||||
if (mimeType) opts.mimeType = mimeType;
|
||||
const builder =
|
||||
kind === "voice"
|
||||
? voice(path, Object.keys(opts).length ? opts : undefined)
|
||||
: attachment(path, Object.keys(opts).length ? opts : undefined);
|
||||
|
||||
const result = await space.send(builder);
|
||||
|
||||
// iMessage delivers the caption as a separate bubble; send it
|
||||
// after the media so the attachment renders first.
|
||||
if (caption && typeof caption === "string") {
|
||||
try {
|
||||
await space.send(spectrumText(caption));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: attachment sent but caption failed: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/react") {
|
||||
const { spaceId, messageId, emoji } = body || {};
|
||||
if (!spaceId || !messageId || typeof emoji !== "string" || !emoji) {
|
||||
return badRequest(res, "spaceId, messageId and emoji are required");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
const target =
|
||||
knownMessages.get(messageId) ?? (await space.getMessage(messageId));
|
||||
if (!target) {
|
||||
return badRequest(res, "message not found");
|
||||
}
|
||||
const handle = await target.react(emoji);
|
||||
if (!handle) {
|
||||
return badRequest(res, "reactions not supported on this platform");
|
||||
}
|
||||
lruSet(
|
||||
reactionHandles,
|
||||
`${spaceId}\u0000${messageId}`,
|
||||
{ emoji, handle },
|
||||
MAX_REACTION_HANDLES
|
||||
);
|
||||
return ok(res, { reactionId: handle.id ?? null });
|
||||
}
|
||||
if (req.url === "/unreact") {
|
||||
const { spaceId, messageId, reactionId } = body || {};
|
||||
if (!spaceId || !messageId) {
|
||||
return badRequest(res, "spaceId and messageId are required");
|
||||
}
|
||||
const key = `${spaceId}\u0000${messageId}`;
|
||||
const slot = reactionHandles.get(key);
|
||||
if (slot) {
|
||||
await slot.handle.unsend();
|
||||
reactionHandles.delete(key);
|
||||
return ok(res, {});
|
||||
}
|
||||
// Restart-recovery: the live handle is gone, so try rehydrating the
|
||||
// reaction message by id and retracting it. Only outbound messages can
|
||||
// be unsent — if the provider rehydrates it as inbound (or not at all)
|
||||
// this throws, and that's an expected soft failure, not a sidecar bug:
|
||||
// a stale tapback self-heals when the next /react replaces it.
|
||||
if (reactionId) {
|
||||
try {
|
||||
const space = await resolveSpace(spaceId);
|
||||
const msg = await space.getMessage(reactionId);
|
||||
if (msg) {
|
||||
await space.unsend(msg);
|
||||
return ok(res, {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: best-effort unreact failed: " +
|
||||
(e && e.message ? e.message : String(e))
|
||||
);
|
||||
}
|
||||
return badRequest(res, "reaction not removable");
|
||||
}
|
||||
return badRequest(res, "no tracked reaction for message");
|
||||
}
|
||||
if (req.url === "/typing") {
|
||||
const { spaceId, state = "start" } = body || {};
|
||||
if (!spaceId) return badRequest(res, "spaceId is required");
|
||||
if (state !== "start" && state !== "stop") {
|
||||
return badRequest(res, "state must be start or stop");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
await space.send(spectrumTyping(state));
|
||||
return ok(res, {});
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
return res.end(JSON.stringify({ ok: false, error: "not found" }));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: handler error: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
// serverError() intentionally returns a generic message — see its
|
||||
// body for the rationale.
|
||||
return serverError(res);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, bind, () => {
|
||||
console.error(`photon-sidecar: listening on ${bind}:${port}`);
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
async function shutdown(signal) {
|
||||
// Re-entry guard: stdin EOF, a signal and /shutdown can all fire together
|
||||
// during one teardown.
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
console.error(`photon-sidecar: received ${signal}, stopping...`);
|
||||
try {
|
||||
await Promise.race([
|
||||
app.stop(),
|
||||
new Promise((resolve) => setTimeout(resolve, 3000)),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("photon-sidecar: app.stop() failed: " + String(e));
|
||||
}
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 500).unref();
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
// Lifetime binding to the parent. The adapter spawns us with stdin as a pipe
|
||||
// it holds open; EOF means the gateway process is gone — including hard
|
||||
// deaths (crash, SIGKILL) where no signal and no /shutdown ever reaches us.
|
||||
// Without this, an orphaned sidecar squats the port and keeps consuming the
|
||||
// inbound gRPC stream, and every replacement spawn dies on EADDRINUSE.
|
||||
// Opt-in via env so manual `node index.mjs` runs aren't affected.
|
||||
if (process.env.PHOTON_SIDECAR_WATCH_STDIN === "1") {
|
||||
process.stdin.resume();
|
||||
process.stdin.on("end", () => shutdown("stdin EOF (parent exited)"));
|
||||
process.stdin.on("error", () => shutdown("stdin error (parent exited)"));
|
||||
}
|
||||
|
||||
// Don't let a stray promise rejection take the process down silently — handlers
|
||||
// catch their own errors, so log and keep serving (Python supervises restart on
|
||||
// a real fatal exit).
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error(
|
||||
"photon-sidecar: unhandledRejection: " +
|
||||
(reason && reason.stack ? reason.stack : String(reason))
|
||||
);
|
||||
});
|
||||
+1858
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"scripts": {
|
||||
"start": "node index.mjs",
|
||||
"postinstall": "node patch-spectrum-mixed-attachments.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"spectrum-ts": "8.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "8.6.1",
|
||||
"@opentelemetry/otlp-transformer": "0.218.0",
|
||||
"@opentelemetry/otlp-exporter-base": "0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "0.218.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
// Patch spectrum-ts' iMessage inbound mapper until upstream preserves mixed
|
||||
// text + attachment Apple events. The mapper returns only
|
||||
// buildAttachmentMessage(...) whenever attachments are present, which drops
|
||||
// `message.content.text` before Hermes can see it. We rewrite the two inbound
|
||||
// mappers — `rebuildFromAppleMessage` (used by `space.getMessage`) and
|
||||
// `toInboundMessages` (used by the live stream) — so a bubble carrying both
|
||||
// text and attachment(s) surfaces as a group whose first child is the typed
|
||||
// text. Paths with no text are rewritten to byte-identical behavior, so only
|
||||
// mixed text+attachment messages change shape.
|
||||
//
|
||||
// Since spectrum-ts 5.x split the SDK into scoped packages, the iMessage mapper
|
||||
// lives in `@spectrum-ts/imessage/dist/index.js` (it used to be a chunk under
|
||||
// `spectrum-ts/dist`). The published output is tab-indented and uses
|
||||
// `const ... = async` declarations; the anchors below match that exactly and
|
||||
// fail loudly if a future spectrum-ts reshapes the mapper.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const MARKER = "Hermes patch: Preserve mixed text + attachment iMessage payloads";
|
||||
|
||||
function scriptDir() {
|
||||
return path.dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function replaceOnce(source, from, to, label) {
|
||||
const count = source.split(from).length - 1;
|
||||
if (count !== 1) {
|
||||
throw new Error(`expected exactly one ${label} match, found ${count}`);
|
||||
}
|
||||
return source.replace(from, to);
|
||||
}
|
||||
|
||||
function replaceExactly(source, from, to, expected, label) {
|
||||
const count = source.split(from).length - 1;
|
||||
if (count !== expected) {
|
||||
throw new Error(
|
||||
`expected exactly ${expected} ${label} matches, found ${count}`
|
||||
);
|
||||
}
|
||||
return source.split(from).join(to);
|
||||
}
|
||||
|
||||
// The text-first child of a mixed text+attachment group, indented `tabs` deep
|
||||
// (the object's closing brace sits at `tabs`; its properties one level in).
|
||||
function textChild(tabs) {
|
||||
const t = "\t".repeat(tabs);
|
||||
return (
|
||||
`{\n${t}\t...base,\n${t}\tid: formatChildId(0, messageGuidStr),` +
|
||||
`\n${t}\tcontent: asText(text2),\n${t}\tpartIndex: 0,` +
|
||||
`\n${t}\tparentId: messageGuidStr\n${t}}`
|
||||
);
|
||||
}
|
||||
|
||||
function patchRebuild(source) {
|
||||
// Capture the bubble text before the attachment branches consume it. The
|
||||
// existing no-attachment branch keeps its own `const text` declaration, so a
|
||||
// distinct name avoids a redeclaration.
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\tconst attachments = messageAttachments(message);\n\tif (attachments.length === 1) {`,
|
||||
`\tconst attachments = messageAttachments(message);\n\tconst text2 = message.content.text;\n\tif (attachments.length === 1) {`,
|
||||
"rebuild text capture"
|
||||
);
|
||||
// Single attachment: when text is present, push it to slot 0 and the
|
||||
// attachment to slot 1, then wrap both in a group.
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\t\treturn buildAttachmentMessage(client, base, info, messageGuidStr, 0);`,
|
||||
`\t\tconst msg2 = await buildAttachmentMessage(client, base, info, text2 ? formatChildId(1, messageGuidStr) : messageGuidStr, text2 ? 1 : 0, text2 ? messageGuidStr : void 0);\n\t\tif (text2) {\n\t\t\tconst textMsg = ${textChild(3)};\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tid: messageGuidStr,\n\t\t\t\tcontent: asProviderGroup([textMsg, msg2])\n\t\t\t};\n\t\t}\n\t\treturn msg2;`,
|
||||
"rebuild single attachment"
|
||||
);
|
||||
// Multi attachment: prepend the text child to the group's items.
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\t\treturn {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
|
||||
`\t\tif (text2) {\n\t\t\titems.unshift(${textChild(3)});\n\t\t}\n\t\treturn {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
|
||||
"rebuild multi attachment text child"
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
function patchInbound(source) {
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\tconst attachments = messageAttachments(event.message);\n\tif (attachments.length === 1) {`,
|
||||
`\tconst attachments = messageAttachments(event.message);\n\tconst text2 = event.message.content.text;\n\tif (attachments.length === 1) {`,
|
||||
"inbound text capture"
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\t\tconst msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);\n\t\tcacheMessage(cache, msg);\n\t\treturn [msg];`,
|
||||
`\t\tconst msg = await buildAttachmentMessage(client, base, info, text2 ? formatChildId(1, messageGuidStr) : messageGuidStr, text2 ? 1 : 0, text2 ? messageGuidStr : void 0);\n\t\tif (text2) {\n\t\t\tconst textMsg = ${textChild(3)};\n\t\t\tconst parent = {\n\t\t\t\t...base,\n\t\t\t\tid: messageGuidStr,\n\t\t\t\tcontent: asProviderGroup([textMsg, msg])\n\t\t\t};\n\t\t\tcacheMessage(cache, parent);\n\t\t\treturn [parent];\n\t\t}\n\t\tcacheMessage(cache, msg);\n\t\treturn [msg];`,
|
||||
"inbound single attachment"
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`\t\tconst parent = {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
|
||||
`\t\tif (text2) {\n\t\t\titems.unshift(${textChild(3)});\n\t\t}\n\t\tconst parent = {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
|
||||
"inbound multi attachment text child"
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
// Shift attachment part indices by one when a text child occupies slot 0. The
|
||||
// push line is byte-identical in both mappers, so patch both occurrences.
|
||||
function patchChildIndices(source) {
|
||||
return replaceExactly(
|
||||
source,
|
||||
`items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));`,
|
||||
`items.push(await buildAttachmentMessage(client, base, info, formatChildId(text2 ? i + 1 : i, messageGuidStr), text2 ? i + 1 : i, messageGuidStr));`,
|
||||
2,
|
||||
"multi attachment child index"
|
||||
);
|
||||
}
|
||||
|
||||
export function patchSpectrumTs(root = scriptDir()) {
|
||||
const dist = path.join(
|
||||
root,
|
||||
"node_modules",
|
||||
"@spectrum-ts",
|
||||
"imessage",
|
||||
"dist"
|
||||
);
|
||||
if (!fs.existsSync(dist)) {
|
||||
throw new Error(`@spectrum-ts/imessage dist not found: ${dist}`);
|
||||
}
|
||||
const files = fs.readdirSync(dist)
|
||||
.filter((name) => name.endsWith(".js"))
|
||||
.map((name) => path.join(dist, name));
|
||||
|
||||
for (const file of files) {
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
if (raw.includes(MARKER)) {
|
||||
return { patched: false, file, reason: "already patched" };
|
||||
}
|
||||
// Normalize to LF for matching so the patch works regardless of the
|
||||
// checkout's line-ending style (Windows git autocrlf produces CRLF,
|
||||
// which would otherwise defeat the \n-based search strings). The
|
||||
// original EOL style is restored on write. Indentation in the published
|
||||
// tarball is tabs; the anchors match that directly.
|
||||
const CR = String.fromCharCode(13);
|
||||
const CRLF = CR + "\n";
|
||||
const usedCRLF = raw.includes(CRLF);
|
||||
const original = usedCRLF ? raw.split(CRLF).join("\n") : raw;
|
||||
if (!original.includes("const toInboundMessages = async") ||
|
||||
!original.includes("const rebuildFromAppleMessage = async")) {
|
||||
continue;
|
||||
}
|
||||
let patched = original;
|
||||
patched = patchRebuild(patched);
|
||||
patched = patchInbound(patched);
|
||||
patched = patchChildIndices(patched);
|
||||
patched = `// ${MARKER}\n${patched}`;
|
||||
if (usedCRLF) {
|
||||
patched = patched.split("\n").join(CRLF);
|
||||
}
|
||||
fs.writeFileSync(file, patched, "utf8");
|
||||
return { patched: true, file };
|
||||
}
|
||||
throw new Error("could not find @spectrum-ts/imessage iMessage inbound chunk to patch");
|
||||
}
|
||||
|
||||
const _invokedDirectly =
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (_invokedDirectly) {
|
||||
try {
|
||||
const root = process.argv[2] ? path.resolve(process.argv[2]) : scriptDir();
|
||||
const result = patchSpectrumTs(root);
|
||||
const action = result.patched ? "patched" : "ok";
|
||||
console.error(`photon-sidecar: spectrum mixed attachment patch ${action}: ${result.file}`);
|
||||
} catch (err) {
|
||||
console.error(`photon-sidecar: spectrum mixed attachment patch failed: ${err?.stack || err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,850 @@
|
||||
"""Raft channel platform adapter.
|
||||
|
||||
Starts a local wake endpoint, spawns ``raft agent bridge`` as a child process,
|
||||
and injects content-free wake hints into Hermes' normal gateway session pipeline.
|
||||
Token and port are auto-generated when not provided via env/config.
|
||||
The bridge remains responsible for Raft message cursors and body materialization;
|
||||
the agent uses the Raft CLI according to the Raft manual.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import weakref
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
|
||||
AIOHTTP_AVAILABLE = True
|
||||
except ImportError:
|
||||
AIOHTTP_AVAILABLE = False
|
||||
web = None # type: ignore[assignment]
|
||||
|
||||
import sys
|
||||
from pathlib import Path as _Path
|
||||
sys.path.insert(0, str(_Path(__file__).resolve().parents[3]))
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
merge_pending_message_event,
|
||||
)
|
||||
from gateway.session import build_session_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 0
|
||||
DEFAULT_PATH = "/wake"
|
||||
DEFAULT_RUNTIME_SESSION = "default"
|
||||
DEFAULT_MAX_BODY_BYTES = 16_384
|
||||
DEFAULT_ACTIVITY_QUEUE_CAP = 500
|
||||
ACTIVITY_CONTENT_CAP = 4096
|
||||
ACTIVITY_EVENT_SCHEMA = "raft-activity.v1"
|
||||
ACTIVITY_DRAIN_SCHEMA = "raft-activity-drain.v1"
|
||||
BRIDGE_TOKEN_HEADER = "x-raft-bridge-token"
|
||||
|
||||
_CONTENT_FIELD_NAMES = {
|
||||
"body",
|
||||
"content",
|
||||
"message",
|
||||
"messages",
|
||||
"preview",
|
||||
"snippet",
|
||||
"text",
|
||||
}
|
||||
|
||||
_SAFE_SCALAR_RE = re.compile(r"^[a-zA-Z0-9._:@/ -]+$")
|
||||
_MAX_SCALAR_LENGTH = 120
|
||||
_ACTIVITY_ALLOWED_FIELDS = {
|
||||
"schema",
|
||||
"eventId",
|
||||
"sessionId",
|
||||
"hookEventName",
|
||||
"status",
|
||||
"occurredAt",
|
||||
"toolName",
|
||||
"toolInput",
|
||||
"toolOutput",
|
||||
"toolInputTruncated",
|
||||
"toolOutputTruncated",
|
||||
"truncated",
|
||||
"errorClass",
|
||||
"durationMs",
|
||||
}
|
||||
_ACTIVE_ADAPTERS: "weakref.WeakSet[RaftAdapter]" = weakref.WeakSet()
|
||||
_ACTIVE_ADAPTERS_LOCK = threading.Lock()
|
||||
_RAFT_CONTEXT_LOCK = threading.Lock()
|
||||
_RAFT_SESSION_IDS: set[str] = set()
|
||||
_RAFT_TURN_IDS: set[str] = set()
|
||||
_RAFT_PROMPT_TURN_IDS: set[str] = set()
|
||||
|
||||
|
||||
def check_raft_requirements() -> bool:
|
||||
"""Check if Raft channel dependencies are available.
|
||||
|
||||
Intentionally silent on failure — this is a passive probe registered as
|
||||
the platform's ``check_fn``. It is called on every
|
||||
``load_gateway_config()`` (message handling, display lookups, agent
|
||||
turns), so logging here floods the logs for every user without the
|
||||
``raft`` CLI installed. The caller (``gateway/platform_registry.py``
|
||||
``create_adapter()``) emits its own warning when requirements are not met
|
||||
and an adapter is actually requested. This matches the convention used by
|
||||
other platform adapters (e.g. ``teams/adapter.py``).
|
||||
"""
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
return False
|
||||
if not shutil.which("raft"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _path_value(value: Any) -> str:
|
||||
path = str(value or DEFAULT_PATH).strip() or DEFAULT_PATH
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
return path
|
||||
|
||||
|
||||
def _has_content_field(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
for key, nested in value.items():
|
||||
if str(key).strip().lower() in _CONTENT_FIELD_NAMES:
|
||||
return True
|
||||
if _has_content_field(nested):
|
||||
return True
|
||||
elif isinstance(value, list):
|
||||
return any(_has_content_field(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _platform_value(value: Any) -> str:
|
||||
return str(getattr(value, "value", value) or "")
|
||||
|
||||
|
||||
def _safe_scalar(value: Any, default: Optional[str] = None) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return default
|
||||
if not value or len(value) > _MAX_SCALAR_LENGTH:
|
||||
return default
|
||||
if not _SAFE_SCALAR_RE.match(value):
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _content_string(value: Any) -> Optional[tuple[str, bool]]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value
|
||||
else:
|
||||
try:
|
||||
text = json.dumps(value, ensure_ascii=False, sort_keys=True)
|
||||
except Exception:
|
||||
return None
|
||||
if not text:
|
||||
return None
|
||||
if len(text) > ACTIVITY_CONTENT_CAP:
|
||||
return text[:ACTIVITY_CONTENT_CAP], True
|
||||
return text, False
|
||||
|
||||
|
||||
def _duration_ms(value: Any) -> Optional[int]:
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
return None
|
||||
duration = int(value)
|
||||
if duration < 0:
|
||||
return None
|
||||
return duration
|
||||
|
||||
|
||||
def _make_activity_event(
|
||||
*,
|
||||
hook_event_name: str,
|
||||
session_id: Any,
|
||||
status: str = "ok",
|
||||
tool_name: Any = None,
|
||||
tool_input: Any = None,
|
||||
tool_output: Any = None,
|
||||
error_class: Any = None,
|
||||
duration_ms: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
event: Dict[str, Any] = {
|
||||
"schema": ACTIVITY_EVENT_SCHEMA,
|
||||
"eventId": f"hermes-{uuid.uuid4()}",
|
||||
"sessionId": _safe_scalar(session_id, "unknown") or "unknown",
|
||||
"hookEventName": hook_event_name,
|
||||
"status": "error" if status == "error" else "ok",
|
||||
"occurredAt": _now_iso(),
|
||||
}
|
||||
safe_tool_name = _safe_scalar(tool_name)
|
||||
if safe_tool_name:
|
||||
event["toolName"] = safe_tool_name
|
||||
safe_error_class = _safe_scalar(error_class)
|
||||
if safe_error_class:
|
||||
event["errorClass"] = safe_error_class
|
||||
safe_duration_ms = _duration_ms(duration_ms)
|
||||
if safe_duration_ms is not None:
|
||||
event["durationMs"] = safe_duration_ms
|
||||
|
||||
truncated = False
|
||||
input_value = _content_string(tool_input)
|
||||
if input_value:
|
||||
event["toolInput"], input_truncated = input_value
|
||||
if input_truncated:
|
||||
event["toolInputTruncated"] = True
|
||||
truncated = True
|
||||
output_value = _content_string(tool_output)
|
||||
if output_value:
|
||||
event["toolOutput"], output_truncated = output_value
|
||||
if output_truncated:
|
||||
event["toolOutputTruncated"] = True
|
||||
truncated = True
|
||||
if truncated:
|
||||
event["truncated"] = True
|
||||
return event
|
||||
|
||||
|
||||
def _validate_activity_event(value: Any) -> Dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("activity event must be an object")
|
||||
if value.get("schema") != ACTIVITY_EVENT_SCHEMA:
|
||||
raise ValueError("unsupported activity event schema")
|
||||
unknown = set(value) - _ACTIVITY_ALLOWED_FIELDS
|
||||
if unknown:
|
||||
raise ValueError(f"activity event field {sorted(unknown)[0]} is not allowed")
|
||||
for key in ("eventId", "sessionId", "hookEventName", "occurredAt"):
|
||||
if not _safe_scalar(value.get(key)):
|
||||
raise ValueError(f"activity event {key} must be a safe non-empty string")
|
||||
if value.get("status") not in {"ok", "error"}:
|
||||
raise ValueError("activity event status must be ok|error")
|
||||
if value.get("toolName") is not None and not _safe_scalar(value.get("toolName")):
|
||||
raise ValueError("activity event toolName must be a safe string")
|
||||
if value.get("errorClass") is not None and not _safe_scalar(value.get("errorClass")):
|
||||
raise ValueError("activity event errorClass must be a safe string")
|
||||
if value.get("durationMs") is not None and _duration_ms(value.get("durationMs")) is None:
|
||||
raise ValueError("activity event durationMs must be a non-negative number")
|
||||
for key in ("truncated", "toolInputTruncated", "toolOutputTruncated"):
|
||||
if value.get(key) is not None and not isinstance(value.get(key), bool):
|
||||
raise ValueError(f"activity event {key} must be a boolean")
|
||||
|
||||
event = dict(value)
|
||||
if event.get("durationMs") is not None:
|
||||
event["durationMs"] = _duration_ms(event["durationMs"])
|
||||
for key in ("toolInput", "toolOutput"):
|
||||
content = event.get(key)
|
||||
if content is None:
|
||||
continue
|
||||
if not isinstance(content, str):
|
||||
raise ValueError(f"activity event {key} must be a string")
|
||||
if len(content) > ACTIVITY_CONTENT_CAP:
|
||||
event[key] = content[:ACTIVITY_CONTENT_CAP]
|
||||
event["truncated"] = True
|
||||
event[f"{key}Truncated"] = True
|
||||
return event
|
||||
|
||||
|
||||
class ActivityQueue:
|
||||
"""Bounded at-most-once queue for Raft external activity telemetry."""
|
||||
|
||||
def __init__(self, cap: int = DEFAULT_ACTIVITY_QUEUE_CAP):
|
||||
self._cap = max(1, int(cap or DEFAULT_ACTIVITY_QUEUE_CAP))
|
||||
self._events: Deque[Dict[str, Any]] = deque()
|
||||
self._dropped_since_drain = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def push(self, event: Dict[str, Any]) -> None:
|
||||
validated = _validate_activity_event(event)
|
||||
with self._lock:
|
||||
self._events.append(validated)
|
||||
while len(self._events) > self._cap:
|
||||
self._events.popleft()
|
||||
self._dropped_since_drain += 1
|
||||
|
||||
def drain(self, max_events: int = 200) -> Dict[str, Any]:
|
||||
limit = max(1, int(max_events or 200))
|
||||
with self._lock:
|
||||
events: List[Dict[str, Any]] = []
|
||||
while self._events and len(events) < limit:
|
||||
events.append(self._events.popleft())
|
||||
dropped = self._dropped_since_drain
|
||||
self._dropped_since_drain = 0
|
||||
return {"schema": ACTIVITY_DRAIN_SCHEMA, "events": events, "dropped": dropped}
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._events)
|
||||
|
||||
|
||||
def _remember_raft_context(session_id: Any, turn_id: Any = None) -> None:
|
||||
safe_session_id = _safe_scalar(session_id)
|
||||
safe_turn_id = _safe_scalar(turn_id)
|
||||
with _RAFT_CONTEXT_LOCK:
|
||||
if safe_session_id:
|
||||
_RAFT_SESSION_IDS.add(safe_session_id)
|
||||
if safe_turn_id:
|
||||
_RAFT_TURN_IDS.add(safe_turn_id)
|
||||
|
||||
|
||||
def _forget_raft_context(session_id: Any, turn_id: Any = None, *, forget_session: bool = False) -> None:
|
||||
safe_session_id = _safe_scalar(session_id)
|
||||
safe_turn_id = _safe_scalar(turn_id)
|
||||
with _RAFT_CONTEXT_LOCK:
|
||||
if safe_turn_id:
|
||||
_RAFT_TURN_IDS.discard(safe_turn_id)
|
||||
_RAFT_PROMPT_TURN_IDS.discard(safe_turn_id)
|
||||
if forget_session and safe_session_id:
|
||||
_RAFT_SESSION_IDS.discard(safe_session_id)
|
||||
|
||||
|
||||
def _is_raft_context(**kwargs: Any) -> bool:
|
||||
if _platform_value(kwargs.get("platform")) == "raft":
|
||||
_remember_raft_context(kwargs.get("session_id"), kwargs.get("turn_id"))
|
||||
return True
|
||||
safe_session_id = _safe_scalar(kwargs.get("session_id"))
|
||||
safe_turn_id = _safe_scalar(kwargs.get("turn_id"))
|
||||
with _RAFT_CONTEXT_LOCK:
|
||||
return bool(
|
||||
(safe_turn_id and safe_turn_id in _RAFT_TURN_IDS)
|
||||
or (safe_session_id and safe_session_id in _RAFT_SESSION_IDS)
|
||||
)
|
||||
|
||||
|
||||
def _report_activity(event: Dict[str, Any]) -> None:
|
||||
with _ACTIVE_ADAPTERS_LOCK:
|
||||
adapters = list(_ACTIVE_ADAPTERS)
|
||||
for adapter in adapters:
|
||||
adapter.report_activity(event)
|
||||
|
||||
|
||||
def _on_session_start(**kwargs: Any) -> None:
|
||||
if not _is_raft_context(**kwargs):
|
||||
return
|
||||
try:
|
||||
from tools.env_passthrough import register_env_passthrough
|
||||
|
||||
register_env_passthrough(["RAFT_PROFILE"])
|
||||
except Exception:
|
||||
logger.debug("[raft] failed to register RAFT_PROFILE env passthrough", exc_info=True)
|
||||
_report_activity(
|
||||
_make_activity_event(
|
||||
hook_event_name="SessionStart",
|
||||
session_id=kwargs.get("session_id"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _on_pre_llm_call(**kwargs: Any) -> None:
|
||||
if not _is_raft_context(**kwargs):
|
||||
return
|
||||
safe_turn_id = _safe_scalar(kwargs.get("turn_id"))
|
||||
if safe_turn_id:
|
||||
with _RAFT_CONTEXT_LOCK:
|
||||
if safe_turn_id in _RAFT_PROMPT_TURN_IDS:
|
||||
return
|
||||
_RAFT_PROMPT_TURN_IDS.add(safe_turn_id)
|
||||
_report_activity(
|
||||
_make_activity_event(
|
||||
hook_event_name="UserPromptSubmit",
|
||||
session_id=kwargs.get("session_id"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _on_pre_tool_call(**kwargs: Any) -> None:
|
||||
if not _is_raft_context(**kwargs):
|
||||
return
|
||||
_report_activity(
|
||||
_make_activity_event(
|
||||
hook_event_name="PreToolUse",
|
||||
session_id=kwargs.get("session_id"),
|
||||
tool_name=kwargs.get("tool_name"),
|
||||
tool_input=kwargs.get("args"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _on_post_tool_call(**kwargs: Any) -> None:
|
||||
if not _is_raft_context(**kwargs):
|
||||
return
|
||||
status = "error" if kwargs.get("status") in {"error", "blocked"} or kwargs.get("error_type") else "ok"
|
||||
hook_name = "PostToolUseFailure" if status == "error" else "PostToolUse"
|
||||
_report_activity(
|
||||
_make_activity_event(
|
||||
hook_event_name=hook_name,
|
||||
session_id=kwargs.get("session_id"),
|
||||
status=status,
|
||||
tool_name=kwargs.get("tool_name"),
|
||||
tool_input=kwargs.get("args"),
|
||||
tool_output=kwargs.get("error_message") or kwargs.get("result"),
|
||||
error_class=kwargs.get("error_type") or ("tool_failure" if status == "error" else None),
|
||||
duration_ms=kwargs.get("duration_ms"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _on_post_llm_call(**kwargs: Any) -> None:
|
||||
if not _is_raft_context(**kwargs):
|
||||
return
|
||||
_report_activity(
|
||||
_make_activity_event(
|
||||
hook_event_name="Stop",
|
||||
session_id=kwargs.get("session_id"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _on_session_end(**kwargs: Any) -> None:
|
||||
if not _is_raft_context(**kwargs):
|
||||
return
|
||||
if kwargs.get("interrupted") or kwargs.get("completed") is False:
|
||||
_report_activity(
|
||||
_make_activity_event(
|
||||
hook_event_name="Stop",
|
||||
session_id=kwargs.get("session_id"),
|
||||
status="error",
|
||||
error_class="interrupted" if kwargs.get("interrupted") else "incomplete",
|
||||
)
|
||||
)
|
||||
_forget_raft_context(kwargs.get("session_id"), kwargs.get("turn_id"))
|
||||
|
||||
|
||||
def _on_session_finalize(**kwargs: Any) -> None:
|
||||
if not _is_raft_context(**kwargs):
|
||||
return
|
||||
_report_activity(
|
||||
_make_activity_event(
|
||||
hook_event_name="SessionEnd",
|
||||
session_id=kwargs.get("session_id"),
|
||||
)
|
||||
)
|
||||
_forget_raft_context(kwargs.get("session_id"), kwargs.get("turn_id"), forget_session=True)
|
||||
|
||||
|
||||
class RaftAdapter(BasePlatformAdapter):
|
||||
"""Local HTTP endpoint for Raft channel bridge delivery."""
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform("raft"))
|
||||
extra = config.extra or {}
|
||||
self._host: str = str(extra.get("host", DEFAULT_HOST))
|
||||
self._port: int = int(extra.get("port", DEFAULT_PORT))
|
||||
self._path: str = _path_value(extra.get("path", DEFAULT_PATH))
|
||||
self._bridge_token: str = str(extra.get("bridge_token", ""))
|
||||
self._runtime_session: str = str(
|
||||
extra.get("runtime_session", DEFAULT_RUNTIME_SESSION)
|
||||
or DEFAULT_RUNTIME_SESSION
|
||||
)
|
||||
self._max_body_bytes: int = int(
|
||||
extra.get("max_body_bytes", DEFAULT_MAX_BODY_BYTES)
|
||||
)
|
||||
self._runner = None
|
||||
self._bridge_process: Optional[subprocess.Popen] = None
|
||||
self._activity_queue = ActivityQueue()
|
||||
|
||||
@property
|
||||
def runtime_session(self) -> str:
|
||||
return self._runtime_session
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
if not self._bridge_token:
|
||||
self._bridge_token = secrets.token_hex(32)
|
||||
logger.info("[raft] Auto-generated bridge token")
|
||||
|
||||
# client_max_size makes aiohttp enforce the cap on every read path,
|
||||
# including Transfer-Encoding: chunked bodies that carry no
|
||||
# Content-Length and would otherwise bypass the header checks below
|
||||
# (mirrors gateway/platforms/webhook.py's connect()).
|
||||
app = web.Application(client_max_size=self._max_body_bytes)
|
||||
app.router.add_get("/health", self._handle_health)
|
||||
app.router.add_post(self._path, self._handle_wake)
|
||||
app.router.add_post("/activity", self._handle_activity)
|
||||
app.router.add_get("/activity/drain", self._handle_activity_drain)
|
||||
|
||||
if self._port != 0:
|
||||
import socket as _socket
|
||||
|
||||
try:
|
||||
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(1)
|
||||
sock.connect(("127.0.0.1", self._port))
|
||||
logger.error(
|
||||
"[raft] Port %d already in use. Set platforms.raft.extra.port in config",
|
||||
self._port,
|
||||
)
|
||||
return False
|
||||
except (ConnectionRefusedError, OSError):
|
||||
pass
|
||||
|
||||
self._runner = web.AppRunner(app)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, self._host, self._port)
|
||||
await site.start()
|
||||
|
||||
bound_port = self._port
|
||||
if bound_port == 0 and site._server and site._server.sockets:
|
||||
bound_port = site._server.sockets[0].getsockname()[1]
|
||||
|
||||
self._mark_connected()
|
||||
with _ACTIVE_ADAPTERS_LOCK:
|
||||
_ACTIVE_ADAPTERS.add(self)
|
||||
logger.info("[raft] Raft channel listening on %s:%d%s", self._host, bound_port, self._path)
|
||||
|
||||
self._spawn_bridge(bound_port)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._stop_bridge()
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
with _ACTIVE_ADAPTERS_LOCK:
|
||||
_ACTIVE_ADAPTERS.discard(self)
|
||||
self._mark_disconnected()
|
||||
logger.info("[raft] Disconnected")
|
||||
|
||||
def _spawn_bridge(self, port: int) -> None:
|
||||
raft_bin = shutil.which("raft")
|
||||
if not raft_bin:
|
||||
logger.warning("[raft] raft CLI not found in PATH; bridge not spawned — wake-only polling mode")
|
||||
return
|
||||
|
||||
profile = os.environ.get("RAFT_PROFILE", "")
|
||||
if not profile:
|
||||
logger.warning("[raft] RAFT_PROFILE not set; bridge not spawned")
|
||||
return
|
||||
|
||||
endpoint = f"http://{self._host}:{port}{self._path}"
|
||||
cmd: List[str] = [
|
||||
raft_bin, "--profile", profile,
|
||||
"agent", "bridge",
|
||||
"--wake-adapter", "wake-channel",
|
||||
"--wake-channel-endpoint", endpoint,
|
||||
]
|
||||
env = {**os.environ, "RAFT_CHANNEL_TOKEN": self._bridge_token}
|
||||
try:
|
||||
self._bridge_process = subprocess.Popen(
|
||||
cmd, env=env, stdin=subprocess.DEVNULL
|
||||
)
|
||||
logger.info("[raft] Spawned bridge pid=%d profile=%s endpoint=%s", self._bridge_process.pid, profile, endpoint)
|
||||
except Exception:
|
||||
logger.exception("[raft] Failed to spawn bridge")
|
||||
|
||||
def _stop_bridge(self) -> None:
|
||||
proc = self._bridge_process
|
||||
if proc is None:
|
||||
return
|
||||
self._bridge_process = None
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
logger.info("[raft] Bridge process terminated (pid=%d)", proc.pid)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
logger.warning("[raft] Bridge process killed after timeout (pid=%d)", proc.pid)
|
||||
except Exception:
|
||||
logger.exception("[raft] Error stopping bridge")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
logger.debug("[raft] adapter send is a no-op; agent delivers via raft CLI")
|
||||
return SendResult(success=True)
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
return {"name": f"raft/{chat_id}", "type": "raft"}
|
||||
|
||||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"platform": "raft",
|
||||
"runtimeSession": self._runtime_session,
|
||||
"activity": {
|
||||
"queueSize": self._activity_queue.size,
|
||||
"endpoint": "/activity",
|
||||
"drainEndpoint": "/activity/drain",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_wake(self, request: "web.Request") -> "web.Response":
|
||||
if not self._validate_bridge_token(request.headers.get(BRIDGE_TOKEN_HEADER, "")):
|
||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
||||
|
||||
content_length = request.content_length or 0
|
||||
if content_length > self._max_body_bytes:
|
||||
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
|
||||
|
||||
try:
|
||||
raw_body = await request.read()
|
||||
except web.HTTPRequestEntityTooLarge:
|
||||
# aiohttp's client_max_size tripped — chunked or lying
|
||||
# Content-Length. Same 413 as the header check above.
|
||||
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
|
||||
except Exception:
|
||||
return web.json_response({"ok": False, "error": "bad_request"}, status=400)
|
||||
if len(raw_body) > self._max_body_bytes:
|
||||
# Defense in depth: enforce the cap on the actual bytes read even
|
||||
# if the server-level limit was bypassed or misconfigured.
|
||||
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
|
||||
|
||||
payload: Dict[str, Any] = {}
|
||||
if raw_body.strip():
|
||||
try:
|
||||
parsed = json.loads(raw_body)
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"ok": False, "error": "invalid_json"}, status=400)
|
||||
if not isinstance(parsed, dict):
|
||||
return web.json_response({"ok": False, "error": "invalid_payload"}, status=400)
|
||||
payload = parsed
|
||||
|
||||
# Do not gate on payload["schema"]: the bridge owns schema evolution;
|
||||
# Hermes only verifies that wake hints are content-free.
|
||||
if _has_content_field(payload):
|
||||
return web.json_response({"ok": False, "error": "content_not_allowed"}, status=400)
|
||||
|
||||
accepted = await self._accept_wake(payload)
|
||||
if not accepted:
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "not_ready",
|
||||
"runtimeSession": self._runtime_session,
|
||||
},
|
||||
status=503,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"runtimeSession": self._runtime_session,
|
||||
},
|
||||
status=202,
|
||||
)
|
||||
|
||||
async def _handle_activity(self, request: "web.Request") -> "web.Response":
|
||||
if not self._validate_bridge_token(request.headers.get(BRIDGE_TOKEN_HEADER, "")):
|
||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
||||
|
||||
content_length = request.content_length or 0
|
||||
if content_length > self._max_body_bytes:
|
||||
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
|
||||
|
||||
try:
|
||||
raw_text = await request.text()
|
||||
except web.HTTPRequestEntityTooLarge:
|
||||
# aiohttp's client_max_size tripped — chunked or lying
|
||||
# Content-Length. Same 413 as the header check above.
|
||||
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
|
||||
except Exception as exc:
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
||||
if len(raw_text.encode("utf-8")) > self._max_body_bytes:
|
||||
# Defense in depth: enforce the cap on the actual bytes read even
|
||||
# if the server-level limit was bypassed or misconfigured.
|
||||
return web.json_response({"ok": False, "error": "payload_too_large"}, status=413)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_text)
|
||||
self._activity_queue.push(payload)
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"ok": False, "error": "invalid_json"}, status=400)
|
||||
except Exception as exc:
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
||||
|
||||
return web.json_response({"ok": True}, status=202)
|
||||
|
||||
async def _handle_activity_drain(self, request: "web.Request") -> "web.Response":
|
||||
if not self._validate_bridge_token(request.headers.get(BRIDGE_TOKEN_HEADER, "")):
|
||||
return web.json_response({"ok": False, "error": "unauthorized"}, status=401)
|
||||
try:
|
||||
max_events = int(request.query.get("max", "200"))
|
||||
except ValueError:
|
||||
max_events = 200
|
||||
return web.json_response(self._activity_queue.drain(max_events))
|
||||
|
||||
def _validate_bridge_token(self, token: str) -> bool:
|
||||
if not self._bridge_token or not token:
|
||||
return False
|
||||
return hmac.compare_digest(token, self._bridge_token)
|
||||
|
||||
async def _accept_wake(self, payload: Dict[str, Any]) -> bool:
|
||||
if not self._message_handler:
|
||||
logger.warning("[raft] Wake received before gateway message handler was attached")
|
||||
return False
|
||||
|
||||
delivery_id = str(
|
||||
payload.get("eventId")
|
||||
or payload.get("attemptId")
|
||||
or payload.get("messageId")
|
||||
or payload.get("delivery_id")
|
||||
or payload.get("wake_id")
|
||||
or payload.get("id")
|
||||
or f"raft-wake-{int(time.time() * 1000)}"
|
||||
)
|
||||
source = self.build_source(
|
||||
chat_id=self._runtime_session,
|
||||
chat_name="Raft channel",
|
||||
chat_type="dm",
|
||||
user_id="raft-bridge",
|
||||
user_name="Raft Bridge",
|
||||
)
|
||||
event = MessageEvent(
|
||||
text=self._wake_prompt(),
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message=payload,
|
||||
message_id=delivery_id,
|
||||
internal=True,
|
||||
)
|
||||
try:
|
||||
await self.handle_message(event)
|
||||
except Exception:
|
||||
logger.exception("[raft] Failed to inject wake event")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def handle_message(self, event: MessageEvent) -> None:
|
||||
"""Accept Raft wake hints without interrupting an active Hermes turn."""
|
||||
if not self._message_handler:
|
||||
return
|
||||
|
||||
session_key = build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
if session_key in self._active_sessions:
|
||||
logger.debug("[raft] Wake queued for busy session %s", session_key)
|
||||
merge_pending_message_event(self._pending_messages, session_key, event)
|
||||
return
|
||||
|
||||
await super().handle_message(event)
|
||||
|
||||
@staticmethod
|
||||
def _wake_prompt() -> str:
|
||||
return (
|
||||
"Raft wake hint received. New Raft messages may be pending. "
|
||||
"If you have not read the Raft manual in this session, run "
|
||||
"`raft manual get raft-cli-overview` before using Raft commands."
|
||||
)
|
||||
|
||||
def report_activity(self, event: Dict[str, Any]) -> None:
|
||||
try:
|
||||
self._activity_queue.push(event)
|
||||
except Exception:
|
||||
logger.debug("[raft] activity event dropped during validation", exc_info=True)
|
||||
|
||||
|
||||
def _is_connected(config: PlatformConfig) -> bool:
|
||||
extra = config.extra or {}
|
||||
return bool(extra.get("enabled") or extra.get("bridge_token"))
|
||||
|
||||
|
||||
def _env_enablement() -> Optional[dict]:
|
||||
"""Seed PlatformConfig.extra from env vars during gateway config load.
|
||||
|
||||
Auto-enables when RAFT_PROFILE is set (the adapter needs it anyway).
|
||||
"""
|
||||
if not os.getenv("RAFT_PROFILE"):
|
||||
return None
|
||||
|
||||
return {"enabled": True}
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Interactive ``hermes gateway setup`` flow for the Raft platform.
|
||||
|
||||
Lazy-imports CLI helpers so the plugin stays importable in gateway runtime
|
||||
and test contexts. The flow persists ``RAFT_PROFILE`` to the Hermes env
|
||||
file so the Raft adapter auto-enables after a gateway restart.
|
||||
"""
|
||||
from hermes_cli.cli_output import (
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
print_warning,
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
)
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
|
||||
print_header("Raft")
|
||||
existing_profile = get_env_value("RAFT_PROFILE")
|
||||
if existing_profile:
|
||||
print_info(f"Raft: already configured (profile: {existing_profile})")
|
||||
if not prompt_yes_no("Reconfigure Raft?", False):
|
||||
print_info(f"Keeping RAFT_PROFILE={existing_profile}.")
|
||||
return
|
||||
|
||||
print_info("Connect Hermes to Raft as an external agent.")
|
||||
print_info("Create the External Agent in Raft first, then run:")
|
||||
print_info(" raft agent login --server <server-url> --agent <agent-id> --profile-slug <slug>")
|
||||
print()
|
||||
|
||||
profile = prompt("Raft profile slug", default=existing_profile or "")
|
||||
if not profile:
|
||||
print_warning("Raft profile slug is required; skipping Raft setup")
|
||||
return
|
||||
|
||||
save_env_value("RAFT_PROFILE", profile.strip())
|
||||
|
||||
print()
|
||||
print_success("Raft configuration saved")
|
||||
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="raft",
|
||||
label="Raft",
|
||||
adapter_factory=lambda cfg: RaftAdapter(cfg),
|
||||
check_fn=check_raft_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["RAFT_PROFILE"],
|
||||
install_hint="Install the Raft CLI from https://raft.build",
|
||||
setup_fn=interactive_setup,
|
||||
env_enablement_fn=_env_enablement,
|
||||
emoji="🔔",
|
||||
platform_hint=(
|
||||
"You are connected to Raft via an external-agent channel. "
|
||||
"Run `raft --profile {profile} profile show` to confirm which agent profile is active. "
|
||||
"Run `raft --profile {profile} manual get raft-cli-overview` to learn available Raft commands. "
|
||||
"Always pass `--profile {profile}` to every raft CLI call."
|
||||
).format(profile=os.environ.get("RAFT_PROFILE", "your-agent-profile")),
|
||||
)
|
||||
ctx.register_hook("on_session_start", _on_session_start)
|
||||
ctx.register_hook("pre_llm_call", _on_pre_llm_call)
|
||||
ctx.register_hook("pre_tool_call", _on_pre_tool_call)
|
||||
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
||||
ctx.register_hook("post_llm_call", _on_post_llm_call)
|
||||
ctx.register_hook("on_session_end", _on_session_end)
|
||||
ctx.register_hook("on_session_finalize", _on_session_finalize)
|
||||
@@ -0,0 +1,19 @@
|
||||
name: raft-platform
|
||||
label: Raft
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Raft gateway adapter for Hermes Agent.
|
||||
Connects to a Raft workspace as an external agent via a local
|
||||
wake-channel bridge. The adapter starts a loopback HTTP endpoint
|
||||
that receives content-free wake hints from the bridge, then
|
||||
injects them into the Hermes gateway session pipeline. The agent
|
||||
reads and sends messages through the Raft CLI — the adapter never
|
||||
touches message bodies or delivery cursors.
|
||||
author: botiverse
|
||||
requires_env:
|
||||
- name: RAFT_PROFILE
|
||||
description: "Raft agent profile slug — auto-enables the adapter when set"
|
||||
prompt: "Raft agent profile"
|
||||
password: false
|
||||
category: setting
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
name: simplex-platform
|
||||
label: SimpleX Chat
|
||||
kind: platform
|
||||
version: 1.1.0
|
||||
description: >
|
||||
SimpleX Chat gateway adapter for Hermes Agent.
|
||||
Connects to a local simplex-chat daemon via WebSocket and relays
|
||||
messages between SimpleX contacts/groups and the Hermes agent.
|
||||
SimpleX is decentralised and assigns no persistent user IDs —
|
||||
every contact is an opaque internal ID generated at connection
|
||||
time, making it one of the most private messengers available.
|
||||
author: Mibayy, jooray
|
||||
# ``requires_env`` and ``optional_env`` entries are surfaced in the
|
||||
# ``hermes config`` UI via the platform-plugin env var injector in
|
||||
# ``hermes_cli/config.py``.
|
||||
requires_env:
|
||||
- name: SIMPLEX_WS_URL
|
||||
description: "WebSocket URL of the simplex-chat daemon (e.g. ws://127.0.0.1:5225)"
|
||||
prompt: "SimpleX daemon WebSocket URL"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: SIMPLEX_ALLOWED_USERS
|
||||
description: "Comma-separated SimpleX contact IDs allowed to talk to the bot"
|
||||
prompt: "Allowed contact IDs (comma-separated)"
|
||||
password: false
|
||||
- name: SIMPLEX_ALLOW_ALL_USERS
|
||||
description: "Allow any contact to talk to the bot (dev only — disables allowlist)"
|
||||
prompt: "Allow all contacts? (true/false)"
|
||||
password: false
|
||||
- name: SIMPLEX_AUTO_ACCEPT
|
||||
description: "Auto-accept incoming contact requests (default: true)"
|
||||
prompt: "Auto-accept contact requests? (true/false)"
|
||||
password: false
|
||||
- name: SIMPLEX_GROUP_ALLOWED
|
||||
description: >-
|
||||
Comma-separated SimpleX group IDs the bot should participate in, or
|
||||
'*' to allow any group. Omit to ignore group messages entirely
|
||||
(safer default — a bot in a group otherwise processes every
|
||||
member's traffic).
|
||||
prompt: "Allowed group IDs (comma-separated, or '*' for any)"
|
||||
password: false
|
||||
- name: SIMPLEX_HOME_CHANNEL
|
||||
description: "Default contact/group ID for cron / notification delivery"
|
||||
prompt: "Home channel contact/group ID (or empty)"
|
||||
password: false
|
||||
- name: SIMPLEX_HOME_CHANNEL_NAME
|
||||
description: "Human label for the home channel (defaults to the ID)"
|
||||
prompt: "Home channel display name (or empty)"
|
||||
password: false
|
||||
- name: HERMES_SIMPLEX_TEXT_BATCH_DELAY
|
||||
description: >-
|
||||
Quiet-period seconds (default: 0.8) used to concatenate rapid-fire
|
||||
inbound text messages into a single MessageEvent — same pattern as
|
||||
Telegram's text batching.
|
||||
prompt: "Text batch flush delay in seconds (default 0.8)"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,512 @@
|
||||
"""Render agent markdown into Slack Block Kit blocks.
|
||||
|
||||
Opt-in (``slack.extra.rich_blocks: true``) alternative to the flat mrkdwn
|
||||
``text`` payload produced by :meth:`SlackAdapter.format_message`. Block Kit
|
||||
gives us real structural primitives — section headers, dividers, and true
|
||||
*nested* lists via ``rich_text`` — that plain mrkdwn can only approximate.
|
||||
|
||||
Design constraints (why this module is deliberately conservative):
|
||||
|
||||
* **Markdown pipe-tables render as native ``table`` blocks** — real grid
|
||||
cells with per-column alignment and inline-formatted ``rich_text`` content.
|
||||
A table that exceeds Slack's limits (100 rows / 20 cols / 10k aggregate
|
||||
cell chars) or won't parse falls back to aligned monospace
|
||||
``rich_text_preformatted`` so a large table never breaks the message.
|
||||
* **Slack caps a message at 50 blocks** and a ``section``/text object at 3000
|
||||
characters. :func:`render_blocks` enforces both and, if the content simply
|
||||
cannot be expressed within them, returns ``None`` so the caller falls back
|
||||
to the plain-text path. A rich render is a nice-to-have; it must never lose
|
||||
a message.
|
||||
* **Every blocks payload MUST ship a ``text`` fallback.** Slack uses it for
|
||||
notifications, screen readers, and old clients. This module only builds the
|
||||
``blocks`` list; the adapter pairs it with the existing mrkdwn string.
|
||||
|
||||
The renderer never raises: any unexpected input degrades to ``None`` (caller
|
||||
uses plain text). It is a pure function of its input — no Slack client, no
|
||||
adapter state — so it is trivially unit-testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Slack Block Kit hard limits (https://docs.slack.dev/reference/block-kit/blocks)
|
||||
MAX_BLOCKS = 50
|
||||
MAX_SECTION_TEXT = 3000
|
||||
MAX_HEADER_TEXT = 150
|
||||
# Native table block limits (https://docs.slack.dev/reference/block-kit/blocks/table-block)
|
||||
MAX_TABLE_ROWS = 100
|
||||
MAX_TABLE_COLS = 20
|
||||
MAX_TABLE_CHARS = 10000 # aggregate across all cells
|
||||
|
||||
Block = Dict[str, Any]
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Line classification
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
_HR_RE = re.compile(r"^\s{0,3}([-*_])(?:\s*\1){2,}\s*$")
|
||||
_HEADER_RE = re.compile(r"^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$")
|
||||
_FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
|
||||
_ORDERED_RE = re.compile(r"^(\s*)(\d+)[.)]\s+(.*)$")
|
||||
_BULLET_RE = re.compile(r"^(\s*)[-*+]\s+(.*)$")
|
||||
_QUOTE_RE = re.compile(r"^\s{0,3}>\s?(.*)$")
|
||||
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$")
|
||||
|
||||
|
||||
def _is_list_line(line: str) -> bool:
|
||||
"""True if ``line`` is a markdown list item (bullet or ordered)."""
|
||||
return bool(_BULLET_RE.match(line) or _ORDERED_RE.match(line))
|
||||
|
||||
|
||||
def _indent_level(spaces: str) -> int:
|
||||
"""Map leading whitespace to a nesting level (2 spaces or 1 tab per level)."""
|
||||
width = 0
|
||||
for ch in spaces:
|
||||
width += 4 if ch == "\t" else 1
|
||||
return min(width // 2, 5) # Slack rich_text_list supports up to indent 5
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Inline markdown → rich_text elements
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Order matters: code first (opaque), then links, then emphasis.
|
||||
_INLINE_CODE_RE = re.compile(r"`([^`]+)`")
|
||||
_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\(([^()\s]+(?:\([^()]*\)[^()\s]*)*)\)")
|
||||
_BOLD_RE = re.compile(r"(?:\*\*|__)(.+?)(?:\*\*|__)")
|
||||
_ITALIC_RE = re.compile(r"(?<![\*_])(?:\*|_)(?![\*_\s])(.+?)(?<![\*_\s])(?:\*|_)(?![\*_])")
|
||||
_STRIKE_RE = re.compile(r"~~(.+?)~~")
|
||||
|
||||
|
||||
def _inline_elements(text: str) -> List[Dict[str, Any]]:
|
||||
"""Parse a run of inline markdown into rich_text section child elements.
|
||||
|
||||
Produces ``text`` elements (optionally styled bold/italic/strike/code) and
|
||||
``link`` elements. Unmatched markup is emitted verbatim as plain text, so
|
||||
this never loses characters.
|
||||
"""
|
||||
elements: List[Dict[str, Any]] = []
|
||||
|
||||
def emit_text(s: str, style: Optional[Dict[str, bool]] = None) -> None:
|
||||
if not s:
|
||||
return
|
||||
el: Dict[str, Any] = {"type": "text", "text": s}
|
||||
if style:
|
||||
el["style"] = style
|
||||
elements.append(el)
|
||||
|
||||
# Tokenize by the highest-priority markers first using a single scan.
|
||||
# We recursively split on code, then links, then emphasis to keep spans
|
||||
# from overlapping incorrectly.
|
||||
def walk(s: str, style: Dict[str, bool]) -> None:
|
||||
pos = 0
|
||||
# inline code is opaque — no nested styling
|
||||
for m in _INLINE_CODE_RE.finditer(s):
|
||||
_walk_links(s[pos:m.start()], style)
|
||||
code_style = dict(style)
|
||||
code_style["code"] = True
|
||||
emit_text(m.group(1), code_style or None)
|
||||
pos = m.end()
|
||||
_walk_links(s[pos:], style)
|
||||
|
||||
def _walk_links(s: str, style: Dict[str, bool]) -> None:
|
||||
pos = 0
|
||||
for m in _LINK_RE.finditer(s):
|
||||
_walk_emphasis(s[pos:m.start()], style)
|
||||
link_el: Dict[str, Any] = {"type": "link", "url": m.group(2), "text": m.group(1)}
|
||||
if style:
|
||||
link_el["style"] = dict(style)
|
||||
elements.append(link_el)
|
||||
pos = m.end()
|
||||
_walk_emphasis(s[pos:], style)
|
||||
|
||||
def _walk_emphasis(s: str, style: Dict[str, bool]) -> None:
|
||||
if not s:
|
||||
return
|
||||
# Try bold, then strike, then italic, recursing into the inner span.
|
||||
for rx, key in ((_BOLD_RE, "bold"), (_STRIKE_RE, "strike"), (_ITALIC_RE, "italic")):
|
||||
m = rx.search(s)
|
||||
if m:
|
||||
_walk_emphasis(s[:m.start()], style)
|
||||
inner_style = dict(style)
|
||||
inner_style[key] = True
|
||||
_walk_emphasis(m.group(1), inner_style)
|
||||
_walk_emphasis(s[m.end():], style)
|
||||
return
|
||||
emit_text(s, dict(style) if style else None)
|
||||
|
||||
walk(text, {})
|
||||
return elements or [{"type": "text", "text": text}]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Structural block builders
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _header_block(text: str) -> Block:
|
||||
# header blocks are plain_text only, 150 char cap.
|
||||
clean = re.sub(r"[*_~`]", "", text).strip()
|
||||
if len(clean) > MAX_HEADER_TEXT:
|
||||
clean = clean[: MAX_HEADER_TEXT - 1] + "…"
|
||||
return {"type": "header", "text": {"type": "plain_text", "text": clean, "emoji": True}}
|
||||
|
||||
|
||||
def _divider_block() -> Block:
|
||||
return {"type": "divider"}
|
||||
|
||||
|
||||
def _preformatted_block(text: str) -> Block:
|
||||
# rich_text_preformatted renders monospace; used for code fences + tables.
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_preformatted",
|
||||
"elements": [{"type": "text", "text": text.rstrip("\n")}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _quote_block(lines: List[str]) -> Block:
|
||||
section_children: List[Dict[str, Any]] = []
|
||||
for i, ln in enumerate(lines):
|
||||
if i:
|
||||
section_children.append({"type": "text", "text": "\n"})
|
||||
section_children.extend(_inline_elements(ln))
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [{"type": "rich_text_quote", "elements": section_children}],
|
||||
}
|
||||
|
||||
|
||||
def _list_block(items: List[Tuple[int, bool, str]]) -> Block:
|
||||
"""Build ONE rich_text block from consecutive list items.
|
||||
|
||||
``items`` is a list of ``(indent, ordered, text)``. Each contiguous run
|
||||
sharing the same (indent, ordered) becomes a ``rich_text_list`` element;
|
||||
indentation changes start a new element, which is how Slack renders true
|
||||
nesting.
|
||||
"""
|
||||
elements: List[Dict[str, Any]] = []
|
||||
cur: Optional[Dict[str, Any]] = None
|
||||
cur_key: Optional[Tuple[int, bool]] = None
|
||||
for indent, ordered, text in items:
|
||||
key = (indent, ordered)
|
||||
if key != cur_key:
|
||||
cur = {
|
||||
"type": "rich_text_list",
|
||||
"style": "ordered" if ordered else "bullet",
|
||||
"indent": indent,
|
||||
"elements": [],
|
||||
}
|
||||
elements.append(cur)
|
||||
cur_key = key
|
||||
assert cur is not None
|
||||
cur["elements"].append(
|
||||
{"type": "rich_text_section", "elements": _inline_elements(text)}
|
||||
)
|
||||
return {"type": "rich_text", "elements": elements}
|
||||
|
||||
|
||||
def _section_block(text: str) -> Block:
|
||||
return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Table handling — native Block Kit ``table`` block, monospace fallback
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_alignment(sep_line: str) -> List[str]:
|
||||
"""Parse a markdown separator row (``|:--|:-:|--:|``) into column aligns.
|
||||
|
||||
Returns a list of ``"left"``/``"center"``/``"right"`` per column.
|
||||
"""
|
||||
aligns: List[str] = []
|
||||
for cell in sep_line.strip().strip("|").split("|"):
|
||||
c = cell.strip()
|
||||
left = c.startswith(":")
|
||||
right = c.endswith(":")
|
||||
if left and right:
|
||||
aligns.append("center")
|
||||
elif right:
|
||||
aligns.append("right")
|
||||
else:
|
||||
aligns.append("left")
|
||||
return aligns
|
||||
|
||||
|
||||
def _split_row(row: str) -> List[str]:
|
||||
"""Split a markdown table row into trimmed cell strings.
|
||||
|
||||
Respects backslash-escaped pipes (``\\|``) so they aren't treated as
|
||||
column separators.
|
||||
"""
|
||||
# Temporarily protect escaped pipes, split on real ones, then restore.
|
||||
protected = row.strip().strip("|").replace(r"\|", "\x00PIPE\x00")
|
||||
return [c.strip().replace("\x00PIPE\x00", "|") for c in protected.split("|")]
|
||||
|
||||
|
||||
def _rich_text_cell(text: str) -> Dict[str, Any]:
|
||||
"""A ``rich_text`` table cell carrying inline-formatted content."""
|
||||
return {
|
||||
"type": "rich_text",
|
||||
"elements": [
|
||||
{"type": "rich_text_section", "elements": _inline_elements(text)}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _table_block(rows: List[str], sep_line: str) -> Optional[Block]:
|
||||
"""Build a native Slack ``table`` block from markdown pipe-table rows.
|
||||
|
||||
``rows`` includes the header row (index 0) and body rows; ``sep_line`` is
|
||||
the ``|---|`` alignment row (already consumed by the caller). Returns
|
||||
``None`` when the table exceeds Slack's limits (100 rows / 20 cols /
|
||||
10,000 aggregate cell chars) or parses to nothing — the caller then falls
|
||||
back to the monospace preformatted rendering.
|
||||
"""
|
||||
parsed = [_split_row(r) for r in rows if r.strip()]
|
||||
if not parsed:
|
||||
return None
|
||||
ncols = max(len(r) for r in parsed)
|
||||
# Reject rather than silently truncate beyond Slack's structural limits.
|
||||
if len(parsed) > MAX_TABLE_ROWS or ncols > MAX_TABLE_COLS:
|
||||
return None
|
||||
for r in parsed:
|
||||
r.extend([""] * (ncols - len(r)))
|
||||
|
||||
total_chars = sum(len(c) for r in parsed for c in r)
|
||||
if total_chars > MAX_TABLE_CHARS:
|
||||
return None
|
||||
|
||||
aligns = _parse_alignment(sep_line)
|
||||
column_settings: List[Optional[Dict[str, Any]]] = []
|
||||
for c in range(min(ncols, MAX_TABLE_COLS)):
|
||||
align = aligns[c] if c < len(aligns) else "left"
|
||||
# Only emit a setting when it differs from the default (left, no wrap);
|
||||
# use null to skip a column, per the Slack schema.
|
||||
column_settings.append({"align": align} if align != "left" else None)
|
||||
|
||||
block: Block = {
|
||||
"type": "table",
|
||||
"rows": [[_rich_text_cell(cell) for cell in row] for row in parsed],
|
||||
}
|
||||
if any(cs is not None for cs in column_settings):
|
||||
block["column_settings"] = column_settings
|
||||
return block
|
||||
|
||||
|
||||
def _render_table(rows: List[str]) -> str:
|
||||
"""Render markdown pipe-table rows as aligned monospace text (fallback)."""
|
||||
parsed: List[List[str]] = []
|
||||
for r in rows:
|
||||
cells = _split_row(r)
|
||||
parsed.append(cells)
|
||||
if not parsed:
|
||||
return "\n".join(rows)
|
||||
ncols = max(len(r) for r in parsed)
|
||||
for r in parsed:
|
||||
r.extend([""] * (ncols - len(r)))
|
||||
widths = [max(len(r[c]) for r in parsed) for c in range(ncols)]
|
||||
out_lines = []
|
||||
for ri, r in enumerate(parsed):
|
||||
line = " | ".join(r[c].ljust(widths[c]) for c in range(ncols))
|
||||
out_lines.append(line.rstrip())
|
||||
if ri == 0: # header underline
|
||||
out_lines.append("-+-".join("-" * widths[c] for c in range(ncols)))
|
||||
return "\n".join(out_lines)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_blocks(
|
||||
markdown: str,
|
||||
mrkdwn_fn=None,
|
||||
) -> Optional[List[Block]]:
|
||||
"""Convert agent markdown to a Slack Block Kit ``blocks`` list.
|
||||
|
||||
Args:
|
||||
markdown: The agent's response text (standard markdown).
|
||||
mrkdwn_fn: Optional callable converting a markdown paragraph to Slack
|
||||
mrkdwn for ``section`` blocks (the adapter passes
|
||||
``format_message``). When ``None``, the raw paragraph text is used.
|
||||
|
||||
Returns:
|
||||
A list of Block Kit block dicts, or ``None`` when the content is empty,
|
||||
exceeds Slack's structural limits, or hits an unexpected shape — the
|
||||
caller then falls back to the flat ``text`` payload. Never raises.
|
||||
"""
|
||||
if not markdown or not markdown.strip():
|
||||
return None
|
||||
|
||||
fmt = mrkdwn_fn or (lambda s: s)
|
||||
|
||||
try:
|
||||
blocks: List[Block] = []
|
||||
lines = markdown.replace("\r\n", "\n").split("\n")
|
||||
i = 0
|
||||
n = len(lines)
|
||||
para: List[str] = []
|
||||
|
||||
def flush_para() -> None:
|
||||
if not para:
|
||||
return
|
||||
text = "\n".join(para).strip()
|
||||
para.clear()
|
||||
if not text:
|
||||
return
|
||||
rendered = fmt(text)
|
||||
# Split oversized sections on the 3000-char limit.
|
||||
for chunk in _split_text(rendered, MAX_SECTION_TEXT):
|
||||
blocks.append(_section_block(chunk))
|
||||
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
|
||||
# Blank line: paragraph boundary
|
||||
if not line.strip():
|
||||
flush_para()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Fenced code block
|
||||
fence = _FENCE_RE.match(line)
|
||||
if fence:
|
||||
flush_para()
|
||||
marker = fence.group(1)
|
||||
body: List[str] = []
|
||||
i += 1
|
||||
while i < n and not lines[i].lstrip().startswith(marker):
|
||||
body.append(lines[i])
|
||||
i += 1
|
||||
i += 1 # consume closing fence
|
||||
blocks.append(_preformatted_block("\n".join(body)))
|
||||
continue
|
||||
|
||||
# Horizontal rule → divider
|
||||
if _HR_RE.match(line):
|
||||
flush_para()
|
||||
blocks.append(_divider_block())
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# ATX header
|
||||
hm = _HEADER_RE.match(line)
|
||||
if hm:
|
||||
flush_para()
|
||||
blocks.append(_header_block(hm.group(2)))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Pipe table: current line has a pipe AND next line is a separator
|
||||
if "|" in line and i + 1 < n and _TABLE_SEP_RE.match(lines[i + 1]):
|
||||
flush_para()
|
||||
header_row = line
|
||||
sep_line = lines[i + 1]
|
||||
trows = [header_row]
|
||||
i += 2 # skip header + separator
|
||||
while i < n and "|" in lines[i] and lines[i].strip():
|
||||
trows.append(lines[i])
|
||||
i += 1
|
||||
# Prefer a native Block Kit table; fall back to aligned
|
||||
# monospace when it exceeds Slack's table limits or won't parse.
|
||||
table = _table_block(trows, sep_line)
|
||||
if table is not None:
|
||||
blocks.append(table)
|
||||
else:
|
||||
blocks.append(_preformatted_block(_render_table(trows)))
|
||||
continue
|
||||
|
||||
# Blockquote group
|
||||
if _QUOTE_RE.match(line):
|
||||
flush_para()
|
||||
qlines: List[str] = []
|
||||
while i < n:
|
||||
qm = _QUOTE_RE.match(lines[i])
|
||||
if not qm:
|
||||
break
|
||||
qlines.append(qm.group(1))
|
||||
i += 1
|
||||
blocks.append(_quote_block(qlines))
|
||||
continue
|
||||
|
||||
# List group (bullets + ordered, with nesting)
|
||||
if _is_list_line(line):
|
||||
flush_para()
|
||||
items: List[Tuple[int, bool, str]] = []
|
||||
while i < n:
|
||||
bm = _BULLET_RE.match(lines[i])
|
||||
om = _ORDERED_RE.match(lines[i])
|
||||
if bm:
|
||||
items.append((_indent_level(bm.group(1)), False, bm.group(2)))
|
||||
i += 1
|
||||
elif om:
|
||||
items.append((_indent_level(om.group(1)), True, om.group(3)))
|
||||
i += 1
|
||||
elif lines[i].strip() and lines[i].startswith((" ", "\t")) and items:
|
||||
# continuation line of the previous item
|
||||
indent, ordered, txt = items[-1]
|
||||
items[-1] = (indent, ordered, txt + " " + lines[i].strip())
|
||||
i += 1
|
||||
elif not lines[i].strip() and items:
|
||||
# Blank line inside a list run. LLM-authored ordered
|
||||
# lists commonly separate items with a blank line; if
|
||||
# the next non-blank line is another list item, treat
|
||||
# the blank(s) as a soft separator and keep the run
|
||||
# going so the items stay in one rich_text_list (Slack
|
||||
# numbers each list independently, so splitting would
|
||||
# restart every item at "1."). Otherwise the blank
|
||||
# ends the list.
|
||||
j = i + 1
|
||||
while j < n and not lines[j].strip():
|
||||
j += 1
|
||||
if j < n and _is_list_line(lines[j]):
|
||||
i = j
|
||||
else:
|
||||
break
|
||||
else:
|
||||
break
|
||||
blocks.append(_list_block(items))
|
||||
continue
|
||||
|
||||
# Default: accumulate into a paragraph
|
||||
para.append(line)
|
||||
i += 1
|
||||
|
||||
flush_para()
|
||||
|
||||
if not blocks:
|
||||
return None
|
||||
if len(blocks) > MAX_BLOCKS:
|
||||
# Too structurally complex to express safely — let the caller fall
|
||||
# back to plain text rather than truncating and losing content.
|
||||
return None
|
||||
return blocks
|
||||
except Exception:
|
||||
# Never let a rendering bug drop a message.
|
||||
return None
|
||||
|
||||
|
||||
def _split_text(text: str, limit: int) -> List[str]:
|
||||
"""Split ``text`` into <= ``limit``-char chunks on line, then hard, boundaries."""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
out: List[str] = []
|
||||
remaining = text
|
||||
while len(remaining) > limit:
|
||||
cut = remaining.rfind("\n", 0, limit)
|
||||
if cut <= 0:
|
||||
cut = limit
|
||||
out.append(remaining[:cut])
|
||||
remaining = remaining[cut:].lstrip("\n")
|
||||
if remaining:
|
||||
out.append(remaining)
|
||||
return out
|
||||
@@ -0,0 +1,39 @@
|
||||
name: slack-platform
|
||||
label: Slack
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Slack gateway adapter for Hermes Agent.
|
||||
Connects to Slack via slack-bolt in Socket Mode and relays messages
|
||||
between Slack channels/DMs and the Hermes agent. Supports slash
|
||||
commands, threads, mrkdwn rendering, approval blocks, free-response
|
||||
channels, mention gating, and channel skill bindings.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: SLACK_BOT_TOKEN
|
||||
description: "Slack bot token (xoxb-...)"
|
||||
prompt: "Slack Bot Token (xoxb-...)"
|
||||
url: "https://api.slack.com/apps"
|
||||
password: true
|
||||
- name: SLACK_APP_TOKEN
|
||||
description: "Slack app-level token for Socket Mode (xapp-..., scope connections:write)"
|
||||
prompt: "Slack App Token (xapp-...)"
|
||||
url: "https://api.slack.com/apps"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: SLACK_ALLOWED_USERS
|
||||
description: "Comma-separated Slack member IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: SLACK_ALLOW_ALL_USERS
|
||||
description: "Allow any Slack user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: SLACK_HOME_CHANNEL
|
||||
description: "Default channel ID for cron / notification delivery (starts with C)"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: SLACK_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Slack home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -0,0 +1,510 @@
|
||||
"""SMS (Twilio) platform adapter.
|
||||
|
||||
Connects to the Twilio REST API for outbound SMS and runs an aiohttp
|
||||
webhook server to receive inbound messages.
|
||||
|
||||
Shares credentials with the optional telephony skill — same env vars:
|
||||
- TWILIO_ACCOUNT_SID
|
||||
- TWILIO_AUTH_TOKEN
|
||||
- TWILIO_PHONE_NUMBER (E.164 from-number, e.g. +15551234567)
|
||||
|
||||
Gateway-specific env vars:
|
||||
- SMS_WEBHOOK_PORT (default 8080)
|
||||
- SMS_WEBHOOK_HOST (default 127.0.0.1)
|
||||
- SMS_WEBHOOK_URL (public URL for Twilio signature validation — required)
|
||||
- SMS_INSECURE_NO_SIGNATURE (true to disable signature validation — dev only)
|
||||
- SMS_ALLOWED_USERS (comma-separated E.164 phone numbers)
|
||||
- SMS_ALLOW_ALL_USERS (true/false)
|
||||
- SMS_HOME_CHANNEL (phone number for cron delivery)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
)
|
||||
from gateway.platforms.helpers import redact_phone, strip_markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
|
||||
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
|
||||
DEFAULT_WEBHOOK_PORT = 8080
|
||||
DEFAULT_WEBHOOK_HOST = "127.0.0.1"
|
||||
_TWILIO_WEBHOOK_MAX_BODY_BYTES = 65_536 # 64 KiB — Twilio payloads are small
|
||||
|
||||
|
||||
def check_sms_requirements() -> bool:
|
||||
"""Check if SMS adapter dependencies are available."""
|
||||
try:
|
||||
import aiohttp # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return bool(os.getenv("TWILIO_ACCOUNT_SID") and os.getenv("TWILIO_AUTH_TOKEN"))
|
||||
|
||||
|
||||
class SmsAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
Twilio SMS <-> Hermes gateway adapter.
|
||||
|
||||
Each inbound phone number gets its own Hermes session (multi-tenant).
|
||||
Replies are always sent from the configured TWILIO_PHONE_NUMBER.
|
||||
"""
|
||||
|
||||
MAX_MESSAGE_LENGTH = MAX_SMS_LENGTH
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.SMS)
|
||||
self._account_sid: str = os.environ["TWILIO_ACCOUNT_SID"]
|
||||
self._auth_token: str = os.environ["TWILIO_AUTH_TOKEN"]
|
||||
self._from_number: str = os.getenv("TWILIO_PHONE_NUMBER", "")
|
||||
self._webhook_port: int = int(
|
||||
os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT))
|
||||
)
|
||||
self._webhook_host: str = os.getenv("SMS_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST)
|
||||
self._webhook_url: str = os.getenv("SMS_WEBHOOK_URL", "").strip()
|
||||
self._runner = None
|
||||
self._http_session: Optional["aiohttp.ClientSession"] = None
|
||||
|
||||
def _basic_auth_header(self) -> str:
|
||||
"""Build HTTP Basic auth header value for Twilio."""
|
||||
creds = f"{self._account_sid}:{self._auth_token}"
|
||||
encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
|
||||
return f"Basic {encoded}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Required abstract methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
if not self._from_number:
|
||||
msg = "[sms] TWILIO_PHONE_NUMBER not set — cannot send replies"
|
||||
logger.error(msg)
|
||||
self._set_fatal_error("sms_missing_phone_number", msg, retryable=False)
|
||||
return False
|
||||
|
||||
insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true"
|
||||
|
||||
if not self._webhook_url and not insecure_no_sig:
|
||||
msg = (
|
||||
"[sms] Refusing to start: SMS_WEBHOOK_URL is required for Twilio "
|
||||
"signature validation. Set it to the public URL configured in your "
|
||||
"Twilio console (e.g. https://example.com/webhooks/twilio). "
|
||||
"For local development without validation, set "
|
||||
"SMS_INSECURE_NO_SIGNATURE=true (NOT recommended for production)."
|
||||
)
|
||||
logger.error(msg)
|
||||
self._set_fatal_error("sms_missing_webhook_url", msg, retryable=False)
|
||||
return False
|
||||
|
||||
if insecure_no_sig and not self._webhook_url:
|
||||
logger.warning(
|
||||
"[sms] SMS_INSECURE_NO_SIGNATURE=true — Twilio signature validation "
|
||||
"is DISABLED. Any client that can reach port %d can inject messages. "
|
||||
"Do NOT use this in production.",
|
||||
self._webhook_port,
|
||||
)
|
||||
|
||||
# client_max_size bounds every read path — including chunked bodies
|
||||
# with no Content-Length — before the handler's own 413 checks run
|
||||
# (#58536/#58902/#59180 pattern).
|
||||
app = web.Application(client_max_size=_TWILIO_WEBHOOK_MAX_BODY_BYTES)
|
||||
app.router.add_post("/webhooks/twilio", self._handle_webhook)
|
||||
app.router.add_get("/health", lambda _: web.Response(text="ok"))
|
||||
|
||||
self._runner = web.AppRunner(app)
|
||||
await self._runner.setup()
|
||||
site = web.TCPSite(self._runner, self._webhook_host, self._webhook_port)
|
||||
await site.start()
|
||||
self._http_session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
trust_env=True,
|
||||
)
|
||||
self._running = True
|
||||
|
||||
logger.info(
|
||||
"[sms] Twilio webhook server listening on %s:%d, from: %s",
|
||||
self._webhook_host,
|
||||
self._webhook_port,
|
||||
redact_phone(self._from_number),
|
||||
)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self._http_session:
|
||||
await self._http_session.close()
|
||||
self._http_session = None
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
self._running = False
|
||||
logger.info("[sms] Disconnected")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
import aiohttp
|
||||
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted)
|
||||
last_result = SendResult(success=True)
|
||||
|
||||
url = f"{TWILIO_API_BASE}/{self._account_sid}/Messages.json"
|
||||
headers = {
|
||||
"Authorization": self._basic_auth_header(),
|
||||
}
|
||||
|
||||
session = self._http_session or aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
trust_env=True,
|
||||
)
|
||||
try:
|
||||
for chunk in chunks:
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field("From", self._from_number)
|
||||
form_data.add_field("To", chat_id)
|
||||
form_data.add_field("Body", chunk)
|
||||
|
||||
try:
|
||||
async with session.post(url, data=form_data, headers=headers) as resp:
|
||||
body = await resp.json()
|
||||
if resp.status >= 400:
|
||||
error_msg = body.get("message", str(body))
|
||||
logger.error(
|
||||
"[sms] send failed to %s: %s %s",
|
||||
redact_phone(chat_id),
|
||||
resp.status,
|
||||
error_msg,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=f"Twilio {resp.status}: {error_msg}",
|
||||
)
|
||||
msg_sid = body.get("sid", "")
|
||||
last_result = SendResult(success=True, message_id=msg_sid)
|
||||
except Exception as e:
|
||||
logger.error("[sms] send error to %s: %s", redact_phone(chat_id), e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
finally:
|
||||
# Close session only if we created a fallback (no persistent session)
|
||||
if not self._http_session and session:
|
||||
await session.close()
|
||||
|
||||
return last_result
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
return {"name": chat_id, "type": "dm"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SMS-specific formatting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Strip markdown — SMS renders it as literal characters."""
|
||||
return strip_markdown(content)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Twilio signature validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _validate_twilio_signature(
|
||||
self, url: str, post_params: dict, signature: str,
|
||||
) -> bool:
|
||||
"""Validate ``X-Twilio-Signature`` header (HMAC-SHA1, base64).
|
||||
|
||||
Tries both with and without the default port for the URL scheme,
|
||||
since Twilio may sign with either variant.
|
||||
|
||||
Algorithm: https://www.twilio.com/docs/usage/security#validating-requests
|
||||
"""
|
||||
if self._check_signature(url, post_params, signature):
|
||||
return True
|
||||
|
||||
variant = self._port_variant_url(url)
|
||||
if variant and self._check_signature(variant, post_params, signature):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _check_signature(
|
||||
self, url: str, post_params: dict, signature: str,
|
||||
) -> bool:
|
||||
"""Compute and compare a single Twilio signature."""
|
||||
data_to_sign = url
|
||||
for key in sorted(post_params.keys()):
|
||||
data_to_sign += key + post_params[key]
|
||||
mac = hmac.new(
|
||||
self._auth_token.encode("utf-8"),
|
||||
data_to_sign.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
)
|
||||
computed = base64.b64encode(mac.digest()).decode("utf-8")
|
||||
return hmac.compare_digest(computed, signature)
|
||||
|
||||
@staticmethod
|
||||
def _port_variant_url(url: str) -> str | None:
|
||||
"""Return the URL with the default port toggled, or None.
|
||||
|
||||
Only toggles default ports (443 for https, 80 for http).
|
||||
Non-standard ports are never modified.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
default_ports = {"https": 443, "http": 80}
|
||||
default_port = default_ports.get(parsed.scheme)
|
||||
if default_port is None:
|
||||
return None
|
||||
|
||||
if parsed.port == default_port:
|
||||
# Has explicit default port → strip it
|
||||
return urllib.parse.urlunparse(
|
||||
(parsed.scheme, parsed.hostname, parsed.path,
|
||||
parsed.params, parsed.query, parsed.fragment)
|
||||
)
|
||||
elif parsed.port is None:
|
||||
# No port → add default
|
||||
netloc = f"{parsed.hostname}:{default_port}"
|
||||
return urllib.parse.urlunparse(
|
||||
(parsed.scheme, netloc, parsed.path,
|
||||
parsed.params, parsed.query, parsed.fragment)
|
||||
)
|
||||
|
||||
# Non-standard port — no variant
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Twilio webhook handler
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_webhook(self, request) -> "aiohttp.web.Response":
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
content_length = request.content_length
|
||||
if content_length is not None and content_length > _TWILIO_WEBHOOK_MAX_BODY_BYTES:
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
status=413,
|
||||
)
|
||||
raw = await request.read()
|
||||
if len(raw) > _TWILIO_WEBHOOK_MAX_BODY_BYTES:
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
status=413,
|
||||
)
|
||||
# Twilio sends form-encoded data, not JSON
|
||||
form = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True)
|
||||
except Exception as e:
|
||||
logger.error("[sms] webhook parse error: %s", e)
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Validate Twilio request signature when SMS_WEBHOOK_URL is configured
|
||||
if self._webhook_url:
|
||||
twilio_sig = request.headers.get("X-Twilio-Signature", "")
|
||||
if not twilio_sig:
|
||||
logger.warning("[sms] Rejected: missing X-Twilio-Signature header")
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
status=403,
|
||||
)
|
||||
flat_params = {k: v[0] for k, v in form.items() if v}
|
||||
if not self._validate_twilio_signature(
|
||||
self._webhook_url, flat_params, twilio_sig
|
||||
):
|
||||
logger.warning("[sms] Rejected: invalid Twilio signature")
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Extract fields (parse_qs returns lists)
|
||||
from_number = (form.get("From", [""]))[0].strip()
|
||||
to_number = (form.get("To", [""]))[0].strip()
|
||||
text = (form.get("Body", [""]))[0].strip()
|
||||
message_sid = (form.get("MessageSid", [""]))[0].strip()
|
||||
|
||||
if not from_number or not text:
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
)
|
||||
|
||||
# Ignore messages from our own number (echo prevention)
|
||||
if from_number == self._from_number:
|
||||
logger.debug("[sms] ignoring echo from own number %s", redact_phone(from_number))
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[sms] inbound from %s -> %s: %s",
|
||||
redact_phone(from_number),
|
||||
redact_phone(to_number),
|
||||
text[:80],
|
||||
)
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=from_number,
|
||||
chat_name=from_number,
|
||||
chat_type="dm",
|
||||
user_id=from_number,
|
||||
user_name=from_number,
|
||||
)
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message=form,
|
||||
message_id=message_sid,
|
||||
)
|
||||
|
||||
# Non-blocking: Twilio expects a fast response
|
||||
task = asyncio.create_task(self.handle_message(event))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
# Return empty TwiML — we send replies via the REST API, not inline TwiML
|
||||
return web.Response(
|
||||
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||
content_type="application/xml",
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Plugin migration glue (#41112 / #3823)
|
||||
#
|
||||
# Added when the SMS (Twilio) adapter moved from gateway/platforms/sms.py into
|
||||
# this bundled plugin. register() exposes the platform via the registry,
|
||||
# replacing the Platform.SMS elif in gateway/run.py, the
|
||||
# _PLATFORM_CONNECTED_CHECKERS entry in gateway/config.py, the _PLATFORMS["sms"]
|
||||
# static dict in hermes_cli/gateway.py, and the _send_sms dispatch in
|
||||
# tools/send_message_tool.py. TWILIO_* env→PlatformConfig seeding stays in core.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _strip_markdown_for_sms(message: str) -> str:
|
||||
"""Strip markdown — SMS renders it as literal characters."""
|
||||
message = re.sub(r"\*\*(.+?)\*\*", r"\1", message, flags=re.DOTALL)
|
||||
message = re.sub(r"\*(.+?)\*", r"\1", message, flags=re.DOTALL)
|
||||
message = re.sub(r"__(.+?)__", r"\1", message, flags=re.DOTALL)
|
||||
message = re.sub(r"_(.+?)_", r"\1", message, flags=re.DOTALL)
|
||||
message = re.sub(r"```[a-z]*\n?", "", message)
|
||||
message = re.sub(r"`(.+?)`", r"\1", message)
|
||||
message = re.sub(r"^#{1,6}\s+", "", message, flags=re.MULTILINE)
|
||||
message = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", message)
|
||||
message = re.sub(r"\n{3,}", "\n\n", message)
|
||||
return message.strip()
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id,
|
||||
message,
|
||||
*,
|
||||
thread_id=None,
|
||||
media_files=None,
|
||||
force_document=False,
|
||||
):
|
||||
"""Out-of-process SMS delivery via the Twilio REST API. Implements the
|
||||
standalone_sender_fn contract; replaces the legacy _send_sms helper."""
|
||||
auth_token = getattr(pconfig, "api_key", None) or os.getenv("TWILIO_AUTH_TOKEN", "")
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
import base64
|
||||
|
||||
account_sid = os.getenv("TWILIO_ACCOUNT_SID", "")
|
||||
from_number = os.getenv("TWILIO_PHONE_NUMBER", "")
|
||||
if not account_sid or not auth_token or not from_number:
|
||||
return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"}
|
||||
|
||||
message = _strip_markdown_for_sms(message)
|
||||
|
||||
def _redacted_error(text):
|
||||
try:
|
||||
from tools.send_message_tool import _error as _e
|
||||
return _e(text)
|
||||
except Exception:
|
||||
return {"error": text}
|
||||
|
||||
try:
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url()
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
creds = f"{account_sid}:{auth_token}"
|
||||
encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
|
||||
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
|
||||
headers = {"Authorization": f"Basic {encoded}"}
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field("From", from_number)
|
||||
form_data.add_field("To", chat_id)
|
||||
form_data.add_field("Body", message)
|
||||
async with session.post(url, data=form_data, headers=headers, **_req_kw) as resp:
|
||||
body = await resp.json()
|
||||
if resp.status >= 400:
|
||||
error_msg = body.get("message", str(body))
|
||||
return _redacted_error(f"Twilio API error ({resp.status}): {error_msg}")
|
||||
return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": body.get("sid", "")}
|
||||
except Exception as e:
|
||||
return _redacted_error(f"SMS send failed: {e}")
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""SMS is connected when Twilio credentials are present. Mirrors the legacy
|
||||
_PLATFORM_CONNECTED_CHECKERS[Platform.SMS] = bool(TWILIO_ACCOUNT_SID)."""
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
return bool((gateway_mod.get_env_value("TWILIO_ACCOUNT_SID") or "").strip())
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs SmsAdapter from a PlatformConfig."""
|
||||
return SmsAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="sms",
|
||||
label="SMS (Twilio)",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_sms_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"],
|
||||
install_hint="pip install aiohttp",
|
||||
allowed_users_env="SMS_ALLOWED_USERS",
|
||||
allow_all_env="SMS_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="SMS_HOME_CHANNEL",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
max_message_length=MAX_SMS_LENGTH,
|
||||
pii_safe=True,
|
||||
emoji="📱",
|
||||
allow_update_command=True,
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
name: sms-platform
|
||||
label: SMS (Twilio)
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
SMS gateway adapter for Hermes Agent via Twilio. Sends and receives SMS
|
||||
through the Twilio REST API + inbound webhook, relaying texts between phone
|
||||
numbers and the Hermes agent. Markdown is stripped to plain text.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: TWILIO_ACCOUNT_SID
|
||||
description: "Twilio Account SID"
|
||||
prompt: "Twilio Account SID"
|
||||
url: "https://www.twilio.com/"
|
||||
password: false
|
||||
- name: TWILIO_AUTH_TOKEN
|
||||
description: "Twilio Auth Token"
|
||||
prompt: "Twilio Auth Token"
|
||||
password: true
|
||||
- name: TWILIO_PHONE_NUMBER
|
||||
description: "Twilio phone number (SMS-capable, E.164 format)"
|
||||
prompt: "Twilio phone number"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: SMS_ALLOWED_USERS
|
||||
description: "Comma-separated phone numbers allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: SMS_HOME_CHANNEL
|
||||
description: "Default phone number for cron / notification delivery"
|
||||
prompt: "Home number"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
name: teams-platform
|
||||
label: Microsoft Teams
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Microsoft Teams gateway adapter for Hermes Agent.
|
||||
Connects to Microsoft Teams via the Bot Framework and relays messages
|
||||
between Teams chats (personal DMs, group chats, channel posts) and
|
||||
the Hermes agent. Supports Adaptive Card approval prompts.
|
||||
author: Aamir Jawaid
|
||||
# ``requires_env`` entries are surfaced in ``hermes config`` UI via the
|
||||
# platform-plugin env var injector in ``hermes_cli/config.py``.
|
||||
requires_env:
|
||||
- name: TEAMS_CLIENT_ID
|
||||
description: "Azure AD application (Bot Framework) client ID"
|
||||
prompt: "Teams / Azure AD client ID"
|
||||
url: "https://portal.azure.com/"
|
||||
password: false
|
||||
- name: TEAMS_CLIENT_SECRET
|
||||
description: "Azure AD application client secret"
|
||||
prompt: "Teams / Azure AD client secret"
|
||||
url: "https://portal.azure.com/"
|
||||
password: true
|
||||
- name: TEAMS_TENANT_ID
|
||||
description: "Azure AD tenant ID hosting the bot application"
|
||||
prompt: "Teams / Azure AD tenant ID"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: TEAMS_PORT
|
||||
description: "Webhook listen port (Bot Framework default: 3978)"
|
||||
prompt: "Webhook port"
|
||||
password: false
|
||||
- name: TEAMS_ALLOWED_USERS
|
||||
description: "Comma-separated Teams user IDs / UPNs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: TEAMS_ALLOW_ALL_USERS
|
||||
description: "Allow any Teams user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: TEAMS_HOME_CHANNEL
|
||||
description: "Default chat/channel ID for cron / notification delivery"
|
||||
prompt: "Home channel (or empty)"
|
||||
password: false
|
||||
- name: TEAMS_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Teams home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
name: telegram-platform
|
||||
label: Telegram
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Telegram gateway adapter for Hermes Agent.
|
||||
Connects to Telegram via python-telegram-bot and relays messages between
|
||||
Telegram chats/groups/topics and the Hermes agent. Supports threads/topics,
|
||||
streaming edits, native media, inline keyboards, slash commands, fallback
|
||||
network transport (direct-IP failover), notification modes, mention gating,
|
||||
and per-user/chat allowlists.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: TELEGRAM_BOT_TOKEN
|
||||
description: "Telegram bot token from @BotFather"
|
||||
prompt: "Telegram bot token"
|
||||
url: "https://t.me/BotFather"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: TELEGRAM_ALLOWED_USERS
|
||||
description: "Comma-separated Telegram user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: TELEGRAM_ALLOW_ALL_USERS
|
||||
description: "Allow any Telegram user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: TELEGRAM_HOME_CHANNEL
|
||||
description: "Default chat ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: TELEGRAM_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Telegram home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Helpers for Telegram Bot API chat identifiers.
|
||||
|
||||
Telegram's Bot API accepts a ``chat_id`` in two forms: a numeric ID (an int,
|
||||
e.g. ``123456789`` for a DM or ``-1001234567890`` for a channel/supergroup) or
|
||||
an ``@username`` string for public channels and groups. Hermes historically
|
||||
coerced every ``chat_id`` with ``int()``, which crashes on the username form
|
||||
(``ValueError: invalid literal for int()``). Normalizing here lets numeric IDs
|
||||
pass through as ints while usernames pass through unchanged — both are valid
|
||||
values for the Bot API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Union
|
||||
|
||||
# Telegram usernames are 5-32 chars: letters, digits, underscores, with a
|
||||
# leading "@". (Telegram also permits 4-char usernames for some legacy/official
|
||||
# accounts, but the 5-32 public rule is the safe lower bound for routing.)
|
||||
_TELEGRAM_USERNAME_RE = re.compile(r"@[A-Za-z0-9_]{4,32}")
|
||||
|
||||
|
||||
def normalize_telegram_chat_id(chat_id: Any) -> Union[int, str]:
|
||||
"""Return a Bot API-compatible chat_id.
|
||||
|
||||
Numeric values (incl. negative channel IDs) are returned as ``int``; any
|
||||
non-numeric value (e.g. an ``@username``) is returned as a stripped string.
|
||||
Telegram's Bot API accepts both, so this never raises on a username the way
|
||||
a bare ``int(chat_id)`` would.
|
||||
"""
|
||||
chat_id_str = str(chat_id).strip()
|
||||
try:
|
||||
return int(chat_id_str)
|
||||
except (TypeError, ValueError):
|
||||
return chat_id_str
|
||||
|
||||
|
||||
def telegram_chat_id_key(chat_id: Any) -> str:
|
||||
"""Stable string key for a chat_id (for dict keys / persisted state)."""
|
||||
return str(normalize_telegram_chat_id(chat_id))
|
||||
|
||||
|
||||
def looks_like_telegram_username(chat_id: Any) -> bool:
|
||||
"""True when the value is an ``@username``-format Telegram chat identifier."""
|
||||
return bool(_TELEGRAM_USERNAME_RE.fullmatch(str(chat_id).strip()))
|
||||
|
||||
|
||||
def parse_telegram_username_target(target_ref: Any) -> Union[str, None]:
|
||||
"""Return the value when it is an ``@username`` target, else ``None``."""
|
||||
value = str(target_ref).strip()
|
||||
return value if looks_like_telegram_username(value) else None
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Telegram-specific network helpers.
|
||||
|
||||
Provides a hostname-preserving fallback transport for networks where
|
||||
api.telegram.org resolves to an endpoint that is unreachable from the current
|
||||
host. The transport keeps the logical request host and TLS SNI as
|
||||
api.telegram.org while retrying the TCP connection against one or more fallback
|
||||
IPv4 addresses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from typing import Iterable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TELEGRAM_API_HOST = "api.telegram.org"
|
||||
|
||||
# DNS-over-HTTPS providers used to discover Telegram API IPs that may differ
|
||||
# from the (potentially unreachable) IP returned by the local system resolver.
|
||||
_DOH_TIMEOUT = 4.0 # seconds — bounded so connect() isn't noticeably delayed
|
||||
|
||||
_DOH_PROVIDERS: list[dict] = [
|
||||
{
|
||||
"url": "https://dns.google/resolve",
|
||||
"params": {"name": _TELEGRAM_API_HOST, "type": "A"},
|
||||
"headers": {},
|
||||
},
|
||||
{
|
||||
"url": "https://cloudflare-dns.com/dns-query",
|
||||
"params": {"name": _TELEGRAM_API_HOST, "type": "A"},
|
||||
"headers": {"Accept": "application/dns-json"},
|
||||
},
|
||||
]
|
||||
|
||||
# Last-resort IPs when DoH is also blocked. These are stable Telegram Bot API
|
||||
# endpoints in the 149.154.160.0/20 block (same seed used by OpenClaw).
|
||||
_SEED_FALLBACK_IPS: list[str] = ["149.154.166.110", "149.154.167.220"]
|
||||
|
||||
|
||||
def _resolve_proxy_url(target_hosts=None) -> str | None:
|
||||
# Delegate to shared implementation (env vars + macOS system proxy detection)
|
||||
from gateway.platforms.base import resolve_proxy_url
|
||||
return resolve_proxy_url("TELEGRAM_PROXY", target_hosts=target_hosts)
|
||||
|
||||
|
||||
class TelegramFallbackTransport(httpx.AsyncBaseTransport):
|
||||
"""Retry Telegram Bot API requests via fallback IPs while preserving TLS/SNI.
|
||||
|
||||
Requests continue to target https://api.telegram.org/... logically, but on
|
||||
connect failures the underlying TCP connection is retried against a known
|
||||
reachable IP. This is effectively the programmatic equivalent of
|
||||
``curl --resolve api.telegram.org:443:<ip>``.
|
||||
"""
|
||||
|
||||
def __init__(self, fallback_ips: Iterable[str], **transport_kwargs):
|
||||
self._fallback_ips = list(dict.fromkeys(_normalize_fallback_ips(fallback_ips)))
|
||||
proxy_url = _resolve_proxy_url(target_hosts=[_TELEGRAM_API_HOST, *self._fallback_ips])
|
||||
if proxy_url and "proxy" not in transport_kwargs:
|
||||
transport_kwargs["proxy"] = proxy_url
|
||||
self._primary = httpx.AsyncHTTPTransport(**transport_kwargs)
|
||||
self._fallbacks = {
|
||||
ip: httpx.AsyncHTTPTransport(**transport_kwargs) for ip in self._fallback_ips
|
||||
}
|
||||
self._sticky_ip: Optional[str] = None
|
||||
self._sticky_lock = asyncio.Lock()
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
if request.url.host != _TELEGRAM_API_HOST or not self._fallback_ips:
|
||||
return await self._primary.handle_async_request(request)
|
||||
|
||||
sticky_ip = self._sticky_ip
|
||||
attempt_order: list[Optional[str]] = [sticky_ip] if sticky_ip else [None]
|
||||
if sticky_ip:
|
||||
attempt_order.append(None) # retry primary DNS after sticky failure
|
||||
for ip in self._fallback_ips:
|
||||
if ip != sticky_ip:
|
||||
attempt_order.append(ip)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for ip in attempt_order:
|
||||
candidate = request if ip is None else _rewrite_request_for_ip(request, ip)
|
||||
transport = self._primary if ip is None else self._fallbacks[ip]
|
||||
try:
|
||||
response = await transport.handle_async_request(candidate)
|
||||
if ip is not None and self._sticky_ip != ip:
|
||||
async with self._sticky_lock:
|
||||
if self._sticky_ip != ip:
|
||||
self._sticky_ip = ip
|
||||
logger.warning(
|
||||
"[Telegram] Primary api.telegram.org path unreachable; using sticky fallback IP %s",
|
||||
ip,
|
||||
)
|
||||
return response
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if not _is_retryable_connect_error(exc):
|
||||
raise
|
||||
if ip is not None and ip == self._sticky_ip:
|
||||
async with self._sticky_lock:
|
||||
if self._sticky_ip == ip:
|
||||
self._sticky_ip = None
|
||||
logger.warning(
|
||||
"[Telegram] Sticky fallback IP %s failed; resetting to primary DNS path",
|
||||
ip,
|
||||
)
|
||||
if ip is None:
|
||||
logger.warning(
|
||||
"[Telegram] Primary api.telegram.org connection failed (%s); trying fallback IPs %s",
|
||||
exc,
|
||||
", ".join(self._fallback_ips),
|
||||
)
|
||||
continue
|
||||
logger.warning("[Telegram] Fallback IP %s failed: %s", ip, exc)
|
||||
continue
|
||||
|
||||
if last_error is None:
|
||||
raise RuntimeError("All Telegram fallback IPs exhausted but no error was recorded")
|
||||
raise last_error
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._primary.aclose()
|
||||
for transport in self._fallbacks.values():
|
||||
await transport.aclose()
|
||||
|
||||
|
||||
def _normalize_fallback_ips(values: Iterable[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
addr = ipaddress.ip_address(raw)
|
||||
except ValueError:
|
||||
logger.warning("Ignoring invalid Telegram fallback IP: %r", raw)
|
||||
continue
|
||||
if addr.version != 4:
|
||||
logger.warning("Ignoring non-IPv4 Telegram fallback IP: %s", raw)
|
||||
continue
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_unspecified:
|
||||
logger.warning("Ignoring private/internal Telegram fallback IP: %s", raw)
|
||||
continue
|
||||
normalized.append(str(addr))
|
||||
return normalized
|
||||
|
||||
|
||||
def parse_fallback_ip_env(value: str | None) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
return _normalize_fallback_ips(parts)
|
||||
|
||||
|
||||
def _resolve_system_dns() -> set[str]:
|
||||
"""Return the IPv4 addresses that the OS resolver gives for api.telegram.org."""
|
||||
try:
|
||||
results = socket.getaddrinfo(_TELEGRAM_API_HOST, 443, socket.AF_INET)
|
||||
return {addr[4][0] for addr in results}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
async def _query_doh_provider(
|
||||
client: httpx.AsyncClient, provider: dict
|
||||
) -> list[str]:
|
||||
"""Query one DoH provider and return A-record IPs."""
|
||||
try:
|
||||
resp = await client.get(
|
||||
provider["url"], params=provider["params"], headers=provider["headers"]
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
ips: list[str] = []
|
||||
for answer in data.get("Answer", []):
|
||||
if answer.get("type") != 1: # A record
|
||||
continue
|
||||
raw = answer.get("data", "").strip()
|
||||
try:
|
||||
ipaddress.ip_address(raw)
|
||||
ips.append(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
return ips
|
||||
except Exception as exc:
|
||||
logger.debug("DoH query to %s failed: %s", provider["url"], exc)
|
||||
return []
|
||||
|
||||
|
||||
async def discover_fallback_ips() -> list[str]:
|
||||
"""Auto-discover Telegram API IPs via DNS-over-HTTPS.
|
||||
|
||||
Resolves api.telegram.org through Google and Cloudflare DoH and returns all
|
||||
unique A records. IPs that match the local system resolver are kept rather
|
||||
than excluded: in many networks the system-DNS IP is the most reliable path
|
||||
to api.telegram.org and a transient primary-path failure should be retried
|
||||
against the same address via the IP-rewrite path before the seed list is
|
||||
consulted (#14520). Falls back to a hardcoded seed list only when DoH
|
||||
yields no usable answers.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(_DOH_TIMEOUT)) as client:
|
||||
doh_tasks = [_query_doh_provider(client, p) for p in _DOH_PROVIDERS]
|
||||
system_dns_task = asyncio.to_thread(_resolve_system_dns)
|
||||
results = await asyncio.gather(system_dns_task, *doh_tasks, return_exceptions=True)
|
||||
|
||||
# results[0] = system DNS IPs (set), results[1:] = DoH IP lists
|
||||
system_ips: set[str] = results[0] if isinstance(results[0], set) else set()
|
||||
|
||||
doh_ips: list[str] = []
|
||||
for r in results[1:]:
|
||||
if isinstance(r, list):
|
||||
doh_ips.extend(r)
|
||||
|
||||
# Deduplicate preserving order
|
||||
seen: set[str] = set()
|
||||
candidates: list[str] = []
|
||||
for ip in doh_ips:
|
||||
if ip not in seen:
|
||||
seen.add(ip)
|
||||
candidates.append(ip)
|
||||
|
||||
# Validate through existing normalization
|
||||
validated = _normalize_fallback_ips(candidates)
|
||||
|
||||
if validated:
|
||||
logger.debug("Discovered Telegram fallback IPs via DoH: %s", ", ".join(validated))
|
||||
return validated
|
||||
|
||||
logger.info(
|
||||
"DoH discovery yielded no usable IPs (system DNS: %s); using seed fallback IPs %s",
|
||||
", ".join(system_ips) or "unknown",
|
||||
", ".join(_SEED_FALLBACK_IPS),
|
||||
)
|
||||
return list(_SEED_FALLBACK_IPS)
|
||||
|
||||
|
||||
def _rewrite_request_for_ip(request: httpx.Request, ip: str) -> httpx.Request:
|
||||
original_host = request.url.host or _TELEGRAM_API_HOST
|
||||
url = request.url.copy_with(host=ip)
|
||||
headers = request.headers.copy()
|
||||
headers["host"] = original_host
|
||||
extensions = dict(request.extensions)
|
||||
extensions["sni_hostname"] = original_host
|
||||
return httpx.Request(
|
||||
method=request.method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
stream=request.stream,
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
|
||||
def _is_retryable_connect_error(exc: Exception) -> bool:
|
||||
return isinstance(exc, (httpx.ConnectTimeout, httpx.ConnectError))
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
"""WeCom callback-mode adapter for self-built enterprise applications.
|
||||
|
||||
Unlike the bot/websocket adapter in ``wecom.py``, this handles the standard
|
||||
WeCom callback flow: WeCom POSTs encrypted XML to an HTTP endpoint, the
|
||||
adapter decrypts it, queues the message for the agent, and immediately
|
||||
acknowledges. The agent's reply is delivered later via the proactive
|
||||
``message/send`` API using an access-token.
|
||||
|
||||
Supports multiple self-built apps under one gateway instance, scoped by
|
||||
``corp_id:user_id`` to avoid cross-corp collisions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import socket as _socket
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
# Security: parse untrusted, pre-auth request bodies (WeCom callbacks) with
|
||||
# defusedxml to block billion-laughs / entity-expansion (and XXE) DoS. The
|
||||
# parsing API (fromstring) is a drop-in for the stdlib calls used below;
|
||||
# response-building XML lives in wecom_crypto.py and is not parsed here.
|
||||
try:
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
DEFUSEDXML_AVAILABLE = True
|
||||
except ImportError:
|
||||
ET = None # type: ignore[assignment]
|
||||
DEFUSEDXML_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
|
||||
AIOHTTP_AVAILABLE = True
|
||||
except ImportError:
|
||||
web = None # type: ignore[assignment]
|
||||
AIOHTTP_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
HTTPX_AVAILABLE = True
|
||||
except ImportError:
|
||||
httpx = None # type: ignore[assignment]
|
||||
HTTPX_AVAILABLE = False
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
||||
from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt, WeComCryptoError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8645
|
||||
DEFAULT_PATH = "/wecom/callback"
|
||||
# Cap pre-auth request bodies. WeCom callbacks are small encrypted XML
|
||||
# envelopes (media is delivered out-of-band via MediaId, never inline), so
|
||||
# 64 KB is ample for any legitimate message while bounding the work an
|
||||
# unauthenticated POST can force before signature verification.
|
||||
_MAX_BODY = 65_536
|
||||
ACCESS_TOKEN_TTL_SECONDS = 7200
|
||||
MESSAGE_DEDUP_TTL_SECONDS = 300
|
||||
|
||||
|
||||
def check_wecom_callback_requirements() -> bool:
|
||||
return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE and DEFUSEDXML_AVAILABLE
|
||||
|
||||
|
||||
class WecomCallbackAdapter(BasePlatformAdapter):
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.WECOM_CALLBACK)
|
||||
extra = config.extra or {}
|
||||
self._host = str(extra.get("host") or DEFAULT_HOST)
|
||||
self._port = int(extra.get("port") or DEFAULT_PORT)
|
||||
self._path = str(extra.get("path") or DEFAULT_PATH)
|
||||
self._apps: List[Dict[str, Any]] = self._normalize_apps(extra)
|
||||
self._runner: Optional[web.AppRunner] = None
|
||||
self._site: Optional[web.TCPSite] = None
|
||||
self._app: Optional[web.Application] = None
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
self._message_queue: asyncio.Queue[MessageEvent] = asyncio.Queue()
|
||||
self._poll_task: Optional[asyncio.Task] = None
|
||||
self._seen_messages: Dict[str, float] = {}
|
||||
self._user_app_map: Dict[str, str] = {}
|
||||
self._access_tokens: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# App normalisation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _user_app_key(corp_id: str, user_id: str) -> str:
|
||||
return f"{corp_id}:{user_id}" if corp_id else user_id
|
||||
|
||||
@staticmethod
|
||||
def _normalize_apps(extra: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
apps = extra.get("apps")
|
||||
if isinstance(apps, list) and apps:
|
||||
return [dict(app) for app in apps if isinstance(app, dict)]
|
||||
if extra.get("corp_id"):
|
||||
return [
|
||||
{
|
||||
"name": extra.get("name") or "default",
|
||||
"corp_id": extra.get("corp_id", ""),
|
||||
"corp_secret": extra.get("corp_secret", ""),
|
||||
"agent_id": str(extra.get("agent_id", "")),
|
||||
"token": extra.get("token", ""),
|
||||
"encoding_aes_key": extra.get("encoding_aes_key", ""),
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
# ``is_reconnect`` is forwarded by GatewayRunner on every retry per
|
||||
# the BasePlatformAdapter.connect contract. Callback adapters have
|
||||
# no server-side queue to preserve, so the flag is accepted-and-
|
||||
# ignored — but the kwarg MUST be present or the reconnect watcher
|
||||
# dies with TypeError and the platform silently stays offline.
|
||||
del is_reconnect
|
||||
if not self._apps:
|
||||
logger.warning("[WecomCallback] No callback apps configured")
|
||||
return False
|
||||
if not check_wecom_callback_requirements():
|
||||
logger.warning("[WecomCallback] aiohttp/httpx not installed")
|
||||
return False
|
||||
|
||||
# Quick port-in-use check.
|
||||
try:
|
||||
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(1)
|
||||
sock.connect(("127.0.0.1", self._port))
|
||||
logger.error("[WecomCallback] Port %d already in use", self._port)
|
||||
return False
|
||||
except (ConnectionRefusedError, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
# Tighter keepalive so idle CLOSE_WAIT drains promptly (#18451).
|
||||
from gateway.platforms._http_client_limits import platform_httpx_limits
|
||||
self._http_client = httpx.AsyncClient(timeout=20.0, limits=platform_httpx_limits())
|
||||
# client_max_size rejects oversized bodies at the aiohttp layer
|
||||
# (413) before our handler — and before any signature work — runs.
|
||||
self._app = web.Application(client_max_size=_MAX_BODY)
|
||||
self._app.router.add_get("/health", self._handle_health)
|
||||
self._app.router.add_get(self._path, self._handle_verify)
|
||||
self._app.router.add_post(self._path, self._handle_callback)
|
||||
self._runner = web.AppRunner(self._app)
|
||||
await self._runner.setup()
|
||||
self._site = web.TCPSite(self._runner, self._host, self._port)
|
||||
await self._site.start()
|
||||
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||
self._mark_connected()
|
||||
logger.info(
|
||||
"[WecomCallback] HTTP server listening on %s:%s%s",
|
||||
self._host, self._port, self._path,
|
||||
)
|
||||
for app in self._apps:
|
||||
try:
|
||||
await self._refresh_access_token(app)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[WecomCallback] Initial token refresh failed for app '%s': %s",
|
||||
app.get("name", "default"), exc,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
await self._cleanup()
|
||||
logger.exception("[WecomCallback] Failed to start")
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._running = False
|
||||
if self._poll_task:
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
await self._poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._poll_task = None
|
||||
await self._cleanup()
|
||||
self._mark_disconnected()
|
||||
logger.info("[WecomCallback] Disconnected")
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
self._site = None
|
||||
if self._runner:
|
||||
await self._runner.cleanup()
|
||||
self._runner = None
|
||||
self._app = None
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Outbound: proactive send via access-token API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
app = self._resolve_app_for_chat(chat_id)
|
||||
touser = chat_id.split(":", 1)[1] if ":" in chat_id else chat_id
|
||||
try:
|
||||
payload = {
|
||||
"touser": touser,
|
||||
"msgtype": "text",
|
||||
"agentid": int(str(app.get("agent_id") or 0)),
|
||||
"text": {"content": content[:2048]},
|
||||
"safe": 0,
|
||||
}
|
||||
for _attempt in range(2):
|
||||
token = await self._get_access_token(app)
|
||||
resp = await self._http_client.post(
|
||||
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}",
|
||||
json=payload,
|
||||
)
|
||||
data = resp.json()
|
||||
errcode = data.get("errcode")
|
||||
if errcode in {40001, 42001} and _attempt == 0:
|
||||
# WeCom rejected the token — evict the cached entry so
|
||||
# the next _get_access_token call forces a fresh fetch.
|
||||
logger.warning(
|
||||
"[WecomCallback] Token rejected for app '%s' (errcode=%s), refreshing",
|
||||
app.get("name", "default"), errcode,
|
||||
)
|
||||
self._access_tokens.pop(app["name"], None)
|
||||
continue
|
||||
if errcode != 0:
|
||||
return SendResult(success=False, error=str(data))
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=str(data.get("msgid", "")),
|
||||
raw_response=data,
|
||||
)
|
||||
return SendResult(success=False, error="send failed after token refresh")
|
||||
except Exception as exc:
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
def _resolve_app_for_chat(self, chat_id: str) -> Dict[str, Any]:
|
||||
"""Pick the app associated with *chat_id*, falling back sensibly."""
|
||||
app_name = self._user_app_map.get(chat_id)
|
||||
if not app_name and ":" not in chat_id:
|
||||
# Legacy bare user_id — try to find a unique match.
|
||||
matching = [k for k in self._user_app_map if k.endswith(f":{chat_id}")]
|
||||
if len(matching) == 1:
|
||||
app_name = self._user_app_map.get(matching[0])
|
||||
app = self._get_app_by_name(app_name) if app_name else None
|
||||
return app or self._apps[0]
|
||||
|
||||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||||
return {"name": chat_id, "type": "dm"}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Inbound: HTTP callback handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok", "platform": "wecom_callback"})
|
||||
|
||||
async def _handle_verify(self, request: web.Request) -> web.Response:
|
||||
"""GET endpoint — WeCom URL verification handshake."""
|
||||
msg_signature = request.query.get("msg_signature", "")
|
||||
timestamp = request.query.get("timestamp", "")
|
||||
nonce = request.query.get("nonce", "")
|
||||
echostr = request.query.get("echostr", "")
|
||||
for app in self._apps:
|
||||
try:
|
||||
crypt = self._crypt_for_app(app)
|
||||
plain = crypt.verify_url(msg_signature, timestamp, nonce, echostr)
|
||||
return web.Response(text=plain, content_type="text/plain")
|
||||
except Exception:
|
||||
continue
|
||||
return web.Response(status=403, text="signature verification failed")
|
||||
|
||||
async def _handle_callback(self, request: web.Request) -> web.Response:
|
||||
"""POST endpoint — receive an encrypted message callback."""
|
||||
msg_signature = request.query.get("msg_signature", "")
|
||||
timestamp = request.query.get("timestamp", "")
|
||||
nonce = request.query.get("nonce", "")
|
||||
# Explicit guard in addition to client_max_size: rejects oversized
|
||||
# payloads before any XML parse / signature check (DoS, zip bombs).
|
||||
body_bytes = await request.read()
|
||||
if len(body_bytes) > _MAX_BODY:
|
||||
logger.warning("[WecomCallback] Payload too large (%d bytes) — rejected", len(body_bytes))
|
||||
return web.Response(status=413, text="payload too large")
|
||||
body = body_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
for app in self._apps:
|
||||
try:
|
||||
decrypted = self._decrypt_request(
|
||||
app, body, msg_signature, timestamp, nonce,
|
||||
)
|
||||
event = self._build_event(app, decrypted)
|
||||
if event is not None:
|
||||
# Deduplicate: WeCom retries callbacks on timeout,
|
||||
# producing duplicate inbound messages (#10305).
|
||||
if event.message_id:
|
||||
now = time.time()
|
||||
if event.message_id in self._seen_messages:
|
||||
if now - self._seen_messages[event.message_id] < MESSAGE_DEDUP_TTL_SECONDS:
|
||||
logger.debug("[WecomCallback] Duplicate MsgId %s, skipping", event.message_id)
|
||||
return web.Response(text="success", content_type="text/plain")
|
||||
del self._seen_messages[event.message_id]
|
||||
self._seen_messages[event.message_id] = now
|
||||
# Prune expired entries when cache grows large
|
||||
if len(self._seen_messages) > 2000:
|
||||
cutoff = now - MESSAGE_DEDUP_TTL_SECONDS
|
||||
self._seen_messages = {k: v for k, v in self._seen_messages.items() if v > cutoff}
|
||||
# Record which app this user belongs to.
|
||||
if event.source and event.source.user_id:
|
||||
map_key = self._user_app_key(
|
||||
str(app.get("corp_id") or ""), event.source.user_id,
|
||||
)
|
||||
self._user_app_map[map_key] = app["name"]
|
||||
await self._message_queue.put(event)
|
||||
# Immediately acknowledge — the agent's reply will arrive
|
||||
# later via the proactive message/send API.
|
||||
return web.Response(text="success", content_type="text/plain")
|
||||
except WeComCryptoError:
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("[WecomCallback] Error handling message")
|
||||
break
|
||||
return web.Response(status=400, text="invalid callback payload")
|
||||
|
||||
async def _poll_loop(self) -> None:
|
||||
"""Drain the message queue and dispatch to the gateway runner."""
|
||||
while True:
|
||||
event = await self._message_queue.get()
|
||||
try:
|
||||
task = asyncio.create_task(self.handle_message(event))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
except Exception:
|
||||
logger.exception("[WecomCallback] Failed to enqueue event")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# XML / crypto helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _decrypt_request(
|
||||
self, app: Dict[str, Any], body: str,
|
||||
msg_signature: str, timestamp: str, nonce: str,
|
||||
) -> str:
|
||||
root = ET.fromstring(body)
|
||||
encrypt = root.findtext("Encrypt", default="")
|
||||
crypt = self._crypt_for_app(app)
|
||||
return crypt.decrypt(msg_signature, timestamp, nonce, encrypt).decode("utf-8")
|
||||
|
||||
def _build_event(self, app: Dict[str, Any], xml_text: str) -> Optional[MessageEvent]:
|
||||
root = ET.fromstring(xml_text)
|
||||
msg_type = (root.findtext("MsgType") or "").lower()
|
||||
# Silently acknowledge lifecycle events.
|
||||
if msg_type == "event":
|
||||
event_name = (root.findtext("Event") or "").lower()
|
||||
if event_name in {"enter_agent", "subscribe"}:
|
||||
return None
|
||||
if msg_type not in {"text", "event"}:
|
||||
return None
|
||||
|
||||
user_id = root.findtext("FromUserName", default="")
|
||||
corp_id = root.findtext("ToUserName", default=app.get("corp_id", ""))
|
||||
scoped_chat_id = self._user_app_key(corp_id, user_id)
|
||||
content = root.findtext("Content", default="").strip()
|
||||
if not content and msg_type == "event":
|
||||
content = "/start"
|
||||
msg_id = (
|
||||
root.findtext("MsgId")
|
||||
or f"{user_id}:{root.findtext('CreateTime', default='0')}"
|
||||
)
|
||||
source = self.build_source(
|
||||
chat_id=scoped_chat_id,
|
||||
chat_name=user_id,
|
||||
chat_type="dm",
|
||||
user_id=user_id,
|
||||
user_name=user_id,
|
||||
)
|
||||
return MessageEvent(
|
||||
text=content,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message=xml_text,
|
||||
message_id=msg_id,
|
||||
)
|
||||
|
||||
def _crypt_for_app(self, app: Dict[str, Any]) -> WXBizMsgCrypt:
|
||||
return WXBizMsgCrypt(
|
||||
token=str(app.get("token") or ""),
|
||||
encoding_aes_key=str(app.get("encoding_aes_key") or ""),
|
||||
receive_id=str(app.get("corp_id") or ""),
|
||||
)
|
||||
|
||||
def _get_app_by_name(self, name: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not name:
|
||||
return None
|
||||
for app in self._apps:
|
||||
if app.get("name") == name:
|
||||
return app
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Access-token management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _get_access_token(self, app: Dict[str, Any]) -> str:
|
||||
cached = self._access_tokens.get(app["name"])
|
||||
now = time.time()
|
||||
if cached and cached.get("expires_at", 0) > now + 60:
|
||||
return cached["token"]
|
||||
return await self._refresh_access_token(app)
|
||||
|
||||
async def _refresh_access_token(self, app: Dict[str, Any]) -> str:
|
||||
resp = await self._http_client.get(
|
||||
"https://qyapi.weixin.qq.com/cgi-bin/gettoken",
|
||||
params={
|
||||
"corpid": app.get("corp_id"),
|
||||
"corpsecret": app.get("corp_secret"),
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errcode") != 0:
|
||||
raise RuntimeError(f"WeCom token refresh failed: {data}")
|
||||
token = data["access_token"]
|
||||
expires_in = int(data.get("expires_in", ACCESS_TOKEN_TTL_SECONDS))
|
||||
self._access_tokens[app["name"]] = {
|
||||
"token": token,
|
||||
"expires_at": time.time() + expires_in,
|
||||
}
|
||||
logger.info(
|
||||
"[WecomCallback] Token refreshed for app '%s' (corp=%s), expires in %ss",
|
||||
app.get("name", "default"),
|
||||
app.get("corp_id", ""),
|
||||
expires_in,
|
||||
)
|
||||
return token
|
||||
@@ -0,0 +1,52 @@
|
||||
name: wecom-platform
|
||||
label: WeCom (Enterprise WeChat)
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
WeCom / Enterprise WeChat gateway adapter for Hermes Agent. Registers two
|
||||
platforms: ``wecom`` (Smart Robot over WebSocket) and ``wecom_callback``
|
||||
(self-built apps over an HTTP callback endpoint with AES message crypto).
|
||||
Relays messages between WeCom chats and the Hermes agent.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: WECOM_BOT_ID
|
||||
description: "WeCom Smart Robot bot ID"
|
||||
prompt: "WeCom bot ID"
|
||||
password: false
|
||||
- name: WECOM_SECRET
|
||||
description: "WeCom Smart Robot secret"
|
||||
prompt: "WeCom secret"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: WECOM_WEBSOCKET_URL
|
||||
description: "WeCom Smart Robot WebSocket URL"
|
||||
prompt: "WeCom WebSocket URL"
|
||||
password: false
|
||||
- name: WECOM_HOME_CHANNEL
|
||||
description: "Default chat ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: WECOM_ALLOWED_USERS
|
||||
description: "Comma-separated WeCom user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: WECOM_CALLBACK_CORP_ID
|
||||
description: "WeCom callback-mode corp ID (self-built apps)"
|
||||
prompt: "WeCom callback corp ID"
|
||||
password: false
|
||||
- name: WECOM_CALLBACK_CORP_SECRET
|
||||
description: "WeCom callback-mode corp secret"
|
||||
prompt: "WeCom callback corp secret"
|
||||
password: true
|
||||
- name: WECOM_CALLBACK_AGENT_ID
|
||||
description: "WeCom callback-mode agent ID"
|
||||
prompt: "WeCom callback agent ID"
|
||||
password: false
|
||||
- name: WECOM_CALLBACK_TOKEN
|
||||
description: "WeCom callback verification token"
|
||||
prompt: "WeCom callback token"
|
||||
password: true
|
||||
- name: WECOM_CALLBACK_ENCODING_AES_KEY
|
||||
description: "WeCom callback EncodingAESKey for message crypto"
|
||||
prompt: "WeCom callback EncodingAESKey"
|
||||
password: true
|
||||
@@ -0,0 +1,142 @@
|
||||
"""WeCom BizMsgCrypt-compatible AES-CBC encryption for callback mode.
|
||||
|
||||
Implements the same wire format as Tencent's official ``WXBizMsgCrypt``
|
||||
SDK so that WeCom can verify, encrypt, and decrypt callback payloads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import struct
|
||||
from typing import Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
|
||||
class WeComCryptoError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SignatureError(WeComCryptoError):
|
||||
pass
|
||||
|
||||
|
||||
class DecryptError(WeComCryptoError):
|
||||
pass
|
||||
|
||||
|
||||
class EncryptError(WeComCryptoError):
|
||||
pass
|
||||
|
||||
|
||||
class PKCS7Encoder:
|
||||
block_size = 32
|
||||
|
||||
@classmethod
|
||||
def encode(cls, text: bytes) -> bytes:
|
||||
amount_to_pad = cls.block_size - (len(text) % cls.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = cls.block_size
|
||||
pad = bytes([amount_to_pad]) * amount_to_pad
|
||||
return text + pad
|
||||
|
||||
@classmethod
|
||||
def decode(cls, decrypted: bytes) -> bytes:
|
||||
if not decrypted:
|
||||
raise DecryptError("empty decrypted payload")
|
||||
pad = decrypted[-1]
|
||||
if pad < 1 or pad > cls.block_size:
|
||||
raise DecryptError("invalid PKCS7 padding")
|
||||
if decrypted[-pad:] != bytes([pad]) * pad:
|
||||
raise DecryptError("malformed PKCS7 padding")
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
def _sha1_signature(token: str, timestamp: str, nonce: str, encrypt: str) -> str:
|
||||
parts = sorted([token, timestamp, nonce, encrypt])
|
||||
return hashlib.sha1("".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class WXBizMsgCrypt:
|
||||
"""Minimal WeCom callback crypto helper compatible with BizMsgCrypt semantics."""
|
||||
|
||||
def __init__(self, token: str, encoding_aes_key: str, receive_id: str):
|
||||
if not token:
|
||||
raise ValueError("token is required")
|
||||
if not encoding_aes_key:
|
||||
raise ValueError("encoding_aes_key is required")
|
||||
if len(encoding_aes_key) != 43:
|
||||
raise ValueError("encoding_aes_key must be 43 chars")
|
||||
if not receive_id:
|
||||
raise ValueError("receive_id is required")
|
||||
|
||||
self.token = token
|
||||
self.receive_id = receive_id
|
||||
self.key = base64.b64decode(encoding_aes_key + "=")
|
||||
self.iv = self.key[:16]
|
||||
|
||||
def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str:
|
||||
plain = self.decrypt(msg_signature, timestamp, nonce, echostr)
|
||||
return plain.decode("utf-8")
|
||||
|
||||
def decrypt(self, msg_signature: str, timestamp: str, nonce: str, encrypt: str) -> bytes:
|
||||
expected = _sha1_signature(self.token, timestamp, nonce, encrypt)
|
||||
if expected != msg_signature:
|
||||
raise SignatureError("signature mismatch")
|
||||
try:
|
||||
cipher_text = base64.b64decode(encrypt)
|
||||
except Exception as exc:
|
||||
raise DecryptError(f"invalid base64 payload: {exc}") from exc
|
||||
try:
|
||||
cipher = Cipher(algorithms.AES(self.key), modes.CBC(self.iv), backend=default_backend())
|
||||
decryptor = cipher.decryptor()
|
||||
padded = decryptor.update(cipher_text) + decryptor.finalize()
|
||||
plain = PKCS7Encoder.decode(padded)
|
||||
content = plain[16:] # skip 16-byte random prefix
|
||||
xml_length = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||
xml_content = content[4:4 + xml_length]
|
||||
receive_id = content[4 + xml_length:].decode("utf-8")
|
||||
except WeComCryptoError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise DecryptError(f"decrypt failed: {exc}") from exc
|
||||
|
||||
if receive_id != self.receive_id:
|
||||
raise DecryptError("receive_id mismatch")
|
||||
return xml_content
|
||||
|
||||
def encrypt(self, plaintext: str, nonce: Optional[str] = None, timestamp: Optional[str] = None) -> str:
|
||||
nonce = nonce or self._random_nonce()
|
||||
timestamp = timestamp or str(int(__import__("time").time()))
|
||||
encrypt = self._encrypt_bytes(plaintext.encode("utf-8"))
|
||||
signature = _sha1_signature(self.token, timestamp, nonce, encrypt)
|
||||
root = ET.Element("xml")
|
||||
ET.SubElement(root, "Encrypt").text = encrypt
|
||||
ET.SubElement(root, "MsgSignature").text = signature
|
||||
ET.SubElement(root, "TimeStamp").text = timestamp
|
||||
ET.SubElement(root, "Nonce").text = nonce
|
||||
return ET.tostring(root, encoding="unicode")
|
||||
|
||||
def _encrypt_bytes(self, raw: bytes) -> str:
|
||||
try:
|
||||
random_prefix = os.urandom(16)
|
||||
msg_len = struct.pack("I", socket.htonl(len(raw)))
|
||||
payload = random_prefix + msg_len + raw + self.receive_id.encode("utf-8")
|
||||
padded = PKCS7Encoder.encode(payload)
|
||||
cipher = Cipher(algorithms.AES(self.key), modes.CBC(self.iv), backend=default_backend())
|
||||
encryptor = cipher.encryptor()
|
||||
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||
return base64.b64encode(encrypted).decode("utf-8")
|
||||
except Exception as exc:
|
||||
raise EncryptError(f"encrypt failed: {exc}") from exc
|
||||
|
||||
@staticmethod
|
||||
def _random_nonce(length: int = 10) -> str:
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
name: whatsapp-platform
|
||||
label: WhatsApp
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
WhatsApp gateway adapter for Hermes Agent.
|
||||
Connects to WhatsApp via a local Node.js bridge (WhatsApp Web client) over
|
||||
an HTTP API and relays messages between WhatsApp chats and the Hermes agent.
|
||||
Supports DM/group policies, mention gating, free-response chats, and
|
||||
per-user allowlists.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: WHATSAPP_ENABLED
|
||||
description: "Enable the WhatsApp adapter (requires the Node.js bridge running)"
|
||||
prompt: "Enable WhatsApp? (true/false)"
|
||||
password: false
|
||||
optional_env:
|
||||
- name: WHATSAPP_ALLOWED_USERS
|
||||
description: "Comma-separated WhatsApp user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: WHATSAPP_ALLOW_ALL_USERS
|
||||
description: "Allow any WhatsApp user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: WHATSAPP_HOME_CHANNEL
|
||||
description: "Default chat ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: WHATSAPP_HOME_CHANNEL_NAME
|
||||
description: "Display name for the WhatsApp home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
Reference in New Issue
Block a user