refactor: unify message ingress forwarding

This commit is contained in:
jxxghp
2026-08-24 03:01:17 +08:00
parent e274672a6a
commit e1230c0277
16 changed files with 463 additions and 127 deletions
+115
View File
@@ -0,0 +1,115 @@
"""消息渠道回环进入宿主消息 API 的统一适配边界。"""
from collections.abc import Callable, Mapping
from typing import Any
from urllib.parse import urlencode
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
from app.runtime.log import logger
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
BackgroundSubmitter = Callable[..., object]
def build_message_ingress_url(source: str | None) -> str:
"""按当前运行配置构造安全编码的本地消息入口 URL。"""
query = {"token": settings.API_TOKEN}
if source:
query["source"] = source
return f"http://127.0.0.1:{settings.PORT}/api/v1/message?{urlencode(query)}"
def forward_message_to_host(
payload: Mapping[str, Any],
source: str | None,
*,
timeout: float = 15,
) -> bool:
"""同步转发渠道 payload,统一判断本地入口是否确认接收。"""
response = None
try:
response = RequestUtils(timeout=timeout).post_res(
build_message_ingress_url(source),
json=dict(payload),
)
if response is None:
logger.error(f"转发渠道消息到本地入口失败:source={source or '-'} - 无响应")
return False
if response.status_code >= 400:
logger.error(
"转发渠道消息到本地入口失败:"
f"source={source or '-'} - HTTP {response.status_code}"
)
return False
return True
except Exception as error:
logger.error(f"转发渠道消息到本地入口失败:source={source or '-'} - {error}")
return False
finally:
close = getattr(response, "close", None)
if callable(close):
try:
close()
except Exception as error:
logger.debug(
f"释放本地消息入口响应失败:source={source or '-'} - {error}"
)
async def async_forward_message_to_host(
payload: Mapping[str, Any],
source: str | None,
*,
timeout: float = 15,
) -> bool:
"""异步转发渠道 payload,供自有事件循环的消息 SDK 复用同一确认语义。"""
response = None
try:
response = await AsyncRequestUtils(timeout=timeout).post_res(
build_message_ingress_url(source),
json=dict(payload),
)
if response is None:
logger.error(f"转发渠道消息到本地入口失败:source={source or '-'} - 无响应")
return False
if response.status_code >= 400:
logger.error(
"转发渠道消息到本地入口失败:"
f"source={source or '-'} - HTTP {response.status_code}"
)
return False
return True
except Exception as error:
logger.error(f"转发渠道消息到本地入口失败:source={source or '-'} - {error}")
return False
finally:
if response is not None:
try:
await response.aclose()
except Exception as error:
logger.debug(
f"释放本地消息入口响应失败:source={source or '-'} - {error}"
)
def submit_message_to_host(
payload: Mapping[str, Any],
source: str | None,
*,
submit: BackgroundSubmitter,
timeout: float = 15,
) -> bool:
"""把同步回环转发提交给调用方注入的受管后台执行器。"""
try:
submit(
forward_message_to_host,
dict(payload),
source,
timeout=timeout,
)
except Exception as error:
logger.error(f"提交渠道消息转发任务失败:source={source or '-'} - {error}")
return False
return True
+9 -20
View File
@@ -3,16 +3,15 @@ import re
import threading
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple, Union
from urllib.parse import quote
import discord
from discord import app_commands
import httpx
from fastapi.concurrency import run_in_threadpool
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import async_forward_message_to_host
from app.domain.context import MediaInfo, Context
from app.domain.metainfo import MetaInfo
from app.runtime.log import logger
@@ -60,17 +59,10 @@ class Discord:
self._token = DISCORD_BOT_TOKEN
self._guild_id = self._to_int(DISCORD_GUILD_ID)
self._channel_id = self._to_int(DISCORD_CHANNEL_ID)
self._config_name = kwargs.get("name")
logger.debug(
f"[Discord] 解析后的 ID: _guild_id={self._guild_id}, _channel_id={self._channel_id}"
)
base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/"
self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}"
if kwargs.get("name"):
# URL encode the source name to handle special characters in config names
encoded_name = quote(kwargs.get("name"), safe="")
self._ds_url = f"{self._ds_url}&source={encoded_name}"
logger.debug(f"[Discord] 消息回调 URL: {self._ds_url}")
intents = discord.Intents.default()
intents.message_content = True
intents.messages = True
@@ -1313,13 +1305,10 @@ class Discord:
return content
async def _post_to_ds(self, payload: Dict[str, Any]) -> None:
try:
proxy = None
if settings.PROXY:
proxy = settings.PROXY.get("https") or settings.PROXY.get("http")
async with httpx.AsyncClient(
timeout=10, verify=False, proxy=proxy
) as client:
await client.post(self._ds_url, json=payload)
except Exception as err:
logger.error(f"转发 Discord 消息失败:{err}")
"""把 Discord 事件异步转交统一消息入口。"""
if not await async_forward_message_to_host(
payload,
self._config_name,
timeout=10,
):
logger.error("转发 Discord 消息失败")
+8 -12
View File
@@ -53,6 +53,7 @@ from lark_oapi.event.callback.model.p2_card_action_trigger import (
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import submit_message_to_host
from app.domain.context import Context, MediaInfo
from app.application.security.user import get_configured_user_channel_lookup
from app.application.messaging.agent import matches_channel_admin
@@ -61,6 +62,7 @@ from app.schemas.message import IncomingMessage
from app.schemas.message import Message
from app.schemas.types import NotificationChannel, MessageType
from app.adapters.network.http import RequestUtils
from app.runtime.thread import ThreadHelper
class UserOper:
@@ -295,19 +297,13 @@ class Feishu:
ws_client._service_id = ""
ws_client._lock.release()
def _forward_to_message_chain(self, payload: dict) -> None:
def _forward_to_message_chain(self, payload: dict) -> bool:
"""将飞书入站消息转发到统一消息入口,复用现有交互主链。"""
def _run() -> None:
try:
RequestUtils(timeout=15).post_res(
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}&source={self._name}",
json=payload,
)
except Exception as err:
logger.error(f"飞书转发消息失败:{err}")
threading.Thread(target=_run, daemon=True).start()
return submit_message_to_host(
payload,
self._name,
submit=ThreadHelper().submit,
)
@staticmethod
def _parse_message_content(message) -> Tuple[
+9 -14
View File
@@ -15,9 +15,11 @@ from app.runtime.cache import FileCache
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import submit_message_to_host
from app.domain.context import MediaInfo, Context
from app.domain.metainfo import MetaInfo
from app.runtime.log import logger
from app.runtime.thread import ThreadHelper
from app.modules.qqbot.api import (
get_access_token,
get_gateway_url,
@@ -103,20 +105,13 @@ class QQBot:
except Exception as e:
logger.debug(f"QQ Bot 保存 known_targets 失败: {e}")
def _forward_to_message_chain(self, payload: dict) -> None:
"""直接调用消息链处理,避免 HTTP 开销"""
def _run():
try:
# 回调
RequestUtils(timeout=15).post_res(
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}&source={self._config_name}",
json=payload
)
except Exception as e:
logger.error(f"QQ Bot 转发消息失败: {e}")
threading.Thread(target=_run, daemon=True).start()
def _forward_to_message_chain(self, payload: dict) -> bool:
"""通过受管线程池把 QQ Bot 入站 payload 转交统一消息入口。"""
return submit_message_to_host(
payload,
self._config_name,
submit=ThreadHelper().submit,
)
def _on_gateway_message(self, payload: dict) -> None:
"""Gateway 收到消息时转发至 MP 消息链,并记录发送者用于广播"""
+15 -19
View File
@@ -3,9 +3,7 @@ import re
from threading import Lock
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote
import requests
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient
@@ -13,6 +11,7 @@ from slack_sdk import WebClient
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import forward_message_to_host
from app.domain.context import MediaInfo, Context
from app.domain.metainfo import MetaInfo
from app.runtime.log import logger
@@ -27,7 +26,6 @@ class Slack:
_client: WebClient = None
_service: SocketModeHandler = None
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
_channel = ""
_oauth_token = ""
_MAX_SLASH_COMMANDS = 50
@@ -65,6 +63,7 @@ class Slack:
self._client = slack_app.client
self._channel = SLACK_CHANNEL
self._oauth_token = SLACK_OAUTH_TOKEN
self._config_name = kwargs.get("name")
self._app_id = (SLACK_APP_ID or "").strip()
self._command_request_url = (SLACK_COMMAND_REQUEST_URL or "").strip()
self._manifest_client = (
@@ -74,41 +73,30 @@ class Slack:
)
self._registered_command_names: set[str] = set()
# 标记消息来源
if kwargs.get("name"):
# URL encode the source name to handle special characters
encoded_name = quote(kwargs.get('name'), safe='')
self._ds_url = f"{self._ds_url}&source={encoded_name}"
# 注册消息响应
@slack_app.event("message")
def slack_message(message):
with requests.post(self._ds_url, json=message, timeout=10) as local_res:
logger.debug("message: %s processed, response is: %s" % (message, local_res.text))
self._forward_to_message_chain(message, timeout=10)
@slack_app.action(re.compile(r"actionId-.*"))
def slack_action(ack, body):
ack()
with requests.post(self._ds_url, json=body, timeout=60) as local_res:
logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
self._forward_to_message_chain(body, timeout=60)
@slack_app.event("app_mention")
def slack_mention(say, body):
say(f"收到,请稍等... <@{body.get('event', {}).get('user')}>")
with requests.post(self._ds_url, json=body, timeout=10) as local_res:
logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
self._forward_to_message_chain(body, timeout=10)
@slack_app.shortcut(re.compile(r"/*"))
def slack_shortcut(ack, body):
ack()
with requests.post(self._ds_url, json=body, timeout=10) as local_res:
logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
self._forward_to_message_chain(body, timeout=10)
@slack_app.command(re.compile(r"/*"))
def slack_command(ack, body):
ack()
with requests.post(self._ds_url, json=body, timeout=10) as local_res:
logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
self._forward_to_message_chain(body, timeout=10)
# 启动服务
try:
@@ -121,6 +109,14 @@ class Slack:
except Exception as err:
logger.error("Slack消息接收服务启动失败: %s" % str(err))
def _forward_to_message_chain(self, payload: dict, *, timeout: float) -> bool:
"""把 Slack SDK 回调同步转交统一消息入口。"""
return forward_message_to_host(
payload,
self._config_name,
timeout=timeout,
)
def stop(self):
if self._service:
try:
+9 -17
View File
@@ -6,7 +6,7 @@ import threading
import time
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Union
from urllib.parse import urljoin, quote
from urllib.parse import urljoin
from app.modules.telegram.compat import ensure_urllib3_header_param_compat
@@ -42,6 +42,7 @@ settings = RuntimeSettingsCompat()
from app.domain.context import MediaInfo, Context # noqa: E402
from app.domain.metainfo import MetaInfo # noqa: E402
from app.application.image import ImageHelper # noqa: E402
from app.application.messaging.ingress import forward_message_to_host # noqa: E402
from app.runtime.thread import ThreadHelper # noqa: E402
from app.runtime.log import logger # noqa: E402
from app.runtime.execution import retry # noqa: E402
@@ -74,9 +75,6 @@ class Telegram:
Telegram 消息客户端,负责发送、编辑、接收和转发 Telegram 消息。
"""
_ds_url = (
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
)
_bot: TeleBot = None
_callback_handlers: Dict[str, Callable] = {} # 存储回调处理器
_user_chat_mapping: Dict[
@@ -137,11 +135,7 @@ class Telegram:
logger.error(f"获取bot信息失败: {e}")
self._bot_username = None
# 标记渠道来源
if kwargs.get("name"):
# URL encode the source name to handle special characters
encoded_name = quote(kwargs.get("name"), safe="")
self._ds_url = f"{self._ds_url}&source={encoded_name}"
self._config_name = kwargs.get("name")
@_bot.message_handler(commands=["start", "help"])
def send_welcome(message):
@@ -164,10 +158,7 @@ class Telegram:
if not payload:
logger.warn("Telegram消息序列化失败,跳过转发")
return
response = RequestUtils(timeout=15).post_res(
self._ds_url, json=payload
)
if not response or response.status_code >= 400:
if not self._forward_to_message_chain(payload):
logger.warn("Telegram消息转发失败")
@_bot.callback_query_handler(func=lambda call: True)
@@ -208,10 +199,7 @@ class Telegram:
_bot.answer_callback_query(call.id)
# 发送给主程序处理
response = RequestUtils(timeout=15).post_res(
self._ds_url, json=callback_json
)
if not response or response.status_code >= 400:
if not self._forward_to_message_chain(callback_json):
logger.warn("Telegram按钮回调转发失败")
except Exception as err:
@@ -232,6 +220,10 @@ class Telegram:
self._polling_thread.start()
logger.info("Telegram消息接收服务启动")
def _forward_to_message_chain(self, payload: dict) -> bool:
"""把 Telegram SDK 回调同步转交统一消息入口。"""
return forward_message_to_host(payload, self._config_name)
@property
def bot(self):
"""
+9 -13
View File
@@ -15,10 +15,12 @@ from app.runtime.cache import FileCache
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import submit_message_to_host
from app.domain.context import MediaInfo, Context
from app.domain.metainfo import MetaInfo
from app.application.messaging.agent import matches_channel_admin
from app.runtime.log import logger
from app.runtime.thread import ThreadHelper
from app.schemas.message import IncomingMessage
from app.schemas.types import NotificationChannel
from app.adapters.network.http import RequestUtils
@@ -34,7 +36,6 @@ class WeChatBot:
"""
_default_ws_url = "wss://openws.work.weixin.qq.com"
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
_heartbeat_interval = 30
_ack_timeout = 10
@@ -512,18 +513,13 @@ class WeChatBot:
)
self._forward_to_message_chain(payload)
def _forward_to_message_chain(self, payload: dict) -> None:
def _run():
try:
# 回调
RequestUtils(timeout=15).post_res(
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}&source={self._config_name}",
json=payload
)
except Exception as err:
logger.error(f"企业微信智能机器人转发消息失败:{err}")
threading.Thread(target=_run, daemon=True).start()
def _forward_to_message_chain(self, payload: dict) -> bool:
"""通过受管线程池把企业微信 payload 转交统一消息入口。"""
return submit_message_to_host(
payload,
self._config_name,
submit=ThreadHelper().submit,
)
@staticmethod
def _normalize_target(userid: Optional[str], default_chat_id: Optional[str]) -> Tuple[Optional[str], int]:
+9 -22
View File
@@ -20,6 +20,7 @@ from app.runtime.cache import FileCache
from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat()
from app.application.messaging.ingress import forward_message_to_host
from app.domain.context import Context, MediaInfo
from app.domain.metainfo import MetaInfo
from app.runtime.log import logger
@@ -1477,9 +1478,6 @@ class WechatClawBot:
self._stop_event = threading.Event()
self._poll_thread: Optional[threading.Thread] = None
self._state = self._load_state()
self._message_endpoint = (
f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}&source={quote(self._config_name, safe='')}"
)
if self._state.get("bot_token") and self._auto_start_polling:
self._start_polling()
@@ -1751,33 +1749,18 @@ class WechatClawBot:
username=message.username,
context_token=message.context_token,
)
response = None
try:
response = RequestUtils(timeout=15).post_res(
self._message_endpoint,
json=message.to_message_payload(),
)
if response is None:
logger.error(
f"转发微信 ClawBot 消息失败:message_id={message.message_id}, "
"本地消息入口无响应"
)
elif response.status_code != 200:
if not self._forward_to_message_chain(
message.to_message_payload()
):
logger.error(
"转发微信 ClawBot 消息失败:"
f"message_id={message.message_id}, status={response.status_code}, "
f"body={self._short_text(response.text)}"
f"message_id={message.message_id}"
)
except Exception as err:
logger.error(
f"转发微信 ClawBot 消息失败:message_id={message.message_id}, error={err}"
)
finally:
if response is not None:
try:
response.close()
except Exception:
pass
consecutive_failures = 0
except Exception as err:
consecutive_failures += 1
@@ -1789,6 +1772,10 @@ class WechatClawBot:
break
self._stop_event.wait(delay)
def _forward_to_message_chain(self, payload: dict) -> bool:
"""把 WeChatClawBot 轮询消息同步转交统一消息入口。"""
return forward_message_to_host(payload, self._config_name)
def _build_known_targets(self) -> List[Dict[str, Any]]:
known_targets = self._state.get("known_targets") or {}
items = []