新增大量WhatsApp适配器相关代码,包括账号管理、会话处理、消息收发、验证授权、媒体处理、互动命令、审批流程、健康检测等完整功能模块,搭建基础的Baileys协议WhatsApp接入能力
514 lines
17 KiB
Python
514 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelType,
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
from .structured_context import ContextSource, StructuredContextEntry, UntrustedStructuredContext
|
|
|
|
_PROTOCOL_REVOKE = 0
|
|
|
|
|
|
def normalize_inbound(raw_payload: dict[str, Any], channel_id: str) -> ChannelMessage:
|
|
key = raw_payload.get("key", {})
|
|
msg = raw_payload.get("message", {})
|
|
|
|
remote_jid = key.get("remoteJid", "unknown")
|
|
msg_id = key.get("id")
|
|
from_me = key.get("fromMe", False)
|
|
|
|
if "protocolMessage" in msg:
|
|
return _normalize_protocol(msg["protocolMessage"], remote_jid, channel_id)
|
|
|
|
if "pollUpdateMessage" in msg:
|
|
return _normalize_poll_vote(msg["pollUpdateMessage"], raw_payload, channel_id)
|
|
|
|
if from_me:
|
|
content_type, content, attachments = _extract_content(msg, msg_id)
|
|
msg_type = _map_message_type(content_type)
|
|
chat_type = _jid_to_chat_type(remote_jid)
|
|
sender_id = _extract_sender_number(remote_jid)
|
|
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id=channel_id,
|
|
channel_type=ChannelType.WHATSAPP,
|
|
channel_user_id=sender_id,
|
|
channel_chat_id=remote_jid,
|
|
channel_message_id=msg_id,
|
|
),
|
|
event_type=EventType.MESSAGE_RECEIVED,
|
|
message_type=msg_type,
|
|
chat_type=chat_type,
|
|
content="",
|
|
attachments=[],
|
|
metadata={
|
|
"from_me": True,
|
|
"push_name": raw_payload.get("pushName", ""),
|
|
"raw_type": content_type,
|
|
"remote_jid": remote_jid,
|
|
},
|
|
)
|
|
|
|
content_type, content, attachments = _extract_content(msg, msg_id)
|
|
|
|
msg_type = _map_message_type(content_type)
|
|
chat_type = _jid_to_chat_type(remote_jid)
|
|
sender_id = _extract_sender_number(remote_jid)
|
|
|
|
metadata = {
|
|
"push_name": raw_payload.get("pushName", ""),
|
|
"broadcast": raw_payload.get("broadcast", False),
|
|
"raw_type": content_type,
|
|
"remote_jid": remote_jid,
|
|
**(_extract_forward_info(msg) or {}),
|
|
}
|
|
|
|
structured = _build_structured_context(msg, content_type, msg_id)
|
|
if not structured.is_empty():
|
|
metadata.update(structured.to_metadata())
|
|
|
|
if content_type == "reaction":
|
|
reaction = msg.get("reactionMessage", {})
|
|
target_key = reaction.get("key", {})
|
|
if target_key:
|
|
metadata["reaction_target_msg_id"] = target_key.get("id")
|
|
metadata["reaction_target_jid"] = target_key.get("remoteJid")
|
|
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id=channel_id,
|
|
channel_type=ChannelType.WHATSAPP,
|
|
channel_user_id=sender_id,
|
|
channel_chat_id=remote_jid,
|
|
channel_message_id=msg_id,
|
|
),
|
|
event_type=EventType.MESSAGE_RECEIVED,
|
|
message_type=msg_type,
|
|
chat_type=chat_type,
|
|
content=content,
|
|
attachments=attachments,
|
|
timestamp=datetime.fromtimestamp(raw_payload.get("messageTimestamp", time.time())),
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
def _normalize_protocol(proto: dict, remote_jid: str, channel_id: str) -> ChannelMessage:
|
|
proto_type = proto.get("type", -1)
|
|
revoked_key = proto.get("key", {})
|
|
revoked_msg_id = revoked_key.get("id", "")
|
|
|
|
if proto_type == _PROTOCOL_REVOKE:
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id=channel_id,
|
|
channel_type=ChannelType.WHATSAPP,
|
|
channel_user_id=_extract_sender_number(remote_jid),
|
|
channel_chat_id=remote_jid,
|
|
channel_message_id=revoked_msg_id,
|
|
),
|
|
event_type=EventType.MESSAGE_DELETED,
|
|
message_type=MessageType.TEXT,
|
|
chat_type=_jid_to_chat_type(remote_jid),
|
|
content="",
|
|
metadata={"protocol_type": "revoke", "remote_jid": remote_jid},
|
|
)
|
|
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id=channel_id,
|
|
channel_type=ChannelType.WHATSAPP,
|
|
channel_user_id=_extract_sender_number(remote_jid),
|
|
channel_chat_id=remote_jid,
|
|
channel_message_id=revoked_msg_id,
|
|
),
|
|
event_type=EventType.SYSTEM_EVENT,
|
|
message_type=MessageType.TEXT,
|
|
chat_type=_jid_to_chat_type(remote_jid),
|
|
content="",
|
|
metadata={"protocol_type": proto_type, "remote_jid": remote_jid},
|
|
)
|
|
|
|
|
|
def _normalize_poll_vote(poll_update: dict, raw_payload: dict, channel_id: str) -> ChannelMessage:
|
|
key = raw_payload.get("key", {})
|
|
remote_jid = key.get("remoteJid", "unknown")
|
|
msg_id = key.get("id")
|
|
|
|
poll_creation_key = poll_update.get("pollCreationMessageKey", {})
|
|
vote_info = poll_update.get("vote", {})
|
|
|
|
selected_options = []
|
|
if isinstance(vote_info, dict):
|
|
selected = vote_info.get("selectedOptions", [])
|
|
if isinstance(selected, list):
|
|
selected_options = selected
|
|
elif isinstance(vote_info, list):
|
|
selected_options = vote_info
|
|
|
|
option_names = [o.get("name", str(o)) if isinstance(o, dict) else str(o) for o in selected_options]
|
|
vote_text = f"[Poll Vote] {' | '.join(option_names)}" if option_names else "[Poll Vote]"
|
|
|
|
return ChannelMessage(
|
|
identity=ChannelIdentity(
|
|
channel_id=channel_id,
|
|
channel_type=ChannelType.WHATSAPP,
|
|
channel_user_id=_extract_sender_number(remote_jid),
|
|
channel_chat_id=remote_jid,
|
|
channel_message_id=msg_id,
|
|
),
|
|
event_type=EventType.MESSAGE_RECEIVED,
|
|
message_type=MessageType.TEXT,
|
|
chat_type=_jid_to_chat_type(remote_jid),
|
|
content=vote_text,
|
|
metadata={
|
|
"raw_type": "poll_vote",
|
|
"remote_jid": remote_jid,
|
|
"poll_msg_id": poll_creation_key.get("id"),
|
|
"poll_remote_jid": poll_creation_key.get("remoteJid"),
|
|
"selected_options": selected_options,
|
|
},
|
|
)
|
|
|
|
|
|
def _extract_content(msg: dict, msg_id: str | None) -> tuple[str, str, list[Attachment]]:
|
|
if "conversation" in msg:
|
|
return "text", msg["conversation"], []
|
|
|
|
if "extendedTextMessage" in msg:
|
|
ext = msg["extendedTextMessage"]
|
|
text = ext.get("text", "")
|
|
context_info = ext.get("contextInfo", {})
|
|
|
|
if "mentionedJid" in context_info:
|
|
mentions = context_info.get("mentionedJid", []) or []
|
|
return "text", text, _build_mention_attachments(text, mentions, msg_id)
|
|
|
|
return "text", text, []
|
|
|
|
if "imageMessage" in msg:
|
|
img = msg["imageMessage"]
|
|
return (
|
|
"image",
|
|
img.get("caption", ""),
|
|
[
|
|
Attachment(
|
|
type="image",
|
|
url=img.get("url"),
|
|
mime_type=img.get("mimetype"),
|
|
file_id=msg_id,
|
|
)
|
|
],
|
|
)
|
|
|
|
if "videoMessage" in msg:
|
|
vid = msg["videoMessage"]
|
|
return (
|
|
"video",
|
|
vid.get("caption", ""),
|
|
[
|
|
Attachment(
|
|
type="video",
|
|
url=vid.get("url"),
|
|
mime_type=vid.get("mimetype"),
|
|
file_id=msg_id,
|
|
)
|
|
],
|
|
)
|
|
|
|
if "audioMessage" in msg:
|
|
aud = msg["audioMessage"]
|
|
return (
|
|
"audio",
|
|
"",
|
|
[
|
|
Attachment(
|
|
type="audio",
|
|
url=aud.get("url"),
|
|
mime_type=aud.get("mimetype"),
|
|
file_id=msg_id,
|
|
)
|
|
],
|
|
)
|
|
|
|
if "documentMessage" in msg:
|
|
doc = msg["documentMessage"]
|
|
return (
|
|
"file",
|
|
doc.get("caption", ""),
|
|
[
|
|
Attachment(
|
|
type="file",
|
|
url=doc.get("url"),
|
|
filename=doc.get("filename"),
|
|
mime_type=doc.get("mimetype"),
|
|
file_id=msg_id,
|
|
)
|
|
],
|
|
)
|
|
|
|
if "reactionMessage" in msg:
|
|
reaction = msg["reactionMessage"]
|
|
return (
|
|
"reaction",
|
|
reaction.get("text", ""),
|
|
[],
|
|
)
|
|
|
|
if "buttonsResponseMessage" in msg:
|
|
br = msg["buttonsResponseMessage"]
|
|
return "text", br.get("selectedDisplayText", br.get("selectedButtonId", "")), []
|
|
|
|
if "listResponseMessage" in msg:
|
|
lr = msg["listResponseMessage"]
|
|
return "text", lr.get("title", lr.get("description", "")), []
|
|
|
|
if "templateButtonReplyMessage" in msg:
|
|
tr = msg["templateButtonReplyMessage"]
|
|
return "text", tr.get("selectedDisplayText", tr.get("selectedId", "")), []
|
|
|
|
if "pollCreationMessage" in msg:
|
|
poll = msg["pollCreationMessage"]
|
|
name = poll.get("name", "")
|
|
options = [o.get("optionName", "") for o in poll.get("options", [])]
|
|
return "text", f"[Poll] {name}: {' | '.join(options)}", []
|
|
|
|
if "locationMessage" in msg:
|
|
loc = msg["locationMessage"]
|
|
return "location", f"Location: {loc.get('degreesLatitude')},{loc.get('degreesLongitude')}", []
|
|
|
|
if "stickerMessage" in msg:
|
|
stk = msg["stickerMessage"]
|
|
return (
|
|
"sticker",
|
|
"",
|
|
[
|
|
Attachment(
|
|
type="sticker",
|
|
url=stk.get("url"),
|
|
file_id=msg_id,
|
|
)
|
|
],
|
|
)
|
|
|
|
if "contactMessage" in msg:
|
|
ct = msg["contactMessage"]
|
|
display = ct.get("displayName", "")
|
|
vcard = ct.get("vcard", "")
|
|
content_parts = [display] if display else []
|
|
if vcard:
|
|
content_parts.append(f"[vCard: {len(vcard)} bytes]")
|
|
return "card", " | ".join(content_parts) if content_parts else "[Contact]", []
|
|
|
|
if "contactsArrayMessage" in msg:
|
|
cam = msg["contactsArrayMessage"]
|
|
names = [c.get("displayName", "") for c in cam.get("contacts", [])]
|
|
return "card", ", ".join(filter(None, names)) or f"[{len(cam.get('contacts', []))} contacts]", []
|
|
|
|
return "text", str(msg)[:500], []
|
|
|
|
|
|
def _map_message_type(content_type: str) -> MessageType:
|
|
mapping: dict[str, MessageType] = {
|
|
"text": MessageType.TEXT,
|
|
"image": MessageType.IMAGE,
|
|
"video": MessageType.VIDEO,
|
|
"audio": MessageType.AUDIO,
|
|
"file": MessageType.FILE,
|
|
"location": MessageType.LOCATION,
|
|
"sticker": MessageType.STICKER,
|
|
"card": MessageType.CARD,
|
|
}
|
|
return mapping.get(content_type, MessageType.TEXT)
|
|
|
|
|
|
def _build_mention_attachments(text: str, mentioned_jids: list[str], msg_id: str | None) -> list[Attachment]:
|
|
attachments: list[Attachment] = []
|
|
for mj in mentioned_jids:
|
|
phone = mj.split("@")[0]
|
|
attachments.append(
|
|
Attachment(
|
|
type="mention",
|
|
file_id=msg_id,
|
|
filename=phone,
|
|
metadata={"jid": mj, "phone": phone},
|
|
)
|
|
)
|
|
return attachments
|
|
|
|
|
|
def _extract_forward_info(msg: dict) -> dict[str, Any] | None:
|
|
for key in ("extendedTextMessage", "imageMessage", "videoMessage", "audioMessage", "documentMessage"):
|
|
content = msg.get(key, {})
|
|
if not content:
|
|
continue
|
|
context_info = content.get("contextInfo", {})
|
|
if context_info.get("isForwarded") or context_info.get("forwardedNewsletterMessageInfo"):
|
|
return {
|
|
"is_forwarded": True,
|
|
"forwarding_score": context_info.get("forwardingScore", 0),
|
|
"forwarded_from": context_info.get("forwardedNewsletterMessageInfo", {}).get("newsletterJid", ""),
|
|
}
|
|
return None
|
|
|
|
|
|
def _jid_to_chat_type(jid: str) -> ChatType:
|
|
if "@g.us" in jid:
|
|
return ChatType.GROUP
|
|
if "@broadcast" in jid:
|
|
return ChatType.GUILD_CHANNEL
|
|
return ChatType.DIRECT
|
|
|
|
|
|
def _extract_sender_number(jid: str) -> str:
|
|
return jid.split("@")[0]
|
|
|
|
|
|
def _build_structured_context(msg: dict, content_type: str, msg_id: str | None) -> UntrustedStructuredContext:
|
|
context = UntrustedStructuredContext()
|
|
|
|
if content_type == "location" and "locationMessage" in msg:
|
|
loc = msg["locationMessage"]
|
|
context.add_entry(
|
|
StructuredContextEntry(
|
|
label="location",
|
|
source=ContextSource.LOCATION,
|
|
data={
|
|
"latitude": loc.get("degreesLatitude"),
|
|
"longitude": loc.get("degreesLongitude"),
|
|
"name": loc.get("name", ""),
|
|
"address": loc.get("address", ""),
|
|
},
|
|
)
|
|
)
|
|
|
|
if content_type == "reaction" and "reactionMessage" in msg:
|
|
reaction = msg["reactionMessage"]
|
|
target_key = reaction.get("key", {})
|
|
context.add_entry(
|
|
StructuredContextEntry(
|
|
label="reaction",
|
|
source=ContextSource.REACTION,
|
|
data={
|
|
"emoji": reaction.get("text", ""),
|
|
"target_msg_id": target_key.get("id"),
|
|
"target_jid": target_key.get("remoteJid"),
|
|
},
|
|
)
|
|
)
|
|
|
|
if content_type == "card" and "contactMessage" in msg:
|
|
ct = msg["contactMessage"]
|
|
vcard = ct.get("vcard", "")
|
|
display = ct.get("displayName", "")
|
|
context.add_entry(
|
|
StructuredContextEntry(
|
|
label="contact",
|
|
source=ContextSource.CONTACT,
|
|
data={
|
|
"display_name": display,
|
|
"vcard": vcard,
|
|
"contacts": [],
|
|
},
|
|
)
|
|
)
|
|
|
|
if content_type == "card" and "contactsArrayMessage" in msg:
|
|
cam = msg["contactsArrayMessage"]
|
|
contacts_list = []
|
|
for c in cam.get("contacts", []):
|
|
contacts_list.append({"displayName": c.get("displayName", ""), "vcard": c.get("vcard", "")})
|
|
context.add_entry(
|
|
StructuredContextEntry(
|
|
label="contacts_array",
|
|
source=ContextSource.CONTACT,
|
|
data={"contacts": contacts_list},
|
|
)
|
|
)
|
|
|
|
if content_type == "text" and "pollCreationMessage" in msg:
|
|
poll = msg["pollCreationMessage"]
|
|
options = [o.get("optionName", "") for o in poll.get("options", [])]
|
|
context.add_entry(
|
|
StructuredContextEntry(
|
|
label="poll",
|
|
source=ContextSource.POLL,
|
|
data={
|
|
"name": poll.get("name", ""),
|
|
"options": options,
|
|
},
|
|
)
|
|
)
|
|
|
|
if content_type == "text" and (
|
|
"buttonsResponseMessage" in msg or "templateButtonReplyMessage" in msg or "listResponseMessage" in msg
|
|
):
|
|
btn = msg.get("buttonsResponseMessage") or msg.get("templateButtonReplyMessage") or {}
|
|
lst = msg.get("listResponseMessage") or {}
|
|
button_id = btn.get("selectedButtonId", btn.get("selectedId", ""))
|
|
display = btn.get("selectedDisplayText", lst.get("title", lst.get("description", "")))
|
|
if button_id or display:
|
|
context.add_entry(
|
|
StructuredContextEntry(
|
|
label="button",
|
|
source=ContextSource.BUTTON,
|
|
data={
|
|
"button_id": button_id,
|
|
"display_text": display,
|
|
},
|
|
)
|
|
)
|
|
|
|
forward_info = _extract_forward_info(msg)
|
|
if forward_info and forward_info.get("is_forwarded"):
|
|
context.add_entry(
|
|
StructuredContextEntry(
|
|
label="forward",
|
|
source=ContextSource.FORWARD,
|
|
data={
|
|
"is_forwarded": True,
|
|
"forwarding_score": forward_info.get("forwarding_score", 0),
|
|
"forwarded_from": forward_info.get("forwarded_from", ""),
|
|
},
|
|
)
|
|
)
|
|
|
|
for msg_key in ("extendedTextMessage",):
|
|
ext = msg.get(msg_key, {})
|
|
context_info = ext.get("contextInfo", {})
|
|
mentioned = context_info.get("mentionedJid", []) or []
|
|
if mentioned:
|
|
context.add_entry(
|
|
StructuredContextEntry(
|
|
label="mention",
|
|
source=ContextSource.MENTION,
|
|
data={"mentioned_jids": mentioned},
|
|
)
|
|
)
|
|
break
|
|
|
|
return context
|
|
|
|
|
|
def format_outbound(response) -> dict[str, Any]:
|
|
result: dict[str, Any] = {
|
|
"jid": response.identity.channel_chat_id,
|
|
"content": response.content,
|
|
}
|
|
if response.reply_to_message_id:
|
|
result["reply_to"] = response.reply_to_message_id
|
|
if response.message_type == MessageType.IMAGE and response.attachments:
|
|
result["media_type"] = "image"
|
|
result["media_path"] = response.attachments[0].url or response.attachments[0].file_id
|
|
return result
|