Merge origin/main into bb/gui (2026-05-24)
Bring 313 commits of upstream main into the bb/gui dashboard
refactor branch. Eight conflicts resolved by hand, the rest
auto-merged. One missing class (_StreamErrorEvent) restored from
main after the auto-merger dropped it.
Conflict resolutions:
apps/dashboard/README.md take HEAD: main's text described
the pre-rename web/ layout that
bb/gui refactored away.
apps/dashboard/package.json combine: keep HEAD's @hermes/shared
workspace dep, take main's
@nous-research/ui 0.16.0 bump.
apps/dashboard/package-lock.json regenerate via
npm install --package-lock-only.
Root lock also regenerated; only
dashboard and apps/desktop entries
moved (apps/desktop version 0.0.1 →
0.0.2 to match bb/gui's
package.json bump).
apps/dashboard/src/pages/ take main (4 hunks): text-xs
EnvPage.tsx replaces text-[0.65rem] per the
typography rule HEAD's own README
documents.
hermes_cli/gateway.py take main (2 hunks): Discord
setup metadata moved to plugin
(architectural migration); s6
service-manager dispatch helpers
additive.
hermes_cli/main.py combine (2 hunks): take main's
Termux-aware
_sync_bundled_skills_for_startup;
combine gui + portal subcommands
in the known-subcommand list.
hermes_cli/web_server.py mixed (10 hunks):
- take main on _PUBLIC_API_PATHS
(bb/gui's own test asserts the
rescan endpoint must require auth)
- combine WS helpers: keep HEAD's
_ws_client_label + main's
Host/Origin guard + composing
_ws_request_is_allowed
- take HEAD's debug-level broadcast
drop log (matches the comment
"subscriber went away mid-send")
- take main's _safe_plugin_api_relpath
GHSA-5qr3-c538-wm9j fix and the
paired discovery-time validation
- take main's {name:path} route
converter for plugin visibility
tui_gateway/server.py take main: PR #31379's verbose-
args gating supersedes HEAD's
unconditional args dump on
tool.start.
Post-merge restoration:
run_agent.py restored class _StreamErrorEvent
(40 lines, from origin/main:288).
Auto-merge silently dropped it,
breaking imports in
agent/codex_runtime.py and three
test files
(test_codex_xai_oauth_recovery.py,
test_streaming.py). Restored
verbatim from main.
Sanity checks:
* git diff --check / --cached --check: clean (no stray markers)
* ast.parse + import on all touched .py files: clean
* targeted pytest on resolved files: 756 passed, 1 pre-existing
Windows-curses failure unrelated to the merge
* full pytest_parallel run: 105 files / 391 failures vs baseline
98 files / 346. Differential vs origin/bb/gui shows all 11
"new" failure files come from main's added tests/code and
reproduce identically against origin/main on the same Windows
host (pure Windows path-separator / perms / git-bash issues
in upstream tests, not merge regressions). 4 baseline
failures fixed: 3 in test_codex_xai_oauth_recovery (the
_StreamErrorEvent restoration), 1 each in test_pairing,
test_runner_startup_failures, test_stream_consumer.
* sentinel-token sweep on main's eight largest commits:
every audited symbol present in the merged tree at expected
counts (TTSProvider 61, NtfyAdapter 29, S6ServiceManager 70,
install_bws 12, security_audit 16, register_image_gen_provider
23, list_profile_gateways 22, DISCORD_FREE_RESPONSE_CHANNELS
48, …).
* byte-diff sweep: 30/30 sampled main-only-modified files
byte-identical to origin/main; the four bb/gui-only files
that drifted (i18n/types.ts, i18n/ru.ts, ThemeSwitcher.tsx,
ToolCall.tsx) correctly absorbed main's web/ → apps/dashboard/
edits through git's rename detection (main's added lines all
present, removed lines all absent).
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
"""FAL.ai image generation backend.
|
||||
|
||||
Wraps the 18-model FAL catalog (FLUX 2, Z-Image, Nano Banana, GPT
|
||||
Image 1.5, Recraft, Imagen 4, Qwen, Ideogram, …) as an
|
||||
:class:`ImageGenProvider` implementation.
|
||||
|
||||
The heavy lifting — model catalog, payload construction, request
|
||||
submission, managed-Nous-gateway selection, Clarity Upscaler chaining
|
||||
— lives in :mod:`tools.image_generation_tool`. This plugin reaches into
|
||||
that module via call-time indirection (``import tools.image_generation_tool as _it``)
|
||||
so:
|
||||
|
||||
* the existing test suite (``tests/tools/test_image_generation.py``,
|
||||
``tests/tools/test_managed_media_gateways.py``) keeps patching
|
||||
``image_tool._submit_fal_request`` / ``image_tool.fal_client`` /
|
||||
``image_tool._managed_fal_client`` without modification, and
|
||||
* there's exactly one canonical FAL code path on disk — the plugin is a
|
||||
registration adapter, not a parallel implementation.
|
||||
|
||||
See issue #26241 for the migration plan and the
|
||||
``plugin-extraction-test-patch-compatibility.md`` rules this follows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
resolve_aspect_ratio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FalImageGenProvider(ImageGenProvider):
|
||||
"""FAL.ai image generation backend.
|
||||
|
||||
Delegates to ``tools.image_generation_tool.image_generate_tool`` so
|
||||
the in-tree FAL implementation (model catalog, payload builder,
|
||||
managed-gateway selection, Clarity Upscaler chaining) is the single
|
||||
source of truth. Everything is resolved at call time via the
|
||||
``_it`` indirection so tests can monkey-patch the legacy module.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "fal"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "FAL.ai"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
# Available when direct FAL_KEY is set OR the managed Nous
|
||||
# gateway resolves a fal-queue origin. Both checks come from the
|
||||
# legacy module so this provider tracks whatever logic ships
|
||||
# there.
|
||||
import tools.image_generation_tool as _it
|
||||
try:
|
||||
return bool(_it.check_fal_api_key())
|
||||
except Exception: # noqa: BLE001 — defensive; never break the picker
|
||||
return False
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
import tools.image_generation_tool as _it
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"display": meta.get("display", model_id),
|
||||
"speed": meta.get("speed", ""),
|
||||
"strengths": meta.get("strengths", ""),
|
||||
"price": meta.get("price", ""),
|
||||
}
|
||||
for model_id, meta in _it.FAL_MODELS.items()
|
||||
]
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
import tools.image_generation_tool as _it
|
||||
return _it.DEFAULT_MODEL
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "FAL.ai",
|
||||
"badge": "paid",
|
||||
"tag": "Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana, etc.",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "FAL_KEY",
|
||||
"prompt": "FAL API key",
|
||||
"url": "https://fal.ai/dashboard/keys",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate an image via the legacy FAL pipeline.
|
||||
|
||||
Forwards prompt + aspect_ratio (and any forward-compat extras
|
||||
the schema supports) into :func:`tools.image_generation_tool.image_generate_tool`,
|
||||
then reshapes its JSON-string response into the provider-ABC
|
||||
dict format consumed by ``_dispatch_to_plugin_provider``.
|
||||
"""
|
||||
import tools.image_generation_tool as _it
|
||||
|
||||
aspect = resolve_aspect_ratio(aspect_ratio)
|
||||
passthrough = {
|
||||
key: kwargs[key]
|
||||
for key in (
|
||||
"num_inference_steps",
|
||||
"guidance_scale",
|
||||
"num_images",
|
||||
"output_format",
|
||||
"seed",
|
||||
)
|
||||
if key in kwargs and kwargs[key] is not None
|
||||
}
|
||||
|
||||
try:
|
||||
raw = _it.image_generate_tool(
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
**passthrough,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — never raise out of generate
|
||||
logger.warning("FAL image_generate_tool raised: %s", exc, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"image": None,
|
||||
"error": f"FAL image generation failed: {exc}",
|
||||
"error_type": type(exc).__name__,
|
||||
"provider": "fal",
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": aspect,
|
||||
}
|
||||
|
||||
try:
|
||||
response = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception: # noqa: BLE001
|
||||
response = {"success": False, "image": None, "error": "Invalid JSON from FAL pipeline"}
|
||||
|
||||
if not isinstance(response, dict):
|
||||
response = {
|
||||
"success": False,
|
||||
"image": None,
|
||||
"error": "FAL pipeline returned a non-dict response",
|
||||
"error_type": "provider_contract",
|
||||
}
|
||||
|
||||
# Stamp provider/prompt/aspect_ratio so downstream consumers see
|
||||
# the uniform shape declared in ``agent.image_gen_provider``.
|
||||
response.setdefault("provider", "fal")
|
||||
response.setdefault("prompt", prompt)
|
||||
response.setdefault("aspect_ratio", aspect)
|
||||
# Annotate model best-effort — the legacy pipeline resolves it
|
||||
# internally, so query it after the fact for the response shape.
|
||||
if "model" not in response:
|
||||
try:
|
||||
model_id, _meta = _it._resolve_fal_model()
|
||||
response["model"] = model_id
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — wire ``FalImageGenProvider`` into the registry."""
|
||||
ctx.register_image_gen_provider(FalImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: fal
|
||||
version: 1.0.0
|
||||
description: "FAL.ai image generation backend (flux-2-klein, flux-2-pro, nano-banana, gpt-image-1.5, recraft-v3, etc.)."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- FAL_KEY
|
||||
@@ -33,6 +33,7 @@ from agent.image_gen_provider import (
|
||||
error_response,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
@@ -266,9 +267,21 @@ class OpenAIImageGenProvider(ImageGenProvider):
|
||||
)
|
||||
image_ref = str(saved_path)
|
||||
elif url:
|
||||
# Defensive — gpt-image-2 returns b64 today, but fall back
|
||||
# gracefully if the API ever changes.
|
||||
image_ref = url
|
||||
# Defensive — gpt-image-2 returns b64 today, but OpenAI's API
|
||||
# has previously returned URLs. Cache the bytes locally so the
|
||||
# gateway never tries to fetch an ephemeral / signed URL after
|
||||
# it expires — same rationale as the xAI provider (#26942).
|
||||
try:
|
||||
saved_path = save_url_image(url, prefix=f"openai_{tier_id}")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"OpenAI image URL %s could not be cached (%s); falling back to bare URL.",
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
image_ref = url
|
||||
else:
|
||||
image_ref = str(saved_path)
|
||||
else:
|
||||
return error_response(
|
||||
error="OpenAI response contained neither b64_json nor URL",
|
||||
|
||||
@@ -29,6 +29,7 @@ from agent.image_gen_provider import (
|
||||
error_response,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials
|
||||
@@ -281,7 +282,24 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
)
|
||||
image_ref = str(saved_path)
|
||||
elif url:
|
||||
image_ref = url
|
||||
# xAI's grok-imagine-image returns ephemeral ``imgen.x.ai/xai-tmp-*``
|
||||
# URLs that 404 within minutes — by the time Telegram's
|
||||
# ``send_photo`` or any downstream consumer fetches them, the
|
||||
# asset is gone (#26942). Materialise the bytes locally at
|
||||
# tool-completion time so the gateway has a stable file path to
|
||||
# upload, mirroring the b64 branch above and the audio_cache
|
||||
# pattern used by text_to_speech.
|
||||
try:
|
||||
saved_path = save_url_image(url, prefix=f"xai_{model_id}")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"xAI image URL %s could not be cached (%s); falling back to bare URL.",
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
image_ref = url
|
||||
else:
|
||||
image_ref = str(saved_path)
|
||||
else:
|
||||
return error_response(
|
||||
error="xAI response contained neither b64_json nor URL",
|
||||
|
||||
@@ -47,6 +47,25 @@ _DEFAULT_ENDPOINT = "http://127.0.0.1:1933"
|
||||
_TIMEOUT = 30.0
|
||||
_REMOTE_RESOURCE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://")
|
||||
|
||||
# Maps the viking_remember `category` enum to a viking:// subdirectory.
|
||||
# Keep in sync with REMEMBER_SCHEMA.parameters.properties.category.enum.
|
||||
_CATEGORY_SUBDIR_MAP = {
|
||||
"preference": "preferences",
|
||||
"entity": "entities",
|
||||
"event": "events",
|
||||
"case": "cases",
|
||||
"pattern": "patterns",
|
||||
}
|
||||
_DEFAULT_MEMORY_SUBDIR = "preferences"
|
||||
|
||||
# Maps the built-in memory tool's `target` ("user" vs "memory") to a subdir
|
||||
# for on_memory_write mirroring. User profile facts → preferences; agent
|
||||
# notes / observations → patterns. Anything unknown falls back to the default.
|
||||
_MEMORY_WRITE_TARGET_SUBDIR_MAP = {
|
||||
"user": "preferences",
|
||||
"memory": "patterns",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-level atexit safety net — ensures pending sessions are committed
|
||||
@@ -607,24 +626,35 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
||||
except Exception as e:
|
||||
logger.warning("OpenViking session commit failed: %s", e)
|
||||
|
||||
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
||||
"""Mirror built-in memory writes to OpenViking as explicit memories."""
|
||||
def _build_memory_uri(self, subdir: str) -> str:
|
||||
"""Build a viking:// memory URI under the configured user/subdir."""
|
||||
slug = uuid.uuid4().hex[:12]
|
||||
return f"viking://user/{self._user}/memories/{subdir}/mem_{slug}.md"
|
||||
|
||||
def on_memory_write(
|
||||
self,
|
||||
action: str,
|
||||
target: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Mirror built-in memory writes to OpenViking via content/write."""
|
||||
if not self._client or action != "add" or not content:
|
||||
return
|
||||
|
||||
subdir = _MEMORY_WRITE_TARGET_SUBDIR_MAP.get(target, _DEFAULT_MEMORY_SUBDIR)
|
||||
uri = self._build_memory_uri(subdir)
|
||||
|
||||
def _write():
|
||||
try:
|
||||
client = _VikingClient(
|
||||
self._endpoint, self._api_key,
|
||||
account=self._account, user=self._user, agent=self._agent,
|
||||
)
|
||||
# Add as a user message with memory context so the commit
|
||||
# picks it up as an explicit memory during extraction
|
||||
client.post(f"/api/v1/sessions/{self._session_id}/messages", {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"type": "text", "text": f"[Memory note — {target}] {content}"},
|
||||
],
|
||||
client.post("/api/v1/content/write", {
|
||||
"uri": uri,
|
||||
"content": content,
|
||||
"mode": "create",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("OpenViking memory mirror failed: %s", e)
|
||||
@@ -858,24 +888,27 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
||||
if not content:
|
||||
return tool_error("content is required")
|
||||
|
||||
# Store as a session message that will be extracted during commit.
|
||||
# The category hint helps OpenViking's extraction classify correctly.
|
||||
category = args.get("category", "")
|
||||
text = f"[Remember] {content}"
|
||||
if category:
|
||||
text = f"[Remember — {category}] {content}"
|
||||
subdir = _CATEGORY_SUBDIR_MAP.get(category, _DEFAULT_MEMORY_SUBDIR)
|
||||
uri = self._build_memory_uri(subdir)
|
||||
|
||||
self._client.post(f"/api/v1/sessions/{self._session_id}/messages", {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"type": "text", "text": text},
|
||||
],
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"status": "stored",
|
||||
"message": "Memory recorded. Will be extracted and indexed on session commit.",
|
||||
})
|
||||
# Write directly via content/write API.
|
||||
# This creates the file, stores the content, and queues vector indexing
|
||||
# in a single call — no dependency on session commit / VLM extraction.
|
||||
try:
|
||||
result = self._client.post("/api/v1/content/write", {
|
||||
"uri": uri,
|
||||
"content": content,
|
||||
"mode": "create",
|
||||
})
|
||||
written = result.get("result", {}).get("written_bytes", 0)
|
||||
return json.dumps({
|
||||
"status": "stored",
|
||||
"message": f"Memory stored ({written}b) and queued for vector indexing.",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("OpenViking content/write failed: %s", e)
|
||||
return tool_error(f"Failed to store memory: {e}")
|
||||
|
||||
def _tool_add_resource(self, args: dict) -> str:
|
||||
url = args.get("url", "")
|
||||
|
||||
@@ -7,9 +7,81 @@ Both use per-model api_mode routing:
|
||||
(this profile)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
|
||||
def _flat_model_name(model: str | None) -> str:
|
||||
"""Return the bare OpenCode model ID, tolerating aggregator prefixes."""
|
||||
return (model or "").strip().rsplit("/", 1)[-1].lower()
|
||||
|
||||
|
||||
def _is_kimi_k2_model(model: str | None) -> bool:
|
||||
return _flat_model_name(model).startswith("kimi-k2")
|
||||
|
||||
|
||||
def _is_deepseek_thinking_model(model: str | None) -> bool:
|
||||
m = _flat_model_name(model)
|
||||
if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"):
|
||||
return True
|
||||
return m == "deepseek-reasoner"
|
||||
|
||||
|
||||
class OpenCodeGoProfile(ProviderProfile):
|
||||
"""OpenCode Go - model-specific reasoning controls."""
|
||||
|
||||
def build_api_kwargs_extras(
|
||||
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
extra_body: dict[str, Any] = {}
|
||||
top_level: dict[str, Any] = {}
|
||||
|
||||
if _is_kimi_k2_model(model):
|
||||
# Kimi K2 on OpenCode Go uses Moonshot's native wire shape:
|
||||
# extra_body.thinking (binary toggle) + top-level reasoning_effort
|
||||
# (low|medium|high). Mirrors the KimiProfile (api.moonshot.ai/v1).
|
||||
if not isinstance(reasoning_config, dict):
|
||||
# No config → leave server defaults alone.
|
||||
return extra_body, top_level
|
||||
|
||||
enabled = reasoning_config.get("enabled") is not False
|
||||
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
|
||||
|
||||
if not enabled:
|
||||
return extra_body, top_level
|
||||
|
||||
effort = (reasoning_config.get("effort") or "").strip().lower()
|
||||
if effort in {"xhigh", "max"}:
|
||||
top_level["reasoning_effort"] = "high"
|
||||
elif effort in {"low", "medium", "high"}:
|
||||
top_level["reasoning_effort"] = effort
|
||||
return extra_body, top_level
|
||||
|
||||
if not _is_deepseek_thinking_model(model):
|
||||
return extra_body, top_level
|
||||
|
||||
enabled = True
|
||||
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
|
||||
enabled = False
|
||||
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
|
||||
|
||||
if not enabled:
|
||||
return extra_body, top_level
|
||||
|
||||
if isinstance(reasoning_config, dict):
|
||||
effort = (reasoning_config.get("effort") or "").strip().lower()
|
||||
if effort in {"xhigh", "max"}:
|
||||
top_level["reasoning_effort"] = "max"
|
||||
elif effort in {"low", "medium", "high"}:
|
||||
top_level["reasoning_effort"] = effort
|
||||
|
||||
return extra_body, top_level
|
||||
|
||||
|
||||
opencode_zen = ProviderProfile(
|
||||
name="opencode-zen",
|
||||
aliases=("opencode", "opencode_zen", "zen"),
|
||||
@@ -18,7 +90,7 @@ opencode_zen = ProviderProfile(
|
||||
default_aux_model="gemini-3-flash",
|
||||
)
|
||||
|
||||
opencode_go = ProviderProfile(
|
||||
opencode_go = OpenCodeGoProfile(
|
||||
name="opencode-go",
|
||||
aliases=("opencode_go", "go", "opencode-go-sub"),
|
||||
env_vars=("OPENCODE_GO_API_KEY",),
|
||||
|
||||
@@ -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
|
||||
@@ -61,6 +61,8 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -89,6 +91,8 @@ except (ModuleNotFoundError, ImportError):
|
||||
except ValueError:
|
||||
return str(home)
|
||||
|
||||
from utils import atomic_replace
|
||||
|
||||
|
||||
def _hermes_home() -> Path:
|
||||
"""Resolve HERMES_HOME at call time (NOT module import).
|
||||
@@ -296,14 +300,11 @@ def list_authorized_emails() -> List[str]:
|
||||
|
||||
|
||||
def _persist_credentials(creds: Any, token_path: Path) -> None:
|
||||
"""Atomic-ish JSON write of refreshed credentials."""
|
||||
"""Persist refreshed credentials atomically with private permissions."""
|
||||
try:
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_path.write_text(
|
||||
json.dumps(
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
indent=2,
|
||||
)
|
||||
_write_private_json(
|
||||
token_path,
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
@@ -325,6 +326,38 @@ def _normalize_authorized_user_payload(payload: dict) -> dict:
|
||||
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:
|
||||
@@ -402,25 +435,21 @@ def store_client_secret(path: str) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
target = _client_secret_path()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(data, indent=2))
|
||||
_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)
|
||||
pending.parent.mkdir(parents=True, exist_ok=True)
|
||||
pending.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": state,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": _REDIRECT_URI,
|
||||
"email": email or "",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
_write_private_json(
|
||||
pending,
|
||||
{
|
||||
"state": state,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": _REDIRECT_URI,
|
||||
"email": email or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -548,8 +577,7 @@ def exchange_auth_code(code: str, email: Optional[str] = None) -> None:
|
||||
token_payload["scopes"] = granted_scopes
|
||||
|
||||
token_path = _token_path(email)
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_path.write_text(json.dumps(token_payload, indent=2))
|
||||
_write_private_json(token_path, token_payload)
|
||||
_pending_auth_path(email).unlink(missing_ok=True)
|
||||
|
||||
print(f"OK: Authenticated. Token saved to {token_path}")
|
||||
|
||||
@@ -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,582 @@
|
||||
"""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
|
||||
|
||||
|
||||
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) -> 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
|
||||
|
||||
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"}
|
||||
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", **_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
|
||||
@@ -282,20 +282,24 @@ def _build_payload(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fal_client lazy import (same pattern as image_generation_tool)
|
||||
# fal_client lazy import (shared with image_generation_tool via fal_common)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_fal_client: Any = None
|
||||
|
||||
|
||||
def _load_fal_client() -> Any:
|
||||
"""Lazy-load the ``fal_client`` SDK and cache it on this module.
|
||||
|
||||
Delegates the actual import to :func:`tools.fal_common.import_fal_client`
|
||||
so the ``lazy_deps`` ensure-install handling stays in one place.
|
||||
"""
|
||||
global _fal_client
|
||||
if _fal_client is not None:
|
||||
return _fal_client
|
||||
import fal_client # type: ignore
|
||||
|
||||
_fal_client = fal_client
|
||||
return fal_client
|
||||
from tools.fal_common import import_fal_client
|
||||
_fal_client = import_fal_client()
|
||||
return _fal_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,7 +11,7 @@ Originally salvaged from PR #10600 by @Jaaneek; reshaped into the
|
||||
generate-only surface.
|
||||
|
||||
Authentication: xAI Grok OAuth tokens (preferred — billed against the
|
||||
user's SuperGrok subscription) or ``XAI_API_KEY``. Both routes are
|
||||
user's SuperGrok or X Premium+ subscription) or ``XAI_API_KEY``. Both routes are
|
||||
resolved through ``tools.xai_http.resolve_xai_http_credentials`` so a
|
||||
single login covers chat + TTS + image gen + video gen + transcription.
|
||||
Output is an HTTPS URL from xAI's CDN; the gateway downloads and
|
||||
@@ -216,7 +216,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
# Auth resolution lives entirely in the shared ``xai_grok`` post_setup
|
||||
# hook (``hermes_cli/tools_config.py``) so the picker doesn't blindly
|
||||
# prompt for an API key when the user is already signed in via xAI
|
||||
# Grok OAuth (SuperGrok Subscription) — TTS / image gen / video gen
|
||||
# Grok OAuth (SuperGrok / Premium+) — TTS / image gen / video gen
|
||||
# all share the same credential resolver. The hook offers an
|
||||
# OAuth-vs-API-key choice when neither is configured.
|
||||
return {
|
||||
@@ -295,7 +295,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
return error_response(
|
||||
error=(
|
||||
"No xAI credentials found. Sign in via `hermes auth add xai-oauth` "
|
||||
"(SuperGrok subscription) or set XAI_API_KEY from "
|
||||
"(SuperGrok / Premium+) or set XAI_API_KEY from "
|
||||
"https://console.x.ai/."
|
||||
),
|
||||
error_type="auth_required",
|
||||
|
||||
Reference in New Issue
Block a user