Merge upstream main into feat/hermes-relay-shared-metrics

# Conflicts:
#	MANIFEST.in
#	pyproject.toml
#	tests/test_project_metadata.py
This commit is contained in:
Alex Fournier 2026-07-23 07:22:03 -07:00
commit b4e105031a
506 changed files with 33840 additions and 5678 deletions

View file

@ -3579,6 +3579,7 @@
h(MetaRow, { label: tx(i18n, "status", "Status"), value: t.status }),
h(AssigneeEditor, { task: t, onPatch: props.onPatch }),
h(PriorityEditor, { task: t, onPatch: props.onPatch }),
h(ModelEditor, { task: t, onPatch: props.onPatch }),
t.tenant ? h(MetaRow, { label: tx(i18n, "tenant", "Tenant"), value: t.tenant }) : null,
h(MetaRow, {
label: tx(i18n, "workspace", "Workspace"),
@ -3985,6 +3986,173 @@
);
}
// Module-level cache for the model-options catalog so opening several
// task drawers doesn't refetch. { providers: [{slug,label,models}] }
let _modelCatalogCache = null;
let _modelCatalogPromise = null;
function fetchModelCatalog() {
if (_modelCatalogCache) return Promise.resolve(_modelCatalogCache);
if (_modelCatalogPromise) return _modelCatalogPromise;
_modelCatalogPromise = SDK.fetchJSON(`${API}/model-options`)
.then(function (data) {
_modelCatalogCache = data && Array.isArray(data.providers) ? data : { providers: [] };
return _modelCatalogCache;
})
.catch(function () {
_modelCatalogPromise = null; // allow retry on next open
return { providers: [] };
});
return _modelCatalogPromise;
}
// Per-task model override dropdown. Value encoding: "" = profile
// default; "<slug>\u0000<model>" = provider+model pair (the separator
// can't appear in either half). A catalog fetch failure degrades to a
// free-text input so the override is still settable.
function ModelEditor(props) {
const { t } = useI18n();
const task = props.task;
const [editing, setEditing] = useState(false);
const [catalog, setCatalog] = useState(_modelCatalogCache);
const [busy, setBusy] = useState(false);
const [freeText, setFreeText] = useState("");
useEffect(function () {
if (!editing || catalog) return;
let alive = true;
fetchModelCatalog().then(function (data) {
if (alive) setCatalog(data);
});
return function () { alive = false; };
}, [editing, catalog]);
const current = task.model_override
? (task.provider_override
? `${task.provider_override}: ${task.model_override}`
: task.model_override)
: tx(t, "modelProfileDefault", "profile default");
if (!editing) {
return h("div", { className: "hermes-kanban-meta-row" },
h("span", { className: "hermes-kanban-meta-label" }, tx(t, "model", "Model")),
h("span", {
className: cn(
"hermes-kanban-meta-value hermes-kanban-editable",
!task.model_override ? "text-muted-foreground" : "",
),
onClick: function () { setEditing(true); },
title: tx(t, "clickToEditModel",
"Click to override the model for this task's next run"),
}, current),
);
}
const apply = function (patch) {
setBusy(true);
props.onPatch(patch).then(function () {
setEditing(false);
}).catch(function () {
// onPatch surfaces its own toast; just re-enable the control.
}).then(function () { setBusy(false); });
};
const onPick = function (value) {
if (value === "") {
apply({ clear_model_override: true });
return;
}
const sep = value.indexOf("\u0000");
if (sep === -1) {
apply({ model_override: value });
return;
}
apply({
provider_override: value.slice(0, sep),
model_override: value.slice(sep + 1),
});
};
const providers = (catalog && catalog.providers) || [];
const loading = editing && !catalog;
const currentValue = task.model_override
? (task.provider_override
? `${task.provider_override}\u0000${task.model_override}`
: task.model_override)
: "";
// Free-text fallback when the catalog is empty (inventory unavailable
// or zero authenticated providers).
if (!loading && providers.length === 0) {
const saveFree = function () {
const v = freeText.trim();
if (!v) { apply({ clear_model_override: true }); return; }
apply({ model_override: v });
};
return h("div", { className: "hermes-kanban-meta-row" },
h("span", { className: "hermes-kanban-meta-label" }, tx(t, "model", "Model")),
h(Input, {
value: freeText, autoFocus: true, disabled: busy,
placeholder: tx(t, "modelFreeTextPlaceholder", "model name (empty = profile default)"),
onChange: function (e) { setFreeText(e.target.value); },
onKeyDown: function (e) {
if (e.key === "Enter") { e.preventDefault(); saveFree(); }
if (e.key === "Escape") setEditing(false);
},
className: "h-7 text-xs flex-1",
style: { textTransform: "none" },
autoCapitalize: "none", autoCorrect: "off", spellCheck: false,
}),
);
}
// Ensure the current override is selectable even when it's not in the
// catalog (e.g. set from the CLI with a model the catalog doesn't list).
let currentInCatalog = currentValue === "";
for (let i = 0; i < providers.length && !currentInCatalog; i++) {
const p = providers[i];
for (let j = 0; j < p.models.length; j++) {
const enc = `${p.slug}\u0000${p.models[j]}`;
if (enc === currentValue || p.models[j] === currentValue) {
currentInCatalog = true;
break;
}
}
}
return h("div", { className: "hermes-kanban-meta-row" },
h("span", { className: "hermes-kanban-meta-label" }, tx(t, "model", "Model")),
loading
? h("span", { className: "hermes-kanban-meta-value text-muted-foreground" },
tx(t, "modelLoading", "loading models…"))
: h("select", {
className: "hermes-kanban-recovery-select",
value: currentValue,
disabled: busy,
autoFocus: true,
onChange: function (e) { onPick(e.target.value); },
onKeyDown: function (e) {
if (e.key === "Escape") setEditing(false);
},
},
h("option", { value: "" },
tx(t, "modelProfileDefaultOption", "(profile default)")),
!currentInCatalog
? h("option", { value: currentValue }, current)
: null,
providers.map(function (p) {
return h("optgroup", { key: p.slug, label: p.label || p.slug },
p.models.map(function (m) {
return h("option", {
key: `${p.slug}\u0000${m}`,
value: `${p.slug}\u0000${m}`,
}, m);
}),
);
}),
),
);
}
function BodyEditor(props) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);

View file

@ -608,6 +608,8 @@ class CreateTaskBody(BaseModel):
skills: Optional[list[str]] = None
goal_mode: bool = False
goal_max_turns: Optional[int] = None
model_override: Optional[str] = None
provider_override: Optional[str] = None
@router.post("/tasks")
@ -632,6 +634,8 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
skills=payload.skills,
goal_mode=payload.goal_mode,
goal_max_turns=payload.goal_max_turns,
model_override=payload.model_override,
provider_override=payload.provider_override,
)
task = kanban_db.get_task(conn, task_id)
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
@ -815,6 +819,13 @@ class UpdateTaskBody(BaseModel):
# complete --summary ... --metadata ...``.
summary: Optional[str] = None
metadata: Optional[dict] = None
# Per-task model/provider override (the board's model dropdown).
# ``model_override=""`` clears both. ``clear_model_override=True`` is
# the explicit clear signal — needed because Optional[str]=None means
# "field not sent" in a PATCH, not "set to NULL".
model_override: Optional[str] = None
provider_override: Optional[str] = None
clear_model_override: bool = False
@router.patch("/tasks/{task_id}")
@ -894,6 +905,22 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
detail=f"status transition to {s!r} not valid from current state",
)
# --- model/provider override ---------------------------------------
if payload.clear_model_override or payload.model_override is not None:
new_model = (
None if payload.clear_model_override
else (payload.model_override or "").strip() or None
)
try:
ok = kanban_db.set_model_override(
conn, task_id, new_model,
provider=payload.provider_override,
)
except (ValueError, RuntimeError) as e:
raise HTTPException(status_code=400, detail=str(e))
if not ok:
raise HTTPException(status_code=404, detail="task not found")
# --- priority -----------------------------------------------------
if payload.priority is not None:
with kanban_db.write_txn(conn):
@ -1155,6 +1182,10 @@ class BulkTaskBody(BaseModel):
summary: Optional[str] = None
metadata: Optional[dict] = None
reclaim_first: bool = False
# Bulk model/provider override — same semantics as UpdateTaskBody.
model_override: Optional[str] = None
provider_override: Optional[str] = None
clear_model_override: bool = False
@router.post("/tasks/bulk")
@ -1246,6 +1277,20 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
(tid, json.dumps({"priority": int(payload.priority)}),
int(time.time())),
)
if payload.clear_model_override or payload.model_override is not None:
new_model = (
None if payload.clear_model_override
else (payload.model_override or "").strip() or None
)
try:
ok = kanban_db.set_model_override(
conn, tid, new_model,
provider=payload.provider_override,
)
if not ok:
entry.update(ok=False, error="model override refused")
except (ValueError, RuntimeError) as e:
entry.update(ok=False, error=str(e))
except Exception as e: # defensive — one bad id shouldn't kill the batch
entry.update(ok=False, error=str(e))
results.append(entry)
@ -1971,6 +2016,49 @@ def dispatch(
conn.close()
# ---------------------------------------------------------------------------
# Model options (the board's per-task model-override dropdown)
# ---------------------------------------------------------------------------
@router.get("/model-options")
def model_options():
"""Authenticated providers + curated model lists for the task drawer's
model-override dropdown.
Thin wrapper around ``hermes_cli.inventory.build_models_payload`` — the
same substrate the dashboard Models page and the TUI picker use, so the
dropdown can never offer a model/provider pair the rest of Hermes
wouldn't accept. Deliberately skips pricing/capability enrichment and
custom-provider probes: the dropdown needs names fast, not $/Mtok
columns (a slow/offline local endpoint must not hang the drawer).
"""
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
payload = build_models_payload(
load_picker_context(),
explicit_only=True,
canonical_order=True,
probe_custom_providers=False,
)
return {
"providers": [
{
"slug": row.get("slug", ""),
"label": row.get("label") or row.get("slug", ""),
"models": list(row.get("models") or []),
}
for row in payload.get("providers", [])
if row.get("models")
],
}
except Exception:
log.exception("kanban model-options failed")
# Degrade to an empty catalog — the UI falls back to a free-text
# input so the feature still works without the inventory module.
return {"providers": []}
# ---------------------------------------------------------------------------
# Boards CRUD (multi-project support)
# ---------------------------------------------------------------------------

View file

@ -26,6 +26,7 @@ import time
from collections import defaultdict
from contextlib import suppress
from typing import Callable, Dict, List, Optional, Any, Tuple
from urllib.parse import urljoin
from agent.async_utils import (
consume_detached_task_result as _consume_background_task_result,
@ -67,6 +68,8 @@ _DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({
"non_conversational",
"non_conversational_history",
})
_DISCORD_IMAGE_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
_DISCORD_IMAGE_MAX_REDIRECTS = 10
# Upgrade-bridge fallback only. The primary mechanism is the persisted
# non-conversational message-ID set populated from explicitly marked sends
# (metadata["non_conversational"]). These regexes exist solely to recognize
@ -135,6 +138,43 @@ from gateway.platforms.base import (
from tools.url_safety import is_safe_url
async def _read_url_image_with_redirect_guard(
session: Any,
url: str,
*,
timeout: Any,
request_kwargs: Dict[str, Any],
) -> Tuple[int, bytes, Dict[str, str]]:
"""Read an image URL while re-checking every redirect target for SSRF."""
current_url = url
for _ in range(_DISCORD_IMAGE_MAX_REDIRECTS + 1):
if not is_safe_url(current_url):
raise ValueError("Blocked unsafe image URL redirect")
async with session.get(
current_url,
timeout=timeout,
allow_redirects=False,
**request_kwargs,
) as resp:
raw_headers = getattr(resp, "headers", {}) or {}
headers = {str(key).lower(): value for key, value in dict(raw_headers).items()}
status = int(getattr(resp, "status", 0))
if status in _DISCORD_IMAGE_REDIRECT_STATUSES:
location = headers.get("location")
if not location:
return status, b"", headers
next_url = urljoin(current_url, str(location))
if not is_safe_url(next_url):
raise ValueError("Blocked redirect to private/internal address")
current_url = next_url
continue
return status, await resp.read(), headers
raise ValueError("Too many image URL redirects")
def _truncate_discord_component_text(text: str, limit: int) -> str:
"""Return text within Discord's UTF-16 component field budget."""
return _prefix_within_utf16_limit(str(text or ""), max(0, limit))
@ -3396,25 +3436,27 @@ class DiscordAdapter(BasePlatformAdapter):
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
if aiohttp_session is None:
aiohttp_session = _aiohttp.ClientSession(**_sess_kw)
async with aiohttp_session.get(
image_url, timeout=_aiohttp.ClientTimeout(total=30), **_req_kw,
) as resp:
if resp.status != 200:
logger.warning(
"[%s] Failed to download image (HTTP %d) in batch: %s",
self.name, resp.status, image_url[:80],
)
continue
data = await resp.read()
ct = resp.headers.get("content-type", "image/png")
ext = "png"
if "jpeg" in ct or "jpg" in ct:
ext = "jpg"
elif "gif" in ct:
ext = "gif"
elif "webp" in ct:
ext = "webp"
files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}"))
status, data, headers = await _read_url_image_with_redirect_guard(
aiohttp_session,
image_url,
timeout=_aiohttp.ClientTimeout(total=30),
request_kwargs=_req_kw,
)
if status != 200:
logger.warning(
"[%s] Failed to download image (HTTP %d) in batch: %s",
self.name, status, image_url[:80],
)
continue
ct = headers.get("content-type", "image/png")
ext = "png"
if "jpeg" in ct or "jpg" in ct:
ext = "jpg"
elif "gif" in ct:
ext = "gif"
elif "webp" in ct:
ext = "webp"
files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}"))
except Exception as dl_err:
logger.warning("[%s] Download failed for %s: %s", self.name, image_url[:80], dl_err)
continue
@ -4531,37 +4573,40 @@ class DiscordAdapter(BasePlatformAdapter):
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
async with aiohttp.ClientSession(**_sess_kw) as session:
async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp:
if resp.status != 200:
raise Exception(f"Failed to download image: HTTP {resp.status}")
status, image_data, headers = await _read_url_image_with_redirect_guard(
session,
image_url,
timeout=aiohttp.ClientTimeout(total=30),
request_kwargs=_req_kw,
)
if status != 200:
raise Exception(f"Failed to download image: HTTP {status}")
image_data = await resp.read()
# Determine filename from URL or content type
content_type = headers.get("content-type", "image/png")
ext = "png"
if "jpeg" in content_type or "jpg" in content_type:
ext = "jpg"
elif "gif" in content_type:
ext = "gif"
elif "webp" in content_type:
ext = "webp"
# Determine filename from URL or content type
content_type = resp.headers.get("content-type", "image/png")
ext = "png"
if "jpeg" in content_type or "jpg" in content_type:
ext = "jpg"
elif "gif" in content_type:
ext = "gif"
elif "webp" in content_type:
ext = "webp"
import io
file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}")
import io
file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}")
if self._is_forum_parent(channel):
return await self._forum_post_file(
channel,
content=(caption or "").strip(),
file=file,
)
msg = await channel.send(
content=caption if caption else None,
if self._is_forum_parent(channel):
return await self._forum_post_file(
channel,
content=(caption or "").strip(),
file=file,
)
return SendResult(success=True, message_id=str(msg.id))
msg = await channel.send(
content=caption if caption else None,
file=file,
)
return SendResult(success=True, message_id=str(msg.id))
except ImportError:
logger.warning(
@ -4610,27 +4655,30 @@ class DiscordAdapter(BasePlatformAdapter):
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
async with aiohttp.ClientSession(**_sess_kw) as session:
async with session.get(animation_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp:
if resp.status != 200:
raise Exception(f"Failed to download animation: HTTP {resp.status}")
status, animation_data, _headers = await _read_url_image_with_redirect_guard(
session,
animation_url,
timeout=aiohttp.ClientTimeout(total=30),
request_kwargs=_req_kw,
)
if status != 200:
raise Exception(f"Failed to download animation: HTTP {status}")
animation_data = await resp.read()
import io
file = discord.File(io.BytesIO(animation_data), filename="animation.gif")
import io
file = discord.File(io.BytesIO(animation_data), filename="animation.gif")
if self._is_forum_parent(channel):
return await self._forum_post_file(
channel,
content=(caption or "").strip(),
file=file,
)
msg = await channel.send(
content=caption if caption else None,
if self._is_forum_parent(channel):
return await self._forum_post_file(
channel,
content=(caption or "").strip(),
file=file,
)
return SendResult(success=True, message_id=str(msg.id))
msg = await channel.send(
content=caption if caption else None,
file=file,
)
return SendResult(success=True, message_id=str(msg.id))
except ImportError:
logger.warning(
@ -9474,7 +9522,7 @@ def register(ctx) -> None:
check_fn=check_discord_requirements,
is_connected=_is_connected,
required_env=["DISCORD_BOT_TOKEN"],
install_hint="pip install 'hermes-agent[messaging]'",
install_hint="Run `hermes setup` to install Discord support.",
# Interactive setup wizard — replaces the central
# hermes_cli/setup.py::_setup_discord function. Same shape as Teams.
setup_fn=interactive_setup,

View file

@ -53,7 +53,9 @@ class DiscordRecoveryStore:
return default
def _initialize(self, conn: sqlite3.Connection) -> None:
conn.execute("PRAGMA journal_mode=WAL")
from hermes_state import apply_wal_with_fallback
apply_wal_with_fallback(conn, db_label="discord_recovery.db")
conn.execute("""
CREATE TABLE IF NOT EXISTS discord_messages (
message_id TEXT PRIMARY KEY,

View file

@ -5465,7 +5465,7 @@ async def _standalone_send(
(images, video, voice, documents). Replaces the legacy _send_feishu helper.
"""
if not FEISHU_AVAILABLE:
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
return {"error": "Feishu dependencies not installed. Run `hermes setup` to install Feishu support."}
media_files = media_files or []
try:
@ -5713,7 +5713,7 @@ def register(ctx) -> None:
is_connected=_is_connected,
validate_config=_is_connected,
required_env=["FEISHU_APP_ID", "FEISHU_APP_SECRET"],
install_hint="pip install 'hermes-agent[feishu]'",
install_hint="Run `hermes setup` to install Feishu support.",
setup_fn=interactive_setup,
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="FEISHU_ALLOWED_USERS",

View file

@ -3689,7 +3689,7 @@ def register(ctx) -> None:
required_env=[
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON",
],
install_hint="pip install 'hermes-agent[google_chat]'",
install_hint="Run `hermes setup` to install Google Chat support.",
setup_fn=interactive_setup,
# Env-driven auto-configuration — the core env-populator hook calls
# this during ``_apply_env_overrides`` and seeds

View file

@ -199,7 +199,7 @@ def load_user_credentials(email: Optional[str] = None) -> Optional[Any]:
except ImportError:
logger.warning(
"[google_chat_user_oauth] google-auth not installed; user-OAuth "
"attachment delivery is disabled. Install hermes-agent[google_chat]."
"attachment delivery is disabled. Run `hermes setup` to install Google Chat support."
)
return None
@ -388,8 +388,7 @@ def install_deps() -> bool:
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]'")
print("Run `hermes setup` to repair the managed installation, then retry.")
return False

View file

@ -44,12 +44,24 @@ _CHANNEL_TYPE_MAP = {
"O": "channel",
}
_MATTERMOST_DISABLE_MENTIONS_PROPS = {"disable_mentions": True}
# Reconnect parameters (exponential backoff).
_RECONNECT_BASE_DELAY = 2.0
_RECONNECT_MAX_DELAY = 60.0
_RECONNECT_JITTER = 0.2
def _with_mentions_disabled(payload: Dict[str, Any]) -> Dict[str, Any]:
"""Return a post payload that prevents Mattermost from firing mentions."""
props = payload.get("props")
if isinstance(props, dict):
payload["props"] = {**props, **_MATTERMOST_DISABLE_MENTIONS_PROPS}
else:
payload["props"] = dict(_MATTERMOST_DISABLE_MENTIONS_PROPS)
return payload
def check_mattermost_requirements() -> bool:
"""Return True if the Mattermost adapter runtime dependency is available."""
try:
@ -361,10 +373,10 @@ class MattermostAdapter(BasePlatformAdapter):
last_id = None
for chunk in chunks:
payload: Dict[str, Any] = {
payload: Dict[str, Any] = _with_mentions_disabled({
"channel_id": chat_id,
"message": chunk,
}
})
# Thread support: reply_to or metadata["thread_id"] is the root post ID.
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
@ -407,7 +419,7 @@ class MattermostAdapter(BasePlatformAdapter):
formatted = self.format_message(content)
data = await self._api_put(
f"posts/{message_id}/patch",
{"message": formatted},
_with_mentions_disabled({"message": formatted}),
)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to edit post")
@ -543,11 +555,11 @@ class MattermostAdapter(BasePlatformAdapter):
if not file_id:
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
payload: Dict[str, Any] = {
payload: Dict[str, Any] = _with_mentions_disabled({
"channel_id": chat_id,
"message": caption or "",
"file_ids": [file_id],
}
})
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
payload["root_id"] = resolved_root
@ -584,11 +596,11 @@ class MattermostAdapter(BasePlatformAdapter):
if not file_id:
return SendResult(success=False, error="File upload failed")
payload: Dict[str, Any] = {
payload: Dict[str, Any] = _with_mentions_disabled({
"channel_id": chat_id,
"message": caption or "",
"file_ids": [file_id],
}
})
resolved_root = await self._thread_root_for_send(reply_to, metadata)
if resolved_root:
payload["root_id"] = resolved_root
@ -672,11 +684,11 @@ class MattermostAdapter(BasePlatformAdapter):
if not file_ids:
continue
payload: Dict[str, Any] = {
payload: Dict[str, Any] = _with_mentions_disabled({
"channel_id": chat_id,
"message": "\n".join(caption_parts),
"file_ids": file_ids,
}
})
resolved_root = await self._thread_root_for_send(None, metadata)
if resolved_root:
payload["root_id"] = resolved_root

File diff suppressed because it is too large Load diff

View file

@ -37,3 +37,9 @@ optional_env:
description: "Display name for the Slack home channel"
prompt: "Home channel display name"
password: false
- name: SLACK_THREAD_REQUIRE_MENTION
description: >-
Require an explicit @mention for Slack thread replies while preserving
top-level free response channels
prompt: "Require mentions in Slack threads? (true/false)"
password: false

View file

@ -9449,7 +9449,7 @@ def register(ctx) -> None:
check_fn=check_telegram_requirements,
is_connected=_is_connected,
required_env=["TELEGRAM_BOT_TOKEN"],
install_hint="pip install 'hermes-agent[telegram]'",
install_hint="Run `hermes setup` to install Telegram support.",
setup_fn=interactive_setup,
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="TELEGRAM_ALLOWED_USERS",

View file

@ -1861,7 +1861,7 @@ def register(ctx) -> None:
is_connected=_is_connected,
validate_config=_is_connected,
required_env=["WECOM_BOT_ID", "WECOM_SECRET"],
install_hint="pip install 'hermes-agent[wecom]'",
install_hint="Run `hermes setup` to install WeCom support.",
setup_fn=interactive_setup,
allowed_users_env="WECOM_ALLOWED_USERS",
allow_all_env="WECOM_ALLOW_ALL_USERS",
@ -1881,7 +1881,7 @@ def register(ctx) -> None:
is_connected=_callback_is_connected,
validate_config=_callback_is_connected,
required_env=["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"],
install_hint="pip install 'hermes-agent[wecom]'",
install_hint="Run `hermes setup` to install WeCom support.",
allowed_users_env="WECOM_CALLBACK_ALLOWED_USERS",
allow_all_env="WECOM_CALLBACK_ALLOW_ALL_USERS",
emoji="💼",