forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user