c5ff900761
Sweep of `ruff check . --select F821 --target-version py311`: 2,234 hits. 2,201 are left
alone on purpose: tui_gateway (2,169; bind_module rebinds bodies onto server.py globals,
all names verified to resolve there), the Feishu adapter (27; globals().update() SDK
binding) and the godmode script (5; dead standalone script). The other 33 were all
genuine defects. No lint config change; no TYPE_CHECKING escape hatches — every
annotation names a real, imported type; ty on the touched files: 0 new diagnostics.
- gateway/slash_commands.py: HISTORY_UNREADABLE never imported after #102117
→ NameError on the /btw error branch (same one-liner as #102952).
- gateway/platforms/whatsapp_common.py: `-> Path` return annotation with no Path
import (the body uses `_Path`). Never raised at runtime thanks to
`from __future__ import annotations`, but `typing.get_type_hints()` and ty
both fail on it.
- gateway/run.py: ActivityProvenance imported at module level
(agent.session_activity has no gateway deps); stringly annotation and the
lazy in-function import are gone.
- tools/patch_parser.py: PatchResult imported at module level; real return
annotation. The "avoid circular import" lazy import guarded a cycle that
does not exist (file_operations_common never imports patch_parser).
- gateway/platforms/helpers.py: base.py imports helpers at module level, so
MessageEvent cannot be named here; TextBatchAggregator only reads .text and
.source, so it is typed by a BatchableEvent Protocol that MessageEvent
satisfies structurally.
- tools/mcp_tool_sampling.py: mcp_tool imports this module, so MCPServerTask
cannot be named here; ElicitationHandler only reads
owner._pending_call_context, typed by an ElicitationOwner Protocol.
- plugins/platforms/sms/adapter.py: aiohttp is an optional dep ([messaging] extra) →
module-level try/except ImportError binding `aiohttp = web = None`, the pattern the
homeassistant / webhook / whatsapp_cloud adapters already use. Retires three lazy
in-function imports and the `_aiohttp_available()` wrapper; `_handle_webhook` typed
`web.Request -> web.Response`.
- plugins/platforms/teams/summary_writer.py: plain module-level `import httpx` — httpx is a
hard core dependency (pyproject `httpx[socks]==0.28.1`), so the lazy import and the
"imported on every CLI start" docstring premise were both wrong (plugin discovery never
imports this module; it is reached only via the Teams adapter / meeting pipeline).
Tests:
- tests/hermes_cli/test_config.py: a test body orphaned by the wave-1 prune
(6b81590c55) sat inside the class as dead code with self/tmp_path unbound
— header restored, so the v11→12 custom_providers migration is covered.
- tests/tools/test_mcp_tool.py: @staticmethod recursing on `self` in the
win32 branch; call portalocker directly.
- tests/test_background_review_list_shapes.py: main() still ran 3 pruned tests.
- tests/agent/test_cursor_optimizations_parity.py: bench() used names only
imported inside a sibling test.
- GatewayRunner / FeishuAdapter / Dict / Optional: missing imports.
155 lines
7.8 KiB
Python
155 lines
7.8 KiB
Python
"""Pipeline-facing Teams outbound delivery (meeting-summary writer).
|
|
|
|
Lives inside the Teams platform plugin so the meeting pipeline reuses one Teams
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import os
|
|
from typing import Any, Optional
|
|
from urllib.parse import quote
|
|
|
|
from gateway.config import PlatformConfig
|
|
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret
|
|
|
|
import httpx
|
|
|
|
|
|
def _parse_bool(value: Any, *, default: bool = False) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str):
|
|
normalized = value.strip().lower()
|
|
if normalized in {"1", "true", "yes", "on"}:
|
|
return True
|
|
if normalized in {"0", "false", "no", "off"}:
|
|
return False
|
|
return default
|
|
|
|
|
|
_LIST_SECTIONS = (("Key decisions", "key_decisions"), ("Action items", "action_items"), ("Risks", "risks"))
|
|
# Env fallbacks for delivery config keys, applied only where nothing else set the key (access_token is a scoped secret).
|
|
_ENV_KEYS = {"delivery_mode": "TEAMS_DELIVERY_MODE", "incoming_webhook_url": "TEAMS_INCOMING_WEBHOOK_URL",
|
|
"access_token": "TEAMS_GRAPH_ACCESS_TOKEN", "team_id": "TEAMS_TEAM_ID", "channel_id": "TEAMS_CHANNEL_ID", "chat_id": "TEAMS_CHAT_ID"}
|
|
|
|
|
|
class _StaticAccessTokenProvider:
|
|
"""Minimal token-provider shim so outbound Graph delivery can reuse the shared client."""
|
|
|
|
def __init__(self, access_token: str):
|
|
self._access_token = str(access_token or "").strip()
|
|
|
|
async def get_access_token(self, *, force_refresh: bool = False) -> str:
|
|
if not self._access_token:
|
|
raise ValueError("TEAMS_GRAPH_ACCESS_TOKEN is required for graph delivery mode.")
|
|
return self._access_token
|
|
|
|
def clear_cache(self) -> None:
|
|
return None
|
|
|
|
|
|
class TeamsSummaryWriter:
|
|
"""Deliver a meeting summary to Teams via incoming webhook or Graph."""
|
|
|
|
def __init__(
|
|
self, platform_config: PlatformConfig | None = None, *,
|
|
graph_client: Any | None = None, transport: httpx.AsyncBaseTransport | None = None,
|
|
) -> None:
|
|
self._platform_config, self._graph_client, self._transport = platform_config, graph_client, transport
|
|
|
|
async def write_summary(self, payload: Any, config: dict[str, Any] | None, existing_record: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
merged = self._resolve_delivery_config(config)
|
|
if existing_record and not _parse_bool(merged.get("force_resend"), default=False):
|
|
return dict(existing_record)
|
|
mode = str(merged.get("delivery_mode") or merged.get("mode") or "").strip().lower()
|
|
if not mode and merged.get("incoming_webhook_url"):
|
|
mode = "incoming_webhook"
|
|
elif not mode and (merged.get("chat_id") or (merged.get("team_id") and merged.get("channel_id"))):
|
|
mode = "graph"
|
|
if mode == "incoming_webhook":
|
|
return await self._write_summary_via_incoming_webhook(payload, merged)
|
|
if mode == "graph":
|
|
return await self._write_summary_via_graph(payload, merged)
|
|
raise ValueError("Teams delivery_mode must be 'incoming_webhook' or 'graph'.")
|
|
|
|
def _resolve_delivery_config(self, config: dict[str, Any] | None) -> dict[str, Any]:
|
|
merged: dict[str, Any] = {}
|
|
platform_cfg = self._platform_config
|
|
if platform_cfg is not None:
|
|
merged.update(dict(platform_cfg.extra or {}))
|
|
if platform_cfg.token and "access_token" not in merged:
|
|
merged["access_token"] = platform_cfg.token
|
|
if platform_cfg.home_channel:
|
|
merged.setdefault("channel_id", platform_cfg.home_channel.chat_id)
|
|
merged.update(dict(config or {}))
|
|
for key, env in _ENV_KEYS.items():
|
|
value = _get_scoped_secret(env, "") if key == "access_token" else os.getenv(env, "")
|
|
if value and not merged.get(key):
|
|
merged[key] = value
|
|
return merged
|
|
|
|
async def _write_summary_via_incoming_webhook(self, payload: Any, config: dict[str, Any]) -> dict[str, Any]:
|
|
webhook_url = str(config.get("incoming_webhook_url") or "").strip()
|
|
if not webhook_url:
|
|
raise ValueError("TEAMS_INCOMING_WEBHOOK_URL is required for incoming_webhook mode.")
|
|
body = {"text": self._render_summary_markdown(payload)}
|
|
async with httpx.AsyncClient(timeout=20.0, transport=self._transport) as client:
|
|
response = await client.post(webhook_url, json=body)
|
|
response.raise_for_status()
|
|
return {"delivery_mode": "incoming_webhook", "webhook_url": webhook_url, "status_code": response.status_code, "delivered": True}
|
|
|
|
async def _write_summary_via_graph(self, payload: Any, config: dict[str, Any]) -> dict[str, Any]:
|
|
graph_client = self._build_graph_client(config)
|
|
chat_id = str(config.get("chat_id") or "").strip()
|
|
if chat_id:
|
|
path = f"/chats/{quote(chat_id, safe='')}/messages"
|
|
target = {"target_type": "chat", "chat_id": chat_id}
|
|
else:
|
|
team_id = str(config.get("team_id") or "").strip()
|
|
channel_id = str(config.get("channel_id") or "").strip()
|
|
if not team_id or not channel_id:
|
|
raise ValueError("Graph delivery mode requires chat_id, or both team_id and channel_id.")
|
|
path = f"/teams/{quote(team_id, safe='')}/channels/{quote(channel_id, safe='')}/messages"
|
|
target = {"target_type": "channel", "team_id": team_id, "channel_id": channel_id}
|
|
response = await graph_client.post_json(path, json_body={"body": {"contentType": "html", "content": self._render_summary_html(payload)}})
|
|
return {"delivery_mode": "graph", **target, "message_id": (response or {}).get("id"), "web_url": (response or {}).get("webUrl")}
|
|
|
|
def _build_graph_client(self, config: dict[str, Any]) -> Any:
|
|
if self._graph_client is not None:
|
|
return self._graph_client
|
|
from tools.microsoft_graph_auth import MicrosoftGraphTokenProvider
|
|
from tools.microsoft_graph_client import MicrosoftGraphClient
|
|
access_token = str(config.get("access_token") or "").strip()
|
|
provider = _StaticAccessTokenProvider(access_token) if access_token else MicrosoftGraphTokenProvider.from_env()
|
|
return MicrosoftGraphClient(provider, transport=self._transport)
|
|
|
|
def _render_summary_markdown(self, payload: Any) -> str:
|
|
lines = [f"**{self._title(payload)}**", "", f"Summary: {self._text(getattr(payload, 'summary', None), 'No summary available.')}"]
|
|
for heading, attr in _LIST_SECTIONS:
|
|
lines += ["", f"{heading}:", *self._bullet_lines(getattr(payload, attr, None))]
|
|
return "\n".join(lines)
|
|
|
|
def _render_summary_html(self, payload: Any) -> str:
|
|
summary = html.escape(self._text(getattr(payload, "summary", None), "No summary available."))
|
|
blocks = [f"<h2>{html.escape(self._title(payload))}</h2>", "<h3>Summary</h3>", f"<p>{summary}</p>"]
|
|
for heading, attr in _LIST_SECTIONS:
|
|
rendered = "".join(f"<li>{html.escape(str(item))}</li>" for item in (getattr(payload, attr, None) or []) if str(item).strip())
|
|
blocks += [f"<h3>{html.escape(heading)}</h3>", f"<ul>{rendered}</ul>" if rendered else "<p>None</p>"]
|
|
return "".join(blocks)
|
|
|
|
@staticmethod
|
|
def _title(payload: Any) -> str:
|
|
if title := getattr(payload, "title", None):
|
|
return str(title)
|
|
meeting_ref = getattr(payload, "meeting_ref", None)
|
|
return f"Meeting {(getattr(meeting_ref, 'meeting_id', None) if meeting_ref else None) or 'summary'}"
|
|
|
|
@staticmethod
|
|
def _text(value: Any, default: str) -> str:
|
|
return str(value or "").strip() or default
|
|
|
|
@classmethod
|
|
def _bullet_lines(cls, values: Any) -> list[str]:
|
|
return [f"- {str(item).strip()}" for item in (values or []) if str(item).strip()] or ["- None"]
|