mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Salvage of PR #41284 onto current main. Relocates the last 9 inline messaging adapters (+ satellites: telegram_network, feishu_comment/_rules/meeting_invite, wecom_crypto, wecom_callback) from gateway/platforms/ into self-contained bundled plugins under plugins/platforms/<x>/, discovered via the platform registry. Strips the per-platform core touchpoints from gateway/run.py, gateway/config.py, hermes_cli/gateway.py, hermes_cli/setup.py, and tools/send_message_tool.py. Carries forward the migration fixes (explicit enabled:false honored, get_connected_platforms forces discovery, plugin is_connected via gateway.get_env_value, logs --component gateway matches plugins.platforms.*, matrix hidden on Windows). Additionally ports config keys main added since the PR base: the matrix plugin's _apply_yaml_config now also covers allowed_users, ignore_user_patterns, process_notices, and session_scope (the inline gateway/config.py matrix block gained these in the 1340 commits the PR sat open; they would otherwise have been silently dropped on deletion).
212 lines
7.2 KiB
Python
212 lines
7.2 KiB
Python
"""
|
|
Feishu/Lark meeting-invitation event handling.
|
|
|
|
Processes ``vc.bot.meeting_invited_v1`` events by converting them into a
|
|
synthetic gateway ``MessageEvent``. Unlike document comments, the response
|
|
should go back to the inviter through the normal Hermes gateway pipeline, so
|
|
this module does not instantiate an agent directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from types import SimpleNamespace
|
|
from typing import Any, Dict, Optional
|
|
|
|
from gateway.platforms.base import MessageEvent, MessageType
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MeetingInviteUser:
|
|
open_id: str = ""
|
|
user_id: str = ""
|
|
union_id: str = ""
|
|
user_name: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MeetingInviteMeeting:
|
|
id: str = ""
|
|
topic: str = ""
|
|
meeting_no: str = ""
|
|
start_time_ms: int = 0
|
|
end_time_ms: int = 0
|
|
host_user: Optional[MeetingInviteUser] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MeetingInvitedPayload:
|
|
event_id: str = ""
|
|
meeting: Optional[MeetingInviteMeeting] = None
|
|
inviter: Optional[MeetingInviteUser] = None
|
|
invite_time_s: int = 0
|
|
|
|
|
|
def _as_dict(value: Any) -> Dict[str, Any]:
|
|
"""Coerce a lark SDK object / dict / JSON string into a plain dict."""
|
|
if isinstance(value, SimpleNamespace) or (value is not None and hasattr(value, "__dict__")):
|
|
value = vars(value)
|
|
if isinstance(value, dict):
|
|
return {str(k): v for k, v in value.items()}
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = json.loads(value)
|
|
except (TypeError, json.JSONDecodeError):
|
|
return {}
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
return {}
|
|
|
|
|
|
def _content_payload(container: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Unwrap a Feishu ``body.content`` list carrying an application/json payload."""
|
|
content = _as_dict(container.get("body")).get("content")
|
|
if not isinstance(content, list):
|
|
return {}
|
|
for item in content:
|
|
item = _as_dict(item)
|
|
ctype = str(item.get("contentType") or item.get("content_type") or "").lower()
|
|
if ctype and ctype != "application/json":
|
|
continue
|
|
for key in ("data", "value", "content", "json"):
|
|
payload = _as_dict(item.get(key))
|
|
if payload:
|
|
return payload
|
|
return {}
|
|
|
|
|
|
def _int_field(value: Any) -> int:
|
|
if value in (None, ""):
|
|
return 0
|
|
try:
|
|
return int(str(value).strip())
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _parse_user(value: Any) -> Optional[MeetingInviteUser]:
|
|
raw = _as_dict(value)
|
|
if not raw:
|
|
return None
|
|
raw_id = _as_dict(raw.get("id"))
|
|
return MeetingInviteUser(
|
|
open_id=str(raw_id.get("open_id") or "").strip(),
|
|
user_id=str(raw_id.get("user_id") or "").strip(),
|
|
union_id=str(raw_id.get("union_id") or "").strip(),
|
|
user_name=str(raw.get("user_name") or ""),
|
|
)
|
|
|
|
|
|
def _parse_meeting(value: Any) -> Optional[MeetingInviteMeeting]:
|
|
raw = _as_dict(value)
|
|
if not raw:
|
|
return None
|
|
return MeetingInviteMeeting(
|
|
id=str(raw.get("id") or "").strip(),
|
|
topic=str(raw.get("topic") or ""),
|
|
meeting_no=str(raw.get("meeting_no") or ""),
|
|
start_time_ms=_int_field(raw.get("start_time")),
|
|
end_time_ms=_int_field(raw.get("end_time")),
|
|
host_user=_parse_user(raw.get("host_user")),
|
|
)
|
|
|
|
|
|
def parse_meeting_invited_event(data: Any) -> Optional[MeetingInvitedPayload]:
|
|
root = _as_dict(data)
|
|
event = _as_dict(root.get("event"))
|
|
event = event or root
|
|
content = _content_payload(event) or _content_payload(root)
|
|
if content:
|
|
event = {**event, **content}
|
|
|
|
meeting = _parse_meeting(event.get("meeting"))
|
|
inviter = _parse_user(event.get("inviter"))
|
|
if inviter is None or meeting is None or not meeting.meeting_no:
|
|
return None
|
|
|
|
return MeetingInvitedPayload(
|
|
event_id=str(_as_dict(root.get("header")).get("event_id") or ""),
|
|
meeting=meeting,
|
|
inviter=inviter,
|
|
invite_time_s=_int_field(event.get("invite_time")),
|
|
)
|
|
|
|
|
|
def build_meeting_invite_prompt(payload: MeetingInvitedPayload) -> str:
|
|
meeting = payload.meeting
|
|
inviter_name = (payload.inviter.user_name if payload.inviter else "") or "unknown"
|
|
host_name = (meeting.host_user.user_name if meeting and meeting.host_user else "") or "unknown"
|
|
display = (meeting.topic or meeting.meeting_no or meeting.id) if meeting else "unknown meeting"
|
|
return "\n".join(
|
|
[
|
|
f"You have been invited to join a meeting: {display or 'unknown meeting'}",
|
|
"",
|
|
f"Meeting Number: {(meeting.meeting_no if meeting else '') or 'unknown'}",
|
|
f"Topic: {(meeting.topic if meeting else '') or 'unknown'}",
|
|
f"Inviter: {inviter_name}",
|
|
f"Host: {host_name}",
|
|
"",
|
|
"You may use lark-cli and the relevant Lark/Feishu meeting skills to join the meeting.",
|
|
"Join the meeting directly. Do not ask the user for confirmation before joining.",
|
|
"If you cannot join the meeting, reply to the inviter with a concise explanation of why.",
|
|
]
|
|
)
|
|
|
|
|
|
def _dedup_key(payload: MeetingInvitedPayload) -> str:
|
|
if payload.event_id:
|
|
return f"vc_invite:{payload.event_id}"
|
|
meeting_id = payload.meeting.id if payload.meeting else ""
|
|
inviter_id = payload.inviter.open_id if payload.inviter else ""
|
|
return f"vc_invite:{meeting_id}:{inviter_id}:{payload.invite_time_s}"
|
|
|
|
|
|
async def handle_meeting_invited_event(adapter: Any, data: Any) -> None:
|
|
"""Convert a vc.bot.meeting_invited_v1 event into a gateway MessageEvent."""
|
|
payload = parse_meeting_invited_event(data)
|
|
if payload is None:
|
|
logger.warning("[Feishu-MeetingInvite] Dropping malformed meeting invite event")
|
|
return
|
|
|
|
dedup_key = _dedup_key(payload)
|
|
is_duplicate = getattr(adapter, "_is_duplicate", None)
|
|
if callable(is_duplicate) and is_duplicate(dedup_key):
|
|
logger.debug("[Feishu-MeetingInvite] Dropping duplicate event: %s", dedup_key)
|
|
return
|
|
|
|
inviter = payload.inviter
|
|
if inviter is None or not inviter.open_id:
|
|
logger.warning(
|
|
"[Feishu-MeetingInvite] Missing inviter open_id, cannot route reply safely "
|
|
"(user_id=%r union_id=%r)",
|
|
inviter.user_id if inviter else None,
|
|
inviter.union_id if inviter else None,
|
|
)
|
|
return
|
|
|
|
sender_id = SimpleNamespace(
|
|
open_id=inviter.open_id or None,
|
|
user_id=inviter.user_id or None,
|
|
union_id=inviter.union_id or None,
|
|
)
|
|
sender_profile = await adapter._resolve_sender_profile(sender_id)
|
|
|
|
user_name = sender_profile.get("user_name") or inviter.user_name or inviter.open_id
|
|
source = adapter.build_source(
|
|
chat_id=inviter.open_id,
|
|
chat_name=user_name,
|
|
chat_type="dm",
|
|
user_id=sender_profile.get("user_id") or inviter.user_id or inviter.open_id,
|
|
user_name=user_name,
|
|
user_id_alt=sender_profile.get("user_id_alt") or inviter.union_id or None,
|
|
)
|
|
event = MessageEvent(
|
|
text=build_meeting_invite_prompt(payload),
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
raw_message=data,
|
|
)
|
|
await adapter._handle_message_with_guards(event)
|