Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson
2026-05-09 09:57:46 -04:00
60 changed files with 4932 additions and 411 deletions
+5 -1
View File
@@ -127,7 +127,11 @@ class MemoryStore:
def _init_db(self) -> None:
"""Create tables, indexes, and triggers if they do not exist. Enable WAL mode."""
self._conn.execute("PRAGMA journal_mode=WAL")
# Use the shared WAL-fallback helper so memory_store.db degrades
# gracefully on NFS/SMB/FUSE-mounted HERMES_HOME (same issue as
# state.db / kanban.db — see hermes_state._WAL_INCOMPAT_MARKERS).
from hermes_state import apply_wal_with_fallback
apply_wal_with_fallback(self._conn, db_label="memory_store.db (holographic)")
self._conn.executescript(_SCHEMA)
# Migrate: add hrr_vector column if missing (safe for existing databases)
columns = {row[1] for row in self._conn.execute("PRAGMA table_info(facts)").fetchall()}
+8 -7
View File
@@ -100,18 +100,19 @@ class _VikingClient:
raise ImportError("httpx is required for OpenViking: pip install httpx")
def _headers(self) -> dict:
# Only send tenant headers when the user actually configured them.
# Legacy installs had account/user defaulted to the literal string
# "default" — treat that as unset so authenticated remote servers
# that derive tenancy from the Bearer key aren't overridden by a
# bogus tenant value.
# Always send tenant headers when account/user are configured.
# OpenViking 0.3.x requires X-OpenViking-Account and X-OpenViking-User
# for ROOT API key requests to tenant-scoped APIs — omitting them
# causes INVALID_ARGUMENT errors even when account="default".
# User-level keys can omit them (server derives tenancy from the key),
# but ROOT keys must always include them explicitly.
h = {
"Content-Type": "application/json",
"X-OpenViking-Agent": self._agent,
}
if self._account and self._account != "default":
if self._account:
h["X-OpenViking-Account"] = self._account
if self._user and self._user != "default":
if self._user:
h["X-OpenViking-User"] = self._user
if self._api_key:
h["X-API-Key"] = self._api_key
+181 -1
View File
@@ -1010,13 +1010,30 @@ class GoogleChatAdapter(BasePlatformAdapter):
+ (sender_email or "unknown").replace("@", "_at_").replace(".", "_")
)
text = envelope.get("text", "") or ""
# Honor the relay's declared sender_type when present so the
# downstream BOT self-filter (sender_type == "BOT") fires for
# bot-originated messages forwarded by the relay. Hardcoding
# "HUMAN" here meant the bot would re-process its own replies
# if the relay forwarded them, and allowed a relay envelope to
# impersonate any allowlisted user without ever being marked
# as a bot. Default to "HUMAN" for backward compatibility when
# the relay does not provide the field.
#
# Operator contract: the relay MUST forward sender.type from
# the upstream Chat event as ``sender_type``. Relays that
# forward bot replies as HUMAN (or omit the field) cannot be
# distinguished from genuine humans here.
sender_type_raw = (envelope.get("sender_type") or "HUMAN")
sender_type = str(sender_type_raw).strip().upper() or "HUMAN"
if sender_type not in {"HUMAN", "BOT"}:
sender_type = "HUMAN"
msg: Dict[str, Any] = {
"name": envelope.get("message_name", "") or "",
"sender": {
"name": sender_name_surrogate,
"email": sender_email,
"displayName": sender_display,
"type": "HUMAN",
"type": sender_type,
},
"text": text,
"argumentText": text,
@@ -3019,6 +3036,165 @@ def interactive_setup() -> None:
print_info("Restart the gateway: hermes gateway restart")
# Strict resource-name pattern. ``spaces/<id>`` and ``users/<id>`` must
# only contain Google Chat's documented character set; anything else
# means a tampered chat_id trying to break out of the REST URL path
# (path traversal, ``?`` query injection, ``#`` fragment truncation).
_GCHAT_CHAT_ID_RE = re.compile(r"^(?:spaces|users)/[A-Za-z0-9_-]+$")
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]:
"""POST a single Google Chat message via the REST API without the SDK.
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=google_chat`` cron jobs fail with ``No live adapter for
platform``.
Configuration: requires service-account credentials via
``GOOGLE_CHAT_SERVICE_ACCOUNT_JSON``, ``GOOGLE_APPLICATION_CREDENTIALS``,
or Application Default Credentials, and a space resource name as
``chat_id`` (e.g. ``spaces/AAAA-BBBB`` or ``users/<id>``).
Security: ``chat_id`` is validated against the documented Google Chat
resource-name character set before substitution into the REST URL so
a tampered value cannot path-traverse or query-inject.
``media_files`` and ``force_document`` are accepted for signature
parity but are not implemented for the standalone path; messages with
attachments send as text-only. The live adapter handles attachments.
"""
if not chat_id:
return {"error": "Google Chat standalone send: chat_id (space resource) is required"}
if not _GCHAT_CHAT_ID_RE.match(chat_id):
return {"error": (
f"Google Chat standalone send: chat_id {chat_id!r} must match "
f"'spaces/<id>' or 'users/<id>' with only [A-Za-z0-9_-] in the id"
)}
if thread_id is not None and not re.match(r"^spaces/[A-Za-z0-9_-]+/threads/[A-Za-z0-9_-]+$", thread_id):
return {"error": (
f"Google Chat standalone send: thread_id {thread_id!r} must match "
f"'spaces/<id>/threads/<id>'"
)}
extra = getattr(pconfig, "extra", {}) or {}
sa_value = (
extra.get("service_account_json")
or os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON")
or os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
)
if service_account is None:
return {"error": "Google Chat standalone send: google-auth not installed"}
try:
from google.auth.transport.requests import Request as _GoogleAuthRequest
except Exception as e:
return {"error": f"Google Chat standalone send: google-auth import failed: {e}"}
try:
if sa_value:
stripped = sa_value.lstrip()
if stripped.startswith("{"):
try:
info = json.loads(sa_value)
except json.JSONDecodeError as exc:
return {"error": f"Google Chat standalone send: inline SA JSON is invalid: {exc}"}
creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)
else:
if not os.path.exists(sa_value):
return {"error": f"Google Chat standalone send: SA JSON file not found at {sa_value}"}
try:
with open(sa_value, "r", encoding="utf-8") as fh:
info = json.load(fh)
except json.JSONDecodeError as exc:
return {"error": f"Google Chat standalone send: SA JSON file is invalid: {exc}"}
creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)
else:
try:
import google.auth as _google_auth
except ImportError:
return {"error": (
"Google Chat standalone send: no SA credentials configured "
"and google-auth is not installed for ADC fallback"
)}
try:
creds, _project = _google_auth.default(scopes=_CHAT_SCOPES)
except Exception as exc:
return {"error": (
f"Google Chat standalone send: no SA credentials configured "
f"and Application Default Credentials are unavailable: {exc}"
)}
except asyncio.CancelledError:
raise
except Exception as e:
return {"error": f"Google Chat standalone send: credential load failed: {e}"}
# Bound the synchronous urllib3-backed token refresh so a hung Google
# STS endpoint cannot stall the cron scheduler indefinitely.
try:
await asyncio.wait_for(
asyncio.to_thread(creds.refresh, _GoogleAuthRequest()),
timeout=10.0,
)
except asyncio.TimeoutError:
return {"error": "Google Chat standalone send: token refresh timed out"}
except asyncio.CancelledError:
raise
except Exception as e:
return {"error": f"Google Chat standalone send: token refresh failed: {e}"}
token = getattr(creds, "token", None)
if not token:
return {"error": "Google Chat standalone send: refreshed credentials have no token"}
body: Dict[str, Any] = {"text": message}
if thread_id:
body["thread"] = {"name": thread_id}
url = f"https://chat.googleapis.com/v1/{chat_id}/messages"
try:
import aiohttp as _aiohttp
except ImportError:
return {"error": "Google Chat standalone send: aiohttp not installed"}
try:
async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0)) as session:
async with session.post(
url,
json=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
) as resp:
if resp.status >= 400:
text = await resp.text()
return {"error": (
f"Google Chat standalone send: API returned "
f"{resp.status}: {text[:300]}"
)}
payload = await resp.json()
return {
"success": True,
"message_id": payload.get("name"),
}
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Google Chat standalone send raised", exc_info=True)
return {"error": f"Google Chat standalone send failed: {e}"}
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system at startup.
@@ -3052,6 +3228,10 @@ def register(ctx) -> None:
# cron jobs route to the configured home space without editing
# cron/scheduler.py's hardcoded sets.
cron_deliver_env_var="GOOGLE_CHAT_HOME_CHANNEL",
# Out-of-process cron delivery via the Chat REST API. Without this
# hook, deliver=google_chat 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="GOOGLE_CHAT_ALLOWED_USERS",
allow_all_env="GOOGLE_CHAT_ALLOW_ALL_USERS",
+231 -7
View File
@@ -53,11 +53,6 @@ from gateway.session import SessionSource
from gateway.config import PlatformConfig, Platform
def _ensure_imports():
"""No-op — kept for backward compatibility with any call sites."""
pass
# ---------------------------------------------------------------------------
# IRC protocol helpers
# ---------------------------------------------------------------------------
@@ -704,8 +699,233 @@ def _env_enablement() -> dict | None:
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."""
"""Plugin entry point: called by the Hermes plugin system."""
ctx.register_platform(
name="irc",
label="IRC",
@@ -716,7 +936,7 @@ def register(ctx):
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
# 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,
@@ -724,6 +944,10 @@ def register(ctx):
# 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",
+174
View File
@@ -418,6 +418,9 @@ def _env_enablement() -> dict | None:
seed["port"] = int(port)
except ValueError:
pass
service_url = os.getenv("TEAMS_SERVICE_URL", "").strip()
if service_url:
seed["service_url"] = service_url
home = os.getenv("TEAMS_HOME_CHANNEL", "").strip()
if home:
seed["home_channel"] = {
@@ -427,6 +430,173 @@ def _env_enablement() -> dict | None:
return seed
# Bot Framework default service URL for the global Teams endpoint. Some
# regional/government tenants need a different host (e.g.
# ``https://smba.infra.gov.teams.microsoft.us/``) which can be supplied via
# ``TEAMS_SERVICE_URL`` or ``extra['service_url']``.
_DEFAULT_TEAMS_SERVICE_URL = "https://smba.trafficmanager.net/teams/"
# Allowlist of Bot Framework service hosts that may receive a freshly
# minted bearer token. Operator-supplied URLs are matched against this
# allowlist to block SSRF / token-exfiltration via a tampered env var.
_ALLOWED_TEAMS_SERVICE_HOSTS = frozenset({
"smba.trafficmanager.net",
"smba.infra.gov.teams.microsoft.us",
})
# Conservative pattern for Bot Framework conversation IDs. Real values
# combine digits, colons, hyphens, dots, '@', and the ``thread.skype`` /
# ``thread.tacv2`` suffixes; reject anything outside this set so a hostile
# value cannot path-traverse out of ``/v3/conversations/<id>/activities``.
import re as _re_teams
_TEAMS_CONV_ID_RE = _re_teams.compile(r"^[A-Za-z0-9:@\-_.]+$")
def _validate_teams_service_url(raw: str) -> Optional[str]:
"""Return a normalized service URL or ``None`` if it is not allowed.
Requires ``https://`` and a host in ``_ALLOWED_TEAMS_SERVICE_HOSTS``.
The trailing slash is added if absent so callers can append
``v3/conversations/...`` without double slashes.
"""
if not raw:
return None
try:
from urllib.parse import urlparse
parsed = urlparse(raw)
except Exception:
return None
if parsed.scheme != "https":
return None
if parsed.hostname not in _ALLOWED_TEAMS_SERVICE_HOSTS:
return None
normalized = raw if raw.endswith("/") else raw + "/"
return normalized
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]:
"""Acquire a Bot Framework bearer token and POST a single message activity.
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=teams`` cron jobs fail with ``No live adapter for platform``.
Configuration: requires ``TEAMS_CLIENT_ID``, ``TEAMS_CLIENT_SECRET``,
``TEAMS_TENANT_ID``, ``TEAMS_HOME_CHANNEL`` (the conversation ID), and
optionally ``TEAMS_SERVICE_URL`` (Bot Framework service host; must be
a known Bot Framework endpoint, see ``_ALLOWED_TEAMS_SERVICE_HOSTS``).
Security: ``service_url`` is validated against an allowlist of known
Bot Framework hosts to block SSRF / token-exfiltration via a tampered
env var. ``chat_id`` is validated to match the documented Bot
Framework ID character set so it cannot escape the URL path.
``media_files`` and ``force_document`` are accepted for signature
parity but not implemented for the standalone path; messages with
attachments will send as text-only. The live adapter handles
attachments via the SDK.
"""
extra = getattr(pconfig, "extra", {}) or {}
client_id = os.getenv("TEAMS_CLIENT_ID") or extra.get("client_id", "")
client_secret = os.getenv("TEAMS_CLIENT_SECRET") or extra.get("client_secret", "")
tenant_id = os.getenv("TEAMS_TENANT_ID") or extra.get("tenant_id", "")
if not (client_id and client_secret and tenant_id):
return {"error": "Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required"}
raw_service_url = (
os.getenv("TEAMS_SERVICE_URL")
or extra.get("service_url", "")
or _DEFAULT_TEAMS_SERVICE_URL
)
service_url = _validate_teams_service_url(raw_service_url)
if service_url is None:
return {"error": (
f"Teams standalone send: TEAMS_SERVICE_URL host is not on the "
f"Bot Framework allowlist; expected one of "
f"{sorted(_ALLOWED_TEAMS_SERVICE_HOSTS)}"
)}
# Bot Framework conversation IDs are restricted to a known character
# set; anything else means a tampered chat_id trying to break out of
# the URL path.
if not chat_id:
return {"error": "Teams standalone send: chat_id (conversation ID) is required"}
if not _TEAMS_CONV_ID_RE.match(chat_id):
return {"error": "Teams standalone send: chat_id contains characters outside the Bot Framework conversation ID set"}
if not _TEAMS_CONV_ID_RE.match(tenant_id):
return {"error": "Teams standalone send: TEAMS_TENANT_ID contains characters outside the expected set"}
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
activities_url = f"{service_url}v3/conversations/{chat_id}/activities"
if not AIOHTTP_AVAILABLE:
return {"error": "Teams standalone send: aiohttp not installed"}
try:
import aiohttp as _aiohttp
# Per-request timeouts so a slow STS endpoint cannot starve the
# subsequent activity POST of its budget.
per_request_timeout = _aiohttp.ClientTimeout(total=15.0)
async with _aiohttp.ClientSession() as session:
async with session.post(
token_url,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "https://api.botframework.com/.default",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=per_request_timeout,
) as token_resp:
if token_resp.status >= 400:
body = await token_resp.text()
return {"error": f"Teams standalone send: token request failed ({token_resp.status}): {body[:300]}"}
token_payload = await token_resp.json()
access_token = token_payload.get("access_token")
if not access_token:
return {"error": "Teams standalone send: token response missing access_token"}
activity = {
"type": "message",
"text": message,
"textFormat": "markdown",
}
async with session.post(
activities_url,
json=activity,
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
timeout=per_request_timeout,
) as send_resp:
if send_resp.status >= 400:
body = await send_resp.text()
return {"error": f"Teams standalone send: activity post failed ({send_resp.status}): {body[:300]}"}
send_payload = await send_resp.json()
return {
"success": True,
"message_id": send_payload.get("id"),
}
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Teams standalone send raised", exc_info=True)
return {"error": f"Teams standalone send failed: {e}"}
# Keep the old name as an alias so existing test imports don't break.
check_teams_requirements = check_requirements
@@ -985,6 +1155,10 @@ def register(ctx) -> None:
# jobs route to the configured Teams chat/channel without editing
# cron/scheduler.py's hardcoded sets.
cron_deliver_env_var="TEAMS_HOME_CHANNEL",
# Out-of-process cron delivery via Bot Framework REST. Without
# this hook, deliver=teams 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="TEAMS_ALLOWED_USERS",
allow_all_env="TEAMS_ALLOW_ALL_USERS",